import { EventEmitter } from 'node:events'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { describe, expect, test, vi } from 'vitest'; import { ensureBackend, formatOwnerLabel, isBackendReady, isProcessGroupAlive, isWorktreeApiServerOwner, isWorktreeSpacetimeOwner, preflightExistingVite, readBackendServiceFailure, readLinuxProcessGroupAlive, readWindowsPortOwnerIdentities, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, verifyAgcBackendOwnership, waitForBackendReady, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; const expectedDatabase = 'genarrative-game-creator-dev'; const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); const expectedExePath = resolve('server-rs/target/debug/api-server.exe'); const ownedBackend = async () => ({ ok: true, reason: 'owned', owners: new Map(), }); function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { return { schemaVersion: spacetimeDataDir ? 2 : 1, database: expectedDatabase, updatedAt: '', ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { status: 'running', url: 'http://127.0.0.1:8082', }, spacetime: { status: 'running', url: 'http://127.0.0.1:3101', }, ...(includeBgfilterWorker ? { 'bgfilter-worker': { status: 'running', url: 'http://127.0.0.1:8083', }, } : {}), }, }; } async function waitForFile(path: string, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (existsSync(path)) { return; } await new Promise((resolveWait) => setTimeout(resolveWait, 25)); } throw new Error(`等待测试进程标记超时: ${path}`); } describe('AI 游戏创作配套后端复用门禁', () => { test('旧状态缺少专用 data dir 时拒绝复用同名健康后端', () => { const targets = resolveBackendTargetsFromState(backendState(), { requireAgcBackend: true, expectedDatabase, expectedSpacetimeDataDir: expectedDataDir, }); expect(targets.hasMatchingDatabase).toBe(true); expect(targets.hasMatchingDataDir).toBe(false); expect(targets.hasMatchingBackend).toBe(false); expect(targets.apiUrl).toBe(''); expect(targets.spacetimeUrl).toBe(''); }); test('只有数据库名和专用 data dir 都匹配时才允许复用', () => { const wrongDir = resolveBackendTargetsFromState( backendState(resolve('server-rs/.spacetimedb/local/data')), { requireAgcBackend: true, expectedDatabase, expectedSpacetimeDataDir: expectedDataDir, }, ); const matching = resolveBackendTargetsFromState( backendState(expectedDataDir), { requireAgcBackend: true, expectedDatabase, expectedSpacetimeDataDir: expectedDataDir, }, ); expect(wrongDir.hasMatchingBackend).toBe(false); expect(matching.hasMatchingBackend).toBe(true); expect(matching.apiUrl).toBe('http://127.0.0.1:8082'); expect(matching.spacetimeUrl).toBe('http://127.0.0.1:3101'); expect(matching.bgfilterWorkerUrl).toBe('http://127.0.0.1:8083'); }); test('worker 缺失或未 ready 时不允许复用后端', async () => { const isReady = vi.fn(async (_url: string) => true); await expect( isBackendReady({ state: backendState(expectedDataDir, false), isReady, verifyOwnership: ownedBackend, }), ).resolves.toBe(false); expect(isReady).not.toHaveBeenCalled(); isReady.mockImplementation(async (url) => url.endsWith('/readyz')); await expect( isBackendReady({ state: backendState(expectedDataDir), isReady, verifyOwnership: ownedBackend, }), ).resolves.toBe(false); expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz'); isReady.mockImplementation(async () => true); await expect( isBackendReady({ state: backendState(expectedDataDir), isReady, verifyOwnership: ownedBackend, }), ).resolves.toBe(true); }); test('后端服务失败时返回具体失败服务,避免外层无限等待', () => { const state = backendState(expectedDataDir); state.services['bgfilter-worker'].status = 'failed'; state.services['bgfilter-worker'].exitCode = 1; state.services['bgfilter-worker'].signal = null; expect(readBackendServiceFailure(state)).toEqual({ serviceName: 'bgfilter-worker', failure: 'code=1', }); }); test('不匹配的旧状态失败记录不会阻断当前后端启动', () => { const state = backendState(resolve('server-rs/.spacetimedb/other/data')); state.services['bgfilter-worker'].status = 'failed'; state.services['bgfilter-worker'].exitCode = 1; expect(readBackendServiceFailure(state)).toBeNull(); }); test('等待后端时立即传播状态文件中的服务失败', async () => { const initialState = backendState(expectedDataDir); initialState.updatedAt = '2026-09-04T08:00:00.000Z'; const state = backendState(expectedDataDir); state.updatedAt = '2026-09-04T08:00:01.000Z'; state.services['bgfilter-worker'].status = 'failed'; state.services['bgfilter-worker'].exitCode = 98; const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null, }); let readCount = 0; await expect( waitForBackendReady(child, 100, { checkBackendReady: async () => false, readState: () => (readCount++ === 0 ? initialState : state), }), ).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98'); }); test('端口上的后端不属于当前工作树时拒绝复用', async () => { const isReady = vi.fn(async () => true); const onOwnershipRejected = vi.fn(); await expect( isBackendReady({ state: backendState(expectedDataDir), isReady, verifyOwnership: async () => ({ ok: false, reason: 'api-server-owner-mismatch', apiOwner: { processId: 4321, name: 'api-server.exe' }, }), onOwnershipRejected, }), ).resolves.toBe(false); expect(onOwnershipRejected).toHaveBeenCalledWith( expect.objectContaining({ reason: 'api-server-owner-mismatch' }), ); expect(isReady).not.toHaveBeenCalled(); }); test('归属探测不可用时退化为旧行为而不是让本地启动失败', async () => { const isReady = vi.fn(async () => true); await expect( isBackendReady({ state: backendState(expectedDataDir), isReady, verifyOwnership: async () => ({ ok: true, reason: 'owner-probe-unavailable', owners: new Map(), }), }), ).resolves.toBe(true); expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz'); }); }); describe('AI 游戏创作配套后端归属校验', () => { const urls = { apiUrl: 'http://127.0.0.1:8082', spacetimeUrl: 'http://127.0.0.1:3101', bgfilterWorkerUrl: 'http://127.0.0.1:8083', }; function ownerMap({ apiExe = expectedExePath, dataDir = expectedDataDir, } = {}) { return new Map([ [ 8082, { port: 8082, processId: 11, name: 'api-server.exe', executablePath: apiExe, commandLine: null, }, ], [ 8083, { port: 8083, processId: 12, name: 'api-server.exe', executablePath: expectedExePath, commandLine: null, }, ], [ 3101, { port: 3101, processId: 13, name: 'spacetimedb-standalone.exe', executablePath: null, commandLine: `spacetimedb-standalone.exe start --data-dir ${dataDir}`, }, ], ]); } test('api-server 可执行文件来自其它工作树时判定为不归属', () => { const result = verifyAgcBackendOwnership({ ...urls, platform: 'win32', expectedExePath, expectedDataDir, readPortOwners: () => ownerMap({ apiExe: resolve( '.worktrees/other/server-rs/target/debug/api-server.exe', ), }), }); expect(result.ok).toBe(false); expect(result.reason).toBe('api-server-owner-mismatch'); }); test('SpacetimeDB 使用其它 data dir 时判定为不归属', () => { const result = verifyAgcBackendOwnership({ ...urls, platform: 'win32', expectedExePath, expectedDataDir, readPortOwners: () => ownerMap({ dataDir: resolve('server-rs/.spacetimedb/local/data') }), }); expect(result.ok).toBe(false); expect(result.reason).toBe('spacetime-owner-mismatch'); }); test('可执行文件与 data dir 都匹配时允许复用', () => { const result = verifyAgcBackendOwnership({ ...urls, platform: 'win32', expectedExePath, expectedDataDir, readPortOwners: () => ownerMap(), }); expect(result).toMatchObject({ ok: true, reason: 'owned' }); }); test('归属探测不可用时不阻断本地启动', () => { const result = verifyAgcBackendOwnership({ ...urls, platform: 'win32', expectedExePath, expectedDataDir, readPortOwners: () => null, }); expect(result).toMatchObject({ ok: true, reason: 'owner-probe-unavailable', }); }); test('非 Windows 平台保持原有复用行为', () => { const result = verifyAgcBackendOwnership({ ...urls, platform: 'linux' }); expect(result).toMatchObject({ ok: true, reason: 'platform-unsupported' }); }); test('可执行文件路径与 data dir 归属判定忽略大小写和 \\\\?\\ 前缀', () => { expect( isWorktreeApiServerOwner( { processId: 1, executablePath: `\\\\?\\${expectedExePath.toUpperCase()}`, }, { expectedExePath }, ), ).toBe(true); expect( isWorktreeSpacetimeOwner( { processId: 2, name: 'spacetimedb-standalone.exe', commandLine: `start --data-dir ${expectedDataDir.toUpperCase()}`, }, { expectedDataDir }, ), ).toBe(true); expect( isWorktreeSpacetimeOwner( { processId: 3, name: 'node.exe', commandLine: expectedDataDir }, { expectedDataDir }, ), ).toBe(false); }); test('端口监听进程探测解析 PowerShell 输出', () => { const spawnImpl = vi.fn(() => ({ status: 0, error: null, stdout: JSON.stringify([ { port: 8082, processId: 4321, name: 'api-server.exe', executablePath: expectedExePath, commandLine: null, }, ]), })); const owners = readWindowsPortOwnerIdentities([8082, 0], { spawnImpl, env: {}, }); expect(owners?.get(8082)).toMatchObject({ processId: 4321 }); expect(spawnImpl).toHaveBeenCalledWith( 'powershell.exe', expect.any(Array), expect.objectContaining({ env: { GENARRATIVE_QUERY_PORTS: '8082' } }), ); }); test('探测失败时返回 null 以触发退化分支', () => { expect( readWindowsPortOwnerIdentities([8082], { spawnImpl: () => ({ status: 1, error: null, stdout: '' }), env: {}, }), ).toBeNull(); expect(readWindowsPortOwnerIdentities([], { env: {} })).toBeNull(); }); test('归属日志包含 pid 与进程标识', () => { expect( formatOwnerLabel({ processId: 4321, executablePath: 'C:\\a\\api-server.exe', }), ).toBe('pid=4321 C:\\a\\api-server.exe'); expect(formatOwnerLabel(null)).toBe('未知进程'); }); }); describe('AI 游戏创作启动子进程生命周期', () => { const posixTest = process.platform === 'win32' ? test.skip : test; test('Linux 进程组只剩僵尸进程时视为已经停止', () => { const procStats = new Map([ ['/proc/101/stat', '101 (node worker) Z 1 700 700 0'], ['/proc/102/stat', '102 (other worker) S 1 701 701 0'], ]); const readLinuxGroupAlive = (processGroupId: number) => readLinuxProcessGroupAlive(processGroupId, { readdirImpl: () => ['101', '102', 'not-a-pid'], readFileImpl: (path: string) => { const stat = procStats.get(path); if (!stat) { throw new Error('missing proc stat fixture'); } return stat; }, }); const killImpl = vi.fn(); expect(readLinuxGroupAlive(700)).toBe(false); expect(readLinuxGroupAlive(701)).toBe(true); expect( isProcessGroupAlive(700, { platform: 'linux', killImpl, readLinuxGroupAlive, }), ).toBe(false); expect(killImpl).toHaveBeenCalledWith(-700, 0); }); posixTest('npm 不可解析时进入受控 error 结果而不是未处理事件', async () => { const child = spawnChild('genarrative-command-that-does-not-exist', [], { cwd: process.cwd(), }); const failure = await waitForChildTermination(child); expect(failure.type).toBe('error'); expect(failure.error).toMatchObject({ code: 'ENOENT' }); }); posixTest('leader 退出后仍按保留的 PGID 清理后代进程', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'agc-process-group-')); const readyPath = join(tempDir, 'descendant-ready'); const stoppedPath = join(tempDir, 'descendant-stopped'); const descendantSource = ` const { writeFileSync } = require('node:fs'); const [readyPath, stoppedPath] = process.argv.slice(1); process.on('SIGTERM', () => { writeFileSync(stoppedPath, 'stopped'); process.exit(0); }); writeFileSync(readyPath, 'ready'); setInterval(() => {}, 1000); `; const leaderSource = ` const { spawn } = require('node:child_process'); const [readyPath, stoppedPath, descendantSource] = process.argv.slice(1); const descendant = spawn( process.execPath, ['-e', descendantSource, readyPath, stoppedPath], { stdio: 'ignore' }, ); descendant.unref(); process.exit(42); `; let child; try { child = spawnChild( process.execPath, ['-e', leaderSource, readyPath, stoppedPath, descendantSource], { cwd: process.cwd() }, ); const failure = await waitForChildTermination(child); expect(failure).toMatchObject({ type: 'exit', code: 42 }); await waitForFile(readyPath); stopChild(child); await waitForFile(stoppedPath); } finally { if (Number.isInteger(child?.pid)) { try { process.kill(-child.pid, 'SIGKILL'); } catch { // 测试后代已经退出。 } } rmSync(tempDir, { recursive: true, force: true }); } }); test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => { const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null, kill: vi.fn(), }); const onBackendChild = vi.fn(); const waitUntilReady = vi.fn(async (receivedChild) => { expect(receivedChild).toBe(child); expect(onBackendChild).toHaveBeenCalledWith(child); throw new Error('等待配套后端和数据库启动超时'); }); await expect( ensureBackend({ checkBackendReady: async () => false, spawnBackend: () => child, onBackendChild, waitUntilReady, }), ).rejects.toThrow('等待配套后端和数据库启动超时'); expect(child.kill).toHaveBeenCalledWith('SIGTERM'); }); test('Windows 通过 taskkill 收束 Tauri CLI 进程树', async () => { const child = Object.assign(new EventEmitter(), { pid: 4821, exitCode: 1, signalCode: null, kill: vi.fn(), }); const taskkillImpl = vi.fn(async () => ({ timedOut: false, code: 0, error: null, })); const result = await terminateChildTree(child, { platform: 'win32', taskkillImpl, }); expect(taskkillImpl).toHaveBeenCalledWith(4821); expect(result).toMatchObject({ stopped: true, forced: true }); }); test('Windows taskkill 固定携带 PID、整树和强制参数', async () => { const taskkill = Object.assign(new EventEmitter(), { kill: vi.fn(), }); const spawnImpl = vi.fn(() => { queueMicrotask(() => taskkill.emit('exit', 0)); return taskkill; }); await expect( runWindowsTaskkill(4821, { spawnImpl, timeoutMs: 100 }), ).resolves.toMatchObject({ timedOut: false, code: 0, error: null }); expect(spawnImpl).toHaveBeenCalledWith( 'taskkill.exe', ['/PID', '4821', '/T', '/F'], expect.objectContaining({ shell: false, windowsHide: true }), ); }); }); describe('AI 游戏创作动态端口启动前预检', () => { const endpoint = { host: '127.0.0.1', port: 10005, url: 'http://127.0.0.1:10005/', markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json', portRange: { start: 10000, end: 10099, label: '10000-10099' }, }; const agcHtml = { statusCode: 200, body: '陶泥儿', }; test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => { await expect( preflightExistingVite({ endpoint, readServer: async () => agcHtml, portListening: async () => true, readMarker: async () => ({ schemaVersion: 1, app: 'ai-game-creator-shell', apiTarget: 'http://127.0.0.1:10001', }), }), ).rejects.toThrow( 'API target http://127.0.0.1:10001. Its owning worktree cannot be proven', ); }); test('marker target 看似匹配时仍拒绝复用无法证明归属的 Vite', async () => { await expect( preflightExistingVite({ endpoint, readServer: async () => agcHtml, portListening: async () => true, readMarker: async () => ({ schemaVersion: 1, app: 'ai-game-creator-shell', apiTarget: 'http://127.0.0.1:10004', }), }), ).rejects.toThrow('Its owning worktree cannot be proven'); }); test('HTTP 探测无响应但端口已监听时失败关闭', async () => { await expect( preflightExistingVite({ endpoint, readServer: async () => null, portListening: async () => true, }), ).rejects.toThrow('non-HTTP or unrecognized server'); }); test('当前动态端口未监听时允许继续启动', async () => { await expect( preflightExistingVite({ endpoint, readServer: async () => null, portListening: async () => false, }), ).resolves.toEqual({ status: 'available', apiTarget: '' }); }); });