Files
Genarrative/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx
T
k88936 876529e668 删除 Project Supervisor 前端链路并将项目对话收敛到 DirectProject 与立项策划
- 删除 ProjectSupervisorView、SupervisorChatOnlyView、ProjectWorkspaceChatPane、AgentConversationOverlay、DeveloperProjectPanels、DeveloperRuntimePanels 与 features/agent-runtime/panels.tsx
- 删除 Supervisor 独立调试窗口:windows.rs 的 supervisor_chat_window_url / open_project_supervisor_chat_window、main.rs 的 invoke 注册、?supervisor-chat 与 ?agent-chat 前端入口、developer.json capability 和对应 Rust 用例
- 删除工作台壳的开发者 Agent 面板:DeveloperAgentPanel、useDeveloperAgentPanel、useDeveloperAgentState、developerAgentControls
- App.tsx 删除只服务退役面板的 state/ref/effect/handler(agentConversation*、文件/记忆/资产/画板/预览面板处理、commandLog、llmConfigStatus、editorBaseUrl、回放历史与 trace 面板状态、selectedAgent 等)以及由此产生的空分支,并把 requestRuntimeConfigOpen 接回本地 RuntimeConfigDialog
- 立项策划独立成模块:Design Agent 与 Planning V2 的容器和表现移到 view/project-development/planning/(PlanningChatView、PlanningUserInputCard、GddApprovalCard、DesignAgentSurface、PlanningLaneRuntimeStrip、planningLane、planningSessionV2、planningSessionContract)
- 改名到中性概念:ProjectSupervisorComponentProps→ProjectChatComponentProps、WorkspaceLauncherShellProps.ProjectSupervisor→ProjectChat、ProjectDevelopmentView.supervisor→chat、orchestrationMode→agentDockVisible、initialSupervisorMessageClaims→initialTurnClaims、ProjectManifestSnapshotSource 'supervisor'→'chat'、CSS project-supervisor-*/project-planning-*→project-chat-*
- 删除 PROJECT_SUPERVISOR_AGENT_ID 与 PROJECT_SUPERVISOR_PLAN_SOURCE 常量
- 工作台壳自己订阅 game-creator-manifest-invalidated,用 revision→清单→revision 配对读(source: asset-event)并入 currentProjectContext,且不重新检查项目目录;测试夹具改为按监听器集合广播该事件
- 删除只钉住退役界面的 appSurface 用例(旧调试窗口、开发者面板、Agent 对话浮层、运行历史面板)
- 同步文档:ADR、DirectProject 聊天模块抽离实施计划与里程碑、Provider 推理里程碑、策划会话 RuntimeV2、AI 游戏创作实施计划,并在 decision-log 新增 2026-09-19 条
- 删除随退役面板失去调用方的模块与导出:projectSummaryCommands.ts(1054 行旧 Supervisor 斜杠命令处理器)、AGENT_RUN_HISTORY_* 常量、agent-runtime/model.ts 与 project-summary/agentPresentation.ts 里的死导出,以及 agentRunTrace / memoryCommands / projectCommandPolicy 中无调用方的校验与解析函数
- 更新 scripts/check-config.mjs 与 scripts/check-native-shells.mjs 的窗口与命令守卫
2026-09-19 21:59:44 +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,
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,
}),
);
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,
),
);
});
});