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

710 lines
22 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 } from 'node:path';
import { describe, expect, test, vi } from 'vitest';
import {
readCargoTarget,
resolveEditorFeatures as resolveEditorFeaturesImpl,
} from '../scripts/cargo-features.mjs';
import {
isProcessGroupAlive,
spawnChild,
terminateChildTree,
} from '../scripts/start-dev-stack.mjs';
import {
buildTauriArguments,
buildTauriDevProcessEnv,
prepareBundledResourcesBeforeTauri as prepareBundledResourcesBeforeTauriImpl,
runTauriDev as runTauriDevRaw,
withDevCargoFeatures,
} from '../scripts/start-tauri-dev.mjs';
// scripts/*.mjs 没有 JSDoc:TS 只能按默认值推导签名,可选回调参数被推成具体实现、
// argv 默认值被推成 never[],与测试注入的 mock / 参数结构不兼容。下面按运行时真实契约
// 重新声明局部签名并重绑定导入实现,只做类型层校正,调用方式与运行时行为完全不变。
type EndpointPortRange = { start: number; end: number; label: string };
type DevEndpoint = {
host: string;
port: number;
url: string;
markerUrl: string;
portRange: EndpointPortRange | null;
};
type ResolveEditorFeaturesOptions = {
argv?: string[];
target?: string;
env?: Record<string, string | undefined>;
};
// 同上:函数的 argv 默认值 [] 被 JS 推断成 never[],无法接收测试传入的 string[]。
const resolveEditorFeatures = resolveEditorFeaturesImpl as unknown as (
options?: ResolveEditorFeaturesOptions,
) => string[];
type PrepareBundledResourcesOptions = {
target: string;
features: Iterable<string>;
};
type PrepareBundledResourcesBeforeTauriOptions = {
prepare?: (options: PrepareBundledResourcesOptions) => unknown[];
log?: (message: string) => void;
hostTarget?: string | null;
};
// 同上:prepare 在 JS 推断里被收窄为 prepareBundledResources 的完整可选参数签名。
const prepareBundledResourcesBeforeTauri =
prepareBundledResourcesBeforeTauriImpl as unknown as (
features: Iterable<string>,
argv?: string[],
options?: PrepareBundledResourcesBeforeTauriOptions,
) => void;
type ChildProcessLike = {
pid?: number;
exitCode?: number | null;
signalCode?: string | null;
};
type RunTauriDevOptions = {
resolveDevEndpoint?: (options?: {
strictConfigured?: boolean;
}) => DevEndpoint | Promise<DevEndpoint>;
preflight?: (options: {
endpoint: DevEndpoint;
}) => unknown | Promise<unknown>;
prepareFrontend?: (
endpoint: DevEndpoint,
options: {
signal: AbortSignal;
onChild: (child: ChildProcessLike) => void;
},
) => unknown | Promise<unknown>;
spawnCli?: (
argv: string[],
options: { env: Record<string, string | undefined> },
) => ChildProcessLike;
waitForCli?: (child: ChildProcessLike) => unknown | Promise<unknown>;
terminateTree?: (child: ChildProcessLike) => unknown | Promise<unknown>;
prepareResources?: (...args: unknown[]) => void;
};
// 同上:resolveDevEndpoint / preflight / spawnCli / terminateTree 在 JS 推断里被收窄为
// 具体实现的返回结构,测试注入的 mock 不可直接赋值。
const runTauriDevImpl = runTauriDevRaw as unknown as (
argv?: string[],
options?: RunTauriDevOptions,
) => Promise<number>;
const testEndpoint = {
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 resolveTestEndpoint = async () => testEndpoint;
const runTauriDev = (
argv: string[],
options: Parameters<typeof runTauriDevImpl>[1],
) =>
runTauriDevImpl(argv, {
prepareFrontend: async () => {},
// 随包资源准备会读取真实上游包与仓库插件工作区;需要断言的用例自行注入。
prepareResources: () => {},
...options,
});
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 游戏创作 Tauri dev 启动参数', () => {
test('开发构建默认带上 Cocos 编辑器 feature', () => {
expect(
withDevCargoFeatures(['--no-watch'], ['cocos-editor-execute']),
).toEqual(['--features=cocos-editor-execute', '--no-watch']);
expect(withDevCargoFeatures(['--no-watch'], [])).toEqual(['--no-watch']);
expect(
withDevCargoFeatures(
['--features', 'custom-feature'],
['cocos-editor-execute'],
),
).toEqual(['--features', 'custom-feature']);
});
test('普通 dev 参数原样交给 Tauri CLI', () => {
expect(buildTauriArguments(['--no-watch'], testEndpoint.url)).toEqual([
'dev',
'--no-watch',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
]);
});
test('动态 devUrl 配置位于用户 Tauri 配置之后且不越过参数分隔符', () => {
expect(
buildTauriArguments(
['--config', 'custom.json', '--', '--', '--example-app-arg'],
testEndpoint.url,
),
).toEqual([
'dev',
'--config',
'custom.json',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
'--',
'--',
'--example-app-arg',
]);
});
test('单个启动器分隔符后的参数进入应用而不是 Cargo', () => {
expect(
buildTauriArguments(
['--', '--config-dir', 'C:\\temp\\agc-dev-config'],
testEndpoint.url,
),
).toEqual([
'dev',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
'--',
'--',
'--config-dir',
'C:\\temp\\agc-dev-config',
]);
});
});
describe('AI 游戏创作 Tauri dev 生命周期', () => {
test('前端就绪前不启动 Tauri,准备失败时清理自有服务', async () => {
const spawnCli = vi.fn();
const frontend = { pid: 1234 };
const terminateTree = vi.fn(async () => ({ stopped: true }));
await expect(
runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
prepareFrontend: async (_, { onChild }) => {
onChild(frontend);
expect(spawnCli).not.toHaveBeenCalled();
throw new Error('backend failed');
},
spawnCli,
terminateTree,
}),
).rejects.toThrow('backend failed');
expect(spawnCli).not.toHaveBeenCalled();
expect(terminateTree).toHaveBeenCalledWith(frontend);
});
test('动态端口预检失败时不启动 Tauri CLI', async () => {
const spawnCli = vi.fn();
await expect(
runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {
throw new Error('stale AGC Vite');
},
spawnCli,
}),
).rejects.toThrow('stale AGC Vite');
expect(spawnCli).not.toHaveBeenCalled();
});
test('预检先于 CLI 启动且 CLI 退出后始终清理进程树', async () => {
const order: string[] = [];
const child = Object.assign(new EventEmitter(), {
pid: 1234,
exitCode: 1,
signalCode: null,
kill: vi.fn(),
});
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {
order.push('preflight');
},
prepareFrontend: async () => {
order.push('frontend-ready');
},
prepareResources: (features) => {
expect(Array.isArray(features)).toBe(true);
order.push('resources');
},
spawnCli: () => {
order.push('spawn');
return child;
},
waitForCli: async () => {
order.push('exit');
return { type: 'exit', code: 1, signal: null };
},
terminateTree: async (receivedChild) => {
expect(receivedChild).toBe(child);
order.push('cleanup');
return { stopped: true, forced: false };
},
});
expect(result).toBe(1);
expect(order).toEqual([
'preflight',
'resources',
'frontend-ready',
'spawn',
'exit',
'cleanup',
]);
});
const posixTest = process.platform === 'win32' ? test.skip : test;
posixTest('Tauri CLI leader 先退出后仍收束同 PGID 的客户端后代', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-tree-'));
const readyPath = join(tempDir, 'client-ready');
const stoppedPath = join(tempDir, 'client-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 { existsSync } = require('node:fs');
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();
const timer = setInterval(() => {
if (existsSync(readyPath)) {
clearInterval(timer);
process.exit(42);
}
}, 10);
`;
let cliChild: ReturnType<typeof spawnChild> | undefined;
try {
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
spawnCli: () => {
cliChild = spawnChild(
process.execPath,
['-e', leaderSource, readyPath, stoppedPath, descendantSource],
{ cwd: process.cwd() },
);
return cliChild;
},
});
expect(result).toBe(42);
await waitForFile(stoppedPath);
} finally {
const cliPid = cliChild?.pid;
if (Number.isInteger(cliPid)) {
try {
process.kill(-cliPid!, 'SIGKILL');
} catch {
// 进程组已经由启动器收束。
}
}
rmSync(tempDir, { recursive: true, force: true });
}
});
posixTest('客户端后代忽略 TERM 时在有界宽限后升级 KILL', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-force-tree-'));
const readyPath = join(tempDir, 'client-ready');
const descendantSource = `
const { writeFileSync } = require('node:fs');
const [readyPath] = process.argv.slice(1);
process.on('SIGTERM', () => {});
writeFileSync(readyPath, 'ready');
setInterval(() => {}, 1000);
`;
const leaderSource = `
const { existsSync } = require('node:fs');
const { spawn } = require('node:child_process');
const [readyPath, descendantSource] = process.argv.slice(1);
const descendant = spawn(
process.execPath,
['-e', descendantSource, readyPath],
{ stdio: 'ignore' },
);
descendant.unref();
const timer = setInterval(() => {
if (existsSync(readyPath)) {
clearInterval(timer);
process.exit(42);
}
}, 10);
`;
let cliChild: ReturnType<typeof spawnChild> | undefined;
try {
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
spawnCli: () => {
cliChild = spawnChild(
process.execPath,
['-e', leaderSource, readyPath, descendantSource],
{ cwd: process.cwd() },
);
return cliChild;
},
terminateTree: (child) =>
terminateChildTree(child, {
gracefulTimeoutMs: 50,
forceTimeoutMs: 2000,
}),
});
expect(result).toBe(42);
expect(isProcessGroupAlive(cliChild!.pid)).toBe(false);
} finally {
const cliPid = cliChild?.pid;
if (Number.isInteger(cliPid)) {
try {
process.kill(-cliPid!, 'SIGKILL');
} catch {
// 进程组已经由启动器强制收束。
}
}
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe('AI 游戏创作 Tauri dev 进程环境', () => {
const posixTest = process.platform === 'win32' ? test.skip : test;
// 本机 `~/.cargo/config.toml` 或仓库级 Cargo 配置里的 sccache wrapper 只有在环境变量
// 非空时才会被覆盖;这里必须显式写入要交给 Tauri Cargo 的 wrapper 决策结果。
posixTest('本地 dev 不把 sccache 交给 Tauri Cargo', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const env = buildTauriDevProcessEnv(testEndpoint, {
RUSTC_WRAPPER: 'sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
});
expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/usr/bin/env');
} finally {
warn.mockRestore();
}
});
posixTest('未显式配置 wrapper 时清空两个变量', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const env = buildTauriDevProcessEnv(testEndpoint, {
CARGO_TERM_COLOR: 'never',
});
expect(env.RUSTC_WRAPPER).toBe('');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
expect(env.CARGO_TERM_COLOR).toBe('never');
} finally {
warn.mockRestore();
}
});
posixTest('保留显式自定义 wrapper 且不改写调用方 env', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const input = { RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' };
const env = buildTauriDevProcessEnv(testEndpoint, input);
expect(env.RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper');
expect(env.GENARRATIVE_AGC_VITE_PORT).toBe(String(testEndpoint.port));
expect(input).toEqual({ RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' });
} finally {
warn.mockRestore();
}
});
});
describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => {
const windowsFeatures = [
'cocos-editor-execute',
'unity-editor-execute',
'godot-editor-execute',
];
// buildTauriArguments 注入的动态 devUrl 配置:最终交给 Tauri 的参数里长这样。
const devConfig =
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}';
test('显式 --target 决定 staging 目标,未指定或属于应用参数时回落宿主目标', () => {
const calls: [string, string[]][] = [];
const hostTarget = 'aarch64-apple-darwin';
const prepare = (options: {
target: string;
features: Iterable<string>;
}) => {
calls.push([options.target, [...options.features]]);
return [];
};
for (const argv of [
['dev', '--config', devConfig],
['dev', '--target', 'x86_64-apple-darwin', '--config', devConfig],
['dev', '--target=x86_64-pc-windows-msvc', '--config', devConfig],
// 启动器把 `--` 之后的参数交给应用(补成第二段分隔符):不能拿它改随包资源目标。
[
'dev',
'--config',
devConfig,
'--',
'--',
'--target=x86_64-pc-windows-msvc',
],
]) {
prepareBundledResourcesBeforeTauri(windowsFeatures, argv, {
hostTarget,
prepare,
log: () => {},
});
}
expect(calls.map(([target]) => target)).toEqual([
hostTarget,
'x86_64-apple-darwin',
'x86_64-pc-windows-msvc',
hostTarget,
]);
expect(calls[0]![1]).toEqual(windowsFeatures);
});
test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => {
const prepare = vi.fn();
const log = vi.fn();
prepareBundledResourcesBeforeTauri(
windowsFeatures,
['dev', '--config', devConfig],
{ hostTarget: null, prepare, log },
);
expect(prepare).not.toHaveBeenCalled();
expect(log.mock.calls.flat().join('\n')).toContain('跳过随包资源准备');
});
async function runCapturingDev(argv: string[]) {
const prepared: unknown[][] = [];
const spawned: string[][] = [];
const child = Object.assign(new EventEmitter(), {
pid: 1234,
exitCode: 0,
signalCode: null,
kill: vi.fn(),
});
const result = await runTauriDev(argv, {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
prepareResources: (...args: unknown[]) => {
prepared.push(args);
},
spawnCli: (args: string[]) => {
spawned.push(args);
return child;
},
waitForCli: async () => ({ type: 'exit', code: 0, signal: null }),
terminateTree: async () => ({ stopped: true, forced: false }),
});
return { result, prepared, spawned };
}
test('显式 -f 优先于环境变量,并同时决定随包资源 feature 与 cargo 参数', async () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures');
try {
const { result, prepared, spawned } = await runCapturingDev([
'-f',
'custom-feature',
]);
expect(result).toBe(0);
expect(prepared.map(([features]) => features)).toEqual([
['custom-feature'],
]);
expect(spawned[0]!.slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']);
expect(spawned[0]!).not.toContain(
`--features=${windowsFeatures.join(',')}`,
);
// 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。
expect(prepared[0]![1]).toEqual(spawned[0]!);
} finally {
vi.unstubAllEnvs();
}
});
test('显式 --target 同时进入随包资源准备与 Tauri 参数', async () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
try {
const { result, prepared, spawned } = await runCapturingDev([
'--target',
'x86_64-apple-darwin',
]);
expect(result).toBe(0);
expect(prepared.map(([features]) => features)).toEqual([[]]);
expect(prepared[0]![1]).toEqual([
'dev',
'--target',
'x86_64-apple-darwin',
'--config',
devConfig,
]);
expect(spawned[0]!).toEqual(prepared[0]![1]);
} finally {
vi.unstubAllEnvs();
}
});
test('启动器 `--` 之后的应用参数不参与 staging feature 解析', async () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
try {
const { result, prepared, spawned } = await runCapturingDev([
'--',
'--features=app-tool',
]);
expect(result).toBe(0);
expect(prepared.map(([features]) => features)).toEqual([[]]);
expect(spawned[0]!).toEqual([
'dev',
'--config',
devConfig,
'--',
'--',
'--features=app-tool',
]);
expect(prepared[0]![1]).toEqual(spawned[0]!);
} finally {
vi.unstubAllEnvs();
}
});
test('空串 AGC_DEV_CARGO_FEATURES 关闭默认 feature,staging 与 cargo 都不带 feature', async () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
try {
const { result, prepared, spawned } = await runCapturingDev([]);
expect(result).toBe(0);
expect(prepared.map(([features]) => features)).toEqual([[]]);
expect(
spawned[0]!.some((argument) => argument.startsWith('--features')),
).toBe(false);
} finally {
vi.unstubAllEnvs();
}
});
test('环境变量只在不与显式 CLI 冲突时生效', () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures');
try {
expect(
resolveEditorFeatures({
argv: ['dev', '--config', devConfig],
target: 'win32',
}),
).toEqual(['template-library-fixtures']);
expect(
resolveEditorFeatures({
argv: ['dev', '-f', 'cli-feature', '--config', devConfig],
target: 'win32',
}),
).toEqual(['cli-feature']);
} finally {
vi.unstubAllEnvs();
}
});
});
describe('AI 游戏创作 Tauri dev 目标与 feature 解析', () => {
test('目标只从构建参数区解析,取值缺失、取到选项或重复都失败关闭', () => {
expect(readCargoTarget([])).toBeUndefined();
expect(readCargoTarget(['--no-watch'])).toBeUndefined();
expect(readCargoTarget(['-t', 'x86_64-apple-darwin'])).toBe(
'x86_64-apple-darwin',
);
expect(readCargoTarget(['--target=x86_64-apple-darwin'])).toBe(
'x86_64-apple-darwin',
);
// runner 参数由 Tauri 追加给 cargo:仍然算构建目标。
expect(
readCargoTarget(['build', '--', '--target=aarch64-apple-darwin']),
).toBe('aarch64-apple-darwin');
// 第二个 `--` 之后是应用参数。
expect(
readCargoTarget(['dev', '--', '--', '--target=aarch64-apple-darwin']),
).toBeUndefined();
expect(() => readCargoTarget(['--target'])).toThrow('缺少有效目标');
expect(() => readCargoTarget(['--target', '--no-watch'])).toThrow(
'缺少有效目标',
);
expect(() => readCargoTarget(['--target', 'a', '-t', 'b'])).toThrow(
'不能重复指定',
);
});
test('feature 解析覆盖 -f 拼写、合并多个 flag 并止步于应用参数', () => {
const cases: [string[], string[]][] = [
[
['-f', 'a,b'],
['a', 'b'],
],
[['-fa'], ['a']],
[['--features=explicit'], ['explicit']],
// Cargo 会合并多个 feature flag:staging 必须拿到同一集合。
[
['--features', 'a', '-f', 'b'],
['a', 'b'],
],
// 显式给空集合即关闭默认,而不是回落默认值。
[['--features='], []],
// 第二个 `--` 之后是应用参数。
[
['dev', '--config', '{}', '--', '--', '-f', 'app-feature'],
[
'cocos-editor-execute',
'unity-editor-execute',
'godot-editor-execute',
],
],
];
for (const [argv, expected] of cases) {
expect(resolveEditorFeatures({ argv, target: 'win32', env: {} })).toEqual(
expected,
);
}
});
});