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[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; }) => { 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, ); } }); });