Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/harness.ts
T
suzmii bd36587e0a 资源画布交互 1:1 复刻美术画布:点卡选中并浮出工具条,移除资源详情面板与全屏编辑路由
新增 features/resource-canvas:资源卡到美术画布 CanvasLayer 的适配、工具条动作可用性判定、C5 当前版本绑定判定、快速编辑草稿工厂与 AGC 宿主 chrome 样式

资源卡点击改为选中(Shift/Ctrl/Meta 多选),选中后在卡片旁复用 ImageCanvasSelectedLayerToolbarView 浮出工具条

快速编辑复用 ImageCanvasQuickEditPanelView 浮层面板,先调 normalize_local_project_raster_resource 正规化任务产物,再走 derive_local_project_resource(editKind=image-reference) 产出新素材,失败重试复用同一 operationId/idempotencyKey

删除 resourceEditorRoute 全链路(ResourceEditSurface 路由、openResourceEditor、handleResourceCardOpenEditor、cancelResourceEditor、submitResourceEdit)与资源详情面板(focusedResource 状态/引用/浮层 JSX/CSS 断言)

分类与标签入口搬到选中工具条;UI 资源保留「UI 编辑器」入口并从工具条打开

移除 apps/ai-game-creator-shell/scripts/check-config.mjs 中 normalize_local_project_raster_resource 的过渡 allowlist 条目

为共享工具条组件新增 opt-in 的 supportedActions/extraActions(默认 null/undefined,网页端行为不变)

AGC vite-env.d.ts 补 wx/ReactNativeWebView/WeixinJSBridge 跨端全局声明;hostBridge fail 回调补显式 unknown 类型

测试:资源卡查询 helper 由资源详情按钮改为选中按钮,清理详情面板断言,画布空白左键改为框选后平移用例改用中键
2026-09-10 15:19:17 +08:00

1329 lines
40 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.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlanGddStateViewV1 } from '../../src/app/types';
import * as clientApi from '../../src/services/clientApi';
import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog';
import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore';
const nativeClipboardMock = vi.hoisted(() => ({
text: '',
}));
const TEST_LOCAL_PROJECT_PATH = '/tmp/genarrative-ai-game-draft';
const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'createObjectURL',
);
const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'revokeObjectURL',
);
const originalPointerEvent = Object.getOwnPropertyDescriptor(
window,
'PointerEvent',
);
const originalClipboardEvent = Object.getOwnPropertyDescriptor(
window,
'ClipboardEvent',
);
const originalDragEvent = Object.getOwnPropertyDescriptor(window, 'DragEvent');
const originalRangeGetBoundingClientRect = Object.getOwnPropertyDescriptor(
Range.prototype,
'getBoundingClientRect',
);
const originalIntersectionObserver = Object.getOwnPropertyDescriptor(
window,
'IntersectionObserver',
);
const originalResizeObserver = Object.getOwnPropertyDescriptor(
window,
'ResizeObserver',
);
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
constructor(
type: string,
init: MouseEventInit & { pointerId?: number } = {},
) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
}
}
// jsdom 没有实现 DragEvent / ClipboardEvent,而 Lexical 在粘贴与拖放处理里用
// `instanceof DragEvent` / `instanceof ClipboardEvent` 判定事件来源,缺失会直接抛
// ReferenceError,粘贴通路根本进不去编辑器。这里只补最小的构造器,让事件能带上
// `clipboardData` 并被 Lexical 认出来,不模拟真实浏览器语义。
class TestClipboardEvent extends Event {}
class TestDragEvent extends MouseEvent {}
// jsdom 的 Range 也没有 getBoundingClientRectLexical 计算选区矩形时会抛 TypeError。
function emptyRangeRect(): DOMRect {
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
toJSON: () => ({}),
} as DOMRect;
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key) {
return values.get(key) ?? null;
},
key(index) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key) {
values.delete(key);
},
setItem(key, value) {
values.set(key, String(value));
},
};
}
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: createMemoryStorage(),
});
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
readImage: vi.fn(async () => {
throw new Error('no native clipboard image');
}),
readText: vi.fn(async () => nativeClipboardMock.text),
}));
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import {
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import {
App,
AuthenticatedClient,
deriveAgentStatusCards,
WorkspaceLauncher,
} from '../../src/App';
import { projectNameFromPath } from '../../src/features/agent-runtime/model';
import ProjectDevelopmentView from '../../src/view/project-development';
const testAuthUser: AuthUser = {
id: 'user-test',
publicUserCode: 'tn-test',
displayName: '测试用户',
avatarUrl: null,
phoneNumber: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
wechatDisplayName: null,
wechatAccount: null,
};
function renderAppAt(path: string) {
window.history.pushState({}, '', path);
render(React.createElement(App));
const params = new URLSearchParams(window.location.search);
if (params.has('dev') && !params.has('projectPath')) {
const projectPathInput = screen.queryByLabelText('本地项目目录');
if (projectPathInput) {
fireEvent.change(projectPathInput, {
target: { value: TEST_LOCAL_PROJECT_PATH },
});
}
}
}
function renderLauncherAt(
path: string,
initialView: 'home' | 'agent-chat' = 'home',
strictMode = false,
) {
window.history.pushState({}, '', path);
const launcher = React.createElement(WorkspaceLauncher, {
currentUser: testAuthUser,
initialView,
onLogout: vi.fn(),
});
render(
strictMode
? React.createElement(React.StrictMode, null, launcher)
: launcher,
);
}
function renderLauncherAgentChatAt(path: string) {
renderLauncherAt(path, 'agent-chat');
}
function selectDeveloperAgentChatMode(mode: 'run' | 'chat' | 'goal') {
fireEvent.click(
screen.getByRole('button', {
name: mode === 'run' ? '执行' : mode === 'chat' ? '聊天' : '目标',
}),
);
}
function renderLauncherProjectsAt(path: string) {
renderLauncherAt(path);
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
}
function pickProjectFromLauncher(
projectPath: string,
action: '打开项目' | '新建项目' = '打开项目',
) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error('Tauri invoke is not configured');
}
window.__TAURI__ = {
...window.__TAURI__,
core: {
...window.__TAURI__.core,
invoke: async (command, args) =>
command === 'pick_local_project_directory'
? projectPath
: invoke(command, args),
},
};
fireEvent.click(screen.getByRole('button', { name: action }));
}
function isNativeTextControl(
element: HTMLElement,
): element is HTMLInputElement | HTMLTextAreaElement {
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA';
}
// Lexical 的编辑器状态提交排在微任务里(外部 value 变化、粘贴、删除都走这条路径),
// 读取或提交前先让 React 追平编辑器内容,否则会读到上一帧的文本/禁用状态。
// 这里让出一个宏任务而不是用 act(async),因为 act 在编辑器持续自更新的用例里会长时间不收敛。
async function settleComposer() {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
// 读取输入区文本:原生 input/textarea 读 valueLexical contenteditable 读 textContent。
async function composerValue(element: HTMLElement) {
await settleComposer();
return isNativeTextControl(element)
? element.value
: (element.textContent ?? '');
}
type ComposerScope = { getByLabelText: (label: string) => HTMLElement };
async function composerText(label = '创作想法', scope: ComposerScope = screen) {
return composerValue(scope.getByLabelText(label));
}
async function composerDisabled(
label = '创作想法',
scope: ComposerScope = screen,
) {
await settleComposer();
const element = scope.getByLabelText(label);
if (isNativeTextControl(element)) {
return element.disabled;
}
const wrapper = element.closest('.resource-reference-input');
return (
wrapper?.getAttribute('data-disabled') === 'true' ||
element.getAttribute('contenteditable') !== 'true'
);
}
// 把整段内容选进 DOM 选区,并让 Lexical 吸收它:jsdom 不会为程序化选区派发
// selectionchange,编辑器内部 selection 不更新的话,粘贴会插在旧内容旁边而不是覆盖。
function selectComposerContents(element: HTMLElement) {
element.focus();
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
}
function clearComposerContents(element: HTMLElement) {
fireEvent.keyDown(element, { key: 'Delete', code: 'Delete' });
}
// 写完内容后把光标收回到末尾,模拟真实输入后的选区;否则残留的“全选”会改变
// Shift+Enter 等按键的后续语义。
function collapseComposerSelectionToEnd(element: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
}
function pasteComposerText(element: HTMLElement, value: string) {
fireEvent.paste(element, {
clipboardData: {
getData: (type: string) => (type === 'text/plain' ? value : ''),
files: [],
items: [],
types: ['text/plain'],
},
});
}
// 向输入区写入文本,保证写入结果等于 value。原生控件用 change 事件;Lexical
// contenteditable 先全选、清空,再走产品真实的 paste 通路写入。
async function setComposerText(element: HTMLElement, value: string) {
if (isNativeTextControl(element)) {
fireEvent.change(element, { target: { value } });
return;
}
selectComposerContents(element);
await settleComposer();
clearComposerContents(element);
await settleComposer();
if (value.length > 0) {
pasteComposerText(element, value);
await settleComposer();
}
collapseComposerSelectionToEnd(element);
await settleComposer();
}
async function submitChat(value: string) {
await setComposerText(screen.getByLabelText('创作想法'), value);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
}
// 同一张资源卡上有两个按钮:选中资源与 V3 新增的 @ 引用按钮,二者的可访问名都包含
// 资源文件名,因此按文件名正则查询会同时命中两个元素。选中按钮的可访问名模板固定为
// `选中资源:<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。
function escapeRegExpLiteral(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function resourceSelectButtonName(label: string) {
return new RegExp(`^选中资源:\\S+ ${escapeRegExpLiteral(label)}$`);
}
function getResourceSelectButton(label: string) {
return screen.getByRole('button', { name: resourceSelectButtonName(label) });
}
function findResourceSelectButton(
label: string,
options?: { timeout?: number; interval?: number },
) {
return screen.findByRole(
'button',
{ name: resourceSelectButtonName(label) },
options,
);
}
function queryResourceSelectButton(label: string) {
return screen.queryByRole('button', {
name: resourceSelectButtonName(label),
});
}
function emptyProjectPolicy() {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: [],
},
};
}
const roleAgentMockReply =
'专业 Agent 已完成本轮判断:先补齐角色规范图、验收口径和后续生成条件。';
function mockRoleAgentReply() {
return roleAgentMockReply;
}
function projectSupervisorResponseStream({
runId,
sequence,
accumulatedText,
status = 'streaming',
appliedSteerCursor = 0,
responseRevision = 0,
loopIteration = 1,
overrides = {},
}: {
runId: string;
sequence: number;
accumulatedText: string;
status?: 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
appliedSteerCursor?: number;
responseRevision?: number;
loopIteration?: number;
overrides?: Record<string, unknown>;
}) {
return {
schemaVersion: 'game-creator-runtime-response-stream.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'supervisor-session-active',
runId,
requestKind: 'final-reply',
requestSlot: `final-reply-loop-${loopIteration}-revision-${responseRevision}`,
appliedSteerCursor,
responseRevision,
sequence,
status,
accumulatedText,
finishReason: status === 'ready' || status === 'committed' ? 'stop' : null,
startedAt: 6000,
updatedAt: 6000 + sequence,
...overrides,
};
}
function agentRuntimeUserInputRequest({
agentId,
sessionId,
runId,
requestId = 'request-user-input-1',
actionId = 'action-user-input-1',
responseId = null,
status = 'pending',
}: {
agentId: string;
sessionId: string;
runId: string;
requestId?: string;
actionId?: string;
responseId?: string | null;
status?: 'pending' | 'answer-prepared';
}) {
return {
schemaVersion: 'game-creator-runtime-user-input.v1',
requestId,
agentId,
taskId: agentId,
sessionId,
runId,
actionId,
status,
questions: [
{
id: 'visual_direction',
header: '美术方向',
question: '首版角色规范图采用哪种美术方向?',
options: [
{
label: '像素风',
description: '优先验证轮廓与动作可读性。',
},
{
label: '手绘风',
description: '优先验证角色气质与细节。',
},
],
},
],
allowFreeform: true,
responseId,
requestedAt: 6000,
updatedAt: 6000,
};
}
function createPlanGddStateView(
overrides: Partial<PlanGddStateViewV1> = {},
): PlanGddStateViewV1 {
const gddRef = {
gddId: 'gdd-plan-0001',
version: 1,
fingerprint: 'sha256-serde-json-v2:1111111111111111',
};
return {
schemaVersion: 'plan-gdd-state-view.v1',
projectId: 'local-project-draft',
gddId: gddRef.gddId,
state: 'ready_for_approval',
session: {
sessionId: 'plan-session-0001',
sessionRevision: 3,
sessionFingerprint: 'sha256-serde-json-v2:2222222222222222',
phase: 'awaiting_gdd_approval',
clarificationRound: 2,
repairDepth: 0,
accumulatedAgentMillis: 42_000,
activeRunId: null,
awaitingAnswerFor: null,
decisionStateCounts: {
confirmed: 2,
defaultPending: 1,
prototypePending: 0,
},
},
versions: [
{
gddRef,
status: 'ready_for_approval',
approvalRequestId: 'gdd-approval-0001',
createdAtUtc: '2026-08-18T00:00:00Z',
decision: null,
},
],
displayGdd: {
schemaVersion: 'plan-gdd.v1',
projectId: 'local-project-draft',
gddId: gddRef.gddId,
version: gddRef.version,
submissionId: 'action-0123456789abcdef01234567',
approvalRequestId: 'gdd-approval-0001',
actionFingerprint: 'a'.repeat(64),
agentId: 'project-planning',
source: 'agent-delegate',
runProfile: 'standard',
runProfileBindingFingerprint: 'b'.repeat(64),
rootAgentId: 'project-supervisor',
rootRunId: 'run-plan-root-0001',
delegationId: 'delegation-0001',
sessionId: 'plan-session-0001',
sourceSessionRevision: 2,
sourceSessionFingerprint: 'sha256-serde-json-v2:3333333333333333',
createdByRunId: 'run-plan-child-0001',
createdAtUtc: '2026-08-18T00:00:00Z',
fingerprint: gddRef.fingerprint,
game: {
title: '灯塔守夜人',
oneLiner: '在潮汐涨落之间调度光束,护送迷航的船只回港。',
genre: { primary: '策略', fusion: null },
artStyle: {
visualType: '像素',
keywords: ['夜色', '海雾'],
moodAndColor: '冷蓝为主,暖黄光束作为唯一高光。',
mvpArtBoundary: '只做灯塔与三类船只的静帧。',
},
pillars: [
{
name: '光束调度',
playerFeel: '在有限视野里做取舍。',
mechanism: '每回合只能照亮一个扇区。',
decisionState: 'confirmed',
basis: null,
},
],
coreLoop: ['观察潮汐', '分配光束', '结算返港'],
targetUsers: {
coreUsers: '喜欢短局策略的玩家',
preferences: '偏好可预测的规则',
sessionLength: '单局 5 分钟',
referenceGames: ['灯塔物语'],
},
platformFacts: {
runtime: 'web',
viewports: ['desktop', 'mobile'],
inputs: ['pointer'],
preview: '本地预览',
},
mvpSystems: [
{
system: '潮汐时钟',
minimalFunction: '固定三段潮汐循环。',
whyRequired: '没有它就没有节奏压力。',
verifyMethod: '观察一局内三段是否各触发一次。',
decisionState: 'confirmed',
basis: null,
},
],
outOfScope: ['多人对战'],
creatorTips: {
doFirst: '先做潮汐时钟。',
deferForNow: '暂缓天气系统。',
howToVerify: '单局跑满三段潮汐。',
expandWhen: '核心循环稳定后再加船种。',
},
},
decisions: [
{
id: 'decision-0001',
topic: '光束是否可分裂',
state: 'confirmed',
answerSource: 'user_option',
round: 1,
answerSummary: '不可分裂,保持取舍压力。',
basis: null,
},
],
prototypeValidationItems: [
{
id: 'proto-0001',
question: '单扇区照明是否足够做出取舍?',
microPrototype: '纸面推演三回合。',
observation: '玩家是否出现犹豫。',
passCriterion: '三回合内至少一次改变计划。',
},
],
},
pendingApproval: {
gddRef,
pendingActionId: 'action-0123456789abcdef01234567',
actionFingerprint: 'a'.repeat(64),
approvalRequestId: 'gdd-approval-0001',
sessionId: 'plan-session-0001',
runId: 'run-plan-root-0001',
},
approvedGddRef: null,
recoveryPending: false,
...overrides,
};
}
function createProjectSupervisorRuntimeHarness({
projectPath = '/tmp/authorized-game',
sessionId = 'supervisor-session-active',
initialSessionExists = true,
projectMessages = [],
supervisorMessages = [],
initialRuntime,
initialResponseStream = null,
initialProjectRevision = 0,
runtimeMapLoader,
expectedRunProfile = 'autonomous-game-build',
planningV2Result = null,
planningV2StartResult = null,
}: {
projectPath?: string;
sessionId?: string;
initialSessionExists?: boolean;
projectMessages?: Array<Record<string, unknown>>;
supervisorMessages?: Array<Record<string, unknown>>;
initialRuntime?: Record<string, unknown>;
initialResponseStream?: Record<string, unknown> | null;
initialProjectRevision?: number;
runtimeMapLoader?: () => Promise<Array<Record<string, unknown>>>;
expectedRunProfile?: 'standard' | 'autonomous-game-build';
planningV2Result?: Record<string, unknown> | null;
planningV2StartResult?: Record<string, unknown> | null;
} = {}) {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let messageSequence = 0;
let selectedModelId = 'quality';
let steerSequence = 0;
let sessionExists = initialSessionExists;
let currentProjectRevision = initialProjectRevision;
const currentProjectMessages = [...projectMessages];
const currentSupervisorMessages = [...supervisorMessages];
const runtimeState = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId,
runId: 'supervisor-idle',
source: 'project-supervisor',
status: 'idle',
phase: 'idle',
currentTask: '',
currentGoal: '',
currentAction: '',
waitingOn: '',
nextStep: '',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: null,
updatedAt: 1000,
...overrides,
});
let currentRuntime = runtimeState(initialRuntime);
let currentResponseStream = initialResponseStream;
let runtimeReader: (() => Promise<Record<string, unknown>>) | null = null;
let confirmRuntime: Record<string, unknown> | null = null;
let rejectRuntime: Record<string, unknown> | null = null;
let answerRuntime: Record<string, unknown> | null = null;
let answerFailuresRemaining = 0;
// 默认不配置策划状态:hydrate 与接入本 harness 之前一样抛出,既有用例行为不变。
let currentPlanningV2Result = planningV2Result;
let currentPlanningV2StartResult = planningV2StartResult;
let planningV2DecisionError: string | null = null;
const planningV2DecisionCalls: Array<Record<string, unknown>> = [];
let runtimeUpdateHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
runId: string;
status: string;
phase: string;
manifestInvalidated: boolean;
runtime: Record<string, unknown>;
};
}) => void)
| null = null;
let manifestInvalidatedHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
};
}) => void)
| null = null;
let progressHandler:
| ((event: {
payload: {
projectPath: string;
stage: string;
message: string;
};
}) => void)
| null = null;
const conversationRecord = (
role: 'user' | 'assistant',
content: string,
messageId: string,
) => ({
schemaVersion: 'game-creator-conversation.v1',
role,
content,
agentId: 'project-supervisor',
messageId,
updatedAt: 2000 + ++messageSequence,
});
const runtimeResult = (
state = currentRuntime,
responseStream = state === currentRuntime ? currentResponseStream : null,
) => ({
state,
sessionPath: `${projectPath}/.agent/runtime/agents/${String(state.agentId)}.json`,
eventPath: `${projectPath}/.agent/runtime/events/${String(state.agentId)}.jsonl`,
taskPath: `${projectPath}/.agent/runtime/tasks/${String(state.agentId)}.jsonl`,
taskQueue: {
total: state.status === 'idle' ? 0 : 1,
pending: state.status === 'pending' ? 1 : 0,
running: state.status === 'running' ? 1 : 0,
waitingForConfirmation:
state.status === 'waiting-for-confirmation' ? 1 : 0,
waitingForUserInput: state.status === 'waiting-for-user-input' ? 1 : 0,
completed: state.status === 'completed' ? 1 : 0,
failed: state.status === 'failed' ? 1 : 0,
latestRunId: String(state.runId),
updatedAt: Number(state.updatedAt),
},
recentEvents: state.recentEvents ?? [],
recentTasks: [],
responseStream,
userInputRequest: state.userInputRequest ?? null,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (
command === 'read_game_creator_app_config' ||
command === 'select_game_creator_model'
) {
if (command === 'select_game_creator_model')
selectedModelId = String(args?.modelId);
return {
path: '/tmp/test-game-creator-config.json',
config: {
schemaVersion: 'game-creator-config.v2',
agentMode: 'codex_app_server',
selectedModelId,
llm: {
apiKey: '',
baseUrl: '',
model: 'quality',
apiKind: 'openai_responses',
reasoningEffort: 'max',
stream: true,
webSearchEnabled: true,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: { baseUrl: 'https://dev.genarrative.world', apiKey: '' },
},
};
}
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'get_local_game_project_revision') {
return { revision: currentProjectRevision };
}
if (command === 'init_local_game_project') {
const requestedProjectPath = String(args?.projectPath ?? '');
return {
projectPath: requestedProjectPath,
manifestPath: `${requestedProjectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [runtimeResult()];
}
if (command === 'list_game_creator_agent_sessions') {
return {
path: `${projectPath}/.agent/conversations/agents/project-supervisor/sessions.json`,
agentId: 'project-supervisor',
activeSessionId: sessionExists ? sessionId : null,
sessions: sessionExists
? [
{
sessionId,
title: '项目总控',
createdAt: 1000,
updatedAt: 2000,
archivedAt: null,
messageCount: currentSupervisorMessages.length,
legacy: false,
},
]
: [],
};
}
if (command === 'create_game_creator_agent_session') {
sessionExists = true;
return {
path: `${projectPath}/.agent/conversations/agents/project-supervisor/sessions.json`,
agentId: 'project-supervisor',
activeSessionId: sessionId,
sessions: [
{
sessionId,
title: String(args?.title ?? ''),
createdAt: 1000,
updatedAt: 2000,
archivedAt: null,
messageCount: currentSupervisorMessages.length,
legacy: false,
},
],
};
}
if (command === 'read_local_conversation') {
if (args?.agentId === null) {
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...currentProjectMessages],
};
}
if (args?.agentId === 'project-supervisor') {
if (args?.sessionId !== sessionId) {
throw new Error(`unexpected supervisor session ${args?.sessionId}`);
}
return {
path: `${projectPath}/.agent/conversations/agents/project-supervisor/sessions/${sessionId}.jsonl`,
agentId: 'project-supervisor',
sessionId,
messages: [...currentSupervisorMessages],
};
}
}
if (command === 'append_local_conversation_message') {
if (args?.agentId !== null) {
throw new Error('React must not append Supervisor conversation');
}
const message = args?.message as {
role: 'user' | 'assistant';
content: string;
agentId: null;
updatedAt?: number;
};
currentProjectMessages.push({
schemaVersion: 'game-creator-conversation.v1',
...message,
...(args?.messageId ? { messageId: String(args.messageId) } : {}),
updatedAt:
typeof message.updatedAt === 'number'
? message.updatedAt
: 1500 + ++messageSequence,
});
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...currentProjectMessages],
};
}
if (command === 'read_game_creator_agent_runtime') {
if (
args?.agentId !== 'project-supervisor' ||
args?.sessionId !== sessionId
) {
throw new Error('unexpected Project Supervisor runtime identity');
}
if (runtimeReader) {
return runtimeReader();
}
return runtimeResult();
}
if (command === 'read_game_creator_agent_runtimes') {
const states = runtimeMapLoader ? await runtimeMapLoader() : [];
return states.map((state) => runtimeResult(state));
}
if (command === 'start_game_creator_supervisor_runtime_task') {
if (args?.runProfile !== expectedRunProfile) {
throw new Error('unexpected Project Supervisor run profile');
}
const runId = String(args?.runId ?? '');
currentSupervisorMessages.push(
conversationRecord(
'user',
String(args?.task ?? ''),
`start-user-${runId}`,
),
);
currentRuntime = runtimeState({
runId,
runProfile: args?.runProfile,
source: args?.source ?? 'project-supervisor',
status: 'running',
phase: 'planning',
currentTask: String(args?.task ?? ''),
currentGoal: String(args?.task ?? ''),
updatedAt: 3000,
});
currentResponseStream = null;
return runtimeResult();
}
if (command === 'steer_game_creator_agent_runtime_task') {
steerSequence += 1;
currentResponseStream = null;
currentSupervisorMessages.push(
conversationRecord(
'user',
String(args?.instruction ?? ''),
`steer-user-${steerSequence}`,
),
);
return {
runtime: runtimeResult(),
steerId: String(args?.steerId ?? ''),
sequence: steerSequence,
status: 'queued',
providerInterrupted: false,
};
}
if (command === 'confirm_game_creator_agent_runtime_task') {
currentResponseStream = null;
currentRuntime =
confirmRuntime ??
runtimeState({
runId: String(currentRuntime.runId),
status: 'running',
phase: 'planning',
updatedAt: 4000,
});
return runtimeResult();
}
if (command === 'reject_game_creator_agent_runtime_task') {
currentResponseStream = null;
currentRuntime =
rejectRuntime ??
runtimeState({
runId: String(currentRuntime.runId),
status: 'running',
phase: 'planning',
updatedAt: 5000,
});
return runtimeResult();
}
if (command === 'answer_game_creator_agent_runtime_user_input') {
if (answerFailuresRemaining > 0) {
answerFailuresRemaining -= 1;
throw new Error('模拟回答提交中断');
}
const answers = args?.answers as Record<string, string>;
currentSupervisorMessages.push(
conversationRecord(
'user',
String(answers?.visual_direction ?? ''),
`answer-user-${String(args?.responseId ?? '')}`,
),
);
currentResponseStream = null;
currentRuntime =
answerRuntime ??
runtimeState({
runId: String(currentRuntime.runId),
status: 'running',
phase: 'planning',
currentAction: '根据用户回答继续规划',
userInputRequest: null,
updatedAt: 6100,
});
return runtimeResult();
}
if (command === 'hydrate_planning_session_v2') {
return currentPlanningV2Result;
}
if (
command === 'start_planning_session_v2' ||
command === 'continue_planning_session_v2'
) {
const nextResult =
command === 'start_planning_session_v2'
? (currentPlanningV2StartResult ?? currentPlanningV2Result)
: currentPlanningV2Result;
if (!nextResult) {
throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED');
}
currentPlanningV2Result = nextResult;
return nextResult;
}
if (command === 'decide_planning_artifact_v2') {
planningV2DecisionCalls.push({ ...(args ?? {}) });
if (planningV2DecisionError) {
const failure = planningV2DecisionError;
planningV2DecisionError = null;
throw new Error(failure);
}
if (!currentPlanningV2Result) {
throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED');
}
return currentPlanningV2Result;
}
throw new Error(`unexpected invoke ${command}`);
},
);
const listen = vi.fn(
async (
eventName: string,
handler: (event: {
payload: {
projectPath: string;
agentId: string;
runId: string;
status: string;
phase: string;
runtime: Record<string, unknown>;
};
}) => void,
) => {
if (eventName === 'game-creator-agent-runtime-update') {
runtimeUpdateHandler = handler;
}
if (eventName === 'game-creator-manifest-invalidated') {
manifestInvalidatedHandler =
handler as unknown as typeof manifestInvalidatedHandler;
}
if (eventName === 'game-creator-agent-progress') {
progressHandler = handler as unknown as typeof progressHandler;
}
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
}
if (manifestInvalidatedHandler === handler) {
manifestInvalidatedHandler = null;
}
if (progressHandler === handler) {
progressHandler = null;
}
};
},
);
return {
invoke,
listen,
projectPath,
sessionId,
runtimeState,
runtimeResult,
setConfirmRuntime(state: Record<string, unknown>) {
confirmRuntime = state;
},
setRejectRuntime(state: Record<string, unknown>) {
rejectRuntime = state;
},
setAnswerRuntime(state: Record<string, unknown>) {
answerRuntime = state;
},
failNextAnswers(count = 1) {
answerFailuresRemaining = count;
},
setPlanningV2Result(state: Record<string, unknown> | null) {
currentPlanningV2Result = state;
},
setPlanningV2StartResult(state: Record<string, unknown> | null) {
currentPlanningV2StartResult = state;
},
failNextPlanningV2Decision(message: string) {
planningV2DecisionError = message;
},
planningV2DecisionCalls,
appendSupervisorMessage(message: Record<string, unknown>) {
currentSupervisorMessages.push(message);
},
setResponseStream(responseStream: Record<string, unknown> | null) {
currentResponseStream = responseStream;
},
setProjectRevision(revision: number) {
currentProjectRevision = revision;
},
setRuntimeReader(reader: (() => Promise<Record<string, unknown>>) | null) {
runtimeReader = reader;
},
emitRuntime(
state: Record<string, unknown>,
responseStream: Record<string, unknown> | null = currentResponseStream,
) {
currentRuntime = state;
currentResponseStream = responseStream;
runtimeUpdateHandler?.({
payload: {
projectPath,
agentId: 'project-supervisor',
runId: String(state.runId),
status: String(state.status),
phase: String(state.phase),
manifestInvalidated: true,
runtime: runtimeResult(currentRuntime, currentResponseStream),
},
});
},
emitAgentRuntime(state: Record<string, unknown>) {
runtimeUpdateHandler?.({
payload: {
projectPath,
agentId: String(state.agentId ?? ''),
runId: String(state.runId ?? ''),
status: String(state.status ?? ''),
phase: String(state.phase ?? ''),
manifestInvalidated: true,
runtime: runtimeResult(state, null),
},
});
},
emitManifestInvalidated(agentId: string) {
manifestInvalidatedHandler?.({
payload: {
projectPath,
agentId,
},
});
},
emitProgress(stage: string, message: string) {
progressHandler?.({
payload: {
projectPath,
stage,
message,
},
});
},
};
}
async function openMainProject(projectPath: string) {
await submitChat(`/project ${projectPath}`);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(`已打开:${projectNameFromPath(projectPath)}`),
).not.toBeNull();
}
beforeEach(() => {
resetLlmModelCatalogCacheForTest();
vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 1,
});
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
});
Object.defineProperty(window, 'ClipboardEvent', {
configurable: true,
value: TestClipboardEvent,
});
Object.defineProperty(window, 'DragEvent', {
configurable: true,
value: TestDragEvent,
});
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: emptyRangeRect,
});
Object.defineProperties(URL, {
createObjectURL: {
configurable: true,
value: vi.fn(() => 'blob:mock-attachment-preview'),
},
revokeObjectURL: {
configurable: true,
value: vi.fn(),
},
});
});
afterEach(() => {
cleanup();
// 首页创作类型存在 module 级 zustand store 里,跨用例不会自动回到初始值。
// 建项按钮的文案依赖它(做方案 → 进入立项策划),漏一次就会让下一个用例
// 找不到「开启创作」。
useLauncherHomeDraftStore.getState().reset();
nativeClipboardMock.text = '';
window.history.pushState({}, '', '/');
window.localStorage.clear();
window.sessionStorage.clear();
delete window.__TAURI__;
vi.restoreAllMocks();
if (originalCreateObjectUrl) {
Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl);
} else {
Reflect.deleteProperty(URL, 'createObjectURL');
}
if (originalRevokeObjectUrl) {
Object.defineProperty(URL, 'revokeObjectURL', originalRevokeObjectUrl);
} else {
Reflect.deleteProperty(URL, 'revokeObjectURL');
}
if (originalPointerEvent) {
Object.defineProperty(window, 'PointerEvent', originalPointerEvent);
} else {
Reflect.deleteProperty(window, 'PointerEvent');
}
if (originalClipboardEvent) {
Object.defineProperty(window, 'ClipboardEvent', originalClipboardEvent);
} else {
Reflect.deleteProperty(window, 'ClipboardEvent');
}
if (originalDragEvent) {
Object.defineProperty(window, 'DragEvent', originalDragEvent);
} else {
Reflect.deleteProperty(window, 'DragEvent');
}
if (originalRangeGetBoundingClientRect) {
Object.defineProperty(
Range.prototype,
'getBoundingClientRect',
originalRangeGetBoundingClientRect,
);
} else {
Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect');
}
if (originalIntersectionObserver) {
Object.defineProperty(
window,
'IntersectionObserver',
originalIntersectionObserver,
);
} else {
Reflect.deleteProperty(window, 'IntersectionObserver');
}
if (originalResizeObserver) {
Object.defineProperty(window, 'ResizeObserver', originalResizeObserver);
} else {
Reflect.deleteProperty(window, 'ResizeObserver');
}
});
export {
act,
agentRuntimeUserInputRequest,
App,
AuthenticatedClient,
cleanup,
composerDisabled,
composerText,
composerValue,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
createPlanGddStateView,
createProjectSupervisorRuntimeHarness,
deriveAgentStatusCards,
describe,
emptyProjectPolicy,
expect,
findResourceSelectButton,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
getResourceSelectButton,
it,
mockRoleAgentReply,
nativeClipboardMock,
openMainProject,
pickProjectFromLauncher,
ProjectDevelopmentView,
projectSupervisorResponseStream,
queryResourceSelectButton,
React,
readFileSync,
render,
renderAppAt,
renderLauncherAgentChatAt,
renderLauncherAt,
renderLauncherProjectsAt,
resolve,
roleAgentMockReply,
screen,
selectDeveloperAgentChatMode,
setComposerText,
submitChat,
TEST_LOCAL_PROJECT_PATH,
testAuthUser,
vi,
waitFor,
within,
WorkspaceLauncher,
};
export type { GameCreationAgentRunTrace };