Files
Genarrative/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts
T
k88936 594063a1e0 测试:修正 AGC 测试文件的类型错误
- 按 vitest 0.34 的参数元组口径改写 mock 泛型
- 修正 noUncheckedIndexedAccess 下的下标访问
- 补齐与当前类型脱节的测试桩字段
- 按 scripts/*.mjs 的运行时契约补局部类型声明
2026-10-03 17:41:30 +08:00

1116 lines
34 KiB
TypeScript
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.
import { EventEmitter } from 'node:events';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, test, vi } from 'vitest';
import {
ensureAdminWeb as ensureAdminWebImpl,
ensureBackend as ensureBackendImpl,
formatOwnerLabel,
formatStartupSummary,
isBackendReady as isBackendReadyImpl,
isProcessGroupAlive,
isWorktreeApiServerOwner,
isWorktreeSpacetimeOwner,
preflightExistingVite as preflightExistingViteImpl,
readAdminWebEnabled,
readBackendServiceFailure,
readLinuxProcessGroupAlive as readLinuxProcessGroupAliveImpl,
readWindowsPortOwnerIdentities as readWindowsPortOwnerIdentitiesImpl,
resolveBackendTargetsFromState,
runWindowsTaskkill as runWindowsTaskkillImpl,
spawnChild,
startAdminWeb as startAdminWebImpl,
stopChild,
terminateChildTree,
verifyAgcBackendOwnership,
waitForBackendReady,
waitForChildTermination,
} from '../scripts/start-dev-stack.mjs';
// scripts/*.mjs 没有 JSDoc:TS 只能按默认值推导签名,可选回调参数被推成具体实现、
// 注入点被推成 null/never[],与测试使用的 mock 结构不兼容。下面按运行时真实契约重新
// 声明局部签名并重绑定导入实现,只做类型层校正,调用方式与运行时行为完全不变。
type EndpointPortRange = { start: number; end: number; label: string };
type PortOwnerIdentity = {
port?: number;
processId?: number;
name?: string | null;
executablePath?: string | null;
commandLine?: string | null;
};
type BackendServiceFixture = {
status: string;
url: string;
repoRoot?: string;
instanceId?: string;
exitCode?: number;
signal?: string | null;
};
type BackendStateFixture = {
schemaVersion: number;
repoRoot?: string;
instanceId?: string;
database: string;
updatedAt: string;
spacetimeDataDir?: string;
services: Record<string, BackendServiceFixture | undefined>;
};
type BackendOwnershipResult = {
ok: boolean;
reason: string;
owners?: Map<number, PortOwnerIdentity>;
apiOwner?: PortOwnerIdentity;
workerOwner?: PortOwnerIdentity;
spacetimeOwner?: PortOwnerIdentity;
};
type IsBackendReadyOptions = {
state?: BackendStateFixture;
isReady?: (url: string) => boolean | Promise<boolean>;
verifyOwnership?: (options?: {
apiUrl?: string;
spacetimeUrl?: string;
bgfilterWorkerUrl?: string;
}) => BackendOwnershipResult | Promise<BackendOwnershipResult>;
onOwnershipRejected?: ((ownership: BackendOwnershipResult) => void) | null;
};
// 导入实现本身是同一份,仅按运行时契约修正推导过窄的签名(JS 默认值导致结构上不可兼容)。
const isBackendReady = isBackendReadyImpl as unknown as (
options?: IsBackendReadyOptions,
) => Promise<boolean>;
type SpawnSyncLike = (
command: string,
args: readonly string[],
options?: { env?: Record<string, string | undefined> },
) => { status: number | null; error?: unknown; stdout: string };
type ReadWindowsPortOwnersOptions = {
spawnImpl?: SpawnSyncLike;
env?: Record<string, string | undefined>;
now?: () => number;
commandLineTtlMs?: number;
commandLineCache?: Map<number, { commandLine: string; at: number }>;
};
// 同上:spawnImpl 在 JS 推断里被收窄为 Node 的 spawnSync 重载,测试注入的同步 mock 不可直接赋值。
const readWindowsPortOwnerIdentities =
readWindowsPortOwnerIdentitiesImpl as unknown as (
ports: number[],
options?: ReadWindowsPortOwnersOptions,
) => Map<number, PortOwnerIdentity> | null;
type ReadLinuxProcessGroupAliveOptions = {
readdirImpl?: (path: string) => string[];
readFileImpl?: (path: string, encoding: string) => string;
};
// 同上:readdirImpl/readFileImpl 在 JS 推断里被收窄为 readdirSync/readFileSync 的重载。
const readLinuxProcessGroupAlive =
readLinuxProcessGroupAliveImpl as unknown as (
processGroupId: number,
options?: ReadLinuxProcessGroupAliveOptions,
) => boolean | null;
type ChildLike = EventEmitter & {
pid?: number;
exitCode?: number | null;
signalCode?: string | null;
kill?: (signal?: string | number) => unknown;
};
type EnsureBackendOptions = {
onBackendChild?: (child: ChildLike) => void;
checkBackendReady?: () => boolean | Promise<boolean>;
resolveTargets?: () => unknown;
spawnBackend?: () => ChildLike;
waitUntilReady?: (child: ChildLike) => unknown | Promise<unknown>;
};
// 同上:spawnBackend 在 JS 推断里被收窄为返回完整 ChildProcessWithoutNullStreams。
const ensureBackend = ensureBackendImpl as unknown as (
options?: EnsureBackendOptions,
) => Promise<{ backendChild: ChildLike | null; targets: unknown }>;
type TaskkillChildLike = EventEmitter & {
kill: (signal?: string | number) => unknown;
};
type RunWindowsTaskkillOptions = {
spawnImpl?: (
command: string,
args: readonly string[],
options: { shell: boolean; stdio: string; windowsHide: boolean },
) => TaskkillChildLike;
timeoutMs?: number;
};
// 同上:spawnImpl 在 JS 推断里被收窄为 Node 的 spawn 重载,测试注入的 EventEmitter mock 不可直接赋值。
const runWindowsTaskkill = runWindowsTaskkillImpl as unknown as (
processId: number,
options?: RunWindowsTaskkillOptions,
) => Promise<{ timedOut: boolean; code: number | null; error: unknown }>;
type PreflightEndpoint = {
host: string;
port: number;
url: string;
markerUrl: string;
portRange: EndpointPortRange | null;
};
type PreflightExistingViteOptions = {
endpoint?: PreflightEndpoint;
readServer?: (
endpoint: PreflightEndpoint,
) => Promise<{ statusCode: number; body: string } | null>;
portListening?: (endpoint: PreflightEndpoint) => Promise<boolean>;
readMarker?: (endpoint: PreflightEndpoint) => Promise<unknown>;
};
// 同上:endpoint 在 JS 推断里被收窄为 portRange: null(createAgcDevEndpoint 的默认值)。
const preflightExistingVite = preflightExistingViteImpl as unknown as (
options?: PreflightExistingViteOptions,
) => Promise<{ status: string; apiTarget: string }>;
type AdminWebEndpoint = {
host: string;
port: number;
basePath?: string;
url: string;
};
type AdminWebChild = { pid?: number };
type StartAdminWebOptions = {
env?: Record<string, string | undefined>;
spawnImpl?: (
command: string,
args: string[],
options: { cwd: string; env: Record<string, string | undefined> },
) => AdminWebChild;
};
// 同上:spawnImpl 在 JS 推断里被收窄为 spawnChild 的完整子进程返回类型。
const startAdminWeb = startAdminWebImpl as unknown as (
apiUrl: string,
endpoint: AdminWebEndpoint,
options?: StartAdminWebOptions,
) => AdminWebChild;
type EnsureAdminWebOptions = {
apiUrl: string;
reservedPorts?: number[];
env?: Record<string, string | undefined>;
enabled?: boolean;
resolveEndpoint?: (options: {
env?: Record<string, string | undefined>;
reservedPorts?: number[];
}) => AdminWebEndpoint | Promise<AdminWebEndpoint>;
spawnAdminWeb?: (
apiUrl: string,
endpoint: AdminWebEndpoint,
options: { env?: Record<string, string | undefined> },
) => AdminWebChild;
waitForExit?: (child: AdminWebChild) => unknown | Promise<unknown>;
warn?: (message: string) => void;
};
// 同上:apiUrl 等无默认值的解构项未被 JS 推断保留,reservedPorts 被推成 never[]。
const ensureAdminWeb = ensureAdminWebImpl as unknown as (
options: EnsureAdminWebOptions,
) => Promise<{
endpoint: AdminWebEndpoint | null;
child: AdminWebChild | null;
}>;
const expectedDatabase = 'genarrative-game-creator-dev';
const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data');
const expectedExePath = resolve('server-rs/target/debug/api-server.exe');
const ownedBackend = async () => ({
ok: true,
reason: 'owned',
owners: new Map(),
});
function backendState(
spacetimeDataDir?: string,
includeBgfilterWorker = true,
): BackendStateFixture {
const instanceId = 'fixture-instance';
const service = (url: string) => ({
status: 'running',
url,
repoRoot: resolve('.'),
instanceId,
});
return {
schemaVersion: spacetimeDataDir ? 2 : 1,
repoRoot: resolve('.'),
instanceId,
database: expectedDatabase,
updatedAt: '',
...(spacetimeDataDir ? { spacetimeDataDir } : {}),
services: {
'api-server': {
...service('http://127.0.0.1:8082'),
},
spacetime: {
...service('http://127.0.0.1:3101'),
},
...(includeBgfilterWorker
? {
'bgfilter-worker': service('http://127.0.0.1:8083'),
}
: {}),
},
};
}
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');
expect(matching.bgfilterWorkerUrl).toBe('http://127.0.0.1:8083');
});
test('旧状态缺少 repoRoot 或 instanceId 时拒绝复用', () => {
const state = backendState(expectedDataDir);
delete state.repoRoot;
delete state.instanceId;
for (const service of Object.values(state.services)) {
if (service) {
delete service.repoRoot;
delete service.instanceId;
}
}
const targets = resolveBackendTargetsFromState(state, {
requireAgcBackend: true,
expectedDatabase,
expectedSpacetimeDataDir: expectedDataDir,
});
expect(targets.hasMatchingDatabase).toBe(true);
expect(targets.hasMatchingDataDir).toBe(true);
expect(targets.hasMatchingRepoRoot).toBe(false);
expect(targets.hasMatchingInstance).toBe(false);
expect(targets.hasMatchingBackend).toBe(false);
expect(targets.apiUrl).toBe('');
});
test('worker 缺失或未 ready 时不允许复用后端', async () => {
const isReady = vi.fn(async (_url: string) => true);
await expect(
isBackendReady({
state: backendState(expectedDataDir, false),
isReady,
verifyOwnership: ownedBackend,
}),
).resolves.toBe(false);
expect(isReady).not.toHaveBeenCalled();
isReady.mockImplementation(async (url) => url.endsWith('/readyz'));
await expect(
isBackendReady({
state: backendState(expectedDataDir),
isReady,
verifyOwnership: ownedBackend,
}),
).resolves.toBe(false);
expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz');
isReady.mockImplementation(async () => true);
await expect(
isBackendReady({
state: backendState(expectedDataDir),
isReady,
verifyOwnership: ownedBackend,
}),
).resolves.toBe(true);
});
test('后端服务失败时返回具体失败服务,避免外层无限等待', () => {
const state = backendState(expectedDataDir);
state.services['bgfilter-worker']!.status = 'failed';
state.services['bgfilter-worker']!.exitCode = 1;
state.services['bgfilter-worker']!.signal = null;
expect(readBackendServiceFailure(state)).toEqual({
serviceName: 'bgfilter-worker',
failure: 'code=1',
});
});
test('不匹配的旧状态失败记录不会阻断当前后端启动', () => {
const state = backendState(resolve('server-rs/.spacetimedb/other/data'));
state.services['bgfilter-worker']!.status = 'failed';
state.services['bgfilter-worker']!.exitCode = 1;
expect(readBackendServiceFailure(state)).toBeNull();
});
test('等待后端时立即传播状态文件中的服务失败', async () => {
const initialState = backendState(expectedDataDir);
initialState.updatedAt = '2026-09-04T08:00:00.000Z';
const state = backendState(expectedDataDir);
state.updatedAt = '2026-09-04T08:00:01.000Z';
state.services['bgfilter-worker']!.status = 'failed';
state.services['bgfilter-worker']!.exitCode = 98;
const child = Object.assign(new EventEmitter(), {
exitCode: null,
signalCode: null,
});
let readCount = 0;
await expect(
waitForBackendReady(child, 100, {
checkBackendReady: async () => false,
readState: () => (readCount++ === 0 ? initialState : state),
}),
).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98');
});
test('端口上的后端不属于当前工作树时拒绝复用', async () => {
const isReady = vi.fn(async () => true);
const onOwnershipRejected = vi.fn();
await expect(
isBackendReady({
state: backendState(expectedDataDir),
isReady,
verifyOwnership: async () => ({
ok: false,
reason: 'api-server-owner-mismatch',
apiOwner: { processId: 4321, name: 'api-server.exe' },
}),
onOwnershipRejected,
}),
).resolves.toBe(false);
expect(onOwnershipRejected).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'api-server-owner-mismatch' }),
);
expect(isReady).not.toHaveBeenCalled();
});
test('归属探测不可用时退化为旧行为而不是让本地启动失败', async () => {
const isReady = vi.fn(async () => true);
await expect(
isBackendReady({
state: backendState(expectedDataDir),
isReady,
verifyOwnership: async () => ({
ok: true,
reason: 'owner-probe-unavailable',
owners: new Map(),
}),
}),
).resolves.toBe(true);
expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz');
});
});
describe('AI 游戏创作配套后端归属校验', () => {
const urls = {
apiUrl: 'http://127.0.0.1:8082',
spacetimeUrl: 'http://127.0.0.1:3101',
bgfilterWorkerUrl: 'http://127.0.0.1:8083',
};
function ownerMap({
apiExe = expectedExePath,
dataDir = expectedDataDir,
} = {}) {
return new Map([
[
8082,
{
port: 8082,
processId: 11,
name: 'api-server.exe',
executablePath: apiExe,
commandLine: null,
},
],
[
8083,
{
port: 8083,
processId: 12,
name: 'api-server.exe',
executablePath: expectedExePath,
commandLine: null,
},
],
[
3101,
{
port: 3101,
processId: 13,
name: 'spacetimedb-standalone.exe',
executablePath: null,
commandLine: `spacetimedb-standalone.exe start --data-dir ${dataDir}`,
},
],
]);
}
test('api-server 可执行文件来自其它工作树时判定为不归属', () => {
const result = verifyAgcBackendOwnership({
...urls,
platform: 'win32',
expectedExePath,
expectedDataDir,
readPortOwners: () =>
ownerMap({
apiExe: resolve(
'.worktrees/other/server-rs/target/debug/api-server.exe',
),
}),
});
expect(result.ok).toBe(false);
expect(result.reason).toBe('api-server-owner-mismatch');
});
test('SpacetimeDB 使用其它 data dir 时判定为不归属', () => {
const result = verifyAgcBackendOwnership({
...urls,
platform: 'win32',
expectedExePath,
expectedDataDir,
readPortOwners: () =>
ownerMap({ dataDir: resolve('server-rs/.spacetimedb/local/data') }),
});
expect(result.ok).toBe(false);
expect(result.reason).toBe('spacetime-owner-mismatch');
});
test('可执行文件与 data dir 都匹配时允许复用', () => {
const result = verifyAgcBackendOwnership({
...urls,
platform: 'win32',
expectedExePath,
expectedDataDir,
readPortOwners: () => ownerMap(),
});
expect(result).toMatchObject({ ok: true, reason: 'owned' });
});
test('归属探测不可用时不阻断本地启动', () => {
const result = verifyAgcBackendOwnership({
...urls,
platform: 'win32',
expectedExePath,
expectedDataDir,
readPortOwners: () => null,
});
expect(result).toMatchObject({
ok: true,
reason: 'owner-probe-unavailable',
});
});
test('非 Windows 平台保持原有复用行为', () => {
const result = verifyAgcBackendOwnership({ ...urls, platform: 'linux' });
expect(result).toMatchObject({ ok: true, reason: 'platform-unsupported' });
});
test('可执行文件路径与 data dir 归属判定忽略大小写和 \\\\?\\ 前缀', () => {
expect(
isWorktreeApiServerOwner(
{
processId: 1,
executablePath: `\\\\?\\${expectedExePath.toUpperCase()}`,
},
{ expectedExePath },
),
).toBe(true);
expect(
isWorktreeSpacetimeOwner(
{
processId: 2,
name: 'spacetimedb-standalone.exe',
commandLine: `start --data-dir ${expectedDataDir.toUpperCase()}`,
},
{ expectedDataDir },
),
).toBe(true);
expect(
isWorktreeSpacetimeOwner(
{ processId: 3, name: 'node.exe', commandLine: expectedDataDir },
{ expectedDataDir },
),
).toBe(false);
});
test('端口监听进程探测解析 PowerShell 输出', () => {
const spawnImpl = vi.fn(() => ({
status: 0,
error: null,
stdout: JSON.stringify([
{
port: 8082,
processId: 4321,
name: 'api-server.exe',
executablePath: expectedExePath,
commandLine: null,
},
]),
}));
const owners = readWindowsPortOwnerIdentities([8082, 0], {
spawnImpl,
env: {},
});
expect(owners?.get(8082)).toMatchObject({ processId: 4321 });
expect(spawnImpl).toHaveBeenCalledWith(
'powershell.exe',
expect.any(Array),
expect.objectContaining({ env: { GENARRATIVE_QUERY_PORTS: '8082' } }),
);
});
test('端口探测改用 netstat 取监听 PID,不再每次调用 WMI 的 Get-NetTCPConnection', () => {
const spawnImpl = vi.fn((_command: string, args: readonly string[]) => ({
status: 0,
error: null,
stdout: '[]',
}));
readWindowsPortOwnerIdentities([8082], { spawnImpl, env: {} });
const probeArgs = spawnImpl.mock.calls[0]![1] ?? [];
const probeScript = String(probeArgs[probeArgs.length - 1] ?? '');
expect(probeScript).toContain('netstat -ano -p tcp');
expect(probeScript).not.toContain('Get-NetTCPConnection');
});
test('命令行按 PID 缓存后随探测请求下发,TTL 过期即失效', () => {
const commandLine =
'spacetimedb-standalone.exe start --data-dir F:\\Projects\\Genarrative\\server-rs\\.spacetimedb\\ai-game-creator\\data';
const spawnImpl = vi.fn((..._args: unknown[]) => ({
status: 0,
error: null,
stdout: JSON.stringify([
{
port: 3102,
processId: 4321,
name: 'spacetimedb-standalone.exe',
executablePath:
'C:\\Users\\kdletters\\AppData\\Local\\SpacetimeDB\\bin\\current\\spacetimedb-standalone.exe',
commandLine,
},
]),
}));
const commandLineCache = new Map<
number,
{ commandLine: string; at: number }
>();
let nowMs = 1_000;
const options = {
spawnImpl,
env: {},
commandLineCache,
now: () => nowMs,
commandLineTtlMs: 60_000,
};
const envAt = (call: number) =>
(spawnImpl.mock.calls[call]![2] as { env: Record<string, string> }).env;
readWindowsPortOwnerIdentities([3102], options);
expect(envAt(0)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' });
expect(commandLineCache.get(4321)).toEqual({ commandLine, at: 1_000 });
nowMs += 30_000;
readWindowsPortOwnerIdentities([3102], options);
expect(envAt(1)).toEqual({
GENARRATIVE_QUERY_PORTS: '3102',
GENARRATIVE_KNOWN_COMMAND_LINES: JSON.stringify({ 4321: commandLine }),
});
nowMs += 61_000;
readWindowsPortOwnerIdentities([3102], options);
expect(envAt(2)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' });
expect(commandLineCache.get(4321)).toEqual({ commandLine, at: nowMs });
});
test('探测失败时返回 null 以触发退化分支', () => {
expect(
readWindowsPortOwnerIdentities([8082], {
spawnImpl: () => ({ status: 1, error: null, stdout: '' }),
env: {},
}),
).toBeNull();
expect(readWindowsPortOwnerIdentities([], { env: {} })).toBeNull();
});
test('归属日志包含 pid 与进程标识', () => {
expect(
formatOwnerLabel({
processId: 4321,
executablePath: 'C:\\a\\api-server.exe',
}),
).toBe('pid=4321 C:\\a\\api-server.exe');
expect(formatOwnerLabel(null)).toBe('未知进程');
});
});
describe('AI 游戏创作启动子进程生命周期', () => {
const posixTest = process.platform === 'win32' ? test.skip : test;
test('Linux 进程组只剩僵尸进程时视为已经停止', () => {
const procStats = new Map([
['/proc/101/stat', '101 (node worker) Z 1 700 700 0'],
['/proc/102/stat', '102 (other worker) S 1 701 701 0'],
]);
const readLinuxGroupAlive = (processGroupId: number) =>
readLinuxProcessGroupAlive(processGroupId, {
readdirImpl: () => ['101', '102', 'not-a-pid'],
readFileImpl: (path: string) => {
const stat = procStats.get(path);
if (!stat) {
throw new Error('missing proc stat fixture');
}
return stat;
},
});
const killImpl = vi.fn();
expect(readLinuxGroupAlive(700)).toBe(false);
expect(readLinuxGroupAlive(701)).toBe(true);
expect(
isProcessGroupAlive(700, {
platform: 'linux',
killImpl,
readLinuxGroupAlive,
}),
).toBe(false);
expect(killImpl).toHaveBeenCalledWith(-700, 0);
});
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 {
const childPid = child?.pid;
if (Number.isInteger(childPid)) {
try {
process.kill(-childPid!, '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');
});
test('Windows 通过 taskkill 收束 Tauri CLI 进程树', async () => {
const child = Object.assign(new EventEmitter(), {
pid: 4821,
exitCode: 1,
signalCode: null,
kill: vi.fn(),
});
const taskkillImpl = vi.fn(async () => ({
timedOut: false,
code: 0,
error: null,
}));
const result = await terminateChildTree(child, {
platform: 'win32',
taskkillImpl,
});
expect(taskkillImpl).toHaveBeenCalledWith(4821);
expect(result).toMatchObject({ stopped: true, forced: true });
});
test('Windows taskkill 固定携带 PID、整树和强制参数', async () => {
const taskkill = Object.assign(new EventEmitter(), {
kill: vi.fn(),
});
const spawnImpl = vi.fn(() => {
queueMicrotask(() => taskkill.emit('exit', 0));
return taskkill;
});
await expect(
runWindowsTaskkill(4821, { spawnImpl, timeoutMs: 100 }),
).resolves.toMatchObject({ timedOut: false, code: 0, error: null });
expect(spawnImpl).toHaveBeenCalledWith(
'taskkill.exe',
['/PID', '4821', '/T', '/F'],
expect.objectContaining({ shell: false, windowsHide: true }),
);
});
});
describe('AI 游戏创作动态端口启动前预检', () => {
const endpoint = {
host: '127.0.0.1',
port: 10005,
url: 'http://127.0.0.1:10005/',
markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json',
portRange: { start: 10000, end: 10099, label: '10000-10099' },
};
const agcHtml = {
statusCode: 200,
body: '<html><title>陶泥儿</title><script src="/src/main.tsx"></script></html>',
};
test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => {
await expect(
preflightExistingVite({
endpoint,
readServer: async () => agcHtml,
portListening: async () => true,
readMarker: async () => ({
schemaVersion: 1,
app: 'ai-game-creator-shell',
apiTarget: 'http://127.0.0.1:10001',
}),
}),
).rejects.toThrow(
'API target http://127.0.0.1:10001. Its owning worktree cannot be proven',
);
});
test('marker target 看似匹配时仍拒绝复用无法证明归属的 Vite', async () => {
await expect(
preflightExistingVite({
endpoint,
readServer: async () => agcHtml,
portListening: async () => true,
readMarker: async () => ({
schemaVersion: 1,
app: 'ai-game-creator-shell',
apiTarget: 'http://127.0.0.1:10004',
}),
}),
).rejects.toThrow('Its owning worktree cannot be proven');
});
test('HTTP 探测无响应但端口已监听时失败关闭', async () => {
await expect(
preflightExistingVite({
endpoint,
readServer: async () => null,
portListening: async () => true,
}),
).rejects.toThrow('non-HTTP or unrecognized server');
});
test('当前动态端口未监听时允许继续启动', async () => {
await expect(
preflightExistingVite({
endpoint,
readServer: async () => null,
portListening: async () => false,
}),
).resolves.toEqual({ status: 'available', apiTarget: '' });
});
});
describe('AGC 开发态后台 Web', () => {
test('默认跟随 AGC 启动,AGC_DEV_ADMIN_WEB=0 时关闭', () => {
expect(readAdminWebEnabled({})).toBe(true);
expect(readAdminWebEnabled({ AGC_DEV_ADMIN_WEB: '1' })).toBe(true);
expect(readAdminWebEnabled({ AGC_DEV_ADMIN_WEB: ' 0 ' })).toBe(false);
});
test('后台 Vite 指向配套后端且严格使用解析后的端口', () => {
const spawnImpl = vi.fn(
(
_command: string,
_args: string[],
_options: { cwd: string; env: Record<string, string | undefined> },
) => ({ pid: 4242 }),
);
startAdminWeb(
'http://127.0.0.1:8084',
{
host: '127.0.0.1',
port: 3103,
basePath: '/admin/',
url: 'http://127.0.0.1:3103/admin/',
},
{ env: { KEEP_ME: 'yes' }, spawnImpl },
);
expect(spawnImpl).toHaveBeenCalledTimes(1);
const [command, args, options] = spawnImpl.mock.calls[0]!;
expect(command).toBe(process.platform === 'win32' ? 'npm.cmd' : 'npm');
expect(args).toEqual([
'--prefix',
'../..',
'exec',
'vite',
'--',
'--host',
'127.0.0.1',
'--port',
'3103',
'--strictPort',
]);
expect(options.cwd).toBe(
resolve(
fileURLToPath(new URL('..', import.meta.url)),
'../../apps/admin-web',
),
);
expect(options.env).toMatchObject({
KEEP_ME: 'yes',
ADMIN_API_TARGET: 'http://127.0.0.1:8084',
GENARRATIVE_API_TARGET: 'http://127.0.0.1:8084',
GENARRATIVE_API_PORT: '8084',
ADMIN_WEB_BASE: '/admin/',
});
});
test('启动汇总一次打印前端、后端、后台、数据库与 worker 实际地址', () => {
expect(
formatStartupSummary({
frontendUrl: 'http://127.0.0.1:3082/',
apiUrl: 'http://127.0.0.1:8084',
adminWebUrl: 'http://127.0.0.1:3103/admin/',
spacetimeUrl: 'http://127.0.0.1:3102',
bgfilterWorkerUrl: 'http://127.0.0.1:8085',
}),
).toBe(
'[ai-game-creator-shell] 启动汇总: 前端 http://127.0.0.1:3082/ | 后端 http://127.0.0.1:8084 | 后台 http://127.0.0.1:3103/admin/ | 数据库 http://127.0.0.1:3102 | bgfilter-worker http://127.0.0.1:8085',
);
});
test('关闭后台 Web 时汇总行不出现占位地址', () => {
const summary = formatStartupSummary({
frontendUrl: 'http://127.0.0.1:3082/',
apiUrl: 'http://127.0.0.1:8084',
adminWebUrl: '',
spacetimeUrl: 'http://127.0.0.1:3102',
bgfilterWorkerUrl: 'http://127.0.0.1:8085',
});
expect(summary).not.toContain('后台');
expect(summary).toBe(
'[ai-game-creator-shell] 启动汇总: 前端 http://127.0.0.1:3082/ | 后端 http://127.0.0.1:8084 | 数据库 http://127.0.0.1:3102 | bgfilter-worker http://127.0.0.1:8085',
);
});
test('AGC_DEV_ADMIN_WEB=0 时既不解析端口也不启动后台 Vite', async () => {
const resolveEndpoint = vi.fn();
const spawnAdminWeb = vi.fn();
const result = await ensureAdminWeb({
apiUrl: 'http://127.0.0.1:8084',
env: { AGC_DEV_ADMIN_WEB: '0' },
resolveEndpoint,
spawnAdminWeb,
});
expect(result).toEqual({ endpoint: null, child: null });
expect(resolveEndpoint).not.toHaveBeenCalled();
expect(spawnAdminWeb).not.toHaveBeenCalled();
});
test('后台端口解析失败只告警,不阻断 AGC 启动', async () => {
const warn = vi.fn();
const spawnAdminWeb = vi.fn();
const result = await ensureAdminWeb({
apiUrl: 'http://127.0.0.1:8084',
env: { ADMIN_WEB_PORT: '80' },
resolveEndpoint: async () => {
throw new Error('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口');
},
spawnAdminWeb,
warn,
});
expect(result).toEqual({ endpoint: null, child: null });
expect(spawnAdminWeb).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('后台 Web 未能启动'),
);
});
test('后台 Web 就绪后保留子进程句柄,退出时只告警不停机', async () => {
const child = { pid: 5150 };
const endpoint = {
host: '127.0.0.1',
port: 3103,
basePath: '/admin/',
url: 'http://127.0.0.1:3103/admin/',
};
const spawnAdminWeb = vi.fn(() => child);
const warn = vi.fn();
let onExit: ((failure: unknown) => void) | null = null;
const result = await ensureAdminWeb({
apiUrl: 'http://127.0.0.1:8084',
env: {},
resolveEndpoint: async ({ reservedPorts }) => {
expect(reservedPorts).toEqual([3082]);
return endpoint;
},
reservedPorts: [3082],
spawnAdminWeb,
waitForExit: (target: unknown) =>
new Promise((resolveExit) => {
expect(target).toBe(child);
onExit = resolveExit;
}),
warn,
});
expect(result).toEqual({ endpoint, child });
expect(spawnAdminWeb).toHaveBeenCalledWith(
'http://127.0.0.1:8084',
endpoint,
{ env: {} },
);
(onExit as unknown as (failure: unknown) => void)({
type: 'exit',
code: 1,
signal: null,
error: null,
});
await Promise.resolve();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('后台 Web 已退出'),
);
});
});