6618207b5b
- 新增 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:记录该竞态的成因、触发面、处理取舍与变异验证结论。
192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
// @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,
|
||
),
|
||
);
|
||
});
|
||
});
|