Files
Genarrative/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx
T
suzmii 6618207b5b 修复运行模块切换版本报 listeners[eventId].handlerId:事件订阅改为自建登记 + 幂等注销
- 新增 apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts:AGC 唯一的事件订阅入口。真实 WebView 内用 plugin:event|listen + transformCallback 自建登记,注销时先 unregisterCallback(handlerId)(callbacks.delete,幂等、缺条目也不抛)再发 plugin:event|unlisten,并对重复注销去重,注销失败只在 console.warn 显式记录;非原生环境沿用注入的 event.listen(两参调用形状不变),未接桥接时返回空操作。tauri 2.11 的注销脚本会先读注册表条目再摘回调,而条目由注册 eval 异步写入,与 IPC 返回无序,这一层自建登记正好绕开该竞态(上游 tauri-apps/tauri#15799 / #15800,2.12 起脚本自带判空,升级后可删掉 internals 分支)。
- App.tsx:5 处事件订阅(game-creator-direct-turn-update / agent-progress / agent-runtime-update / planning-session-v2-stream / manifest-invalidated)与角色 Agent 流式回复监听改用订阅入口,守卫由 window.__TAURI__?.event?.listen 换成 canSubscribeTauriEvents()。
- features/app-shell/useDeveloperAgentPanel.ts:Agent Runtime 与角色 Agent 流式回复两处订阅改用订阅入口。
- services/errorReportingBridge.ts:error-report-updated 订阅改用订阅入口,不再直接依赖库内 listen。
- components/AppUpdateNotice.tsx:更新下载进度订阅改用订阅入口。
- components/WindowChrome.tsx:窗口尺寸监听不再走 nativeWindow.onResized,改为订阅 tauri://resize(限定当前窗口),避开库内注销竞态。
- 新增 tests/tauriEventFake.ts:与 tauri 2.11.3 等价的 Tauri 事件替身(注册表条目由注册 eval 异步写入、库内注销脚本读缺失条目即抛并留痕、全局桥 event.listen 按库内实现返回会读条目的注销函数)。
- 新增 tests/tauriEventSubscription.test.ts(7 例):注册 eval 未落地就注销、重复注销只摘一次、注册落地后能投递且注销后不再投递、真实 WebView 不走库内注销脚本、无 internals 时回落注入桥接、无桥接时空操作。
- 新增 tests/runVersionSwitchEventSubscription.test.tsx(2 例):真实 launcher + 运行模块连续切换两次版本(断言 start_local_game_preview 被调用)、卸载后后端订阅与 JS 回调都不泄漏、切换版本后清单失效事件仍能送达并重读清单。
- docs/project-memory/shared-memory/pitfalls.md:记录该竞态的成因、触发面、处理取舍与变异验证结论。
2026-09-12 17:23:22 +08:00

192 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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<string, number>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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,
),
);
});
});