Files
Genarrative/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts
T
menghao 4494153104
Project CI / Repository checks (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Successful in 4m18s
Project CI / Native shell tests (pull_request) Successful in 12m18s
Project CI / Frontend tests (pull_request) Successful in 1m25s
修复AI游戏创作跨平台运行竞态
保存独立进程组标识并在启动失败或退出时清理后代进程

兼容macOS下带前置选项的npm脚本命令校验

修复进程会话输出上限投影、测试夹具与锁污染问题

收敛Agent运行时lane释放、并发锁初始化与异步审计时序竞态

补充Node与Native Shell回归测试并同步排障文档
2026-07-22 20:27:43 +08:00

179 lines
5.5 KiB
TypeScript

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');
});
});