189 lines
6.1 KiB
JavaScript
189 lines
6.1 KiB
JavaScript
// 一次性隔离 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);
|
||
}
|