Files
Genarrative/scripts/e2e-stack.mjs
T

189 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 一次性隔离 e2e 栈:用**专用端口 + 专用数据库 + 临时 data-dir**拉起 dev.mjs,跑完把栈停掉、临时目录删掉。
//
// 为什么需要它:e2e 脚本会造夹具,跑在用户正在用的开发栈上会把用户的库灌脏(2026-10-07 盘点:
// `genarrative-game-creator-dev` 里有 92 条夹具)。隔离栈与用户的栈**不共享数据库、不共享
// data-dir、端口也不重叠**,因此可以随便造。
//
// 用法(等价 npm script:`npm run e2e:run -- <suite.mjs>` / `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 <path> 起栈就绪后顺序跑一个或多个(可重复)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);
}