Files
Genarrative/apps/ai-game-creator-shell/tests/sessionPreview.test.ts
T
suzmii 531392e506 退出路径统一收尾本地预览,陈旧预览记录不再留给下次进入
- preview.rs:新增 stop_local_game_preview_on_exit——退出时若 registry 里确有 running 预览,就用 stop_local_game_preview_for_root 把它真正停掉并写 stopped(顺带落 preview.log 与 trace 收尾)。预览服务器是进程内线程,进程一退 URL 就永久失效;不在这里收尾,.agent/manifest.json 会一直写着 running。
- main.rs:handle_game_creator_gui_run_event 的 RunEvent::Exit 分支先做预览收尾,再做既有的 Agent server / runner 关闭;失败按 `preview.gui_exit.stop_failed` 记日志,不阻断退出。
- 语义变更(用户已确认接受):关窗不再保留预览——退出即停止预览并把记录写成 stopped,下次进项目一律落到资源管理。与"进入项目时按 registry 活体核对"配套:退出路径覆盖正常关闭,强杀 / 断电那类不走退出路径的情况由进入时的核对自愈。不引入 stale DTO 字段(判定仍要活体探测,且会把状态扩到跨端契约与全部消费方)。
- tests/project.rs:新增 gui_exit_stops_the_preview_and_clears_the_stale_record——退出收尾后 registry 归零且 manifest 记录为 stopped。
- tests/sessionPreview.test.ts:补两条 Rust 侧结构性守卫(本仓既有做法:直接解析源码)。① RunEvent::Exit 分支必须出现 preview::stop_local_game_preview_on_exit——这条接线没有别的行为测试覆盖(注册全局 registry 的用例会互相打架);② 同项目重启预览的替换分支里,收尾必须排在"旧预览是否属于另一个项目"的判定之后。变异验证:把 main.rs 的退出收尾摘掉 → 守卫①立即红灯(已实测)。
- decision-log.md:新增 2026-09-11 决策条目,逐条写下背景(偶发进运行界面的成因链:进程内预览线程 + 随机临时端口 + 退出零清理 + 进门不核对 + 同项目重启覆盖刚写的 running)、A/B/C 三项决策、关窗不再保留预览这条语义变更及"为什么两者都做而不加 stale 字段"、不做什么与验证方式。同文件被 prettier 补了几处标题前空行(格式规范化,无内容变化)。
- 验证:cargo check --locked --all-targets exit 0;Rust 定向用例 restarting_preview_for_the_same_project / replacing_preview_for_another_project / stopping_without_a_live_preview / gui_exit_stops_the_preview 各 1 passed;AGC 子集 1198 passed / 4 skipped / 0 failed;共享组件 1385 passed;typecheck exit 0;编码 4378 文件;git diff --check 干净。
2026-09-11 12:36:08 +08:00

201 lines
6.5 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import type { GameCreationAppPreviewState } from '../../../packages/shared/src/contracts/gameCreationApp';
import { resolveSessionPreviewOnProjectOpen } from '../src/features/app-shell/sessionPreview';
const projectPath = '/tmp/session-preview-project';
function createInvoke(
responses: Record<string, unknown>,
calls: Array<[string, Record<string, unknown> | undefined]> = [],
) {
return vi.fn(async (command: string, args?: Record<string, unknown>) => {
calls.push([command, args]);
if (!(command in responses)) {
throw new Error(`unexpected invoke ${command}`);
}
return responses[command];
});
}
describe('进入项目时的预览活体核验', () => {
it('registry 里没有活体时不给会话预览,并把陈旧的 running 记录对齐成 stopped', async () => {
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
const invoke = createInvoke(
{
get_local_game_preview_status: {
status: 'stopped',
url: null,
port: null,
root: null,
},
stop_local_game_preview: {
status: 'stopped',
url: null,
port: null,
root: null,
},
},
calls,
);
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
recordedPreview: {
status: 'running',
url: 'http://127.0.0.1:4173/',
port: 4173,
},
});
// 没有活体预览:本次会话不带预览(工作台因此不会自动进运行视图)。
expect(resolution.sessionPreview).toBeNull();
// 落盘记录被判陈旧,会话 manifest 立刻按真相投影。
expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' });
expect(calls).toEqual([
['get_local_game_preview_status', { projectPath }],
['stop_local_game_preview', { projectPath }],
]);
});
it('registry 确实在跑时只认活体,且不写落盘记录', async () => {
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
const invoke = createInvoke(
{
get_local_game_preview_status: {
status: 'running',
url: 'http://127.0.0.1:5210/',
port: 5210,
root: projectPath,
},
},
calls,
);
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
// 落盘记录落后于真相(例如同项目重启后记录没跟上)也不影响:活体说了算。
recordedPreview: { status: 'stopped' },
});
expect(resolution.sessionPreview).toEqual({
status: 'running',
url: 'http://127.0.0.1:5210/',
port: 5210,
});
expect(resolution.manifestPreviewPatch).toBeNull();
expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
});
it('registry 不可读时按没有在跑处理,不把落盘记录当活体', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'get_local_game_preview_status') {
throw new Error('需要在陶泥儿客户端内运行');
}
return { status: 'stopped', url: null, port: null, root: null };
});
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
recordedPreview: {
status: 'running',
url: 'http://127.0.0.1:4173/',
port: 4173,
},
});
expect(resolution.sessionPreview).toBeNull();
expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' });
});
it('记录本来就不是 running 时只读活体,不发多余的停止命令', async () => {
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
const invoke = createInvoke(
{
get_local_game_preview_status: {
status: 'stopped',
url: null,
port: null,
root: null,
},
},
calls,
);
const stoppedRecord: GameCreationAppPreviewState = { status: 'stopped' };
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
recordedPreview: stoppedRecord,
});
expect(resolution).toEqual({
sessionPreview: null,
manifestPreviewPatch: null,
});
expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
});
it('没有 invoke(浏览器里跑)时不做任何请求', async () => {
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke: null,
projectPath,
recordedPreview: {
status: 'running',
url: 'http://127.0.0.1:4173/',
port: 4173,
},
});
expect(resolution).toEqual({
sessionPreview: null,
manifestPreviewPatch: null,
});
});
});
describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => {
const mainSource = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src-tauri/src/main.rs'),
'utf8',
);
const previewSource = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src-tauri/src/preview.rs',
),
'utf8',
);
it('客户端退出的 RunEvent::Exit 分支必须接上预览收尾', () => {
// 退出路径是"记录与真相不再分叉"的主要保证:进程内监听线程随进程消失,而落盘记录
// 会留在 running 上。这条接线没有别的行为测试覆盖(注册全局 registry 的用例会互相
// 打架),所以按本仓既有做法直接钉源码结构。
const exitBranch =
/matches!\(event, tauri::RunEvent::Exit\)\s*\{([\s\S]*?)\n {8}\}/u.exec(
mainSource,
)?.[1] ?? '';
expect(exitBranch).toContain('preview::stop_local_game_preview_on_exit');
});
it('同一个项目重启预览时,旧预览身份不得收尾新写的 running 记录', () => {
// record_replaced_preview_stop 只允许在旧预览属于别的项目时调用:同项目重启时它会把
// 刚写进去的 running 覆盖成 stopped。
const replaceBranch =
/let \(preview, previous_preview\) = registry\.set_running\(preview, stop\);([\s\S]*?)\n {4}\}/u.exec(
previewSource,
)?.[1] ?? '';
expect(replaceBranch).toContain('ensure_preview_belongs_to_project');
expect(replaceBranch).toContain('record_replaced_preview_stop');
expect(
replaceBranch.indexOf('ensure_preview_belongs_to_project'),
).toBeLessThan(replaceBranch.indexOf('record_replaced_preview_stop'));
});
});