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, calls: Array<[string, Record | undefined]> = [], ) { return vi.fn(async (command: string, args?: Record) => { 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 | 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 | 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 calls: Array<[string, Record | undefined]> = []; const invoke = vi.fn( async (command: string, args?: Record) => { calls.push([command, args]); 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).toEqual({ sessionPreview: null, manifestPreviewPatch: null, }); // 关键:不许发 stop_local_game_preview —— 它按项目停的是活体预览, // 读失败时发出去会把真正在跑的预览一起停掉。 expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]); }); it('记录本来就不是 running 时只读活体,不发多余的停止命令', async () => { const calls: Array<[string, Record | 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')); }); });