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 { isProcessGroupAlive, spawnChild, terminateChildTree, } from '../scripts/start-dev-stack.mjs'; import { buildTauriArguments, runTauriDev as runTauriDevImpl, } 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 () => {}, ...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('普通 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'); }, 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', '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 }); } }); });