import { EventEmitter } from 'node:events'; import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; import { resolve } from 'node:path'; import { describe, expect, test } from 'vitest'; import { agcDatabase, agcDataDir, buildDevAllBaseEnvironment, buildDevAllEnvironment, buildManagedSpawnOptions, buildRootDevArgs, buildStartupSummary, isHttpReady, isSharedRootStackReady, renderStartupTable, runDevAll, validateDevAllArgs, } from './dev-all.mjs'; type FakeChild = EventEmitter & { pid: number; exitCode: number | null; signalCode: string | null; }; function makeReadyState(root = resolve('fixture-root')) { const instanceId = 'dev-fixture'; const service = (status = 'running', url = 'http://127.0.0.1:1') => ({ repoRoot: root, instanceId, status, url, }); return { command: 'all', instanceId, repoRoot: root, database: agcDatabase, spacetimeDataDir: agcDataDir, services: { spacetime: service('reused', 'http://127.0.0.1:3101'), 'api-server': service('running', 'http://127.0.0.1:8082'), 'bgfilter-worker': service('running', 'http://127.0.0.1:8083'), web: service('running', 'http://127.0.0.1:3000'), 'admin-web': service('running', 'http://127.0.0.1:3102/admin/'), }, }; } describe('dev:all 参数和共享开发栈合同', () => { test('根 dev 固定使用 AGC 配套数据库,并保留调用方选项', () => { expect(buildRootDevArgs(['--watch', '--api-port', '9082'])).toEqual([ 'run', 'dev', '--', '--database', agcDatabase, '--spacetime-data-dir', 'server-rs/.spacetimedb/ai-game-creator/data', '--preserve-database', '--no-interactive', '--watch', '--api-port', '9082', ]); }); test('拒绝会破坏单后端拓扑的数据库和 data dir 覆盖', () => { expect(() => validateDevAllArgs(['--database', 'other'])).toThrow( '不能覆盖 --database 或 --spacetime-data-dir', ); expect(() => validateDevAllArgs(['--database=other'])).toThrow( '不能覆盖 --database 或 --spacetime-data-dir', ); expect(() => validateDevAllArgs(['--spacetime-data-dir', 'other'])).toThrow( '不能覆盖 --database 或 --spacetime-data-dir', ); expect(() => validateDevAllArgs(['backend'])).toThrow('只接受 dev 选项'); }); test('预留 AGC Vite 端口并关闭 AGC 自带后台', () => { const baseEnv = { AGC_DEV_ADMIN_WEB: '1' }; const env = buildDevAllEnvironment(baseEnv, 3080); expect(env.GENARRATIVE_AGC_VITE_PORT).toBe('3080'); expect(env.AGC_DEV_ADMIN_WEB).toBe('0'); expect(baseEnv.AGC_DEV_ADMIN_WEB).toBe('1'); }); test('CLI port-range 覆盖旧环境并同步给 AGC', () => { const env = buildDevAllBaseEnvironment(['--port-range', '10100-10199'], { GENARRATIVE_DEV_PORT_RANGE: '10000-10099', }); expect(env.GENARRATIVE_DEV_PORT_RANGE).toBe('10100-10199'); }); test.each([ [200, true], [204, true], [299, true], [400, false], [401, false], [403, false], [404, false], [500, false], [503, false], ])('HTTP %i 的前端就绪结果为 %s', async (status, expected) => { const server = createServer((_request, response) => { response.writeHead(status).end(); }); await new Promise((resolveListen) => { server.listen(0, '127.0.0.1', resolveListen); }); try { const { port } = server.address() as AddressInfo; await expect(isHttpReady(`http://127.0.0.1:${port}`)).resolves.toBe( expected, ); } finally { server.closeAllConnections(); await new Promise((resolveClose, rejectClose) => { server.close((error) => { if (error) rejectClose(error); else resolveClose(); }); }); } }); test('Windows 下通过 shell 启动 npm.cmd', () => { const options = buildManagedSpawnOptions(process.env); expect(options.shell).toBe(process.platform === 'win32'); expect(options.windowsHide).toBe(process.platform === 'win32'); expect(options.detached).toBe(process.platform !== 'win32'); }); test('按最终端口构造完整启动摘要表', () => { const rows = buildStartupSummary(makeReadyState(), { url: 'http://127.0.0.1:3080/', }); expect(rows).toHaveLength(7); expect(rows).toEqual( expect.arrayContaining([ expect.objectContaining({ 项目: 'SpacetimeDB', 状态: '已复用', 地址: 'http://127.0.0.1:3101', }), expect.objectContaining({ 项目: 'Admin 后台', 地址: 'http://127.0.0.1:3102/admin/', }), expect.objectContaining({ 项目: 'AGC Vite', 地址: 'http://127.0.0.1:3080/', }), ]), ); }); test('渲染摘要表时不输出字符串引号', () => { const rows = buildStartupSummary(makeReadyState(), { url: 'http://127.0.0.1:3080/', }); const table = renderStartupTable(rows); expect(table).not.toContain("'"); expect(table).toContain('| Web 主站'); expect(table).toContain('http://127.0.0.1:3080/'); }); test('仅接受同一工作树、实例、数据库和 data dir 的完整栈', () => { const root = resolve('fixture-root'); const ready = makeReadyState(root); expect( isSharedRootStackReady(ready, { expectedRepoRoot: root, expectedDatabase: agcDatabase, expectedDataDir: agcDataDir, }), ).toBe(true); const wrongInstance = structuredClone(ready); wrongInstance.services.web.instanceId = 'other-instance'; expect( isSharedRootStackReady(wrongInstance, { expectedRepoRoot: root, expectedDatabase: agcDatabase, expectedDataDir: agcDataDir, }), ).toBe(false); const incomplete = structuredClone(ready); incomplete.services['admin-web'].status = 'idle'; expect( isSharedRootStackReady(incomplete, { expectedRepoRoot: root, expectedDatabase: agcDatabase, expectedDataDir: agcDataDir, }), ).toBe(false); }); test('任一子进程退出时收束另一侧进程树', async () => { const children: FakeChild[] = []; const stopCalls: FakeChild[] = []; let nextPid = 100; const run = runDevAll([], { resolveEndpoint: async () => ({ port: 3080, url: 'http://127.0.0.1:3080/', }), spawnProcess: () => { const child = new EventEmitter() as FakeChild; child.pid = nextPid; child.exitCode = null; child.signalCode = null; nextPid += 1; children.push(child); if (children.length === 2) { queueMicrotask(() => { child.exitCode = 1; child.emit('exit', 1, null); }); } return child; }, waitForRoot: async () => makeReadyState(), readState: () => null, waitForAgc: async () => true, stopProcessTree: async (child) => { stopCalls.push(child); if (child?.exitCode == null) { child.exitCode = 0; child.emit('exit', 0, null); } }, }); await expect(run).resolves.toBe(1); expect(children).toHaveLength(2); expect(stopCalls).toEqual([children[1], children[0]]); }); });