4f3f0f24ff
新增后台 AGC 模型目录、别名、启停和默认项管理 客户端设置页恢复原状,对话框右下角按别名选择模型 服务端按稳定模型标识映射并校验实际模型白名单 修复 AGC 配套后端端口漂移、启动等待和 SpacetimeDB 版本检查 补充迁移、文档、启动与模型选择测试
289 lines
8.5 KiB
TypeScript
289 lines
8.5 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,
|
|
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<typeof runTauriDevImpl>[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 });
|
|
}
|
|
});
|
|
});
|