Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/harness.ts
T
menghao 62e0fe94ef
Project CI / Repository checks (push) Successful in 1m2s
Project CI / Frontend tests (push) Successful in 3m5s
Project CI / Backend tests (push) Successful in 3m41s
Project CI / Native shell tests (push) Successful in 13m7s
资源卡依赖关系及类型分类预览 (#129)
完成资源卡按类型和按依赖分类展现的功能

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/129
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-08-05 19:15:46 +08:00

803 lines
22 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';
const nativeClipboardMock = vi.hoisted(() => ({
text: '',
}));
const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'createObjectURL',
);
const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'revokeObjectURL',
);
const originalPointerEvent = Object.getOwnPropertyDescriptor(
window,
'PointerEvent',
);
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
constructor(
type: string,
init: MouseEventInit & { pointerId?: number } = {},
) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
}
}
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,
GameChatReleaseApp,
WorkspaceLauncher,
} from '../../src/App';
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));
}
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 submitChat(value: string) {
fireEvent.change(screen.getByLabelText('创作想法'), {
target: { value },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
}
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 createProjectSupervisorRuntimeHarness({
projectPath = '/tmp/authorized-game',
sessionId = 'supervisor-session-active',
initialSessionExists = true,
projectMessages = [],
supervisorMessages = [],
initialRuntime,
initialResponseStream = null,
initialProjectRevision = 0,
runtimeMapLoader,
expectedRunProfile = 'autonomous-game-build',
}: {
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';
} = {}) {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let messageSequence = 0;
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;
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;
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 === '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();
}
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;
}
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
}
if (manifestInvalidatedHandler === handler) {
manifestInvalidatedHandler = 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;
},
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,
},
});
},
};
}
async function openMainProject(projectPath: string) {
submitChat(`/project ${projectPath}`);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(await screen.findByText(`已打开:${projectPath}`)).not.toBeNull();
}
beforeEach(() => {
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
});
Object.defineProperties(URL, {
createObjectURL: {
configurable: true,
value: vi.fn(() => 'blob:mock-attachment-preview'),
},
revokeObjectURL: {
configurable: true,
value: vi.fn(),
},
});
});
afterEach(() => {
cleanup();
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');
}
});
export {
act,
agentRuntimeUserInputRequest,
App,
AuthenticatedClient,
cleanup,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
createProjectSupervisorRuntimeHarness,
deriveAgentStatusCards,
describe,
emptyProjectPolicy,
expect,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GameChatReleaseApp,
it,
mockRoleAgentReply,
nativeClipboardMock,
openMainProject,
ProjectDevelopmentView,
projectSupervisorResponseStream,
React,
readFileSync,
render,
renderAppAt,
renderLauncherAgentChatAt,
renderLauncherAt,
renderLauncherProjectsAt,
resolve,
roleAgentMockReply,
screen,
selectDeveloperAgentChatMode,
submitChat,
testAuthUser,
vi,
waitFor,
within,
WorkspaceLauncher,
};
export type { GameCreationAgentRunTrace };