1e186369c9
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
193 lines
6.6 KiB
TypeScript
193 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,
|
||
createProjectChatRuntimeHarness,
|
||
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 chatHarness = createProjectChatRuntimeHarness({
|
||
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 chatHarness.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,
|
||
previewSource: 'user',
|
||
}),
|
||
);
|
||
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: 'planning-agent-v2',
|
||
});
|
||
|
||
await waitFor(() =>
|
||
expect(invokeCounts.get('get_local_game_manifest') ?? 0).toBeGreaterThan(
|
||
manifestReadsBefore,
|
||
),
|
||
);
|
||
});
|
||
});
|