594063a1e0
- 按 vitest 0.34 的参数元组口径改写 mock 泛型 - 修正 noUncheckedIndexedAccess 下的下标访问 - 补齐与当前类型脱节的测试桩字段 - 按 scripts/*.mjs 的运行时契约补局部类型声明
1116 lines
34 KiB
TypeScript
1116 lines
34 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 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 已退出'),
|
||
);
|
||
});
|
||
});
|