Files
Genarrative/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts
T
kdletters 2bdee990a2 AGC 开发态补齐后台 Web 启动与端口汇总,端口归属探测从 WMI 换成 netstat
- dev-port.mjs 新增 resolveAgcAdminWebEndpoint:后台 Web 端口按 Linux 用户端口段 start + 3、非 Linux 优先 3102 解析,ADMIN_WEB_PORT 可显式指定,并把已解析的 AGC Vite 端口列为保留端口
- start-dev-stack.mjs 随 AGC 一起拉起 apps/admin-web(AGC_DEV_ADMIN_WEB=0 关闭):与 AGC Vite 一样由启动器直接持有并纳入信号与退出收束,不走会整体重写 .app/dev-stack.json 的 dev:admin-web
- start-dev-stack.mjs 新增启动汇总行,一次给出前端、后端、后台、数据库与 bgfilter-worker 的实际地址,端口漂移后以该行为准
- start-dev-stack.mjs 端口归属探测改用 netstat -ano 取端口到 PID、.NET Process 读可执行文件路径,仅在核对 SpacetimeDB --data-dir 归属时按 PID 取命令行并做 5 分钟 TTL 缓存:本机实测单轮探测由约 43 秒降到 0.37 秒,agc:serve 的 starting backend stack 到 backend ready 由约 80 秒降到 16.7 秒
- 后台 Web 端口解析或启动失败只告警,不阻断也不连带停止 AGC 客户端与配套后端
- tests/dev-port.test.ts、tests/start-dev-stack.test.ts 补充用例覆盖后台 Web 端口解析与严格占用、失败软化、启动汇总、netstat 探测脚本与命令行缓存失效
- docs/【开发运维】与 docs/technical 同步 AGC 开发态启动口径,pitfalls.md 记录 WMI 慢速探测的实测数据、netstat 改法与 PID 取最后一列的易错点
2026-09-14 21:02:08 +08:00

903 lines
27 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 { fileURLToPath } from 'node:url';
import { describe, expect, test, vi } from 'vitest';
import {
ensureAdminWeb,
ensureBackend,
formatOwnerLabel,
formatStartupSummary,
isBackendReady,
isProcessGroupAlive,
isWorktreeApiServerOwner,
isWorktreeSpacetimeOwner,
preflightExistingVite,
readAdminWebEnabled,
readBackendServiceFailure,
readLinuxProcessGroupAlive,
readWindowsPortOwnerIdentities,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
startAdminWeb,
stopChild,
terminateChildTree,
verifyAgcBackendOwnership,
waitForBackendReady,
waitForChildTermination,
} from '../scripts/start-dev-stack.mjs';
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) {
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((..._args: unknown[]) => ({
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 {
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');
});
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(() => ({ 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 已退出'),
);
});
});