Files
Genarrative/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts
T
suzmii 0bfc5768cc 修复AGC跨平台资源构建目标
让Linux no-bundle smoke不再误用Windows随包资源目标。

统一dev与release的Cargo目标和feature解析,补充跨平台回归测试。

同步随包资源staging技术方案中的实际构建目标约定。
2026-09-30 20:24:23 +08:00

628 lines
19 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,
} from '../scripts/cargo-features.mjs';
import {
isProcessGroupAlive,
spawnChild,
terminateChildTree,
} from '../scripts/start-dev-stack.mjs';
import {
buildTauriArguments,
buildTauriDevProcessEnv,
prepareBundledResourcesBeforeTauri,
runTauriDev as runTauriDevImpl,
withDevCargoFeatures,
} from '../scripts/start-tauri-dev.mjs';
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;
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 {
if (Number.isInteger(cliChild?.pid)) {
try {
process.kill(-cliChild.pid, '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;
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 {
if (Number.isInteger(cliChild?.pid)) {
try {
process.kill(-cliChild.pid, '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,
);
}
});
});