// @vitest-environment jsdom /** * 运行模块「切换游戏版本」的事件订阅回归用例。 * * 背景:tauri 2.11 的 lib 注销脚本会先读 webview 侧监听注册表条目 * (`listeners[eventId].handlerId`),而条目由注册 eval 异步写入;AGC 在 * `React.StrictMode` 下挂载即注销,运行模块切换版本又会让工作区重渲染、 * 重载预览并触发运行时事件,因此这条路径最容易撞上该竞态并抛出 * `undefined is not an object (evaluating 'listeners[eventId].handlerId')`。 * * 这里用与 tauri 2.11 等价的替身(见 `tauriEventFake.ts`)驱动真实的 * `WorkspaceLauncher` + 运行模块 + 版本切换,断言: * 1. 切换版本(含连续切换两次)期间不会走库内那条会读注册表条目的注销路径; * 2. 组件卸载后后端订阅不泄漏(旧写法会因抛在 invoke 之前而泄漏); * 3. 新登记路径仍然真的能把事件送到 App(清单失效 → 重读清单)。 */ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { cleanup, createGameCreationAppManifest, createGameCreationAppSeedTasks, createProjectSupervisorRuntimeHarness, fireEvent, pickProjectFromLauncher, renderLauncherProjectsAt, screen, waitFor, within, } from './appSurface/harness'; import { createTauriEventFake, type TauriEventFake } from './tauriEventFake'; const PROJECT_PATH = '/tmp/run-module-version-switch'; let fake: TauriEventFake | null = null; afterEach(() => { cleanup(); fake?.restore(); fake = null; vi.restoreAllMocks(); }); function createVersionedManifest(): GameCreationAppManifest { const manifest = createGameCreationAppManifest( 'run-module-version-switch', '运行模块版本切换', ); // 运行模块的可用性与版本入口无关,这里用一条已完成原型任务把它打开。 manifest.tasks = createGameCreationAppSeedTasks().map((task) => task.id === 'code-prototype' ? { ...task, status: 'completed' as const } : task, ); // createdAt 是 Unix 秒(写入侧 manifest.rs 用 unix_timestamp())。 manifest.versions = [ { versionId: 'version-root', parentVersionId: null, projectRevision: 3, resourceBindings: [], createdReason: 'initial', createdAt: 1_788_075_047, }, { versionId: 'version-child', parentVersionId: 'version-root', projectRevision: 4, resourceBindings: [], createdReason: 'agent-revision', createdAt: 1_788_075_104, }, ]; return manifest; } function installProjectWithRunModule(manifest: GameCreationAppManifest) { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath: PROJECT_PATH, initialSessionExists: false, }); const invokeCounts = new Map(); const invoke = vi.fn( async (command: string, args?: Record) => { invokeCounts.set(command, (invokeCounts.get(command) ?? 0) + 1); if (command === 'inspect_local_project_directory') { return { projectPath: PROJECT_PATH, exists: true, isDirectory: true, isGameCreatorProject: true, projectName: '运行模块版本切换', recentRunStatus: null, recentRunStopReason: null, }; } if (command === 'get_local_game_manifest') { return manifest; } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } if (command === 'start_local_game_preview') { return { url: 'http://127.0.0.1:43210/game/index.html', port: 43210, root: PROJECT_PATH, }; } return supervisorHarness.invoke(command, args); }, ); fake = createTauriEventFake({ invoke }); fake.install(); renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(PROJECT_PATH); return { invoke, invokeCounts }; } async function openRunModuleAndPickVersion(label: RegExp) { fireEvent.click(screen.getByRole('tab', { name: '运行' })); fireEvent.click(await screen.findByLabelText(/^当前版本:/)); const menu = await screen.findByRole('listbox', { name: '切换游戏版本' }); fireEvent.click(within(menu).getByRole('option', { name: label })); } describe('运行模块切换版本时的事件订阅', () => { it('切换版本期间不读监听注册表条目,卸载后也不泄漏后端订阅', async () => { const { invoke } = installProjectWithRunModule(createVersionedManifest()); await screen.findByLabelText('陶泥儿项目对话'); const activeFake = fake as TauriEventFake; expect(activeFake.listeners.length).toBeGreaterThan(0); // 连续切换两次(用户口径:反复切换两次以上不再报错)。 await openRunModuleAndPickVersion(/初始版本/); await waitFor(() => expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath: PROJECT_PATH, }), ); await openRunModuleAndPickVersion(/智能体修订/); // 切换与重渲染全程都不该碰库内那条「先读注册表条目」的注销脚本。 expect(activeFake.racyUnregisterCalls).toBe(0); expect(activeFake.racyUnregisterViolations).toEqual([]); // 卸载:此时注册 eval 还没落地(故意不 flush),旧写法会在这里抛错并泄漏。 cleanup(); expect(activeFake.racyUnregisterViolations).toEqual([]); expect(activeFake.listeners).toEqual([]); expect(activeFake.jsCallbackCount()).toBe(0); }); it('切换版本后仍能收到清单失效事件并重读清单', async () => { const { invokeCounts } = installProjectWithRunModule( createVersionedManifest(), ); await screen.findByLabelText('陶泥儿项目对话'); const activeFake = fake as TauriEventFake; // 让注册 eval 落地:之后 `emit` 才会真正投递到 AGC 的 handler。 activeFake.flushRegistrationEvals(); await openRunModuleAndPickVersion(/初始版本/); await waitFor(() => expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan( 0, ), ); const manifestReadsBefore = invokeCounts.get('get_local_game_manifest') ?? 0; activeFake.emit('game-creator-manifest-invalidated', { projectPath: PROJECT_PATH, agentId: 'project-supervisor', }); await waitFor(() => expect(invokeCounts.get('get_local_game_manifest') ?? 0).toBeGreaterThan( manifestReadsBefore, ), ); }); });