53afc85319
- appSurface/harness 补齐三处 jsdom 缺口:ClipboardEvent、DragEvent、Range.prototype.getBoundingClientRect;否则 Lexical 的粘贴通路直接抛 ReferenceError / TypeError
- appSurface/harness 的 submitChat 改为 async:全选并让编辑器吸收选区、清空、走产品真实 paste 通路写入,再让出一帧让 React 追平 draft,最后点发送
- 新增 composerText / composerValue / composerDisabled / setComposerText 助手,按原生控件与 Lexical contenteditable 两种 DOM 口径读写输入区
- 15 个测试文件中 116 处 toHaveProperty('value', …) 断言等义改写为 await composerText() / await composerValue();1 处 placeholder 断言改查输入区占位文案;2 处 disabled 断言改用 composerDisabled(同时覆盖 data-disabled 与 contenteditable=false);9 处 fireEvent.change 写入改用 setComposerText
- 328 处 submitChat 调用补 await,9 个非 async 用例补 async
- Godot 输入区键盘语义用例按 Lexical 实际行为改写:Shift+Enter 与组合态 Enter 由编辑器消化并插入换行、不发送、草稿保留
- 未改动 src 下任何产品代码,未放宽或删除断言
1329 lines
40 KiB
TypeScript
1329 lines
40 KiB
TypeScript
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 也没有 getBoundingClientRect,Lexical 计算选区矩形时会抛 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 读 value,Lexical 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 resourceDetailButtonName(label: string) {
|
||
return new RegExp(`^打开资源详情:\\S+ ${escapeRegExpLiteral(label)}$`);
|
||
}
|
||
|
||
function getResourceDetailButton(label: string) {
|
||
return screen.getByRole('button', { name: resourceDetailButtonName(label) });
|
||
}
|
||
|
||
function findResourceDetailButton(
|
||
label: string,
|
||
options?: { timeout?: number; interval?: number },
|
||
) {
|
||
return screen.findByRole(
|
||
'button',
|
||
{ name: resourceDetailButtonName(label) },
|
||
options,
|
||
);
|
||
}
|
||
|
||
function queryResourceDetailButton(label: string) {
|
||
return screen.queryByRole('button', {
|
||
name: resourceDetailButtonName(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,
|
||
findResourceDetailButton,
|
||
fireEvent,
|
||
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
getResourceDetailButton,
|
||
it,
|
||
mockRoleAgentReply,
|
||
nativeClipboardMock,
|
||
openMainProject,
|
||
pickProjectFromLauncher,
|
||
ProjectDevelopmentView,
|
||
projectSupervisorResponseStream,
|
||
queryResourceDetailButton,
|
||
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 };
|