a1b9b24891
closes #314 ## 现象 `npm run agc` 按 Ctrl+C 后有概率残留上个工作树的 `api-server.exe` / SpacetimeDB,切换 worktree 再启动时 AGC 复用旧后端,改过数据库 / schema 的工作树会串库。 ## 根因 1. Windows 下长驻服务都经 Node `shell: true` 的 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 先杀包装层(`0xC000013A`);`dev.mjs` 的 `stopProcess` 见到直接子进程已退出就 return,`taskkill /PID <已退出 PID> /T /F` 也只会失败,深处的 `cargo → api-server.exe` 无人清理。 2. 按根 PID 遍历依赖快照里的父子链,中间层先消失时链断,只能拿到根 PID。 3. 复用判据只看 `.app/dev-stack.json` status 与 `/healthz`、`/readyz`、`/v1/ping`,不校验端口上的进程属于哪个工作树,残留后端照样被判为健康并复用。 ## 改动 - 新增 `scripts/dev-windows-process.mjs`:按根 PID 遍历 + 按身份匹配(`server-rs/target/debug/api-server.exe` 绝对路径、SpacetimeDB `--data-dir`)两条独立清理路径,带 1s 快照缓存避免清理被拖慢。 - `scripts/dev.mjs`:直接子进程已退出时仍按记录 PID 清理后代;退出时按身份兜底清扫本工作树后端(复用他人 standalone 时跳过);启动前清理旧 api-server 保留 `Wait-Process` 语义,避免 `failed to remove file`。 - `apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`:复用前校验端口监听进程归属,无法证明归属就不复用、改为启动本工作树后端并允许端口漂移;信号与 `finally` 各兜底清扫一次;`taskkill` 失败时降级按 PID 遍历;等待就绪时输出归属校验失败原因,避免静默超时。探测不可用时退化为旧行为,不阻断本地启动。 - 测试与文档:新增 `scripts/dev-windows-process.test.ts`、扩充 AGC 复用门禁用例;同步 `docs/project-memory/shared-memory/pitfalls.md` 与本地开发运维文档。 ## 验证 - 伪造 `api-server.exe` 进程:按身份精确命中并杀掉(`matched=[17284] stopped=[17284]`)。 - 3 个真实监听进程下归属判定:`owned` / `api-server-owner-mismatch` / `spacetime-owner-mismatch` 均正确。 - `npx vitest run scripts/dev.test.ts scripts/dev-windows-process.test.ts scripts/dev-stack-port-utils.test.ts apps/ai-game-creator-shell/tests/...`:119 passed(唯一失败为 Windows 文件权限用例,已确认在合并基线 `origin/master` 上同样失败)。 - `node --check`、`eslint --max-warnings 0`、`prettier --check`、`npm run check:encoding`、`git diff --check` 全部通过。 ## 备注 Rust 侧 `api-server` 的 `with_graceful_shutdown` 没有超时上限,是「有概率」的来源之一;本次只在 Node 侧收口,是否给优雅退出加 deadline 可另行评估。 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/315 Co-authored-by: Suzumiya <suzmii@qq.com> Co-committed-by: Suzumiya <suzmii@qq.com>
644 lines
19 KiB
TypeScript
644 lines
19 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,
|
|
formatOwnerLabel,
|
|
isBackendReady,
|
|
isProcessGroupAlive,
|
|
isWorktreeApiServerOwner,
|
|
isWorktreeSpacetimeOwner,
|
|
preflightExistingVite,
|
|
readBackendServiceFailure,
|
|
readLinuxProcessGroupAlive,
|
|
readWindowsPortOwnerIdentities,
|
|
resolveBackendTargetsFromState,
|
|
runWindowsTaskkill,
|
|
spawnChild,
|
|
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) {
|
|
return {
|
|
schemaVersion: spacetimeDataDir ? 2 : 1,
|
|
database: expectedDatabase,
|
|
updatedAt: '',
|
|
...(spacetimeDataDir ? { spacetimeDataDir } : {}),
|
|
services: {
|
|
'api-server': {
|
|
status: 'running',
|
|
url: 'http://127.0.0.1:8082',
|
|
},
|
|
spacetime: {
|
|
status: 'running',
|
|
url: 'http://127.0.0.1:3101',
|
|
},
|
|
...(includeBgfilterWorker
|
|
? {
|
|
'bgfilter-worker': {
|
|
status: 'running',
|
|
url: '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('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('探测失败时返回 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: '' });
|
|
});
|
|
});
|