876529e668
- 删除 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 的窗口与命令守卫
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,
|
||
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,
|
||
),
|
||
);
|
||
});
|
||
});
|