53e37ea361
新增App Run独立开发profile并隔离端口、数据库、Tauri身份与AppData 常驻资源排列切换并避免布局状态挤压操作入口 显式扩展资源画布横向滚动范围并收敛依赖图可见连线 补充启动器、资源布局、界面回归测试与项目文档
273 lines
7.8 KiB
TypeScript
273 lines
7.8 KiB
TypeScript
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,
|
|
parseLauncherArguments,
|
|
runTauriDev,
|
|
} from '../scripts/start-tauri-dev.mjs';
|
|
|
|
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'])).toEqual(['dev', '--no-watch']);
|
|
});
|
|
|
|
test('app-run 使用独立 Tauri 配置并保留 CLI 参数', () => {
|
|
expect(buildTauriArguments(['--app-run', '--no-watch'])).toEqual([
|
|
'dev',
|
|
'--config',
|
|
'src-tauri/tauri.app-run-dev.conf.json',
|
|
'--no-watch',
|
|
]);
|
|
expect(parseLauncherArguments(['--app-run'])).toEqual({
|
|
appRun: true,
|
|
gameChat: false,
|
|
args: [],
|
|
});
|
|
});
|
|
|
|
test('app-run 与 game-chat 不允许混用', () => {
|
|
expect(() =>
|
|
buildTauriArguments(['--app-run', '--game-chat']),
|
|
).toThrow('app-run profile 不能与 game-chat 入口同时使用');
|
|
});
|
|
|
|
test('game-chat 参数进入应用参数区且保留项目参数', () => {
|
|
expect(
|
|
buildTauriArguments([
|
|
'--game-chat',
|
|
'--project-path',
|
|
'/tmp/example-game',
|
|
]),
|
|
).toEqual([
|
|
'dev',
|
|
'--',
|
|
'--',
|
|
'--game-chat',
|
|
'--project-path',
|
|
'/tmp/example-game',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('AI 游戏创作 Tauri dev 生命周期', () => {
|
|
test('app-run 预检 3081 profile 并把 profile 传给 Tauri 子进程', async () => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
pid: 1234,
|
|
exitCode: 0,
|
|
signalCode: null,
|
|
kill: vi.fn(),
|
|
});
|
|
const preflight = vi.fn(async () => {});
|
|
const spawnCli = vi.fn(() => child);
|
|
|
|
await expect(
|
|
runTauriDev(['--app-run'], {
|
|
preflight,
|
|
spawnCli,
|
|
waitForCli: async () => ({ type: 'exit', code: 0, signal: null }),
|
|
terminateTree: async () => ({ stopped: true, forced: false }),
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(preflight).toHaveBeenCalledWith({
|
|
profile: expect.objectContaining({ name: 'app-run', vitePort: 3081 }),
|
|
});
|
|
expect(spawnCli).toHaveBeenCalledWith(
|
|
[
|
|
'dev',
|
|
'--config',
|
|
'src-tauri/tauri.app-run-dev.conf.json',
|
|
],
|
|
{ profileName: 'app-run' },
|
|
);
|
|
});
|
|
|
|
test('3080 预检失败时不启动 Tauri CLI', async () => {
|
|
const spawnCli = vi.fn();
|
|
|
|
await expect(
|
|
runTauriDev([], {
|
|
preflight: async () => {
|
|
throw new Error('stale 3080');
|
|
},
|
|
spawnCli,
|
|
}),
|
|
).rejects.toThrow('stale 3080');
|
|
|
|
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([], {
|
|
preflight: async () => {
|
|
order.push('preflight');
|
|
},
|
|
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', '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([], {
|
|
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([], {
|
|
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 });
|
|
}
|
|
});
|
|
});
|