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, resolveBackendTargetsFromState, spawnChild, stopChild, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; const expectedDatabase = 'genarrative-game-creator-dev'; const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); function backendState(spacetimeDataDir?: string) { return { schemaVersion: spacetimeDataDir ? 2 : 1, database: expectedDatabase, ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { status: 'running', url: 'http://127.0.0.1:8082', }, spacetime: { status: 'running', url: 'http://127.0.0.1:3101', }, }, }; } 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'); }); }); describe('AI 游戏创作启动子进程生命周期', () => { const posixTest = process.platform === 'win32' ? test.skip : test; 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'); }); });