// 一次性隔离 e2e 栈:用**专用端口 + 专用数据库 + 临时 data-dir**拉起 dev.mjs,跑完把栈停掉、临时目录删掉。 // // 为什么需要它:e2e 脚本会造夹具,跑在用户正在用的开发栈上会把用户的库灌脏(2026-10-07 盘点: // `genarrative-game-creator-dev` 里有 92 条夹具)。隔离栈与用户的栈**不共享数据库、不共享 // data-dir、端口也不重叠**,因此可以随便造。 // // 用法(等价 npm script:`npm run e2e:run -- ` / `npm run e2e:stack`): // node scripts/e2e-stack.mjs --suite scripts/check-game-distribution-lineage-e2e.mjs // node scripts/e2e-stack.mjs # 只起栈(前台长驻,Ctrl-C 结束) // 选项: // --suite 起栈就绪后顺序跑一个或多个(可重复)suite,跑完自动停栈 // --stack-only 只起栈(前台),不跑 suite // --keep-data 结束后保留临时 data-dir(默认删除) // --keep-stack 跑完 suite 也不停栈(供人工继续看) // // 端口/库名与 `scripts/e2e-target.mjs` 的 `E2E_ISOLATED_STACK` 一致(单一事实来源)。 import { spawn } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { E2E_ISOLATED_STACK } from './e2e-target.mjs'; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', ); const argv = process.argv.slice(2); const readFlagValues = (flag) => { const values = []; for (let index = 0; index < argv.length; index += 1) { if (argv[index] === flag && argv[index + 1]) values.push(argv[index + 1]); } return values; }; const suites = readFlagValues('--suite'); const stackOnly = argv.includes('--stack-only') || suites.length === 0; const keepData = argv.includes('--keep-data'); const keepStack = argv.includes('--keep-stack'); const dataDir = mkdtempSync(path.join(tmpdir(), 'genarrative-e2e-spacetime-')); console.log(`[e2e-stack] 隔离栈数据目录 ${dataDir}`); const devArgs = [ 'scripts/dev.mjs', '--database', E2E_ISOLATED_STACK.database, '--spacetime-data-dir', dataDir, '--api-port', String(E2E_ISOLATED_STACK.apiPort), '--bgfilter-worker-port', String(E2E_ISOLATED_STACK.bgfilterWorkerPort), '--spacetime-port', String(E2E_ISOLATED_STACK.spacetimePort), '--admin-web-port', String(E2E_ISOLATED_STACK.adminWebPort), '--web-port', String(E2E_ISOLATED_STACK.webPort), '--no-interactive', ]; const stack = spawn(process.execPath, devArgs, { cwd: repoRoot, stdio: 'inherit', env: { ...process.env, // **关键**:隔离栈把自己的 dev-stack 标记写到临时目录,绝不覆盖仓库根的 `.app/dev-stack.json`, // 否则用户正在跑的那套 dev 栈会以为自己的标记被替换而自行退出(2026-10-07 实测踩到)。 GENARRATIVE_DEV_STACK_STATE_PATH: path.join(dataDir, 'dev-stack.json'), GENARRATIVE_ADMIN_USERNAME: process.env.GENARRATIVE_ADMIN_USERNAME ?? process.env.E2E_ADMIN_USER ?? 'admin', GENARRATIVE_ADMIN_PASSWORD: process.env.GENARRATIVE_ADMIN_PASSWORD ?? process.env.E2E_ADMIN_PASSWORD ?? '123456', }, }); let stopping = false; const stopStack = async () => { if (stopping || keepStack) return; stopping = true; await new Promise((resolve) => { if (stack.exitCode !== null || stack.signalCode !== null) { resolve(); return; } const timer = setTimeout(() => { stack.kill('SIGKILL'); resolve(); }, 15_000); stack.once('exit', () => { clearTimeout(timer); resolve(); }); stack.kill('SIGTERM'); }); }; const cleanupDataDir = () => { if (keepData) { console.log(`[e2e-stack] 按 --keep-data 保留 ${dataDir}`); return; } try { rmSync(dataDir, { recursive: true, force: true }); console.log(`[e2e-stack] 已删除临时数据目录 ${dataDir}`); } catch (error) { console.log(`[e2e-stack] 删除临时目录失败(可手工清理):${error.message}`); } }; async function waitForReady(timeoutMs = 300_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (stack.exitCode !== null || stack.signalCode !== null) { throw new Error( `dev 栈提前退出:code=${stack.exitCode} signal=${stack.signalCode}`, ); } try { const response = await fetch( `http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort}/healthz`, { signal: AbortSignal.timeout(2_000) }, ); if (response.status === 200) return; } catch { // 未就绪:继续等 } await new Promise((resolve) => setTimeout(resolve, 1_000)); } throw new Error('等待隔离栈 api-server /healthz 超时'); } async function runSuite(scriptPath) { console.log(`[e2e-stack] 运行 ${scriptPath}`); const child = spawn(process.execPath, [scriptPath], { cwd: repoRoot, stdio: 'inherit', env: { ...process.env, E2E_API_BASE: `http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort}`, E2E_WEB_BASE: `http://127.0.0.1:${E2E_ISOLATED_STACK.webPort}`, }, }); const code = await new Promise((resolve) => child.once('exit', resolve)); return code ?? 1; } for (const signal of ['SIGINT', 'SIGTERM']) { process.on(signal, () => { void (async () => { await stopStack(); cleanupDataDir(); process.exit(130); })(); }); } try { await waitForReady(); console.log( `[e2e-stack] 隔离栈就绪:api=http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort} web=http://127.0.0.1:${E2E_ISOLATED_STACK.webPort} 库=${E2E_ISOLATED_STACK.database}`, ); if (stackOnly) { console.log( '[e2e-stack] 只起栈模式:另一个终端里用 E2E_API_BASE/E2E_WEB_BASE 指向上面的地址跑脚本;Ctrl-C 结束。', ); } else { let exitCode = 0; for (const suite of suites) { const code = await runSuite(suite); if (code !== 0) exitCode = code; } await stopStack(); cleanupDataDir(); process.exit(exitCode); } } catch (error) { console.error(`[e2e-stack] ${error.message}`); await stopStack(); cleanupDataDir(); process.exit(1); }