Files
suzmii 4f3f0f24ff 接入AGC后台模型目录与对话模型选择
新增后台 AGC 模型目录、别名、启停和默认项管理

客户端设置页恢复原状,对话框右下角按别名选择模型

服务端按稳定模型标识映射并校验实际模型白名单

修复 AGC 配套后端端口漂移、启动等待和 SpacetimeDB 版本检查

补充迁移、文档、启动与模型选择测试
2026-09-05 19:10:49 +08:00

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 });
}
});
});