5e16837f3d
普通 Launcher、开发工作台和独立 game-chat 统一显示 External Editor 配置。 补齐配置可见性、密码输入和持久化回归测试。 同步配置静态门禁、PRD、技术合同与共享决策。
11171 lines
369 KiB
TypeScript
11171 lines
369 KiB
TypeScript
import type {
|
||
ProjectResourceCanvasLayout,
|
||
ProjectResourceCanvasPosition,
|
||
} from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||
import {
|
||
consumeInitialGameChatMessage,
|
||
latestGameChatPlayableRevision,
|
||
} from '../../src/App';
|
||
import type {
|
||
AgentRuntimeEventRecord,
|
||
AgentRuntimeState,
|
||
} from '../../src/app/types';
|
||
import { MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE } from '../../src/features/agent-runtime/model';
|
||
import {
|
||
buildGameChatProgressEvidence,
|
||
collectGameChatResultImages,
|
||
collectGameChatRuntimeEvents,
|
||
formatGameChatStageRecord,
|
||
gameChatFinalReplyMessages,
|
||
gameChatRuntimeEventMessages,
|
||
isGameChatStageRecordMessage,
|
||
mergeGameChatFinalReplyMessagesIntoHistory,
|
||
SupervisorChatOnlyView,
|
||
} from '../../src/features/project-workspace/SupervisorChatOnlyView';
|
||
import {
|
||
RESOURCE_CANVAS_CARD_WIDTH,
|
||
RESOURCE_CANVAS_COLUMN_GAP,
|
||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||
} from '../../src/view/project-development/resourceCanvasLayoutModel';
|
||
import { normalizeProjectResourceGraph } from '../../src/view/project-development/resourceDependencyGraphModel';
|
||
import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay';
|
||
import type { ProjectAgentResultSummary } from '../../src/view/project-development/resourceProjectionModel';
|
||
import {
|
||
RESOURCE_CANVAS_VERTICAL_INSET,
|
||
RESOURCE_SECTION_DEFAULT_HEIGHT,
|
||
RESOURCE_SECTION_HEIGHT_STEP,
|
||
} from '../../src/view/project-development/resourceSectionHeightModel';
|
||
import {
|
||
act,
|
||
agentRuntimeUserInputRequest,
|
||
App,
|
||
cleanup,
|
||
createGameCreationAppManifest,
|
||
createGameCreationAppSeedTasks,
|
||
createProjectSupervisorRuntimeHarness,
|
||
emptyProjectPolicy,
|
||
expect,
|
||
fireEvent,
|
||
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
GameChatReleaseApp,
|
||
type GameCreationAgentRunTrace,
|
||
it,
|
||
mockRoleAgentReply,
|
||
ProjectDevelopmentView,
|
||
projectSupervisorResponseStream,
|
||
React,
|
||
readFileSync,
|
||
render,
|
||
renderAppAt,
|
||
renderLauncherProjectsAt,
|
||
resolve,
|
||
screen,
|
||
submitChat,
|
||
vi,
|
||
waitFor,
|
||
within,
|
||
} from './harness';
|
||
|
||
function resourceGraphForInputs(args?: Record<string, unknown>) {
|
||
const resources =
|
||
(args?.resources as
|
||
| Array<{ resourceId: string; producerTaskId: string | null }>
|
||
| undefined) ?? [];
|
||
return {
|
||
resourceIds: resources.map(({ resourceId }) => resourceId),
|
||
referenceEdges: [],
|
||
taskFlows: [],
|
||
connectionIndex: resources.map(({ resourceId }) => ({
|
||
resourceId,
|
||
upstreamReferenceResourceIds: [],
|
||
downstreamReferenceResourceIds: [],
|
||
referenceEdgeIds: [],
|
||
taskFlowIds: [],
|
||
})),
|
||
producerAssignments: resources.flatMap((resource) =>
|
||
resource.producerTaskId
|
||
? [
|
||
{
|
||
resourceId: resource.resourceId,
|
||
taskId: resource.producerTaskId,
|
||
},
|
||
]
|
||
: [],
|
||
),
|
||
dependencyDepths: resources.map((resource) => ({
|
||
resourceId: resource.resourceId,
|
||
dependencyDepth: 0,
|
||
})),
|
||
unresolvedReferenceResourceIds: [],
|
||
cyclicResourceIds: [],
|
||
cyclicTaskIds: [],
|
||
producerMappingTruncated: false,
|
||
};
|
||
}
|
||
|
||
function installResourceCardIntersectionObserver() {
|
||
const instances: Array<{
|
||
callback: IntersectionObserverCallback;
|
||
observed: Set<Element>;
|
||
observer: IntersectionObserver;
|
||
}> = [];
|
||
|
||
class ResourceCardIntersectionObserver {
|
||
readonly root = null;
|
||
readonly rootMargin = '160px';
|
||
readonly thresholds = [0];
|
||
readonly observed = new Set<Element>();
|
||
|
||
constructor(readonly callback: IntersectionObserverCallback) {
|
||
instances.push({
|
||
callback,
|
||
observed: this.observed,
|
||
observer: this as unknown as IntersectionObserver,
|
||
});
|
||
}
|
||
|
||
observe(element: Element) {
|
||
this.observed.add(element);
|
||
}
|
||
|
||
unobserve(element: Element) {
|
||
this.observed.delete(element);
|
||
}
|
||
|
||
disconnect() {
|
||
this.observed.clear();
|
||
}
|
||
|
||
takeRecords() {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
Object.defineProperty(window, 'IntersectionObserver', {
|
||
configurable: true,
|
||
value: ResourceCardIntersectionObserver,
|
||
});
|
||
|
||
return {
|
||
triggerVisible(elements?: Element[]) {
|
||
const instance = instances.at(-1);
|
||
if (!instance) {
|
||
throw new Error('resource card IntersectionObserver was not created');
|
||
}
|
||
const targets = elements ?? Array.from(instance.observed);
|
||
instance.callback(
|
||
targets.map(
|
||
(target) =>
|
||
({
|
||
target,
|
||
isIntersecting: true,
|
||
intersectionRatio: 1,
|
||
}) as IntersectionObserverEntry,
|
||
),
|
||
instance.observer,
|
||
);
|
||
},
|
||
observedCount() {
|
||
return instances.at(-1)?.observed.size ?? 0;
|
||
},
|
||
};
|
||
}
|
||
|
||
function gameChatRuntimeEvent({
|
||
agentId = 'project-supervisor',
|
||
taskId = agentId,
|
||
sessionId = `${agentId}-session`,
|
||
runId = 'game-chat-supervisor-run',
|
||
eventType = 'agent.progress',
|
||
status = 'running',
|
||
phase = 'execution',
|
||
summary,
|
||
detail = null,
|
||
updatedAt,
|
||
eventId = `${agentId}-${runId}-${updatedAt}-${eventType}`,
|
||
publicText = summary,
|
||
}: {
|
||
agentId?: string;
|
||
taskId?: string;
|
||
sessionId?: string;
|
||
runId?: string;
|
||
eventType?: string;
|
||
status?: string;
|
||
phase?: string;
|
||
summary: string;
|
||
detail?: string | null;
|
||
eventId?: string;
|
||
publicText?: string | null;
|
||
updatedAt: number;
|
||
}): AgentRuntimeEventRecord {
|
||
return {
|
||
schemaVersion: 'game-creator-runtime-event.v1',
|
||
agentId,
|
||
taskId,
|
||
sessionId,
|
||
runId,
|
||
source:
|
||
agentId === 'project-supervisor'
|
||
? 'project-supervisor-game-chat'
|
||
: 'agent-delegate',
|
||
eventId,
|
||
eventType,
|
||
status,
|
||
phase,
|
||
summary,
|
||
detail,
|
||
publicText,
|
||
updatedAt,
|
||
};
|
||
}
|
||
|
||
function gameChatRuntimeState(
|
||
overrides: Partial<AgentRuntimeState> = {},
|
||
): AgentRuntimeState {
|
||
return {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'project-supervisor',
|
||
taskId: 'project-supervisor',
|
||
sessionId: 'game-chat-supervisor-session',
|
||
runId: 'game-chat-supervisor-run',
|
||
source: 'project-supervisor-game-chat',
|
||
status: 'running',
|
||
phase: 'execution',
|
||
currentTask: '生成首版游戏',
|
||
currentGoal: '完成可运行原型',
|
||
currentAction: '执行当前计划',
|
||
waitingOn: '',
|
||
nextStep: '继续执行',
|
||
plan: [],
|
||
observations: [],
|
||
allowedTools: [],
|
||
pendingToolAction: null,
|
||
lastResponse: null,
|
||
error: null,
|
||
updatedAt: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
const GAME_CHAT_STAGE_TASK_IDS = [
|
||
'design-director',
|
||
'art-director',
|
||
'art-asset-plan',
|
||
'code-director',
|
||
'code-prototype',
|
||
'preview-readiness',
|
||
'preview-playtest',
|
||
] as const;
|
||
|
||
function isGameChatStageTask(taskId: string) {
|
||
return (GAME_CHAT_STAGE_TASK_IDS as readonly string[]).includes(taskId);
|
||
}
|
||
|
||
function gameChatPreviewPlaytestRuntime({
|
||
parentRunId,
|
||
revision,
|
||
updatedAt,
|
||
}: {
|
||
parentRunId: string;
|
||
revision: number;
|
||
updatedAt: number;
|
||
}): AgentRuntimeState {
|
||
const runId = `preview-playtest-${parentRunId}-${revision}`;
|
||
const sessionId = `preview-playtest-session-${revision}`;
|
||
return gameChatRuntimeState({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId,
|
||
runId,
|
||
source: 'agent-ready-task-scheduler',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentTask: '执行浏览器试玩验证',
|
||
currentGoal: '确认当前 revision 可以试玩',
|
||
currentAction: '浏览器试玩验证已通过',
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId,
|
||
runId,
|
||
eventType: 'observation',
|
||
status: 'completed',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:ok · 浏览器验证已通过',
|
||
detail: JSON.stringify({
|
||
diagnosticsCount: 0,
|
||
passed: true,
|
||
playtestPassed: true,
|
||
revision,
|
||
}),
|
||
updatedAt,
|
||
}),
|
||
],
|
||
updatedAt,
|
||
});
|
||
}
|
||
|
||
function renderGameChatStatus({
|
||
runtime,
|
||
runtimeByAgentId = {},
|
||
manifest = null,
|
||
viewOverrides = {},
|
||
}: {
|
||
runtime: AgentRuntimeState;
|
||
runtimeByAgentId?: Record<string, AgentRuntimeState | undefined>;
|
||
manifest?: ReturnType<typeof createGameCreationAppManifest> | null;
|
||
viewOverrides?: Partial<React.ComponentProps<typeof SupervisorChatOnlyView>>;
|
||
}) {
|
||
return render(
|
||
React.createElement(SupervisorChatOnlyView, {
|
||
chatAgentBusy: false,
|
||
chatInput: '',
|
||
messagesRef: React.createRef<HTMLDivElement>(),
|
||
onCancelConfirmation: vi.fn(),
|
||
onChatInputChange: vi.fn(),
|
||
onCloseRuntimeConfig: vi.fn(),
|
||
onConfirmConfirmation: vi.fn(),
|
||
onOpenRuntimeConfig: vi.fn(),
|
||
onScroll: vi.fn(),
|
||
onShowEarlierMessages: vi.fn(),
|
||
onSubmit: vi.fn(),
|
||
onToolAction: vi.fn(),
|
||
onUserInput: vi.fn(),
|
||
pendingConfirmation: null,
|
||
projectPath: '/tmp/game-chat-status',
|
||
runtime,
|
||
runtimeConfigOpen: false,
|
||
runtimeError: '',
|
||
transientReply: '',
|
||
hasConversationControls: false,
|
||
hiddenConversationCount: 0,
|
||
needsUserInput: false,
|
||
visibleMessages: [],
|
||
workspaceStatus: '已打开',
|
||
gameChatMode: true,
|
||
runtimeByAgentId,
|
||
manifest,
|
||
projectReady: true,
|
||
...viewOverrides,
|
||
}),
|
||
);
|
||
}
|
||
|
||
function createDeferred<T>() {
|
||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||
let reject!: (reason?: unknown) => void;
|
||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||
resolve = nextResolve;
|
||
reject = nextReject;
|
||
});
|
||
return { promise, reject, resolve };
|
||
}
|
||
|
||
type GameChatPreviewFixture = {
|
||
port: number;
|
||
root: string;
|
||
url: string;
|
||
};
|
||
|
||
async function renderGameChatAutoPreviewDriver({
|
||
projectPath,
|
||
port,
|
||
readPolicy,
|
||
startPreview,
|
||
}: {
|
||
projectPath: string;
|
||
port: number;
|
||
readPolicy?: (callIndex: number) => Promise<Record<string, unknown>>;
|
||
startPreview?: (
|
||
args: Record<string, unknown>,
|
||
callIndex: number,
|
||
) => Promise<GameChatPreviewFixture>;
|
||
}) {
|
||
let backgroundRuntimes: Array<Record<string, unknown>> = [];
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
runtimeMapLoader: async () => backgroundRuntimes,
|
||
});
|
||
const manifest = createGameCreationAppManifest(
|
||
projectPath.split(/[\\/]/u).filter(Boolean).at(-1) ?? 'game-chat-race',
|
||
'game-chat-race',
|
||
);
|
||
manifest.tasks = manifest.tasks.map((task) =>
|
||
task.id === 'code-prototype'
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
let parentRunId = '';
|
||
let currentPreview: GameChatPreviewFixture | null = null;
|
||
let policyCallCount = 0;
|
||
let startCallCount = 0;
|
||
const stoppedPreviews: GameChatPreviewFixture[] = [];
|
||
const invoke = vi.fn(
|
||
async (command: string, args: Record<string, unknown> = {}) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: manifest.name,
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return currentPreview
|
||
? { status: 'running', ...currentPreview }
|
||
: { status: 'stopped', url: null, port: null, root: null };
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
policyCallCount += 1;
|
||
return readPolicy
|
||
? readPolicy(policyCallCount)
|
||
: {
|
||
path: '.agent/policy.json',
|
||
policy: { deniedCommands: [], confirmCommands: [] },
|
||
};
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
startCallCount += 1;
|
||
const started = startPreview
|
||
? await startPreview(args, startCallCount)
|
||
: {
|
||
url: `http://127.0.0.1:${port}`,
|
||
port,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
currentPreview = started;
|
||
return started;
|
||
}
|
||
if (command === 'stop_local_game_preview_if_matches') {
|
||
const expected = args.expectedPreview as GameChatPreviewFixture;
|
||
const matches =
|
||
currentPreview?.url === expected?.url &&
|
||
currentPreview.port === expected.port &&
|
||
currentPreview.root === expected.root;
|
||
if (matches) {
|
||
stoppedPreviews.push(expected);
|
||
currentPreview = null;
|
||
}
|
||
return matches;
|
||
}
|
||
if (command === 'start_game_creator_supervisor_runtime_task') {
|
||
parentRunId = String(args.runId ?? '');
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
const surface = await screen.findByLabelText('游戏创作聊天');
|
||
const composer = within(surface).getByLabelText('项目总控对话内容');
|
||
const send = within(surface).getByRole('button', { name: '发送' });
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
expect((send as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
|
||
const submit = async (prompt: string) => {
|
||
const previousSubmissionCount = invoke.mock.calls.filter(
|
||
([command]) =>
|
||
command === 'start_game_creator_supervisor_runtime_task' ||
|
||
command === 'steer_game_creator_agent_runtime_task',
|
||
).length;
|
||
fireEvent.change(composer, { target: { value: prompt } });
|
||
fireEvent.click(send);
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) =>
|
||
command === 'start_game_creator_supervisor_runtime_task' ||
|
||
command === 'steer_game_creator_agent_runtime_task',
|
||
),
|
||
).toHaveLength(previousSubmissionCount + 1);
|
||
});
|
||
await waitFor(() => {
|
||
expect((send as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
};
|
||
const emitValidation = async (revision: number, validatedAt: number) => {
|
||
harness.setProjectRevision(revision);
|
||
const runtime = gameChatPreviewPlaytestRuntime({
|
||
parentRunId,
|
||
revision,
|
||
updatedAt: validatedAt,
|
||
});
|
||
backgroundRuntimes = [runtime];
|
||
await act(async () => {
|
||
harness.emitAgentRuntime(runtime);
|
||
await Promise.resolve();
|
||
});
|
||
};
|
||
const readAuthorization = () => {
|
||
const raw = window.localStorage.getItem(
|
||
'genarrative.game-chat.auto-preview-authorization.v2',
|
||
);
|
||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : null;
|
||
};
|
||
|
||
return {
|
||
emitValidation,
|
||
get currentPreview() {
|
||
return currentPreview;
|
||
},
|
||
get parentRunId() {
|
||
return parentRunId;
|
||
},
|
||
harness,
|
||
invoke,
|
||
readAuthorization,
|
||
stoppedPreviews,
|
||
submit,
|
||
surface,
|
||
};
|
||
}
|
||
|
||
async function assertNewGameChatAuthorizationSupersedesDeferredAttempt(
|
||
deferredStage: 'policy' | 'start',
|
||
) {
|
||
const projectPath = `/tmp/game-chat-deferred-${deferredStage}`;
|
||
const deferredPolicy = createDeferred<Record<string, unknown>>();
|
||
const deferredStart = createDeferred<GameChatPreviewFixture>();
|
||
const policyResult = {
|
||
path: '.agent/policy.json',
|
||
policy: { deniedCommands: [], confirmCommands: [] },
|
||
};
|
||
const staleStartedPreview = {
|
||
url: 'http://127.0.0.1:4391',
|
||
port: 4391,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
const driver = await renderGameChatAutoPreviewDriver({
|
||
projectPath,
|
||
port: 4391,
|
||
readPolicy: async (callIndex) =>
|
||
deferredStage === 'policy' && callIndex === 1
|
||
? deferredPolicy.promise
|
||
: policyResult,
|
||
startPreview: async (args, callIndex) => {
|
||
expect(args).toEqual({
|
||
projectPath,
|
||
expectedRevision: deferredStage === 'policy' ? 2 : callIndex,
|
||
});
|
||
if (deferredStage === 'start' && callIndex === 1) {
|
||
return deferredStart.promise;
|
||
}
|
||
return {
|
||
url: 'http://127.0.0.1:4391',
|
||
port: 4391,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
},
|
||
});
|
||
|
||
await driver.submit('生成首个可玩版本');
|
||
await driver.emitValidation(1, 1000);
|
||
await waitFor(
|
||
() => {
|
||
const reachedDeferredStage = driver.invoke.mock.calls.some(
|
||
([command]) =>
|
||
command ===
|
||
(deferredStage === 'policy'
|
||
? 'read_project_permission_policy'
|
||
: 'start_local_game_preview'),
|
||
);
|
||
expect(reachedDeferredStage).toBe(true);
|
||
},
|
||
{ timeout: 2_500 },
|
||
);
|
||
const firstAuthorization = driver.readAuthorization();
|
||
expect(firstAuthorization?.authorizationId).toEqual(expect.any(String));
|
||
|
||
await driver.submit('同一 Run 内替换自动预览授权');
|
||
await waitFor(() => {
|
||
const nextAuthorization = driver.readAuthorization();
|
||
expect(nextAuthorization?.authorizationId).toEqual(expect.any(String));
|
||
expect(nextAuthorization?.authorizationId).not.toBe(
|
||
firstAuthorization?.authorizationId,
|
||
);
|
||
expect(nextAuthorization).toMatchObject({
|
||
afterRevision: 1,
|
||
afterValidatedAt: 1000,
|
||
projectPath,
|
||
runId: driver.parentRunId,
|
||
});
|
||
});
|
||
const replacementAuthorizationId =
|
||
driver.readAuthorization()?.authorizationId;
|
||
|
||
await act(async () => {
|
||
if (deferredStage === 'policy') {
|
||
deferredPolicy.resolve(policyResult);
|
||
await deferredPolicy.promise;
|
||
} else {
|
||
deferredStart.resolve(staleStartedPreview);
|
||
await deferredStart.promise;
|
||
}
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
if (deferredStage === 'start') {
|
||
await waitFor(() => {
|
||
expect(driver.stoppedPreviews).toEqual([staleStartedPreview]);
|
||
});
|
||
expect(driver.invoke).toHaveBeenCalledWith(
|
||
'stop_local_game_preview_if_matches',
|
||
{ projectPath, expectedPreview: staleStartedPreview },
|
||
);
|
||
} else {
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(0);
|
||
}
|
||
expect(driver.readAuthorization()?.authorizationId).toBe(
|
||
replacementAuthorizationId,
|
||
);
|
||
expect(screen.queryByLabelText('游戏运行')).toBeNull();
|
||
|
||
await driver.emitValidation(2, 2000);
|
||
expect(
|
||
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
|
||
).not.toBeNull();
|
||
const expectedStartCount = deferredStage === 'start' ? 2 : 1;
|
||
await waitFor(() => {
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(expectedStartCount);
|
||
});
|
||
expect(
|
||
driver.invoke.mock.calls
|
||
.filter(([command]) => command === 'start_local_game_preview')
|
||
.at(-1),
|
||
).toEqual(['start_local_game_preview', { projectPath, expectedRevision: 2 }]);
|
||
expect(driver.readAuthorization()).toBeNull();
|
||
}
|
||
|
||
export function registerProjectWorkbenchFoundationTests() {
|
||
it('renders the first project workbench slice with honest disabled run and local approval UI', () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-draft',
|
||
'陶泥儿工作台测试',
|
||
);
|
||
expect(
|
||
manifest.tasks.find((task) => task.id === 'design-foundation'),
|
||
).toMatchObject({
|
||
title: '确定玩法规格与界面原型',
|
||
artifacts: [
|
||
'memory/project.md',
|
||
'game/game_design.md',
|
||
'assets/ui-prototype.png',
|
||
],
|
||
acceptanceCriteria: [
|
||
'核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图',
|
||
],
|
||
});
|
||
expect(
|
||
manifest.tasks.find((task) => task.id === 'art-asset-plan'),
|
||
).toMatchObject({
|
||
title: '生成首版美术素材',
|
||
artifacts: ['assets/manifest.art.json', 'assets/art-spritesheet.png'],
|
||
acceptanceCriteria: [
|
||
'角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记',
|
||
],
|
||
});
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '陶泥儿工作台测试',
|
||
projectPath: '/tmp/workbench-draft',
|
||
manifest,
|
||
attachments: [
|
||
{
|
||
fileName: 'broken-reference.png',
|
||
mediaType: 'image/png',
|
||
status: 'failed',
|
||
error: '图片解码失败',
|
||
},
|
||
],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement(
|
||
'div',
|
||
{ 'aria-label': '测试项目总控' },
|
||
'项目总控对话内容',
|
||
),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
|
||
expect(screen.getByLabelText('项目主视窗')).not.toBeNull();
|
||
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
|
||
expect(screen.getByLabelText('测试项目总控')).not.toBeNull();
|
||
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
|
||
expect(screen.getByRole('article', { name: /策划 Agent/ })).not.toBeNull();
|
||
expect(screen.getByRole('article', { name: /美术 Agent/ })).not.toBeNull();
|
||
expect(screen.getByRole('article', { name: /程序 Agent/ })).not.toBeNull();
|
||
|
||
const runTab = screen.getByRole('tab', {
|
||
name: '运行',
|
||
}) as HTMLButtonElement;
|
||
expect(runTab.disabled).toBe(false);
|
||
expect(runTab.getAttribute('data-unavailable')).toBe('true');
|
||
fireEvent.click(runTab);
|
||
expect(runTab.getAttribute('aria-selected')).toBe('false');
|
||
expect(
|
||
screen.getByText('首个可运行原型尚未完成,运行视图暂不可用'),
|
||
).not.toBeNull();
|
||
expect(screen.getByLabelText('附件导入失败')).not.toBeNull();
|
||
expect(screen.getByText('broken-reference.png')).not.toBeNull();
|
||
expect(screen.getByText('图片解码失败')).not.toBeNull();
|
||
|
||
fireEvent.click(
|
||
screen.getByRole('button', {
|
||
name: '审批配置,当前严格审批',
|
||
}),
|
||
);
|
||
expect(
|
||
screen.getByRole('dialog', { name: '陶泥儿的操作权限' }),
|
||
).not.toBeNull();
|
||
const riskApproval = screen.getByRole('radio', { name: /风险审批/ });
|
||
fireEvent.click(riskApproval);
|
||
expect(riskApproval.getAttribute('aria-checked')).toBe('false');
|
||
expect(riskApproval.getAttribute('data-unavailable')).toBe('true');
|
||
expect(screen.getAllByText('Rank 规则待定,当前暂不可用')).toHaveLength(2);
|
||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||
expect(
|
||
screen.getByRole('button', {
|
||
name: '审批配置,当前严格审批',
|
||
}),
|
||
).not.toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '更多小组' }));
|
||
expect(screen.getByRole('article', { name: /数值 Agent/ })).not.toBeNull();
|
||
expect(screen.getByRole('article', { name: /音频 Agent/ })).not.toBeNull();
|
||
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
|
||
});
|
||
|
||
it('keeps four resource section heights independent across modes, projects, details, and viewport clamps', async () => {
|
||
function addSectionResources(
|
||
manifest: ReturnType<typeof createGameCreationAppManifest>,
|
||
) {
|
||
manifest.assets = [
|
||
{
|
||
id: 'section-document',
|
||
kind: 'design-document',
|
||
mediaType: 'text/markdown',
|
||
localPath: 'memory/section.md',
|
||
source: { kind: 'generated', taskId: 'design-foundation' },
|
||
},
|
||
{
|
||
id: 'section-art',
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/section.png',
|
||
source: { kind: 'generated', taskId: 'art-asset-plan' },
|
||
},
|
||
{
|
||
id: 'section-audio',
|
||
kind: 'background-music',
|
||
mediaType: 'audio/mpeg',
|
||
localPath: 'assets/section.mp3',
|
||
source: { kind: 'generated', taskId: 'audio-asset-plan' },
|
||
},
|
||
];
|
||
manifest.versions = [
|
||
{
|
||
versionId: 'section-version',
|
||
parentVersionId: null,
|
||
projectRevision: 1,
|
||
resourceBindings: [],
|
||
createdReason: 'initial',
|
||
createdAt: 1,
|
||
},
|
||
];
|
||
}
|
||
|
||
const manifestA = createGameCreationAppManifest(
|
||
'workbench-section-height-a',
|
||
'分区高度项目甲',
|
||
);
|
||
addSectionResources(manifestA);
|
||
const manifestB = createGameCreationAppManifest(
|
||
'workbench-section-height-b',
|
||
'分区高度项目乙',
|
||
);
|
||
addSectionResources(manifestB);
|
||
let layoutRevision = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: args?.expectedProjectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: [],
|
||
updatedAt: layoutRevision,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
layoutRevision += 1;
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: args?.expectedProjectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: args?.positions,
|
||
updatedAt: layoutRevision,
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
const viewProps = {
|
||
projectName: manifestA.name,
|
||
projectPath: '/tmp/workbench-section-height-a',
|
||
manifest: manifestA,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
};
|
||
const rendered = render(
|
||
React.createElement(ProjectDevelopmentView, viewProps),
|
||
);
|
||
await screen.findByRole('button', { name: '缩小文档分区' });
|
||
|
||
const sectionHeight = (label: string) =>
|
||
screen
|
||
.getByRole('region', { name: label })
|
||
.style.getPropertyValue('--resource-section-height');
|
||
const sectionZoom = (label: string) =>
|
||
Number(
|
||
screen
|
||
.getByRole('region', { name: label })
|
||
.querySelector<HTMLElement>('[data-resource-section-plane]')?.dataset
|
||
.resourceSectionScale ?? 0,
|
||
);
|
||
expect(sectionHeight('文档')).toBe(`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`);
|
||
expect(sectionHeight('项目版本')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
|
||
);
|
||
expect(sectionHeight('美术资源')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
|
||
);
|
||
expect(sectionHeight('音乐音效资源')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
|
||
);
|
||
expect(sectionZoom('文档')).toBe(1);
|
||
expect(sectionZoom('美术资源')).toBe(1);
|
||
|
||
const layoutUpdatesBeforeZoom = invoke.mock.calls.filter(
|
||
([command]) => command === 'update_local_project_resource_canvas_layout',
|
||
).length;
|
||
fireEvent.click(screen.getByRole('button', { name: '放大文档内容' }));
|
||
expect(sectionZoom('文档')).toBe(1.1);
|
||
expect(sectionZoom('项目版本')).toBe(1);
|
||
const documentZoomViewport = screen.getByLabelText('文档分区内容');
|
||
const ordinaryWheel = new WheelEvent('wheel', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
deltaY: -100,
|
||
});
|
||
expect(documentZoomViewport.dispatchEvent(ordinaryWheel)).toBe(true);
|
||
expect(ordinaryWheel.defaultPrevented).toBe(false);
|
||
expect(sectionZoom('文档')).toBe(1.1);
|
||
const zoomWheel = new WheelEvent('wheel', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clientX: 0,
|
||
clientY: 0,
|
||
ctrlKey: true,
|
||
deltaY: -100,
|
||
});
|
||
let zoomWheelDispatchResult = true;
|
||
act(() => {
|
||
zoomWheelDispatchResult = documentZoomViewport.dispatchEvent(zoomWheel);
|
||
});
|
||
expect(zoomWheelDispatchResult).toBe(false);
|
||
expect(zoomWheel.defaultPrevented).toBe(true);
|
||
await waitFor(() => expect(sectionZoom('文档')).toBeGreaterThan(1.1));
|
||
const zoomBeforeWebKitGesture = sectionZoom('文档');
|
||
const gestureStart = new Event('gesturestart', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
});
|
||
Object.defineProperties(gestureStart, {
|
||
clientX: { value: 0 },
|
||
clientY: { value: 0 },
|
||
});
|
||
const gestureChange = new Event('gesturechange', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
});
|
||
Object.defineProperty(gestureChange, 'scale', { value: 1.1 });
|
||
act(() => {
|
||
documentZoomViewport.dispatchEvent(gestureStart);
|
||
documentZoomViewport.dispatchEvent(gestureChange);
|
||
documentZoomViewport.dispatchEvent(
|
||
new Event('gestureend', { bubbles: true, cancelable: true }),
|
||
);
|
||
});
|
||
await waitFor(() =>
|
||
expect(sectionZoom('文档')).toBeGreaterThan(zoomBeforeWebKitGesture),
|
||
);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) =>
|
||
command === 'update_local_project_resource_canvas_layout',
|
||
),
|
||
).toHaveLength(layoutUpdatesBeforeZoom);
|
||
|
||
const dependencyDocumentDecrease = screen.getByRole('button', {
|
||
name: '缩小文档分区',
|
||
});
|
||
dependencyDocumentDecrease.focus();
|
||
const layoutUpdatesBeforeResize = invoke.mock.calls.filter(
|
||
([command]) => command === 'update_local_project_resource_canvas_layout',
|
||
).length;
|
||
fireEvent.click(dependencyDocumentDecrease);
|
||
expect(document.activeElement).toBe(dependencyDocumentDecrease);
|
||
expect(sectionHeight('文档')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT - RESOURCE_SECTION_HEIGHT_STEP}px`,
|
||
);
|
||
expect(sectionHeight('项目版本')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
|
||
);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) =>
|
||
command === 'update_local_project_resource_canvas_layout',
|
||
),
|
||
).toHaveLength(layoutUpdatesBeforeResize);
|
||
|
||
const dependencyOuterCanvas = screen.getByLabelText(
|
||
'资源依赖视图',
|
||
) as HTMLDivElement;
|
||
dependencyOuterCanvas.scrollLeft = 31;
|
||
dependencyOuterCanvas.scrollTop = 47;
|
||
fireEvent.scroll(dependencyOuterCanvas);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
const typeOuterCanvas = screen.getByLabelText(
|
||
'资源类型视图',
|
||
) as HTMLDivElement;
|
||
expect(typeOuterCanvas.scrollLeft).toBe(0);
|
||
expect(typeOuterCanvas.scrollTop).toBe(0);
|
||
typeOuterCanvas.scrollLeft = 52;
|
||
typeOuterCanvas.scrollTop = 68;
|
||
fireEvent.scroll(typeOuterCanvas);
|
||
expect(sectionHeight('文档')).toBe(`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`);
|
||
expect(sectionZoom('文档')).toBe(1);
|
||
fireEvent.click(screen.getByRole('button', { name: '放大文档内容' }));
|
||
expect(sectionZoom('文档')).toBe(1.1);
|
||
fireEvent.click(screen.getByRole('button', { name: '放大文档分区' }));
|
||
expect(sectionHeight('文档')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT + RESOURCE_SECTION_HEIGHT_STEP}px`,
|
||
);
|
||
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
|
||
expect(
|
||
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollLeft,
|
||
).toBe(31);
|
||
expect(
|
||
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollTop,
|
||
).toBe(47);
|
||
expect(sectionHeight('文档')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT - RESOURCE_SECTION_HEIGHT_STEP}px`,
|
||
);
|
||
expect(sectionZoom('文档')).toBeGreaterThan(1.1);
|
||
|
||
const documentViewport = screen.getByLabelText(
|
||
'文档分区内容',
|
||
) as HTMLDivElement;
|
||
documentViewport.scrollTop = 44;
|
||
documentViewport.scrollLeft = 12;
|
||
fireEvent.scroll(documentViewport);
|
||
fireEvent.click(
|
||
await screen.findByRole('button', {
|
||
name: /打开资源详情:项目版本 版本 1/,
|
||
}),
|
||
);
|
||
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
|
||
const restoredDocumentViewport = screen.getByLabelText(
|
||
'文档分区内容',
|
||
) as HTMLDivElement;
|
||
expect(restoredDocumentViewport.scrollTop).toBe(44);
|
||
expect(restoredDocumentViewport.scrollLeft).toBe(12);
|
||
expect(sectionHeight('文档')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT - RESOURCE_SECTION_HEIGHT_STEP}px`,
|
||
);
|
||
|
||
let canvasHeight = 420;
|
||
const canvas = screen.getByLabelText('资源依赖视图');
|
||
Object.defineProperty(canvas, 'clientHeight', {
|
||
configurable: true,
|
||
get: () => canvasHeight,
|
||
});
|
||
await act(async () => {
|
||
fireEvent(window, new Event('resize'));
|
||
await new Promise<void>((resolveFrame) =>
|
||
window.requestAnimationFrame(() => resolveFrame()),
|
||
);
|
||
});
|
||
const artIncrease = screen.getByRole('button', {
|
||
name: '放大美术资源分区',
|
||
}) as HTMLButtonElement;
|
||
fireEvent.click(artIncrease);
|
||
fireEvent.click(artIncrease);
|
||
fireEvent.click(artIncrease);
|
||
await waitFor(() => expect(artIncrease.disabled).toBe(true));
|
||
expect(sectionHeight('美术资源')).toBe(
|
||
`${canvasHeight - RESOURCE_CANVAS_VERTICAL_INSET}px`,
|
||
);
|
||
|
||
canvasHeight = 300;
|
||
await act(async () => {
|
||
fireEvent(window, new Event('resize'));
|
||
await new Promise<void>((resolveFrame) =>
|
||
window.requestAnimationFrame(() => resolveFrame()),
|
||
);
|
||
});
|
||
await waitFor(() =>
|
||
expect(sectionHeight('美术资源')).toBe(
|
||
`${canvasHeight - RESOURCE_CANVAS_VERTICAL_INSET}px`,
|
||
),
|
||
);
|
||
|
||
canvasHeight = 500;
|
||
rendered.rerender(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
...viewProps,
|
||
projectName: manifestB.name,
|
||
projectPath: '/tmp/workbench-section-height-b',
|
||
manifest: manifestB,
|
||
}),
|
||
);
|
||
await act(async () => {
|
||
fireEvent(window, new Event('resize'));
|
||
await new Promise<void>((resolveFrame) =>
|
||
window.requestAnimationFrame(() => resolveFrame()),
|
||
);
|
||
});
|
||
await waitFor(() =>
|
||
expect(sectionHeight('文档')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`,
|
||
),
|
||
);
|
||
expect(sectionZoom('文档')).toBe(1);
|
||
expect(
|
||
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollTop,
|
||
).toBe(0);
|
||
rendered.rerender(React.createElement(ProjectDevelopmentView, viewProps));
|
||
await waitFor(() =>
|
||
expect(sectionHeight('文档')).toBe(
|
||
`${RESOURCE_SECTION_DEFAULT_HEIGHT - RESOURCE_SECTION_HEIGHT_STEP}px`,
|
||
),
|
||
);
|
||
expect(sectionZoom('文档')).toBeGreaterThan(1.1);
|
||
expect(
|
||
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollLeft,
|
||
).toBe(31);
|
||
expect(
|
||
(screen.getByLabelText('资源依赖视图') as HTMLDivElement).scrollTop,
|
||
).toBe(47);
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
expect(
|
||
(screen.getByLabelText('资源类型视图') as HTMLDivElement).scrollLeft,
|
||
).toBe(52);
|
||
expect(
|
||
(screen.getByLabelText('资源类型视图') as HTMLDivElement).scrollTop,
|
||
).toBe(68);
|
||
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
|
||
fireEvent.click(
|
||
screen.getByRole('button', {
|
||
name: '恢复文档分区默认尺寸',
|
||
}),
|
||
);
|
||
expect(sectionHeight('文档')).toBe(`${RESOURCE_SECTION_DEFAULT_HEIGHT}px`);
|
||
});
|
||
|
||
it('renders one body-first card system in both layouts and separates detail from single-media playback', async () => {
|
||
const observer = installResourceCardIntersectionObserver();
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-body-first-cards',
|
||
'本体化资源卡测试',
|
||
);
|
||
manifest.assets = [
|
||
{
|
||
id: 'hero-image',
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/hero.png',
|
||
source: { kind: 'generated', taskId: 'art-asset-plan' },
|
||
},
|
||
{
|
||
id: 'intro-video',
|
||
kind: 'video',
|
||
mediaType: 'video/mp4',
|
||
localPath: 'assets/intro.mp4',
|
||
source: { kind: 'generated', taskId: 'art-asset-plan' },
|
||
},
|
||
{
|
||
id: 'theme-audio',
|
||
kind: 'background-music',
|
||
mediaType: 'audio/mpeg',
|
||
localPath: 'assets/theme.mp3',
|
||
source: { kind: 'generated', taskId: 'audio-asset-plan' },
|
||
},
|
||
{
|
||
id: 'design-document',
|
||
kind: 'design-document',
|
||
mediaType: 'text/markdown',
|
||
localPath: 'memory/design.md',
|
||
source: { kind: 'generated', taskId: 'design-foundation' },
|
||
},
|
||
];
|
||
manifest.versions = [
|
||
{
|
||
versionId: 'version-initial',
|
||
parentVersionId: null,
|
||
projectRevision: 1,
|
||
resourceBindings: [],
|
||
createdReason: 'initial',
|
||
createdAt: 1,
|
||
},
|
||
];
|
||
let layoutRevision = 0;
|
||
const imageDataUrl =
|
||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: manifest.projectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
layoutRevision += 1;
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: manifest.projectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: args?.positions,
|
||
updatedAt: layoutRevision,
|
||
},
|
||
};
|
||
}
|
||
if (command === 'read_local_project_image_preview') {
|
||
return {
|
||
path: String(args?.relativePath ?? ''),
|
||
mediaType: 'image/png',
|
||
byteLen: 12,
|
||
dataUrl: imageDataUrl,
|
||
};
|
||
}
|
||
if (command === 'read_local_project_text_preview') {
|
||
return {
|
||
path: String(args?.relativePath ?? ''),
|
||
mediaType: 'text/markdown',
|
||
byteLen: 24,
|
||
content: '# 玩法摘要\n\n这是安全的卡片正文摘要。',
|
||
};
|
||
}
|
||
if (command === 'read_local_project_media_preview') {
|
||
const relativePath = String(args?.relativePath ?? '');
|
||
return relativePath.endsWith('.mp3')
|
||
? {
|
||
path: relativePath,
|
||
mediaType: 'audio/mpeg',
|
||
byteLen: 32,
|
||
dataUrl: 'data:audio/mpeg;base64,SUQz',
|
||
}
|
||
: {
|
||
path: relativePath,
|
||
mediaType: 'video/mp4',
|
||
byteLen: 48,
|
||
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
const play = vi
|
||
.spyOn(HTMLMediaElement.prototype, 'play')
|
||
.mockResolvedValue(undefined);
|
||
const pause = vi
|
||
.spyOn(HTMLMediaElement.prototype, 'pause')
|
||
.mockImplementation(() => undefined);
|
||
|
||
const viewProps = {
|
||
projectName: manifest.name,
|
||
projectPath: '/tmp/workbench-body-first-cards',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
};
|
||
const rendered = render(
|
||
React.createElement(ProjectDevelopmentView, viewProps),
|
||
);
|
||
|
||
const heroDetailButton = await screen.findByRole('button', {
|
||
name: /打开资源详情:美术资源 hero\.png/,
|
||
});
|
||
const heroCard = heroDetailButton.closest('.game-resource-card');
|
||
expect(heroCard?.textContent).not.toContain('hero.png');
|
||
expect(heroCard?.textContent).not.toContain('assets/hero.png');
|
||
expect(heroCard?.textContent).not.toContain('Agent 生成');
|
||
expect(
|
||
heroCard?.querySelector('.game-resource-card-open button'),
|
||
).toBeNull();
|
||
await waitFor(() => expect(observer.observedCount()).toBe(5));
|
||
act(() => observer.triggerVisible());
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
heroCard
|
||
?.querySelector('.game-resource-card-visual > img')
|
||
?.getAttribute('src'),
|
||
).toBe('blob:mock-attachment-preview');
|
||
expect(screen.getByText(/这是安全的卡片正文摘要。/)).not.toBeNull();
|
||
});
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'read_local_project_media_preview' &&
|
||
args?.category === 'audio',
|
||
),
|
||
).toBe(false);
|
||
|
||
const videoControl = screen.getByRole('button', {
|
||
name: '播放 intro.mp4',
|
||
});
|
||
const videoCard = videoControl.closest('.game-resource-card');
|
||
const video = videoCard?.querySelector('video');
|
||
expect(video).not.toBeNull();
|
||
expect(video?.preload).toBe('auto');
|
||
fireEvent.loadedData(video!);
|
||
fireEvent.click(videoControl);
|
||
expect(screen.queryByRole('region', { name: 'intro.mp4' })).toBeNull();
|
||
expect(screen.getByLabelText('资源依赖视图')).not.toBeNull();
|
||
const pauseVideoControl = screen.getByRole('button', {
|
||
name: '暂停 intro.mp4',
|
||
});
|
||
pauseVideoControl.focus();
|
||
|
||
rendered.rerender(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
...viewProps,
|
||
manifest: { ...manifest },
|
||
}),
|
||
);
|
||
const stableVideoControl = screen.getByRole('button', {
|
||
name: '暂停 intro.mp4',
|
||
});
|
||
expect(stableVideoControl).toBe(pauseVideoControl);
|
||
expect(document.activeElement).toBe(stableVideoControl);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '播放 theme.mp3' }));
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'read_local_project_media_preview' &&
|
||
args?.category === 'audio',
|
||
),
|
||
).toBe(true);
|
||
expect(play).toHaveBeenCalled();
|
||
});
|
||
expect(pause.mock.instances).toContain(video);
|
||
expect(screen.queryByRole('region', { name: 'theme.mp3' })).toBeNull();
|
||
|
||
fireEvent.click(heroDetailButton);
|
||
const heroFocus = await screen.findByRole('region', { name: 'hero.png' });
|
||
expect(within(heroFocus).getByText('assets/hero.png')).not.toBeNull();
|
||
expect(within(heroFocus).getByText('Agent 生成')).not.toBeNull();
|
||
expect(within(heroFocus).getByText('image/png')).not.toBeNull();
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'read_local_project_image_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
fireEvent.click(
|
||
within(heroFocus).getByRole('button', { name: '收起资源' }),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
await waitFor(() => expect(observer.observedCount()).toBe(5));
|
||
act(() => observer.triggerVisible());
|
||
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
|
||
const typeVideoControl = await screen.findByRole('button', {
|
||
name: '播放 intro.mp4',
|
||
});
|
||
const typeVideo = typeVideoControl
|
||
.closest('.game-resource-card')
|
||
?.querySelector('video');
|
||
expect(typeVideo).not.toBeNull();
|
||
fireEvent.loadedData(typeVideo!);
|
||
fireEvent.click(typeVideoControl);
|
||
await waitFor(() =>
|
||
expect(
|
||
screen.getByRole('button', { name: '暂停 intro.mp4' }),
|
||
).not.toBeNull(),
|
||
);
|
||
pause.mockClear();
|
||
const search = screen.getByLabelText('搜索项目资源');
|
||
fireEvent.change(search, { target: { value: 'assets/hero.png' } });
|
||
await waitFor(() => expect(pause.mock.instances).toContain(typeVideo));
|
||
expect(screen.getByRole('button', { name: /hero\.png/ })).not.toBeNull();
|
||
expect(screen.queryByRole('button', { name: /intro\.mp4/ })).toBeNull();
|
||
const typeHeroCard = screen
|
||
.getByRole('button', { name: /hero\.png/ })
|
||
.closest('.game-resource-card');
|
||
await waitFor(() =>
|
||
expect(
|
||
typeHeroCard?.querySelector('.game-resource-card-visual > img'),
|
||
).not.toBeNull(),
|
||
);
|
||
fireEvent.error(
|
||
typeHeroCard!.querySelector('.game-resource-card-visual > img')!,
|
||
);
|
||
expect(
|
||
typeHeroCard?.querySelector('.game-resource-card-visual > img'),
|
||
).toBeNull();
|
||
expect(
|
||
typeHeroCard?.querySelector('.game-resource-card-placeholder'),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('bounds visible preview concurrency, deduplicates requests, evicts old entries, and drops late project results', async () => {
|
||
const observer = installResourceCardIntersectionObserver();
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-preview-scheduler',
|
||
'预览调度测试',
|
||
);
|
||
manifest.assets = Array.from({ length: 49 }, (_, index) => ({
|
||
id: `image-${index}`,
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: `assets/image-${index}.png`,
|
||
source: { kind: 'generated' as const },
|
||
}));
|
||
const pending: Array<{
|
||
path: string;
|
||
resolve: (value: {
|
||
path: string;
|
||
mediaType: string;
|
||
byteLen: number;
|
||
dataUrl: string;
|
||
}) => void;
|
||
}> = [];
|
||
let layoutRevision = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: manifest.projectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
layoutRevision += 1;
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: manifest.projectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: args?.positions,
|
||
updatedAt: layoutRevision,
|
||
},
|
||
};
|
||
}
|
||
if (command === 'read_local_project_image_preview') {
|
||
const path = String(args?.relativePath ?? '');
|
||
return new Promise((resolvePreview) => {
|
||
pending.push({ path, resolve: resolvePreview });
|
||
});
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
const props = {
|
||
projectName: manifest.name,
|
||
projectPath: '/tmp/workbench-preview-scheduler',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
};
|
||
const rendered = render(React.createElement(ProjectDevelopmentView, props));
|
||
await waitFor(() => expect(observer.observedCount()).toBe(49));
|
||
act(() => {
|
||
observer.triggerVisible();
|
||
observer.triggerVisible();
|
||
});
|
||
await waitFor(() => expect(pending).toHaveLength(3));
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'read_local_project_image_preview',
|
||
),
|
||
).toHaveLength(3);
|
||
|
||
for (let index = 0; index < 49; index += 1) {
|
||
await waitFor(() => expect(pending[index]).toBeDefined());
|
||
const request = pending[index]!;
|
||
act(() => {
|
||
request.resolve({
|
||
path: request.path,
|
||
mediaType: 'image/png',
|
||
byteLen: 1,
|
||
dataUrl: `data:image/png;base64,${window.btoa(String(index))}`,
|
||
});
|
||
});
|
||
}
|
||
await waitFor(() => {
|
||
expect(
|
||
document.querySelectorAll('.game-resource-card-visual > img'),
|
||
).toHaveLength(48);
|
||
});
|
||
|
||
const lateManifest = createGameCreationAppManifest(
|
||
'workbench-preview-scheduler-next',
|
||
'新项目',
|
||
);
|
||
lateManifest.assets = [
|
||
{
|
||
id: 'next-image',
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/next.png',
|
||
source: { kind: 'generated' },
|
||
},
|
||
];
|
||
manifest.projectId = lateManifest.projectId;
|
||
rendered.rerender(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
...props,
|
||
projectPath: '/tmp/workbench-preview-scheduler-next',
|
||
projectName: lateManifest.name,
|
||
manifest: lateManifest,
|
||
}),
|
||
);
|
||
await waitFor(() =>
|
||
expect(screen.getByRole('button', { name: /next\.png/ })).not.toBeNull(),
|
||
);
|
||
await waitFor(() => expect(observer.observedCount()).toBe(1));
|
||
act(() => observer.triggerVisible());
|
||
await waitFor(() => expect(pending[49]).toBeDefined());
|
||
manifest.projectId = 'workbench-preview-scheduler-final';
|
||
rendered.rerender(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
...props,
|
||
projectPath: '/tmp/workbench-preview-scheduler-final',
|
||
projectName: '最终空项目',
|
||
manifest: {
|
||
...createGameCreationAppManifest(
|
||
'workbench-preview-scheduler-final',
|
||
'最终空项目',
|
||
),
|
||
assets: [],
|
||
},
|
||
}),
|
||
);
|
||
const objectUrlCountBeforeLateResult = vi.mocked(URL.createObjectURL).mock
|
||
.calls.length;
|
||
act(() => {
|
||
pending[49]!.resolve({
|
||
path: 'assets/next.png',
|
||
mediaType: 'image/png',
|
||
byteLen: 1,
|
||
dataUrl: 'data:image/png;base64,LATE',
|
||
});
|
||
});
|
||
await act(async () => Promise.resolve());
|
||
expect(URL.createObjectURL).toHaveBeenCalledTimes(
|
||
objectUrlCountBeforeLateResult,
|
||
);
|
||
});
|
||
|
||
it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-versions',
|
||
'版本工作台测试',
|
||
);
|
||
manifest.assets = [
|
||
{
|
||
id: 'asset-player',
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/player.png',
|
||
source: { kind: 'generated' },
|
||
},
|
||
];
|
||
manifest.versions = [
|
||
{
|
||
versionId: 'version-root',
|
||
parentVersionId: null,
|
||
projectRevision: 3,
|
||
resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }],
|
||
createdReason: 'initial',
|
||
createdAt: 100,
|
||
},
|
||
{
|
||
versionId: 'version-child',
|
||
parentVersionId: 'version-root',
|
||
projectRevision: 4,
|
||
resourceBindings: [
|
||
{ slotId: 'player', resourceId: 'asset-player' },
|
||
{ slotId: 'historical', resourceId: 'asset-removed' },
|
||
],
|
||
createdReason: 'agent-revision',
|
||
createdAt: 200,
|
||
},
|
||
];
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: manifest.name,
|
||
projectPath: '/tmp/workbench-versions',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
const rootVersionButton = screen.getByRole('button', {
|
||
name: /打开资源详情:项目版本 版本 1/,
|
||
});
|
||
const childVersionButton = screen.getByRole('button', {
|
||
name: /打开资源详情:项目版本 版本 2/,
|
||
});
|
||
const rootVersionCard = rootVersionButton.closest('.game-resource-card');
|
||
const childVersionCard = childVersionButton.closest('.game-resource-card');
|
||
expect(rootVersionCard?.textContent).toContain('1 个直接子版本');
|
||
expect(childVersionCard?.textContent).toContain('暂无直接子版本');
|
||
expect(childVersionCard?.textContent).not.toContain('version-root');
|
||
|
||
fireEvent.click(childVersionButton);
|
||
const versionFocus = screen.getByRole('region', { name: '版本 2' });
|
||
expect(within(versionFocus).getByText('version-child')).not.toBeNull();
|
||
expect(within(versionFocus).getByText('version-root')).not.toBeNull();
|
||
expect(within(versionFocus).getByText('Agent 修订')).not.toBeNull();
|
||
expect(
|
||
within(versionFocus).getByText(
|
||
'player → asset-player;historical → asset-removed',
|
||
),
|
||
).not.toBeNull();
|
||
expect(screen.queryByText('asset:asset-removed')).toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
|
||
const playerCard = screen
|
||
.getByRole('button', { name: /player\.png/ })
|
||
.closest('.game-resource-card');
|
||
expect(playerCard?.classList.contains('is-relation-version-binding')).toBe(
|
||
true,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
|
||
await waitFor(() => {
|
||
const dependencyPlayerCard = screen
|
||
.getByRole('button', { name: /player\.png/ })
|
||
.closest('.game-resource-card');
|
||
expect(
|
||
dependencyPlayerCard?.classList.contains('is-relation-version-binding'),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
it('opens text receipts in the central focus state and restores the resource list context', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-resource-details',
|
||
'不应显示的项目标题',
|
||
);
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '不应显示的项目标题',
|
||
projectPath: '/tmp/workbench-resource-details',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
agentRuntimeSummaries: [
|
||
{
|
||
group: 'art',
|
||
label: '美术 Agent',
|
||
status: 'completed',
|
||
statusLabel: '已完成',
|
||
currentTask: '本轮工作已完成',
|
||
currentAction: null,
|
||
waitingOn: null,
|
||
completedCount: 4,
|
||
totalCount: 4,
|
||
},
|
||
],
|
||
agentResults: [
|
||
{
|
||
agentId: 'design-foundation',
|
||
runId: 'design-result-run',
|
||
label: '玩法策划 Agent',
|
||
title: '玩法策划 Agent 文本回执',
|
||
content: '策划回执正文',
|
||
updatedAt: 1,
|
||
},
|
||
{
|
||
agentId: 'art-asset-plan',
|
||
runId: 'art-result-run',
|
||
label: '美术资源计划 Agent',
|
||
title: '美术资源计划 Agent 文本回执',
|
||
content:
|
||
'# 美术计划\n\n- 仅有美术计划\n- 没有图片文件\n\n**等待实际素材生成。**',
|
||
updatedAt: 2,
|
||
},
|
||
],
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
expect(screen.queryByText('不应显示的项目标题')).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '回首页' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '项目组' })).toBeNull();
|
||
expect(screen.getByText('仅完成计划')).not.toBeNull();
|
||
expect(
|
||
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
|
||
).not.toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
const searchInput = screen.getByLabelText(
|
||
'搜索项目资源',
|
||
) as HTMLInputElement;
|
||
fireEvent.change(searchInput, { target: { value: '美术资源计划' } });
|
||
const resourceCanvas = screen.getByLabelText(
|
||
'资源类型视图',
|
||
) as HTMLDivElement;
|
||
resourceCanvas.scrollLeft = 48;
|
||
resourceCanvas.scrollTop = 36;
|
||
|
||
const artReceiptButton = screen.getByRole('button', {
|
||
name: /打开资源详情:文档 美术资源计划 Agent 文本回执/,
|
||
});
|
||
const artReceiptCard = artReceiptButton.closest('.game-resource-card');
|
||
expect(artReceiptCard).not.toBeNull();
|
||
const originalStyle = artReceiptCard?.getAttribute('style');
|
||
fireEvent.pointerDown(artReceiptButton, {
|
||
pointerId: 7,
|
||
button: 0,
|
||
clientX: 0,
|
||
clientY: 0,
|
||
});
|
||
fireEvent.pointerMove(artReceiptButton, {
|
||
pointerId: 7,
|
||
clientX: 240,
|
||
clientY: 32,
|
||
});
|
||
fireEvent.pointerUp(artReceiptButton, {
|
||
pointerId: 7,
|
||
clientX: 240,
|
||
clientY: 32,
|
||
});
|
||
fireEvent.pointerCancel(artReceiptButton, { pointerId: 7 });
|
||
expect(artReceiptCard?.getAttribute('style')).toBe(originalStyle);
|
||
expect(artReceiptCard?.classList.contains('is-dragging')).toBe(false);
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-resource-card-open\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s,
|
||
);
|
||
expect(styles).not.toMatch(/\.game-resource-card\.is-dragging/);
|
||
|
||
fireEvent.click(artReceiptButton);
|
||
const workbenchStage = screen.getByLabelText('项目主视窗');
|
||
expect(workbenchStage.getAttribute('data-resource-view-state')).toBe(
|
||
'resources.focused.document',
|
||
);
|
||
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '按类型' })).toBeNull();
|
||
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
|
||
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
|
||
const receiptFocus = screen.getByRole('region', {
|
||
name: '美术资源计划 Agent 文本回执',
|
||
});
|
||
expect(
|
||
await within(receiptFocus).findByRole('heading', { name: '美术计划' }),
|
||
).not.toBeNull();
|
||
expect(within(receiptFocus).getByText('仅有美术计划')).not.toBeNull();
|
||
expect(within(receiptFocus).getByText('没有图片文件')).not.toBeNull();
|
||
expect(within(receiptFocus).getByText('等待实际素材生成。')).not.toBeNull();
|
||
expect(
|
||
receiptFocus.querySelector('.game-resource-focus-body'),
|
||
).not.toBeNull();
|
||
expect(receiptFocus.querySelector('ul')).not.toBeNull();
|
||
expect(receiptFocus.querySelector('strong')).not.toBeNull();
|
||
expect(receiptFocus.closest('.game-workbench-stage')).toBe(workbenchStage);
|
||
expect(receiptFocus.closest('.game-resource-focus-layer')).toBeNull();
|
||
expect(styles).not.toMatch(
|
||
/\.game-resource-focus-titlebar\s*\{[^}]*cursor:/s,
|
||
);
|
||
|
||
fireEvent.click(
|
||
within(receiptFocus).getByRole('button', { name: '收起资源' }),
|
||
);
|
||
expect(workbenchStage.getAttribute('data-resource-view-state')).toBe(
|
||
'resources.list',
|
||
);
|
||
expect(
|
||
(screen.getByLabelText('搜索项目资源') as HTMLInputElement).value,
|
||
).toBe('美术资源计划');
|
||
expect(
|
||
screen
|
||
.getByRole('button', { name: '按类型' })
|
||
.getAttribute('aria-pressed'),
|
||
).toBe('true');
|
||
const restoredCanvas = screen.getByLabelText(
|
||
'资源类型视图',
|
||
) as HTMLDivElement;
|
||
expect(restoredCanvas.scrollLeft).toBe(48);
|
||
expect(restoredCanvas.scrollTop).toBe(36);
|
||
const restoredCard = screen.getByRole('button', {
|
||
name: /打开资源详情:文档 美术资源计划 Agent 文本回执/,
|
||
});
|
||
expect(restoredCard?.getAttribute('aria-pressed')).toBe('true');
|
||
expect(document.activeElement).toBe(restoredCard);
|
||
fireEvent.click(restoredCard);
|
||
expect(
|
||
screen.getByRole('region', {
|
||
name: '美术资源计划 Agent 文本回执',
|
||
}),
|
||
).not.toBeNull();
|
||
fireEvent.keyDown(window, { key: 'Escape' });
|
||
const escapeRestoredCard = screen.getByRole('button', {
|
||
name: /打开资源详情:文档 美术资源计划 Agent 文本回执/,
|
||
});
|
||
expect(document.activeElement).toBe(escapeRestoredCard);
|
||
});
|
||
|
||
it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-resource-media',
|
||
'资源媒体测试',
|
||
);
|
||
manifest.assets.push(
|
||
{
|
||
id: 'design-document',
|
||
kind: 'design-document',
|
||
mediaType: 'text/markdown',
|
||
localPath: 'game/design.md',
|
||
source: { kind: 'generated' },
|
||
},
|
||
{
|
||
id: 'art-svg',
|
||
kind: 'icon',
|
||
mediaType: 'image/svg+xml',
|
||
localPath: 'assets/icon.svg',
|
||
source: { kind: 'generated' },
|
||
},
|
||
{
|
||
id: 'art-video',
|
||
kind: 'animation',
|
||
mediaType: 'video/mp4',
|
||
localPath: 'assets/intro.mp4',
|
||
source: { kind: 'generated' },
|
||
},
|
||
{
|
||
id: 'audio-bgm',
|
||
kind: 'bgm',
|
||
mediaType: 'audio/mpeg',
|
||
localPath: 'assets/bgm.mp3',
|
||
source: { kind: 'generated' },
|
||
},
|
||
{
|
||
id: 'blocked-document',
|
||
kind: 'design-document',
|
||
mediaType: 'text/markdown',
|
||
localPath: 'game/blocked.md',
|
||
source: { kind: 'generated' },
|
||
},
|
||
);
|
||
let layoutRevision = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: manifest.projectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
layoutRevision += 1;
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: manifest.projectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: args?.positions,
|
||
updatedAt: layoutRevision,
|
||
},
|
||
};
|
||
}
|
||
if (command === 'read_local_project_text_preview') {
|
||
if (args?.relativePath === 'game/blocked.md') {
|
||
throw new Error('项目权限策略要求用户确认:file.read');
|
||
}
|
||
expect(args).toMatchObject({
|
||
projectPath: '/tmp/workbench-resource-media',
|
||
relativePath: 'game/design.md',
|
||
});
|
||
return {
|
||
path: 'game/design.md',
|
||
mediaType: 'text/markdown',
|
||
byteLen: 64,
|
||
content:
|
||
'# 本地设计文档\n\n[外部链接](https://example.com)\n\n\n\n<script>window.pwned = true</script>',
|
||
};
|
||
}
|
||
if (command === 'read_local_project_media_preview') {
|
||
if (args?.category === 'art') {
|
||
if (args?.relativePath === 'assets/intro.mp4') {
|
||
return {
|
||
path: 'assets/intro.mp4',
|
||
mediaType: 'video/mp4',
|
||
byteLen: 128,
|
||
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
|
||
};
|
||
}
|
||
return {
|
||
path: 'assets/icon.svg',
|
||
mediaType: 'image/svg+xml',
|
||
byteLen: 48,
|
||
dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+',
|
||
};
|
||
}
|
||
return {
|
||
path: 'assets/bgm.mp3',
|
||
mediaType: 'audio/mpeg',
|
||
byteLen: 1024,
|
||
dataUrl: 'data:audio/mpeg;base64,SUQz',
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '资源媒体测试',
|
||
projectPath: '/tmp/workbench-resource-media',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
fireEvent.click(await screen.findByRole('button', { name: /design\.md/ }));
|
||
const documentFocus = await screen.findByRole('region', {
|
||
name: 'design.md',
|
||
});
|
||
expect(
|
||
within(documentFocus).getByRole('heading', { name: '本地设计文档' }),
|
||
).not.toBeNull();
|
||
expect(documentFocus.querySelector('script')).toBeNull();
|
||
expect(documentFocus.querySelector('a')).toBeNull();
|
||
expect(within(documentFocus).getByText('图片:远程图片')).not.toBeNull();
|
||
fireEvent.click(
|
||
within(documentFocus).getByRole('button', { name: '收起资源' }),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /icon\.svg/ }));
|
||
const artDetails = await screen.findByRole('region', { name: 'icon.svg' });
|
||
expect(within(artDetails).getByText('assets/icon.svg')).not.toBeNull();
|
||
expect(within(artDetails).getByText('image/svg+xml')).not.toBeNull();
|
||
expect(within(artDetails).getByText('依赖层级')).not.toBeNull();
|
||
expect(screen.queryByLabelText('icon.svg 图片预览')).toBeNull();
|
||
expect(artDetails.querySelector('img')).toBeNull();
|
||
fireEvent.click(
|
||
within(artDetails).getByRole('button', { name: '收起资源' }),
|
||
);
|
||
|
||
fireEvent.click(
|
||
screen.getByRole('button', {
|
||
name: '打开资源详情:美术资源 intro.mp4',
|
||
}),
|
||
);
|
||
const videoDetails = await screen.findByRole('region', {
|
||
name: 'intro.mp4',
|
||
});
|
||
expect(within(videoDetails).getByText('assets/intro.mp4')).not.toBeNull();
|
||
expect(within(videoDetails).getByText('video/mp4')).not.toBeNull();
|
||
expect(screen.queryByLabelText('intro.mp4 视频预览')).toBeNull();
|
||
expect(videoDetails.querySelector('video')).toBeNull();
|
||
fireEvent.click(
|
||
within(videoDetails).getByRole('button', { name: '收起资源' }),
|
||
);
|
||
|
||
fireEvent.click(
|
||
screen.getByRole('button', {
|
||
name: '打开资源详情:音乐音效资源 bgm.mp3',
|
||
}),
|
||
);
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'read_local_project_media_preview' &&
|
||
args?.category === 'audio',
|
||
),
|
||
).toBe(false);
|
||
fireEvent.click(screen.getByRole('button', { name: '播放 bgm.mp3' }));
|
||
const audio = (await screen.findByLabelText(
|
||
'bgm.mp3 音频播放器',
|
||
)) as HTMLAudioElement;
|
||
expect(audio.controls).toBe(true);
|
||
expect(audio.getAttribute('src')).toBe('blob:mock-attachment-preview');
|
||
expect(screen.getByText('载入后显示')).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'read_local_project_media_preview',
|
||
expect.objectContaining({ category: 'audio' }),
|
||
);
|
||
fireEvent.click(
|
||
within(screen.getByRole('region', { name: 'bgm.mp3' })).getByRole(
|
||
'button',
|
||
{ name: '收起资源' },
|
||
),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /blocked\.md/ }));
|
||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||
'当前项目策略要求先确认读取文档,确认后请关闭详情并重试',
|
||
);
|
||
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
|
||
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
|
||
});
|
||
|
||
it('preserves internal media focus across same-resource manifest updates and falls back when the resource is deleted', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-resource-focus-updates',
|
||
'资源焦点更新测试',
|
||
);
|
||
manifest.assets = [
|
||
{
|
||
id: 'focus-audio',
|
||
kind: 'background-music',
|
||
mediaType: 'audio/mpeg',
|
||
localPath: 'assets/focus.mp3',
|
||
source: { kind: 'generated' },
|
||
},
|
||
];
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: manifest.projectId,
|
||
mode: args?.mode,
|
||
revision: 0,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: manifest.projectId,
|
||
mode: args?.mode,
|
||
revision: 1,
|
||
positions: args?.positions,
|
||
updatedAt: 1,
|
||
},
|
||
};
|
||
}
|
||
if (command === 'read_local_project_media_preview') {
|
||
return {
|
||
path: 'assets/focus.mp3',
|
||
mediaType: 'audio/mpeg',
|
||
byteLen: 1024,
|
||
dataUrl: 'data:audio/mpeg;base64,SUQz',
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
||
const viewProps = {
|
||
projectName: manifest.name,
|
||
projectPath: '/tmp/workbench-resource-focus-updates',
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
};
|
||
const rendered = render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
...viewProps,
|
||
manifest,
|
||
}),
|
||
);
|
||
|
||
fireEvent.click(
|
||
await screen.findByRole('button', {
|
||
name: '打开资源详情:音乐音效资源 focus.mp3',
|
||
}),
|
||
);
|
||
fireEvent.click(screen.getByRole('button', { name: '播放 focus.mp3' }));
|
||
const audio = (await screen.findByLabelText(
|
||
'focus.mp3 音频播放器',
|
||
)) as HTMLAudioElement;
|
||
audio.focus();
|
||
expect(document.activeElement).toBe(audio);
|
||
|
||
const updatedManifest = {
|
||
...manifest,
|
||
tasks: manifest.tasks.map((task) =>
|
||
task.id === 'audio-director'
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
),
|
||
assets: manifest.assets.map((asset) => ({
|
||
...asset,
|
||
source: { ...asset.source, taskId: 'audio-director' },
|
||
})),
|
||
};
|
||
rendered.rerender(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
...viewProps,
|
||
manifest: updatedManifest,
|
||
}),
|
||
);
|
||
|
||
expect(document.activeElement).toBe(audio);
|
||
expect(screen.getByRole('region', { name: 'focus.mp3' })).not.toBe(
|
||
document.activeElement,
|
||
);
|
||
|
||
rendered.rerender(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
...viewProps,
|
||
manifest: { ...updatedManifest, assets: [] },
|
||
}),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.queryByRole('region', { name: 'focus.mp3' })).toBeNull();
|
||
expect(document.activeElement).toBe(
|
||
screen.getByLabelText('搜索项目资源'),
|
||
);
|
||
});
|
||
expect(
|
||
screen
|
||
.queryAllByTitle('打开资源详情')
|
||
.some((card) => card.getAttribute('aria-pressed') === 'true'),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('renders, filters, and destroys same-category resource dependency lines', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-resource-graph',
|
||
'资源依赖图测试',
|
||
);
|
||
manifest.assets.push(
|
||
{
|
||
id: 'dependency-spec',
|
||
kind: 'ui-spec',
|
||
mediaType: 'application/json',
|
||
localPath: 'assets/spec-source.json',
|
||
source: {
|
||
kind: 'canvas',
|
||
taskId: 'task-1',
|
||
resourceId: 'canvas-spec-source',
|
||
},
|
||
},
|
||
{
|
||
id: 'dependency-ui',
|
||
kind: 'ui-prototype',
|
||
mediaType: 'application/json',
|
||
localPath: 'assets/ui-dependency.json',
|
||
source: {
|
||
kind: 'canvas',
|
||
taskId: 'task-2',
|
||
resourceId: 'canvas-ui-target',
|
||
referenceResourceIds: ['canvas-spec-source'],
|
||
},
|
||
},
|
||
{
|
||
id: 'unrelated-cycle',
|
||
kind: 'metadata',
|
||
mediaType: 'application/json',
|
||
localPath: 'assets/unrelated-cycle.json',
|
||
source: {
|
||
kind: 'canvas',
|
||
resourceId: 'canvas-unrelated',
|
||
referenceResourceIds: ['canvas-unrelated'],
|
||
},
|
||
},
|
||
);
|
||
|
||
const referenceId =
|
||
'asset-reference:["asset:dependency-spec","asset:dependency-ui"]';
|
||
const selfReferenceId =
|
||
'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]';
|
||
const crossReferenceId =
|
||
'asset-reference:["asset:dependency-spec","asset:unrelated-cycle"]';
|
||
const flowId = 'task-flow:["art-director","design-foundation"]';
|
||
let layoutRevision = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
expect(args?.resources).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
resourceId: 'asset:dependency-spec',
|
||
manifestAssetId: 'dependency-spec',
|
||
producerTaskId: null,
|
||
}),
|
||
]),
|
||
);
|
||
return {
|
||
resourceIds: [
|
||
'asset:dependency-spec',
|
||
'asset:dependency-ui',
|
||
'asset:unrelated-cycle',
|
||
],
|
||
referenceEdges: [
|
||
{
|
||
id: referenceId,
|
||
kind: 'asset-reference',
|
||
sourceResourceId: 'asset:dependency-spec',
|
||
targetResourceId: 'asset:dependency-ui',
|
||
cyclic: false,
|
||
},
|
||
{
|
||
id: selfReferenceId,
|
||
kind: 'asset-reference',
|
||
sourceResourceId: 'asset:unrelated-cycle',
|
||
targetResourceId: 'asset:unrelated-cycle',
|
||
cyclic: true,
|
||
},
|
||
{
|
||
id: crossReferenceId,
|
||
kind: 'asset-reference',
|
||
sourceResourceId: 'asset:dependency-spec',
|
||
targetResourceId: 'asset:unrelated-cycle',
|
||
cyclic: false,
|
||
},
|
||
],
|
||
taskFlows: [
|
||
{
|
||
id: flowId,
|
||
kind: 'task-flow',
|
||
sourceTaskId: 'art-director',
|
||
targetTaskId: 'design-foundation',
|
||
sourceResourceIds: ['asset:dependency-spec'],
|
||
targetResourceIds: ['asset:dependency-ui'],
|
||
cyclic: false,
|
||
},
|
||
],
|
||
connectionIndex: [
|
||
{
|
||
resourceId: 'asset:dependency-spec',
|
||
upstreamReferenceResourceIds: [],
|
||
downstreamReferenceResourceIds: [
|
||
'asset:dependency-ui',
|
||
'asset:unrelated-cycle',
|
||
],
|
||
referenceEdgeIds: [referenceId, crossReferenceId],
|
||
taskFlowIds: [flowId],
|
||
},
|
||
{
|
||
resourceId: 'asset:dependency-ui',
|
||
upstreamReferenceResourceIds: ['asset:dependency-spec'],
|
||
downstreamReferenceResourceIds: [],
|
||
referenceEdgeIds: [referenceId],
|
||
taskFlowIds: [flowId],
|
||
},
|
||
{
|
||
resourceId: 'asset:unrelated-cycle',
|
||
upstreamReferenceResourceIds: [
|
||
'asset:dependency-spec',
|
||
'asset:unrelated-cycle',
|
||
],
|
||
downstreamReferenceResourceIds: ['asset:unrelated-cycle'],
|
||
referenceEdgeIds: [selfReferenceId, crossReferenceId],
|
||
taskFlowIds: [],
|
||
},
|
||
],
|
||
producerAssignments: [
|
||
{
|
||
resourceId: 'asset:dependency-spec',
|
||
taskId: 'art-director',
|
||
},
|
||
{
|
||
resourceId: 'asset:dependency-ui',
|
||
taskId: 'design-foundation',
|
||
},
|
||
],
|
||
dependencyDepths: [
|
||
{
|
||
resourceId: 'asset:dependency-spec',
|
||
dependencyDepth: 0,
|
||
},
|
||
{
|
||
resourceId: 'asset:dependency-ui',
|
||
dependencyDepth: 1,
|
||
},
|
||
{
|
||
resourceId: 'asset:unrelated-cycle',
|
||
dependencyDepth: 0,
|
||
},
|
||
],
|
||
unresolvedReferenceResourceIds: [],
|
||
cyclicResourceIds: ['asset:unrelated-cycle'],
|
||
cyclicTaskIds: [],
|
||
producerMappingTruncated: false,
|
||
};
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: 'workbench-resource-graph',
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
layoutRevision += 1;
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId: 'workbench-resource-graph',
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: args?.positions,
|
||
updatedAt: layoutRevision,
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
const view = render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '资源依赖图测试',
|
||
projectPath: '/tmp/workbench-resource-graph',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
let overlay = await screen.findByTestId('resource-dependency-overlay');
|
||
await waitFor(() => {
|
||
expect(
|
||
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
||
).toHaveLength(2);
|
||
expect(
|
||
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
|
||
).toHaveLength(0);
|
||
});
|
||
const dependencyCanvas = screen.getByLabelText('资源依赖视图');
|
||
const descriptionId = dependencyCanvas.getAttribute('aria-describedby');
|
||
expect(descriptionId).not.toBeNull();
|
||
const relationshipDescription = document.getElementById(descriptionId!);
|
||
expect(relationshipDescription?.textContent).toContain(
|
||
'ui-dependency.json(待视觉验收) 引用 spec-source.json',
|
||
);
|
||
expect(
|
||
overlay.querySelector(`[data-edge-id='${crossReferenceId}']`),
|
||
).toBeNull();
|
||
expect(relationshipDescription?.textContent).not.toContain(
|
||
'unrelated-cycle.json 引用 spec-source.json',
|
||
);
|
||
expect(relationshipDescription?.textContent).not.toContain('art-director');
|
||
expect(
|
||
Array.from(overlay.querySelectorAll('svg')).every(
|
||
(sectionOverlay) =>
|
||
sectionOverlay.getAttribute('aria-hidden') === 'true',
|
||
),
|
||
).toBe(true);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
expect(screen.queryByTestId('resource-dependency-overlay')).toBeNull();
|
||
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
|
||
overlay = await screen.findByTestId('resource-dependency-overlay');
|
||
|
||
const search = screen.getByLabelText('搜索项目资源');
|
||
fireEvent.change(search, { target: { value: 'ui-dependency' } });
|
||
await waitFor(() => {
|
||
expect(overlay.querySelector('[data-edge-kind]')).toBeNull();
|
||
});
|
||
fireEvent.change(search, { target: { value: '' } });
|
||
|
||
let sourceCard = screen.getByRole('button', {
|
||
name: /spec-source\.json/,
|
||
});
|
||
const targetCard = screen.getByRole('button', {
|
||
name: /ui-dependency\.json/,
|
||
});
|
||
const referenceSelector =
|
||
'[data-edge-kind="asset-reference"]' +
|
||
'[data-source-resource-id="asset:dependency-spec"]' +
|
||
'[data-target-resource-id="asset:dependency-ui"]';
|
||
const firstPath = await waitFor(() => {
|
||
const path = overlay.querySelector(referenceSelector);
|
||
expect(path).not.toBeNull();
|
||
return path?.getAttribute('d');
|
||
});
|
||
const sourceStyle = sourceCard.getAttribute('style');
|
||
const layoutUpdatesBeforePointer = invoke.mock.calls.filter(
|
||
([command]) => command === 'update_local_project_resource_canvas_layout',
|
||
).length;
|
||
|
||
fireEvent.pointerDown(sourceCard, {
|
||
pointerId: 27,
|
||
button: 0,
|
||
clientX: 0,
|
||
clientY: 0,
|
||
});
|
||
fireEvent.pointerMove(sourceCard, {
|
||
pointerId: 27,
|
||
clientX: 72,
|
||
clientY: 28,
|
||
});
|
||
fireEvent.pointerUp(sourceCard, {
|
||
pointerId: 27,
|
||
clientX: 72,
|
||
clientY: 28,
|
||
});
|
||
fireEvent.pointerCancel(sourceCard, { pointerId: 27 });
|
||
expect(sourceCard.getAttribute('style')).toBe(sourceStyle);
|
||
expect(sourceCard.classList.contains('is-dragging')).toBe(false);
|
||
expect(overlay.querySelector(referenceSelector)?.getAttribute('d')).toBe(
|
||
firstPath,
|
||
);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) =>
|
||
command === 'update_local_project_resource_canvas_layout',
|
||
),
|
||
).toHaveLength(layoutUpdatesBeforePointer);
|
||
|
||
fireEvent.click(targetCard);
|
||
const targetFocus = screen.getByRole('region', {
|
||
name: /ui-dependency\.json/u,
|
||
});
|
||
fireEvent.click(
|
||
within(targetFocus).getByRole('button', { name: '收起资源' }),
|
||
);
|
||
overlay = await screen.findByTestId('resource-dependency-overlay');
|
||
sourceCard = screen.getByRole('button', { name: /spec-source\.json/ });
|
||
await waitFor(() =>
|
||
expect(overlay.querySelector(referenceSelector)).not.toBeNull(),
|
||
);
|
||
expect(sourceCard.classList.contains('is-relation-upstream')).toBe(false);
|
||
expect(
|
||
overlay
|
||
.querySelector(referenceSelector)
|
||
?.classList.contains('is-highlighted'),
|
||
).toBe(false);
|
||
expect(
|
||
overlay
|
||
.querySelector('[data-source-resource-id="asset:unrelated-cycle"]')
|
||
?.classList.contains('is-dimmed'),
|
||
).toBe(false);
|
||
|
||
const previousReferencePaths = Array.from(
|
||
overlay.querySelectorAll('[data-edge-kind]'),
|
||
);
|
||
const nextManifest = createGameCreationAppManifest(
|
||
'workbench-resource-graph-next',
|
||
'新资源依赖图测试',
|
||
);
|
||
view.rerender(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '新资源依赖图测试',
|
||
projectPath: '/tmp/workbench-resource-graph-next',
|
||
manifest: nextManifest,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
const nextOverlay = await screen.findByTestId(
|
||
'resource-dependency-overlay',
|
||
);
|
||
expect(previousReferencePaths).not.toHaveLength(0);
|
||
await waitFor(() => {
|
||
expect(
|
||
previousReferencePaths.every(
|
||
(previousReferencePath) => !previousReferencePath.isConnected,
|
||
),
|
||
).toBe(true);
|
||
expect(nextOverlay.querySelector('[data-edge-kind]')).toBeNull();
|
||
});
|
||
});
|
||
|
||
it('coalesces section scroll geometry, keeps partial endpoints stable, and cleans one dependency observer', async () => {
|
||
const referenceId = 'asset-reference:["resource-a","resource-b"]';
|
||
const graph = normalizeProjectResourceGraph({
|
||
resourceIds: ['resource-a', 'resource-b'],
|
||
referenceEdges: [
|
||
{
|
||
id: referenceId,
|
||
kind: 'asset-reference',
|
||
sourceResourceId: 'resource-a',
|
||
targetResourceId: 'resource-b',
|
||
cyclic: false,
|
||
},
|
||
],
|
||
taskFlows: [],
|
||
connectionIndex: [
|
||
{
|
||
resourceId: 'resource-a',
|
||
upstreamReferenceResourceIds: [],
|
||
downstreamReferenceResourceIds: ['resource-b'],
|
||
referenceEdgeIds: [referenceId],
|
||
taskFlowIds: [],
|
||
},
|
||
{
|
||
resourceId: 'resource-b',
|
||
upstreamReferenceResourceIds: ['resource-a'],
|
||
downstreamReferenceResourceIds: [],
|
||
referenceEdgeIds: [referenceId],
|
||
taskFlowIds: [],
|
||
},
|
||
],
|
||
producerAssignments: [],
|
||
dependencyDepths: [],
|
||
unresolvedReferenceResourceIds: [],
|
||
cyclicResourceIds: [],
|
||
cyclicTaskIds: [],
|
||
producerMappingTruncated: false,
|
||
});
|
||
const positions: ProjectResourceCanvasPosition[] = [
|
||
{
|
||
resourceId: 'resource-a',
|
||
section: 'art',
|
||
x: 0,
|
||
y: 0,
|
||
manuallyPlaced: false,
|
||
},
|
||
{
|
||
resourceId: 'resource-b',
|
||
section: 'art',
|
||
x: 0,
|
||
y: 144,
|
||
manuallyPlaced: false,
|
||
},
|
||
];
|
||
let observerCount = 0;
|
||
let observerDisconnected = false;
|
||
class TestResizeObserver {
|
||
constructor(readonly callback: ResizeObserverCallback) {
|
||
observerCount += 1;
|
||
}
|
||
|
||
observe() {}
|
||
|
||
unobserve() {}
|
||
|
||
disconnect() {
|
||
observerDisconnected = true;
|
||
}
|
||
}
|
||
Object.defineProperty(window, 'ResizeObserver', {
|
||
configurable: true,
|
||
value: TestResizeObserver,
|
||
});
|
||
const animationFrame = vi.spyOn(window, 'requestAnimationFrame');
|
||
const originalGetBoundingClientRect =
|
||
HTMLElement.prototype.getBoundingClientRect;
|
||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||
function getSectionRect(this: HTMLElement) {
|
||
const rect = (
|
||
left: number,
|
||
top: number,
|
||
width: number,
|
||
height: number,
|
||
) =>
|
||
({
|
||
x: left,
|
||
y: top,
|
||
left,
|
||
top,
|
||
right: left + width,
|
||
bottom: top + height,
|
||
width,
|
||
height,
|
||
toJSON: () => ({}),
|
||
}) as DOMRect;
|
||
if (this.classList.contains('test-resource-content')) {
|
||
return rect(0, 0, 600, 800);
|
||
}
|
||
if (this.dataset.resourceSectionScroll === 'art') {
|
||
return rect(0, 100, 600, 300);
|
||
}
|
||
if (this.dataset.resourceSectionPlane === 'art') {
|
||
const viewport = this.closest<HTMLElement>(
|
||
'[data-resource-section-scroll="art"]',
|
||
);
|
||
return rect(
|
||
-(viewport?.scrollLeft ?? 0),
|
||
100 - (viewport?.scrollTop ?? 0),
|
||
600,
|
||
400,
|
||
);
|
||
}
|
||
return originalGetBoundingClientRect.call(this);
|
||
},
|
||
);
|
||
|
||
const rendered = render(
|
||
React.createElement(
|
||
'div',
|
||
{ className: 'test-resource-outer' },
|
||
React.createElement(
|
||
'div',
|
||
{
|
||
className: 'test-resource-content',
|
||
'data-testid': 'resource-dependency-overlay',
|
||
},
|
||
React.createElement(
|
||
'section',
|
||
null,
|
||
React.createElement(
|
||
'div',
|
||
{
|
||
'data-resource-section-scroll': 'art',
|
||
},
|
||
React.createElement(
|
||
'div',
|
||
{
|
||
'data-resource-section-plane': 'art',
|
||
'data-resource-section-scale': '1',
|
||
},
|
||
React.createElement(ResourceDependencyOverlay, {
|
||
graph,
|
||
positions,
|
||
section: 'art',
|
||
visibleResourceIds: new Set(['resource-a', 'resource-b']),
|
||
geometryRevision: '340@1',
|
||
}),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||
const referenceSelector = `[data-edge-id='${referenceId}']`;
|
||
await waitFor(() => {
|
||
const referencePath = overlay.querySelector(referenceSelector);
|
||
expect(referencePath?.getAttribute('d')).toBeTruthy();
|
||
expect(referencePath?.getAttribute('data-route-axis')).toBe('vertical');
|
||
expect(referencePath?.getAttribute('marker-end')).toMatch(
|
||
/^url\(#.+-asset-reference-arrow\)$/u,
|
||
);
|
||
});
|
||
expect(observerCount).toBe(1);
|
||
expect(
|
||
overlay
|
||
.querySelector('[data-testid="resource-dependency-overlay-art"]')
|
||
?.parentElement?.getAttribute('data-resource-section-plane'),
|
||
).toBe('art');
|
||
expect(overlay.querySelector('clipPath')).toBeNull();
|
||
|
||
const viewport = rendered.container.querySelector<HTMLElement>(
|
||
'[data-resource-section-scroll="art"]',
|
||
);
|
||
if (!viewport) {
|
||
throw new Error('missing art section viewport');
|
||
}
|
||
const removeViewportListener = vi.spyOn(viewport, 'removeEventListener');
|
||
const referenceBeforeScroll = overlay.querySelector(referenceSelector);
|
||
expect(referenceBeforeScroll).not.toBeNull();
|
||
const frameCountBeforeScroll = animationFrame.mock.calls.length;
|
||
viewport.scrollTop = 80;
|
||
fireEvent.scroll(viewport);
|
||
fireEvent.scroll(viewport);
|
||
fireEvent.scroll(viewport);
|
||
// A geometry frame can already be pending from the initial height pass.
|
||
// In that case every scroll reuses it; otherwise the first scroll schedules
|
||
// exactly one frame. Both cases satisfy the one-frame coalescing contract.
|
||
expect(
|
||
animationFrame.mock.calls.length - frameCountBeforeScroll,
|
||
).toBeLessThanOrEqual(1);
|
||
await waitFor(() => {
|
||
expect(overlay.querySelector(referenceSelector)).toBe(
|
||
referenceBeforeScroll,
|
||
);
|
||
expect(
|
||
overlay
|
||
.querySelector('[data-testid="resource-dependency-overlay-art"]')
|
||
?.getAttribute('data-logical-viewport'),
|
||
).toBe('0,80,600,300');
|
||
});
|
||
|
||
viewport.scrollTop = 0;
|
||
fireEvent.scroll(viewport);
|
||
await waitFor(() =>
|
||
expect(overlay.querySelector(referenceSelector)).toBe(
|
||
referenceBeforeScroll,
|
||
),
|
||
);
|
||
rendered.unmount();
|
||
expect(observerDisconnected).toBe(true);
|
||
expect(removeViewportListener).toHaveBeenCalledWith(
|
||
'scroll',
|
||
expect.any(Function),
|
||
);
|
||
});
|
||
|
||
it('waits for the scoped resource graph before initializing dependency layout', async () => {
|
||
const projectId = 'workbench-delayed-resource-graph';
|
||
const projectPath = '/tmp/workbench-delayed-resource-graph';
|
||
const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试');
|
||
const agentResults = [
|
||
{
|
||
agentId: 'design-foundation',
|
||
runId: 'delayed-graph-run',
|
||
label: '玩法策划 Agent',
|
||
title: '延迟依赖图回执',
|
||
content: '图就绪后再初始化布局',
|
||
updatedAt: 1,
|
||
},
|
||
];
|
||
let resolveGraph: (() => void) | null = null;
|
||
let dependencyLayoutReads = 0;
|
||
let layoutRevision = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return await new Promise((resolve) => {
|
||
resolveGraph = () => resolve(resourceGraphForInputs(args));
|
||
});
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
if (args?.mode === 'dependency') {
|
||
dependencyLayoutReads += 1;
|
||
}
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
layoutRevision += 1;
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode: args?.mode,
|
||
revision: layoutRevision,
|
||
positions: args?.positions,
|
||
updatedAt: layoutRevision,
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '延迟依赖图测试',
|
||
projectPath,
|
||
manifest,
|
||
attachments: [],
|
||
agentResults,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
}),
|
||
);
|
||
|
||
await waitFor(() => expect(resolveGraph).not.toBeNull());
|
||
expect(dependencyLayoutReads).toBe(0);
|
||
expect(
|
||
screen.queryByRole('button', {
|
||
name: '打开资源详情:文档 延迟依赖图回执',
|
||
}),
|
||
).toBeNull();
|
||
|
||
await act(async () => {
|
||
resolveGraph?.();
|
||
await Promise.resolve();
|
||
});
|
||
expect(
|
||
await screen.findByRole('button', {
|
||
name: '打开资源详情:文档 延迟依赖图回执',
|
||
}),
|
||
).not.toBeNull();
|
||
expect(dependencyLayoutReads).toBe(1);
|
||
});
|
||
|
||
it('keeps resource cards mounted across equivalent rerenders and graph refreshes', async () => {
|
||
const projectId = 'workbench-stable-resource-graph';
|
||
const projectPath = '/tmp/workbench-stable-resource-graph';
|
||
const manifest = createGameCreationAppManifest(projectId, '稳定依赖图测试');
|
||
const firstResult = {
|
||
agentId: 'design-foundation',
|
||
runId: 'stable-graph-run',
|
||
label: '玩法策划 Agent',
|
||
title: '稳定依赖图回执',
|
||
content: '轮询刷新时保留现有资源卡',
|
||
updatedAt: 1,
|
||
};
|
||
let graphReads = 0;
|
||
let layoutRevision = 0;
|
||
let resolveGraphRefresh: (() => void) | null = null;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
graphReads += 1;
|
||
if (graphReads === 1) {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
return await new Promise((resolve) => {
|
||
resolveGraphRefresh = () => resolve(resourceGraphForInputs(args));
|
||
});
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode: 'dependency',
|
||
revision: layoutRevision,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
layoutRevision += 1;
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode: 'dependency',
|
||
revision: layoutRevision,
|
||
positions: args?.positions,
|
||
updatedAt: layoutRevision,
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
const renderView = (agentResults: ProjectAgentResultSummary[]) =>
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '稳定依赖图测试',
|
||
projectPath,
|
||
manifest,
|
||
attachments: [],
|
||
agentResults,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
});
|
||
const view = render(renderView([firstResult]));
|
||
const firstCardButton = await screen.findByRole('button', {
|
||
name: '打开资源详情:文档 稳定依赖图回执',
|
||
});
|
||
const firstCard = firstCardButton.closest('.game-resource-card');
|
||
expect(firstCard).not.toBeNull();
|
||
expect(graphReads).toBe(1);
|
||
|
||
await act(async () => {
|
||
view.rerender(renderView([{ ...firstResult }]));
|
||
await Promise.resolve();
|
||
});
|
||
expect(graphReads).toBe(1);
|
||
expect(firstCard?.isConnected).toBe(true);
|
||
|
||
const secondResult = {
|
||
agentId: 'code-director',
|
||
runId: 'stable-graph-code-run',
|
||
label: '程序 Agent',
|
||
title: '刷新期间新增回执',
|
||
content: '新图返回前也不清空旧布局',
|
||
updatedAt: 2,
|
||
};
|
||
view.rerender(renderView([{ ...firstResult }, secondResult]));
|
||
await waitFor(() => expect(graphReads).toBe(2));
|
||
expect(resolveGraphRefresh).not.toBeNull();
|
||
expect(firstCard?.isConnected).toBe(true);
|
||
expect(
|
||
screen.getByRole('button', {
|
||
name: '打开资源详情:文档 稳定依赖图回执',
|
||
}),
|
||
).not.toBeNull();
|
||
|
||
await act(async () => {
|
||
resolveGraphRefresh?.();
|
||
await Promise.resolve();
|
||
});
|
||
expect(
|
||
await screen.findByRole('button', {
|
||
name: '打开资源详情:文档 刷新期间新增回执',
|
||
}),
|
||
).not.toBeNull();
|
||
expect(firstCard?.isConnected).toBe(true);
|
||
});
|
||
|
||
it('keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout', async () => {
|
||
const projectId = 'workbench-truncated-resource-graph';
|
||
const projectPath = '/tmp/workbench-truncated-resource-graph';
|
||
const manifest = createGameCreationAppManifest(
|
||
projectId,
|
||
'截断依赖图布局保护测试',
|
||
);
|
||
manifest.assets.push(
|
||
{
|
||
id: 'truncated-depth-0',
|
||
kind: 'design-spec',
|
||
mediaType: 'application/json',
|
||
localPath: 'assets/truncated-depth-0.json',
|
||
source: {
|
||
kind: 'canvas',
|
||
resourceId: 'external-truncated-depth-0',
|
||
},
|
||
},
|
||
{
|
||
id: 'truncated-depth-1',
|
||
kind: 'metadata',
|
||
mediaType: 'application/json',
|
||
localPath: 'assets/truncated-depth-1.json',
|
||
source: {
|
||
kind: 'canvas',
|
||
resourceId: 'external-truncated-depth-1',
|
||
referenceResourceIds: ['external-truncated-depth-0'],
|
||
},
|
||
},
|
||
{
|
||
id: 'truncated-depth-2',
|
||
kind: 'metadata',
|
||
mediaType: 'application/json',
|
||
localPath: 'assets/truncated-depth-2.json',
|
||
source: {
|
||
kind: 'canvas',
|
||
resourceId: 'external-truncated-depth-2',
|
||
referenceResourceIds: ['external-truncated-depth-1'],
|
||
},
|
||
},
|
||
);
|
||
const slotWidth =
|
||
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
|
||
const existingPositions: ProjectResourceCanvasPosition[] = [
|
||
{
|
||
resourceId: 'asset:truncated-depth-0',
|
||
section: 'document',
|
||
x: 0,
|
||
y: 0,
|
||
manuallyPlaced: false,
|
||
},
|
||
{
|
||
resourceId: 'asset:truncated-depth-1',
|
||
section: 'document',
|
||
x: slotWidth,
|
||
y: 0,
|
||
manuallyPlaced: false,
|
||
},
|
||
{
|
||
resourceId: 'asset:truncated-depth-2',
|
||
section: 'document',
|
||
x: slotWidth * 2,
|
||
y: 0,
|
||
manuallyPlaced: false,
|
||
},
|
||
];
|
||
const layoutUpdates: ProjectResourceCanvasPosition[][] = [];
|
||
let layoutReads = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return {
|
||
resourceIds: existingPositions.map(({ resourceId }) => resourceId),
|
||
referenceEdges: [
|
||
{
|
||
id: 'reference:truncated-0-1',
|
||
kind: 'asset-reference',
|
||
sourceResourceId: 'asset:truncated-depth-0',
|
||
targetResourceId: 'asset:truncated-depth-1',
|
||
cyclic: false,
|
||
},
|
||
{
|
||
id: 'reference:truncated-1-2',
|
||
kind: 'asset-reference',
|
||
sourceResourceId: 'asset:truncated-depth-1',
|
||
targetResourceId: 'asset:truncated-depth-2',
|
||
cyclic: false,
|
||
},
|
||
],
|
||
taskFlows: [
|
||
{
|
||
id: 'flow:untrusted-producer',
|
||
kind: 'task-flow',
|
||
sourceTaskId: 'art-director',
|
||
targetTaskId: 'design-foundation',
|
||
sourceResourceIds: ['asset:truncated-depth-0'],
|
||
targetResourceIds: ['asset:truncated-depth-1'],
|
||
cyclic: true,
|
||
},
|
||
],
|
||
connectionIndex: [
|
||
{
|
||
resourceId: 'asset:truncated-depth-0',
|
||
upstreamReferenceResourceIds: [],
|
||
downstreamReferenceResourceIds: ['asset:truncated-depth-1'],
|
||
referenceEdgeIds: ['reference:truncated-0-1'],
|
||
taskFlowIds: ['flow:untrusted-producer'],
|
||
},
|
||
{
|
||
resourceId: 'asset:truncated-depth-1',
|
||
upstreamReferenceResourceIds: ['asset:truncated-depth-0'],
|
||
downstreamReferenceResourceIds: ['asset:truncated-depth-2'],
|
||
referenceEdgeIds: [
|
||
'reference:truncated-0-1',
|
||
'reference:truncated-1-2',
|
||
],
|
||
taskFlowIds: ['flow:untrusted-producer'],
|
||
},
|
||
{
|
||
resourceId: 'asset:truncated-depth-2',
|
||
upstreamReferenceResourceIds: ['asset:truncated-depth-1'],
|
||
downstreamReferenceResourceIds: [],
|
||
referenceEdgeIds: ['reference:truncated-1-2'],
|
||
taskFlowIds: [],
|
||
},
|
||
],
|
||
producerAssignments: [
|
||
{
|
||
resourceId: 'asset:truncated-depth-0',
|
||
taskId: 'art-director',
|
||
},
|
||
],
|
||
dependencyDepths: [
|
||
{
|
||
resourceId: 'asset:truncated-depth-0',
|
||
dependencyDepth: 0,
|
||
},
|
||
{
|
||
resourceId: 'asset:truncated-depth-1',
|
||
dependencyDepth: 1,
|
||
},
|
||
{
|
||
resourceId: 'asset:truncated-depth-2',
|
||
dependencyDepth: 2,
|
||
},
|
||
],
|
||
unresolvedReferenceResourceIds: [],
|
||
cyclicResourceIds: [],
|
||
cyclicTaskIds: ['art-director'],
|
||
producerMappingTruncated: true,
|
||
};
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
layoutReads += 1;
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode: args?.mode,
|
||
revision: 7,
|
||
positions: structuredClone(existingPositions),
|
||
updatedAt: 7,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
layoutUpdates.push(
|
||
structuredClone(args?.positions as ProjectResourceCanvasPosition[]),
|
||
);
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode: args?.mode,
|
||
revision: 8,
|
||
positions: structuredClone(
|
||
args?.positions as ProjectResourceCanvasPosition[],
|
||
),
|
||
updatedAt: 8,
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '截断依赖图布局保护测试',
|
||
projectPath,
|
||
manifest,
|
||
attachments: [],
|
||
agentResults: [],
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
}),
|
||
);
|
||
|
||
await waitFor(() => expect(layoutReads).toBe(1));
|
||
const cards = [0, 1, 2].map((depth) =>
|
||
screen
|
||
.getByRole('button', {
|
||
name: new RegExp(`truncated-depth-${depth}\\.json`, 'u'),
|
||
})
|
||
.closest('.game-resource-card'),
|
||
);
|
||
await waitFor(() => {
|
||
expect(cards[0]?.getAttribute('style')).toContain('--resource-x: 0px');
|
||
expect(cards[1]?.getAttribute('style')).toContain(
|
||
`--resource-x: ${slotWidth}px`,
|
||
);
|
||
expect(cards[2]?.getAttribute('style')).toContain(
|
||
`--resource-x: ${slotWidth * 2}px`,
|
||
);
|
||
});
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
});
|
||
expect(layoutUpdates).toEqual([]);
|
||
});
|
||
|
||
it('restores historical resource positions but never moves or persists them from pointer input', async () => {
|
||
const projectId = 'workbench-layout-persistence';
|
||
const projectPath = '/tmp/workbench-layout-persistence';
|
||
const resourceId = 'agent-result:design-foundation:layout-result-run';
|
||
const manifest = createGameCreationAppManifest(projectId, '布局持久化测试');
|
||
const agentResults = [
|
||
{
|
||
agentId: 'design-foundation',
|
||
runId: 'layout-result-run',
|
||
label: '玩法策划 Agent',
|
||
title: '布局持久化回执',
|
||
content: '布局持久化正文',
|
||
updatedAt: 1,
|
||
},
|
||
];
|
||
const persistedLayout: ProjectResourceCanvasLayout = {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode: 'dependency',
|
||
revision: 1,
|
||
positions: [
|
||
{
|
||
resourceId,
|
||
section: 'document',
|
||
x: 12,
|
||
y: 24,
|
||
manuallyPlaced: true,
|
||
},
|
||
],
|
||
updatedAt: 100,
|
||
};
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return structuredClone(persistedLayout);
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
throw new Error('pointer input must not submit a manual layout CAS');
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
function renderWorkbench() {
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '布局持久化测试',
|
||
projectPath,
|
||
manifest,
|
||
attachments: [],
|
||
agentResults,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
}),
|
||
);
|
||
}
|
||
|
||
renderWorkbench();
|
||
const cardButton = await screen.findByRole('button', {
|
||
name: '打开资源详情:文档 布局持久化回执',
|
||
});
|
||
const card = cardButton.closest('.game-resource-card');
|
||
expect(card).not.toBeNull();
|
||
await waitFor(() => {
|
||
expect(card?.getAttribute('style')).toContain('--resource-x: 12px');
|
||
expect(card?.getAttribute('style')).toContain('--resource-y: 24px');
|
||
});
|
||
const originalStyle = card?.getAttribute('style');
|
||
const layoutUpdatesBeforePointer = invoke.mock.calls.filter(
|
||
([command]) => command === 'update_local_project_resource_canvas_layout',
|
||
).length;
|
||
|
||
fireEvent.pointerDown(cardButton, {
|
||
pointerId: 11,
|
||
button: 0,
|
||
clientX: 20,
|
||
clientY: 30,
|
||
});
|
||
fireEvent.pointerMove(cardButton, {
|
||
pointerId: 11,
|
||
clientX: 100,
|
||
clientY: 60,
|
||
});
|
||
fireEvent.pointerUp(cardButton, {
|
||
pointerId: 11,
|
||
clientX: 100,
|
||
clientY: 60,
|
||
});
|
||
fireEvent.pointerCancel(cardButton, { pointerId: 11 });
|
||
expect(card?.getAttribute('style')).toBe(originalStyle);
|
||
expect(card?.classList.contains('is-dragging')).toBe(false);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) =>
|
||
command === 'update_local_project_resource_canvas_layout',
|
||
),
|
||
).toHaveLength(layoutUpdatesBeforePointer);
|
||
|
||
fireEvent.click(cardButton);
|
||
expect(
|
||
screen.getByRole('region', { name: '布局持久化回执' }),
|
||
).not.toBeNull();
|
||
|
||
cleanup();
|
||
renderWorkbench();
|
||
const restoredCard = (
|
||
await screen.findByRole('button', {
|
||
name: '打开资源详情:文档 布局持久化回执',
|
||
})
|
||
).closest('.game-resource-card');
|
||
await waitFor(() => {
|
||
expect(restoredCard?.getAttribute('style')).toContain(
|
||
'--resource-x: 12px',
|
||
);
|
||
expect(restoredCard?.getAttribute('style')).toContain(
|
||
'--resource-y: 24px',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('maps manifest asset kinds into stable type layout ordering', async () => {
|
||
const projectId = 'workbench-layout-asset-subtypes';
|
||
const manifest = createGameCreationAppManifest(
|
||
projectId,
|
||
'资源子类型排序测试',
|
||
);
|
||
manifest.assets.push(
|
||
{
|
||
id: 'ui-prototype-first-by-label',
|
||
kind: 'ui-prototype',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/a-ui-prototype.png',
|
||
source: { kind: 'generated', taskId: 'design-foundation' },
|
||
},
|
||
{
|
||
id: 'art-spritesheet-last-by-label',
|
||
kind: 'art-spritesheet',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/z-art-spritesheet.png',
|
||
source: { kind: 'generated', taskId: 'art-asset-plan' },
|
||
},
|
||
);
|
||
const updates: Array<{
|
||
mode: 'dependency' | 'type';
|
||
positions: ProjectResourceCanvasPosition[];
|
||
}> = [];
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
const mode = args?.mode as 'dependency' | 'type';
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode,
|
||
revision: 0,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
const positions = structuredClone(
|
||
args?.positions as ProjectResourceCanvasPosition[],
|
||
);
|
||
updates.push({ mode, positions });
|
||
return {
|
||
status: 'updated',
|
||
layout: {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode,
|
||
revision: 1,
|
||
positions,
|
||
updatedAt: 1,
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '资源子类型排序测试',
|
||
projectPath: '/tmp/workbench-layout-asset-subtypes',
|
||
manifest,
|
||
attachments: [],
|
||
agentResults: [],
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
}),
|
||
);
|
||
await waitFor(() =>
|
||
expect(updates.some(({ mode }) => mode === 'dependency')).toBe(true),
|
||
);
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
await waitFor(() =>
|
||
expect(updates.some(({ mode }) => mode === 'type')).toBe(true),
|
||
);
|
||
|
||
const typePositions = updates.find(
|
||
({ mode }) => mode === 'type',
|
||
)?.positions;
|
||
expect(typePositions).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
resourceId: 'asset:art-spritesheet-last-by-label',
|
||
x: 0,
|
||
y: 0,
|
||
}),
|
||
expect.objectContaining({
|
||
resourceId: 'asset:ui-prototype-first-by-label',
|
||
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
|
||
y: 0,
|
||
}),
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('keeps newly reconciled resources visible when their automatic layout save fails', async () => {
|
||
const projectId = 'workbench-layout-save-failure';
|
||
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_resource_graph') {
|
||
return resourceGraphForInputs(args);
|
||
}
|
||
if (command === 'read_local_project_resource_canvas_layout') {
|
||
return {
|
||
schemaVersion: 'game-creator-resource-layout.v1',
|
||
projectId,
|
||
mode: 'dependency',
|
||
revision: 2,
|
||
positions: [],
|
||
updatedAt: 200,
|
||
};
|
||
}
|
||
if (command === 'update_local_project_resource_canvas_layout') {
|
||
throw new Error('disk full');
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '布局失败测试',
|
||
projectPath: '/tmp/workbench-layout-save-failure',
|
||
manifest,
|
||
attachments: [],
|
||
agentResults: [
|
||
{
|
||
agentId: 'design-foundation',
|
||
runId: 'save-failure-run',
|
||
label: '玩法策划 Agent',
|
||
title: '自动排版失败回执',
|
||
content: '自动排版失败正文',
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
}),
|
||
);
|
||
|
||
expect(
|
||
await screen.findByText('布局保存失败,已保留当前会话布局'),
|
||
).not.toBeNull();
|
||
expect(
|
||
screen.getByRole('button', {
|
||
name: '打开资源详情:文档 自动排版失败回执',
|
||
}),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('keeps the landscape workbench inside the viewport with internal chat scrolling', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
|
||
expect(styles).toMatch(
|
||
/\.game-project-workbench\s*\{[^}]*width:\s*calc\(100vw - 72px\)[^}]*padding:\s*40px 8px 8px[^}]*overflow:\s*hidden/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/@media \(min-width: 761px\)[\s\S]*?\.game-project-workbench\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto[^}]*height:\s*100dvh/,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*grid-template-rows:\s*minmax\(96px, 1fr\) minmax\(0, auto\) auto auto auto[^}]*height:\s*100%[^}]*overflow:\s*hidden/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat \.agent-runtime-status\s*\{[^}]*max-height:\s*clamp\(120px, 24dvh, 240px\)[^}]*overflow-y:\s*auto/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat \.project-runtime-summary\s*\{[^}]*position:\s*sticky[^}]*top:\s*-10px/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat \.project-supervisor-composer\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 82px[^}]*z-index:\s*2[^}]*border-top:\s*1px solid var\(--platform-line-soft\)/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat \.project-supervisor-composer button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s,
|
||
);
|
||
const agentDockRule = styles.match(/\.game-agent-dock\s*\{([^}]*)\}/s);
|
||
expect(agentDockRule?.[1]).toContain('overflow: visible;');
|
||
expect(agentDockRule?.[1]).not.toContain('overflow-x:');
|
||
expect(agentDockRule?.[1]).toContain('z-index: 30;');
|
||
expect(agentDockRule?.[1]).toContain('isolation: isolate;');
|
||
expect(agentDockRule?.[1]).toContain('min-height: 58px;');
|
||
expect(styles).toMatch(
|
||
/\.game-agent-dock-item\s*\{[^}]*flex:\s*1 1 128px[^}]*min-width:\s*0[^}]*max-width:\s*170px/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/@media \(max-width: 760px\)[\s\S]*?\.game-workbench-layout\s*\{[^}]*height:\s*auto/,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-resource-canvas-content\s*\{[^}]*width:\s*100%[^}]*min-width:\s*max\(100%, 620px\)/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-resource-section\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)[^}]*height:\s*var\(--resource-section-height\)[^}]*overflow:\s*hidden/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-resource-section-viewport\s*\{[^}]*overflow:\s*auto[^}]*overscroll-behavior:\s*contain/s,
|
||
);
|
||
expect(styles).not.toMatch(
|
||
/\.game-resource-section\s*\{[^}]*position:\s*absolute/s,
|
||
);
|
||
});
|
||
|
||
it('enables the run presentation and renders registered images in the resource viewer', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-runnable',
|
||
'可运行工作台',
|
||
);
|
||
const codePrototype = manifest.tasks.find(
|
||
(task) => task.id === 'code-prototype',
|
||
);
|
||
if (!codePrototype) {
|
||
throw new Error('missing code-prototype seed task');
|
||
}
|
||
codePrototype.status = 'completed';
|
||
manifest.preview = {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4173',
|
||
port: 4173,
|
||
};
|
||
manifest.assets.push({
|
||
id: 'hero-art',
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/hero.png',
|
||
source: {
|
||
kind: 'generated',
|
||
taskId: 'art-asset-plan',
|
||
},
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'read_local_project_image_preview') {
|
||
expect(args).toEqual({
|
||
projectPath: '/tmp/workbench-runnable',
|
||
relativePath: 'assets/hero.png',
|
||
});
|
||
return {
|
||
path: 'assets/hero.png',
|
||
mediaType: 'image/png',
|
||
byteLen: 12,
|
||
dataUrl:
|
||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '可运行工作台',
|
||
projectPath: '/tmp/workbench-runnable',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: 'completed',
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
const runTab = screen.getByRole('tab', {
|
||
name: '运行',
|
||
}) as HTMLButtonElement;
|
||
expect(runTab.disabled).toBe(false);
|
||
fireEvent.click(runTab);
|
||
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
|
||
const previewFrame = screen.getByTitle(
|
||
'可运行工作台 游戏运行画面',
|
||
) as HTMLIFrameElement;
|
||
expect(previewFrame.getAttribute('src')).toBe('http://127.0.0.1:4173/');
|
||
expect(previewFrame.getAttribute('sandbox')).toBe(
|
||
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
|
||
);
|
||
expect(screen.getByLabelText('测试切片控件')).not.toBeNull();
|
||
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
|
||
expect(screen.getByLabelText('数值微调面板')).not.toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
|
||
fireEvent.change(screen.getByLabelText('搜索项目资源'), {
|
||
target: { value: 'hero.png' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: /hero\.png/ }));
|
||
const heroDetails = screen.getByRole('region', { name: 'hero.png' });
|
||
expect(within(heroDetails).getByText('assets/hero.png')).not.toBeNull();
|
||
expect(within(heroDetails).getByText('image/png')).not.toBeNull();
|
||
expect(within(heroDetails).getByText('引用上游')).not.toBeNull();
|
||
expect(heroDetails.querySelector('img')).toBeNull();
|
||
expect(screen.queryByRole('img', { name: 'hero.png 图片预览' })).toBeNull();
|
||
});
|
||
|
||
it('marks an unvalidated UI prototype as a candidate image', () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-ui-candidate',
|
||
'候选界面图测试',
|
||
);
|
||
manifest.assets.push({
|
||
id: 'ui-prototype-candidate',
|
||
kind: 'ui-prototype',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/ui-prototype.png',
|
||
source: {
|
||
kind: 'canvas',
|
||
taskId: 'design-foundation',
|
||
},
|
||
});
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '候选界面图测试',
|
||
projectPath: '/tmp/workbench-ui-candidate',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
agentRuntimeSummaries: [
|
||
{
|
||
group: 'art',
|
||
label: '美术 Agent',
|
||
status: 'completed',
|
||
statusLabel: '已完成',
|
||
currentTask: '本轮工作已完成',
|
||
currentAction: null,
|
||
waitingOn: null,
|
||
completedCount: 4,
|
||
totalCount: 4,
|
||
},
|
||
],
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
const candidate = screen.getByRole('button', {
|
||
name: /ui-prototype\.png(待视觉验收)/,
|
||
});
|
||
const candidateCard = candidate.closest('.game-resource-card');
|
||
expect(candidateCard?.textContent).not.toContain('画板 · 候选界面图');
|
||
expect(candidateCard?.textContent).not.toContain('assets/ui-prototype.png');
|
||
expect(screen.getByText('仅完成计划')).not.toBeNull();
|
||
expect(
|
||
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('refuses to embed a non-loopback game preview in the client workbench', () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'workbench-remote-preview',
|
||
'远程预览拒绝测试',
|
||
);
|
||
const codePrototype = manifest.tasks.find(
|
||
(task) => task.id === 'code-prototype',
|
||
);
|
||
if (!codePrototype) {
|
||
throw new Error('missing code-prototype seed task');
|
||
}
|
||
codePrototype.status = 'completed';
|
||
manifest.preview = {
|
||
status: 'running',
|
||
url: 'https://example.com/game',
|
||
port: 443,
|
||
};
|
||
|
||
render(
|
||
React.createElement(ProjectDevelopmentView, {
|
||
projectName: '远程预览拒绝测试',
|
||
projectPath: '/tmp/workbench-remote-preview',
|
||
manifest,
|
||
attachments: [],
|
||
recentRunStatus: 'completed',
|
||
recentRunStopReason: null,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||
expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull();
|
||
expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull();
|
||
});
|
||
}
|
||
|
||
export function registerUserSurfaceBoundaryTests() {
|
||
it('keeps the user surface to chat, upload, config and command confirmation', async () => {
|
||
renderAppAt('/?main');
|
||
|
||
expect(screen.getByLabelText('聊天')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 状态')).not.toBeNull();
|
||
const composerInput = screen.getByLabelText('创作想法');
|
||
expect(composerInput).not.toBeNull();
|
||
expect(screen.getByText('上传')).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '命令' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '能力' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '配置' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: 'LLM状态' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '显示目录' })).not.toBeNull();
|
||
expect(screen.queryByLabelText('项目摘要')).toBeNull();
|
||
expect(
|
||
(screen.getByRole('button', { name: '灵感草稿' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(false);
|
||
expect(
|
||
(screen.getByRole('button', { name: '项目状态' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '权限' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '审计' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '运行' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '资产' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '打开画板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(false);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '导入画板资产',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '任务' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '索引确认' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '资产登记确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '记忆写入确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '预览确认' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '打开预览确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '停止预览确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: 'Agent确认' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '读对话确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '存对话确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: 'Trace' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '文件' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '索引' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '记忆' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '短期记忆' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '黑板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '记到黑板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '覆盖黑板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '清空黑板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '快照' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '快照列表' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '历史' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '白名单' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(false);
|
||
expect(
|
||
(screen.getByRole('button', { name: '静态自检' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '启动预览' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '打开预览' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '预览状态' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '停止预览' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '刷新状态' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '刷新 Agent' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '状态' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '终止' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '重试' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '继续' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '继续说明' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '输出' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '活动' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '上下文包' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
fireEvent.click(screen.getByRole('button', { name: '打开画板' }));
|
||
expect(composerInput).toHaveProperty('value', '/canvas ');
|
||
fireEvent.click(screen.getByRole('button', { name: '灵感草稿' }));
|
||
expect(composerInput).toHaveProperty(
|
||
'value',
|
||
'像素风厨房弹幕小游戏:玩家用方向键躲避飞来的食材,收集调料加分,60 秒内尽量高分,失败后可一键重开。',
|
||
);
|
||
expect(screen.queryByText('game.generate_draft')).toBeNull();
|
||
fireEvent.click(screen.getByRole('button', { name: '白名单' }));
|
||
expect(await screen.findByText(/可运行受限命令:/)).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '切换项目' })).not.toBeNull();
|
||
expect(screen.getByText('想做什么游戏?')).not.toBeNull();
|
||
expect(screen.getAllByText('暂无最近运行证据').length).toBeGreaterThan(0);
|
||
expect(screen.queryByLabelText('工作区管理')).toBeNull();
|
||
expect(screen.queryByLabelText('开发环境')).toBeNull();
|
||
expect(screen.queryByLabelText('运行时配置')).toBeNull();
|
||
expect(screen.queryByText('Agent 能力')).toBeNull();
|
||
expect(screen.queryByText('编排 Trace')).toBeNull();
|
||
});
|
||
}
|
||
|
||
export function registerProjectSupervisorSurfaceTests() {
|
||
it('keeps the Project Supervisor welcome and empty runtime surface before the first message', async () => {
|
||
const projectPath = '/tmp/launcher-empty-supervisor-game';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-empty-supervisor-game',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialSessionExists: false,
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-empty-supervisor-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||
target: { value: projectPath },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '打开' }));
|
||
|
||
const supervisorSurface = await screen.findByLabelText('项目总控对话');
|
||
const messageList =
|
||
within(supervisorSurface).getByLabelText('项目总控消息');
|
||
expect(
|
||
await within(messageList).findByText('想做什么游戏?'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(supervisorSurface).getByLabelText('项目总控 Agent 状态'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(supervisorSurface).getByText('项目总控 Agent · 尚未开始'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(supervisorSurface).getByText('告诉陶泥儿你想做什么游戏'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(supervisorSurface).getByRole('button', { name: '发送' }),
|
||
).toHaveProperty('disabled', false);
|
||
});
|
||
|
||
it('hydrates a persisted needs-reconciliation Supervisor runtime without an active Session index', async () => {
|
||
const projectPath = '/tmp/launcher-reconciliation-supervisor-game';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-reconciliation-supervisor-game',
|
||
);
|
||
const reconciliationRuntime = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'project-supervisor',
|
||
taskId: 'project-supervisor',
|
||
sessionId: 'persisted-supervisor-session',
|
||
runId: 'persisted-reconciliation-run',
|
||
source: 'project-supervisor',
|
||
status: 'needs-reconciliation',
|
||
phase: 'needs-reconciliation',
|
||
currentTask: '帮我生成一个贪吃蛇',
|
||
currentGoal: '完成贪吃蛇原型',
|
||
currentAction: '等待核对 Provider 回复交接',
|
||
waitingOn: '人工核对',
|
||
nextStep: '核对后继续或取消',
|
||
plan: [],
|
||
observations: [],
|
||
allowedTools: [],
|
||
pendingToolAction: null,
|
||
lastResponse: null,
|
||
error: 'tool-plan-unknown',
|
||
updatedAt: 7000,
|
||
};
|
||
const cancelledQueue = {
|
||
total: 2,
|
||
pending: 1,
|
||
running: 0,
|
||
waitingForConfirmation: 0,
|
||
waitingForUserInput: 0,
|
||
cancelled: 1,
|
||
completed: 0,
|
||
failed: 0,
|
||
latestRunId: 'persisted-reconciliation-run',
|
||
updatedAt: 8000,
|
||
};
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialSessionExists: false,
|
||
initialRuntime: reconciliationRuntime,
|
||
runtimeMapLoader: async () => [reconciliationRuntime],
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-reconciliation-supervisor-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'cancel_game_creator_agent_runtime_task') {
|
||
const state = supervisorHarness.runtimeState({
|
||
...reconciliationRuntime,
|
||
status: 'cancelled',
|
||
phase: 'cancelled',
|
||
currentAction: '待核对的旧任务已结束',
|
||
waitingOn: '队列中的下一个任务',
|
||
error: null,
|
||
taskQueue: cancelledQueue,
|
||
updatedAt: 8000,
|
||
});
|
||
return {
|
||
...supervisorHarness.runtimeResult(state),
|
||
taskQueue: cancelledQueue,
|
||
};
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||
target: { value: projectPath },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '打开' }));
|
||
|
||
const supervisorSurface = await screen.findByLabelText('项目总控对话');
|
||
expect(
|
||
await within(supervisorSurface).findByText('项目总控 Agent · 失败'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(supervisorSurface).getByText('当前阶段:待核对'),
|
||
).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', {
|
||
projectPath,
|
||
});
|
||
const reconcileButton = within(supervisorSurface).getByRole('button', {
|
||
name: '已核对,结束旧任务',
|
||
});
|
||
fireEvent.click(reconcileButton);
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'cancel_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId: 'persisted-reconciliation-run',
|
||
},
|
||
);
|
||
});
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'confirm_retry_game_creator_agent_runtime_task',
|
||
expect.anything(),
|
||
);
|
||
expect(
|
||
await within(supervisorSurface).findByText(
|
||
'旧任务已结束,队列中还有 1 个待处理任务,队列将继续处理',
|
||
),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(supervisorSurface).queryByRole('button', {
|
||
name: '在当前项目重试总控',
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('allows retrying a cancelled reconciled Supervisor only after its queue is empty', async () => {
|
||
const projectPath = '/tmp/launcher-reconciliation-empty-queue';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-reconciliation-empty-queue',
|
||
);
|
||
const runId = 'reconciliation-empty-queue-run';
|
||
const reconciliationRuntime = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'project-supervisor',
|
||
taskId: 'project-supervisor',
|
||
sessionId: 'reconciliation-empty-queue-session',
|
||
runId,
|
||
source: 'project-supervisor',
|
||
status: 'needs-reconciliation',
|
||
phase: 'needs-reconciliation',
|
||
currentTask: '生成贪吃蛇原型',
|
||
currentGoal: '完成可玩原型',
|
||
currentAction: '等待核对 Provider 回复交接',
|
||
waitingOn: '人工核对',
|
||
nextStep: '核对后结束旧任务',
|
||
plan: [],
|
||
observations: [],
|
||
allowedTools: [],
|
||
pendingToolAction: null,
|
||
lastResponse: null,
|
||
error: 'tool-plan-unknown',
|
||
updatedAt: 7000,
|
||
};
|
||
const emptyQueue = {
|
||
total: 1,
|
||
pending: 0,
|
||
running: 0,
|
||
waitingForConfirmation: 0,
|
||
waitingForUserInput: 0,
|
||
cancelled: 1,
|
||
completed: 0,
|
||
failed: 0,
|
||
latestRunId: runId,
|
||
updatedAt: 8000,
|
||
};
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
sessionId: 'reconciliation-empty-queue-session',
|
||
initialSessionExists: false,
|
||
initialRuntime: reconciliationRuntime,
|
||
runtimeMapLoader: async () => [reconciliationRuntime],
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-reconciliation-empty-queue',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'cancel_game_creator_agent_runtime_task') {
|
||
const state = supervisorHarness.runtimeState({
|
||
...reconciliationRuntime,
|
||
status: 'cancelled',
|
||
phase: 'cancelled',
|
||
currentAction: '待核对的旧任务已结束',
|
||
waitingOn: '',
|
||
error: null,
|
||
taskQueue: emptyQueue,
|
||
updatedAt: 8000,
|
||
});
|
||
return {
|
||
...supervisorHarness.runtimeResult(state),
|
||
taskQueue: emptyQueue,
|
||
};
|
||
}
|
||
if (command === 'confirm_retry_game_creator_agent_runtime_task') {
|
||
const nextRunId = String(args?.nextRunId ?? '');
|
||
const state = supervisorHarness.runtimeState({
|
||
...reconciliationRuntime,
|
||
runId: nextRunId,
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentAction: '重新生成项目总控计划',
|
||
waitingOn: 'Agent 输出计划或回复',
|
||
error: null,
|
||
updatedAt: 9000,
|
||
});
|
||
return {
|
||
...supervisorHarness.runtimeResult(state),
|
||
acceptedRunId: nextRunId,
|
||
};
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||
target: { value: projectPath },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '打开' }));
|
||
|
||
const supervisorSurface = await screen.findByLabelText('项目总控对话');
|
||
fireEvent.click(
|
||
await within(supervisorSurface).findByRole('button', {
|
||
name: '已核对,结束旧任务',
|
||
}),
|
||
);
|
||
expect(
|
||
await within(supervisorSurface).findByText(
|
||
'旧任务已结束,当前队列为空,可重新启动项目总控',
|
||
),
|
||
).not.toBeNull();
|
||
const retryButton = await within(supervisorSurface).findByRole('button', {
|
||
name: '在当前项目重试总控',
|
||
});
|
||
fireEvent.click(retryButton);
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'confirm_retry_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId,
|
||
nextRunId: expect.stringMatching(/^project-supervisor-retry-/),
|
||
},
|
||
);
|
||
});
|
||
const cancelCallIndex = invoke.mock.calls.findIndex(
|
||
([command]) => command === 'cancel_game_creator_agent_runtime_task',
|
||
);
|
||
const retryCallIndex = invoke.mock.calls.findIndex(
|
||
([command]) =>
|
||
command === 'confirm_retry_game_creator_agent_runtime_task',
|
||
);
|
||
expect(cancelCallIndex).toBeGreaterThanOrEqual(0);
|
||
expect(retryCallIndex).toBeGreaterThan(cancelCallIndex);
|
||
});
|
||
|
||
it('starts the game-chat surface as project-free chat with project and settings controls', () => {
|
||
render(
|
||
React.createElement(App, {
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = screen.getByLabelText('游戏创作聊天');
|
||
expect(within(surface).getByText('未选择项目')).not.toBeNull();
|
||
expect(within(surface).getByLabelText('最新状态')).not.toBeNull();
|
||
expect(within(surface).getByText('请选择项目目录')).not.toBeNull();
|
||
expect(
|
||
within(surface).getByRole('button', { name: '选择项目路径' }),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(surface).getByRole('button', { name: '设置' }),
|
||
).not.toBeNull();
|
||
expect(
|
||
(
|
||
within(surface).getByLabelText(
|
||
'项目总控对话内容',
|
||
) as HTMLTextAreaElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(screen.queryByLabelText('游戏运行')).toBeNull();
|
||
expect(document.querySelector('iframe')).toBeNull();
|
||
expect(document.querySelector('.game-chat-shell')?.className).toBe(
|
||
'game-chat-shell',
|
||
);
|
||
});
|
||
|
||
it('exposes External Editor credentials from the game-chat settings', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_game_creator_app_config') {
|
||
return {
|
||
path: '/home/test/AppData/game-creator.config.json',
|
||
config: {
|
||
editorApi: {
|
||
baseUrl: 'http://127.0.0.1:8082',
|
||
apiKey: 'game-chat-editor-secret',
|
||
},
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
});
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderGameChatStatus({
|
||
runtime: gameChatRuntimeState(),
|
||
viewOverrides: {
|
||
onOpenRuntimeConfig: vi.fn(),
|
||
runtimeConfigOpen: true,
|
||
},
|
||
});
|
||
|
||
expect(
|
||
await screen.findByRole('dialog', { name: '运行时配置' }),
|
||
).not.toBeNull();
|
||
expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty(
|
||
'value',
|
||
'http://127.0.0.1:8082',
|
||
);
|
||
expect(screen.getByLabelText('External Editor API Key')).toHaveProperty(
|
||
'type',
|
||
'password',
|
||
);
|
||
expect(
|
||
screen
|
||
.getByLabelText('External Editor API Key')
|
||
.getAttribute('autocomplete'),
|
||
).toBe('off');
|
||
});
|
||
|
||
it('opens the dedicated game-chat release without platform authentication', async () => {
|
||
const fetchMock = vi
|
||
.spyOn(globalThis, 'fetch')
|
||
.mockRejectedValue(new Error('platform api-server unavailable'));
|
||
|
||
render(React.createElement(GameChatReleaseApp));
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(screen.getByLabelText('游戏创作聊天')).not.toBeNull();
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('submits one initial game-chat message per page across StrictMode remounts and terminal updates', async () => {
|
||
const projectPath = '/tmp/game-chat-initial-message-latch';
|
||
const prompt = '继续完成当前贪吃蛇';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-initial-message-latch',
|
||
'初始消息闩锁',
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: '初始消息闩锁',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
const renderInitialMessageApp = (message: string) =>
|
||
React.createElement(
|
||
React.StrictMode,
|
||
null,
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
initialSupervisorMessage: message,
|
||
}),
|
||
);
|
||
|
||
const rendered = render(renderInitialMessageApp(prompt));
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) =>
|
||
command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
const firstRunId = String(
|
||
invoke.mock.calls.find(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
)?.[1]?.runId ?? '',
|
||
);
|
||
expect(firstRunId).not.toBe('');
|
||
|
||
for (const [index, status] of [
|
||
'cancelled',
|
||
'failed',
|
||
'completed',
|
||
].entries()) {
|
||
act(() => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
runId: firstRunId,
|
||
status,
|
||
phase: status,
|
||
updatedAt: 4000 + index,
|
||
}),
|
||
);
|
||
});
|
||
rendered.rerender(renderInitialMessageApp(`${prompt}-${status}`));
|
||
}
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(1);
|
||
|
||
const initCallCount = invoke.mock.calls.filter(
|
||
([command]) => command === 'init_local_game_project',
|
||
).length;
|
||
rendered.unmount();
|
||
render(renderInitialMessageApp(prompt));
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'init_local_game_project',
|
||
).length,
|
||
).toBeGreaterThan(initCallCount);
|
||
expect(
|
||
(screen.getByLabelText('项目总控对话内容') as HTMLTextAreaElement)
|
||
.disabled,
|
||
).toBe(false);
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
it('removes the initial game-chat message from the launch URL before a page reload', () => {
|
||
const searchParams = new URLSearchParams(
|
||
'game-chat=&projectPath=%2Ftmp%2Ftcs&initialMessage=%E7%BB%A7%E7%BB%AD%E8%B4%AA%E5%90%83%E8%9B%87',
|
||
);
|
||
let replacedUrl = '';
|
||
|
||
expect(
|
||
consumeInitialGameChatMessage(
|
||
searchParams,
|
||
{ pathname: '/index.html', hash: '#game-chat' },
|
||
(url) => {
|
||
replacedUrl = url;
|
||
},
|
||
),
|
||
).toBe('继续贪吃蛇');
|
||
expect(searchParams.has('initialMessage')).toBe(false);
|
||
expect(replacedUrl).not.toContain('initialMessage');
|
||
expect(replacedUrl).toContain('projectPath=%2Ftmp%2Ftcs');
|
||
expect(
|
||
consumeInitialGameChatMessage(
|
||
searchParams,
|
||
{ pathname: '/index.html', hash: '#game-chat' },
|
||
() => {
|
||
throw new Error('重载后不应再次改写 URL');
|
||
},
|
||
),
|
||
).toBe('');
|
||
});
|
||
|
||
it('does not deliver a launch message when the opened game-chat project changed', async () => {
|
||
const launchProjectPath = '/tmp/game-chat-initial-project';
|
||
const openedProjectPath = '/tmp/game-chat-replacement-project';
|
||
const prompt = '这条消息只能投递给启动项目';
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath: openedProjectPath,
|
||
});
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-replacement-project',
|
||
'替换项目',
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath: launchProjectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: '启动项目',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath: openedProjectPath,
|
||
manifestPath: `${openedProjectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: launchProjectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
initialSupervisorMessage: prompt,
|
||
}),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
(screen.getByLabelText('项目总控对话内容') as HTMLTextAreaElement)
|
||
.disabled,
|
||
).toBe(false);
|
||
expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', {
|
||
projectPath: openedProjectPath,
|
||
});
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(0);
|
||
expect(screen.queryByText(prompt)).toBeNull();
|
||
});
|
||
|
||
it('keeps the non-empty folder confirmation when game-chat initializes a picked project', async () => {
|
||
const projectPath = '/tmp/game-chat-non-empty';
|
||
const initialSupervisorMessage = '不要在确认前启动这一轮';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'pick_local_project_directory') {
|
||
return projectPath;
|
||
}
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: false,
|
||
projectName: null,
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'is_local_project_directory_non_empty') {
|
||
return true;
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
initialSupervisorMessage,
|
||
}),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '选择项目路径' }));
|
||
|
||
const dialog = await screen.findByRole('dialog', {
|
||
name: '文件夹不是空的',
|
||
});
|
||
expect(within(dialog).getByText(projectPath)).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'init_local_game_project',
|
||
expect.anything(),
|
||
);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(0);
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '继续新建' }));
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
|
||
projectPath,
|
||
projectId: 'local-project-draft',
|
||
name: 'game-chat-non-empty',
|
||
});
|
||
});
|
||
expect(await screen.findByText('game-chat-non-empty')).not.toBeNull();
|
||
});
|
||
|
||
it('embeds only a running loopback preview in the game-chat surface with the shared sandbox', async () => {
|
||
const projectPath = '/tmp/game-chat-loopback';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-loopback',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4173',
|
||
port: 4173,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const preview = await screen.findByLabelText('游戏运行');
|
||
const iframe = within(preview).getByTitle(
|
||
'game-chat-loopback 游戏运行画面',
|
||
);
|
||
expect(iframe.getAttribute('src')).toBe('http://127.0.0.1:4173/');
|
||
expect(iframe.getAttribute('sandbox')).toBe(
|
||
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
|
||
);
|
||
expect(iframe.getAttribute('allow')).toBe('autoplay; fullscreen; gamepad');
|
||
expect(
|
||
document.querySelector('.game-chat-shell.has-preview'),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('routes game-chat /preview through the built-in confirmation flow instead of starting an autonomous run', async () => {
|
||
const projectPath = '/tmp/game-chat-manual-preview';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
let previewStarted = false;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-manual-preview',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return previewStarted
|
||
? {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4310',
|
||
port: 4310,
|
||
root: `${projectPath}/game`,
|
||
}
|
||
: {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return {
|
||
path: '.agent/policy.json',
|
||
policy: {
|
||
deniedCommands: [],
|
||
confirmCommands: ['preview.start'],
|
||
},
|
||
};
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
previewStarted = true;
|
||
return {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4310',
|
||
port: 4310,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('游戏创作聊天');
|
||
const composer = within(surface).getByLabelText('项目总控对话内容');
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.change(composer, { target: { value: '/preview' } });
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
|
||
const commandConfirmation =
|
||
await within(surface).findByLabelText('项目总控 Agent 待确认命令');
|
||
expect(
|
||
within(commandConfirmation).getByText('preview.start'),
|
||
).not.toBeNull();
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toBe(false);
|
||
fireEvent.click(
|
||
within(commandConfirmation).getByRole('button', { name: '确认' }),
|
||
);
|
||
|
||
const policyConfirmation =
|
||
await within(surface).findByLabelText('项目总控 Agent 待确认操作');
|
||
fireEvent.click(
|
||
within(policyConfirmation).getByRole('button', { name: '确认' }),
|
||
);
|
||
|
||
expect(await screen.findByLabelText('游戏运行')).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
|
||
projectPath,
|
||
});
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('removes the game-chat game region after the registered preview stops', async () => {
|
||
const projectPath = '/tmp/game-chat-stopped-preview';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
let previewStatusReads = 0;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-stopped-preview',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
previewStatusReads += 1;
|
||
return previewStatusReads === 1
|
||
? {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4174',
|
||
port: 4174,
|
||
root: `${projectPath}/game`,
|
||
}
|
||
: {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
expect(await screen.findByLabelText('游戏运行')).not.toBeNull();
|
||
await waitFor(
|
||
() => {
|
||
expect(screen.queryByLabelText('游戏运行')).toBeNull();
|
||
expect(
|
||
document.querySelector('.game-chat-shell.has-preview'),
|
||
).toBeNull();
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
});
|
||
|
||
it('does not render a game region for a running remote preview URL', async () => {
|
||
const projectPath = '/tmp/game-chat-remote-preview';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-remote-preview',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'running',
|
||
url: 'https://preview.example.com/game',
|
||
port: 443,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith('get_local_game_preview_status', {
|
||
projectPath,
|
||
});
|
||
});
|
||
expect(await screen.findByLabelText('游戏创作聊天')).not.toBeNull();
|
||
expect(screen.queryByLabelText('游戏运行')).toBeNull();
|
||
expect(document.querySelector('iframe')).toBeNull();
|
||
expect(document.querySelector('.game-chat-shell.has-preview')).toBeNull();
|
||
});
|
||
|
||
it('filters game-chat events to the active parent run, sorts and deduplicates them', () => {
|
||
const duplicate = gameChatRuntimeEvent({
|
||
runId: 'active-parent-run',
|
||
summary: '总控事件 2',
|
||
updatedAt: 20,
|
||
});
|
||
const supervisor = gameChatRuntimeState({
|
||
runId: 'active-parent-run',
|
||
source: 'project-supervisor-game-chat',
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId: 'active-parent-run',
|
||
summary: '总控事件 1',
|
||
updatedAt: 10,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId: 'previous-parent-run',
|
||
summary: '总控旧 run 事件',
|
||
updatedAt: 50,
|
||
}),
|
||
duplicate,
|
||
duplicate,
|
||
],
|
||
});
|
||
const currentChild = gameChatRuntimeState({
|
||
agentId: 'design-foundation',
|
||
taskId: 'design-foundation',
|
||
sessionId: 'design-session',
|
||
runId: 'design-run',
|
||
source: 'agent-ready-task-scheduler',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: 'active-parent-run',
|
||
updatedAt: 30,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
agentId: 'design-foundation',
|
||
runId: 'design-run',
|
||
summary: '当前子 Agent 事件',
|
||
updatedAt: 30,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
agentId: 'design-foundation',
|
||
runId: 'previous-design-run',
|
||
summary: '子 Agent 旧 run 事件',
|
||
updatedAt: 60,
|
||
}),
|
||
],
|
||
});
|
||
const staleChild = gameChatRuntimeState({
|
||
agentId: 'art-asset-plan',
|
||
taskId: 'art-asset-plan',
|
||
sessionId: 'art-session',
|
||
runId: 'art-run',
|
||
source: 'agent-delegate',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: 'stale-parent-run',
|
||
updatedAt: 40,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
agentId: 'art-asset-plan',
|
||
runId: 'art-run',
|
||
summary: '过期父 run 事件',
|
||
updatedAt: 40,
|
||
}),
|
||
],
|
||
});
|
||
|
||
expect(supervisor.recentEvents?.map((event) => event.summary)).toEqual([
|
||
'总控事件 1',
|
||
'总控旧 run 事件',
|
||
'总控事件 2',
|
||
'总控事件 2',
|
||
]);
|
||
|
||
const events = collectGameChatRuntimeEvents(supervisor, {
|
||
'design-foundation': currentChild,
|
||
'art-asset-plan': staleChild,
|
||
});
|
||
|
||
expect(events.map((item) => item.event.summary)).toEqual([
|
||
'当前子 Agent 事件',
|
||
'总控事件 2',
|
||
'总控事件 1',
|
||
]);
|
||
expect(events.map((item) => item.agentLabel)).toEqual([
|
||
'玩法策划 Agent',
|
||
'项目总控 Agent',
|
||
'项目总控 Agent',
|
||
]);
|
||
});
|
||
|
||
it('turns user-visible game-chat runtime outputs into chronological chat messages and filters protocol payloads', () => {
|
||
const runId = 'game-chat-runtime-message-run';
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'turn.started',
|
||
summary: 'Synthetic start event must stay out of chat',
|
||
updatedAt: 30,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'turn.failed',
|
||
status: 'failed',
|
||
phase: 'failed',
|
||
summary: 'Root terminal event is covered by durable public status',
|
||
updatedAt: 35,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'turn.progress',
|
||
summary: 'Generated prototype progress',
|
||
detail: 'internal loop iteration 4',
|
||
updatedAt: 40,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'tool.request',
|
||
summary: 'agent.runtime.tool.request',
|
||
detail: '{"tool":"agent.delegate","arguments":{"secret":"x"}}',
|
||
publicText: null,
|
||
updatedAt: 50,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'action',
|
||
summary: 'call tool agent.delegate',
|
||
detail: 'code agent repair collision',
|
||
publicText: 'code agent repair collision',
|
||
updatedAt: 60,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
summary: 'command.output_read:ok',
|
||
detail: '{"output":"private process output"}',
|
||
publicText: null,
|
||
updatedAt: 70,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
summary: 'preview.validate:ok',
|
||
detail: '{"passed":true,"revision":3}',
|
||
updatedAt: 80,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'turn.progress',
|
||
summary: 'legacy output without stable event identity',
|
||
eventId: '',
|
||
publicText: 'legacy output without stable event identity',
|
||
updatedAt: 90,
|
||
}),
|
||
],
|
||
});
|
||
const designRuntime = gameChatRuntimeState({
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'design-director-session',
|
||
runId: 'design-director-child-run',
|
||
source: 'agent-delegate',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: runId,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
agentId: 'design-director',
|
||
runId: 'design-director-child-run',
|
||
eventType: 'turn.started',
|
||
summary: 'Design Agent started and must stay visible',
|
||
updatedAt: 32,
|
||
}),
|
||
],
|
||
updatedAt: 32,
|
||
});
|
||
|
||
const messages = gameChatRuntimeEventMessages(runtime, {
|
||
'design-director': designRuntime,
|
||
});
|
||
|
||
expect(messages.map((message) => message.updatedAt)).toEqual([
|
||
32, 40, 60, 80,
|
||
]);
|
||
expect(messages.map((message) => message.text)).toEqual([
|
||
expect.stringContaining('Design Agent started and must stay visible'),
|
||
expect.stringContaining('Generated prototype progress'),
|
||
expect.stringContaining('code agent repair collision'),
|
||
expect.stringContaining('preview.validate:ok'),
|
||
]);
|
||
expect(messages.every((message) => message.role === 'assistant')).toBe(
|
||
true,
|
||
);
|
||
expect(
|
||
messages.every((message) =>
|
||
message.messageId?.startsWith('game-chat-runtime-event:'),
|
||
),
|
||
).toBe(true);
|
||
expect(messages.map((message) => message.text).join('\n')).not.toContain(
|
||
'private process output',
|
||
);
|
||
expect(messages.map((message) => message.text).join('\n')).not.toContain(
|
||
'agent.runtime.tool.request',
|
||
);
|
||
|
||
for (const continuation of [
|
||
{ kind: 'receipt', source: 'agent-delegate-receipt' },
|
||
{ kind: 'isolated join', source: 'agent-isolated-join' },
|
||
]) {
|
||
const continuationRunId = `supervisor-${continuation.kind.replaceAll(' ', '-')}-continuation`;
|
||
const supervisorContinuation = gameChatRuntimeState({
|
||
runId: continuationRunId,
|
||
source: continuation.source,
|
||
parentAgentId: null,
|
||
parentRunId: runId,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId: continuationRunId,
|
||
eventType: 'turn.started',
|
||
summary: `Supervisor ${continuation.kind} continuation started`,
|
||
updatedAt: 100,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId: continuationRunId,
|
||
eventType: 'turn.failed',
|
||
status: 'failed',
|
||
phase: 'failed',
|
||
summary: `Supervisor ${continuation.kind} continuation failed`,
|
||
updatedAt: 110,
|
||
}),
|
||
],
|
||
});
|
||
expect(
|
||
gameChatRuntimeEventMessages(supervisorContinuation, {}).map(
|
||
(message) => message.text,
|
||
),
|
||
).toEqual([
|
||
expect.stringContaining(
|
||
`Supervisor ${continuation.kind} continuation started`,
|
||
),
|
||
expect.stringContaining(
|
||
`Supervisor ${continuation.kind} continuation failed`,
|
||
),
|
||
]);
|
||
}
|
||
});
|
||
|
||
it('turns only professional final-reply streams into labeled game-chat messages', () => {
|
||
const makeStream = (
|
||
agentId: string,
|
||
status: 'ready' | 'committed',
|
||
accumulatedText: string,
|
||
responseRevision = 1,
|
||
) => ({
|
||
schemaVersion: 'game-creator-runtime-response-stream.v1',
|
||
agentId,
|
||
taskId: agentId,
|
||
sessionId: 'supervisor-session-active',
|
||
runId: 'game-chat-final-reply-run',
|
||
requestKind: 'final-reply',
|
||
requestSlot: `final-reply-loop-1-revision-${responseRevision}`,
|
||
appliedSteerCursor: 0,
|
||
responseRevision,
|
||
sequence: 2,
|
||
status,
|
||
accumulatedText,
|
||
finishReason: 'stop',
|
||
startedAt: 100,
|
||
updatedAt: 200 + responseRevision,
|
||
});
|
||
const messages = gameChatFinalReplyMessages([
|
||
makeStream('design-director', 'committed', '玩法方向已完成'),
|
||
makeStream('art-director', 'ready', '视觉方向已完成'),
|
||
makeStream('art-asset-plan', 'committed', '平台美术图集已生成并登记'),
|
||
makeStream('code-director', 'ready', '程序方案已完成'),
|
||
makeStream('code-prototype', 'ready', '代码原型已完成'),
|
||
makeStream('preview-readiness', 'committed', '预览就绪检查已完成'),
|
||
makeStream('preview-playtest', 'ready', '试玩验证已完成'),
|
||
{
|
||
...makeStream('code-prototype', 'ready', 'tool plan should be hidden'),
|
||
requestKind: 'tool-plan',
|
||
},
|
||
]);
|
||
expect(messages).toHaveLength(7);
|
||
expect(messages.map((message) => message.text)).toEqual([
|
||
expect.stringContaining('玩法方向已完成'),
|
||
expect.stringContaining('视觉方向已完成'),
|
||
expect.stringContaining('平台美术图集已生成并登记'),
|
||
expect.stringContaining('程序方案已完成'),
|
||
expect.stringContaining('代码原型已完成'),
|
||
expect.stringContaining('预览就绪检查已完成'),
|
||
expect.stringContaining('试玩验证已完成'),
|
||
]);
|
||
expect(messages[0]?.messageId).toContain('design-director');
|
||
expect(messages[0]?.messageId).toContain('game-chat-final-reply:');
|
||
expect(messages.every((message) => message.agentId)).toBe(true);
|
||
const hydrated = mergeGameChatFinalReplyMessagesIntoHistory(
|
||
[messages[0]!],
|
||
messages,
|
||
);
|
||
expect(
|
||
hydrated.filter(
|
||
(message) => message.messageId === messages[0]?.messageId,
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => {
|
||
const projectPath = '/tmp/game-chat-final-reply-hydration';
|
||
const runId = 'game-chat-final-reply-hydration-run';
|
||
const rootRuntime = gameChatRuntimeState({
|
||
sessionId: 'supervisor-session-active',
|
||
runId,
|
||
status: 'running',
|
||
phase: 'execution',
|
||
updatedAt: 100,
|
||
});
|
||
const makeRuntimeResult = (
|
||
agentId: string,
|
||
status: 'ready' | 'committed',
|
||
text: string,
|
||
updatedAt: number,
|
||
) => {
|
||
const state = gameChatRuntimeState({
|
||
agentId,
|
||
taskId: agentId,
|
||
sessionId: 'supervisor-session-active',
|
||
runId: `${agentId}-${runId}`,
|
||
source: 'agent-delegate',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: runId,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
updatedAt,
|
||
});
|
||
return {
|
||
state,
|
||
sessionPath: `${projectPath}/.agent/runtime/${agentId}.json`,
|
||
eventPath: `${projectPath}/.agent/runtime/${agentId}.jsonl`,
|
||
responseStream: {
|
||
schemaVersion: 'game-creator-runtime-response-stream.v1',
|
||
agentId,
|
||
taskId: agentId,
|
||
sessionId: 'supervisor-session-active',
|
||
runId: state.runId,
|
||
requestKind: 'final-reply',
|
||
requestSlot: 'final-reply-loop-1-revision-1',
|
||
appliedSteerCursor: 0,
|
||
responseRevision: 1,
|
||
sequence: 2,
|
||
status,
|
||
accumulatedText: text,
|
||
finishReason: 'stop',
|
||
startedAt: updatedAt - 20,
|
||
updatedAt,
|
||
},
|
||
};
|
||
};
|
||
const professionalResults = [
|
||
makeRuntimeResult('design-director', 'committed', '玩法方向已完成', 170),
|
||
makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180),
|
||
makeRuntimeResult(
|
||
'art-asset-plan',
|
||
'committed',
|
||
'平台美术图集已生成并登记',
|
||
190,
|
||
),
|
||
makeRuntimeResult('code-director', 'ready', '程序方案已完成', 195),
|
||
makeRuntimeResult('code-prototype', 'ready', '代码原型已完成', 200),
|
||
makeRuntimeResult(
|
||
'preview-readiness',
|
||
'committed',
|
||
'预览就绪已完成',
|
||
210,
|
||
),
|
||
makeRuntimeResult('preview-playtest', 'ready', '试玩验证已完成', 220),
|
||
];
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialRuntime: rootRuntime,
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-final-reply-hydration',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return professionalResults;
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
const renderRelease = () =>
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
let rendered = renderRelease();
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/视觉方向已完成/)).not.toBeNull();
|
||
expect(screen.getByText(/平台美术图集已生成并登记/)).not.toBeNull();
|
||
expect(screen.getByText(/代码原型已完成/)).not.toBeNull();
|
||
expect(screen.getByText(/预览就绪已完成/)).not.toBeNull();
|
||
expect(screen.getByText(/试玩验证已完成/)).not.toBeNull();
|
||
});
|
||
const finalReplyAppends = () =>
|
||
invoke.mock.calls.filter(
|
||
([command, args]) =>
|
||
command === 'append_local_conversation_message' &&
|
||
String(args?.messageId ?? '').startsWith('game-chat-final-reply:'),
|
||
);
|
||
await waitFor(() => {
|
||
expect(finalReplyAppends()).toHaveLength(7);
|
||
});
|
||
rendered.unmount();
|
||
rendered = renderRelease();
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/代码原型已完成/)).not.toBeNull();
|
||
});
|
||
expect(finalReplyAppends()).toHaveLength(7);
|
||
rendered.unmount();
|
||
});
|
||
|
||
it('persists game-chat runtime event messages with stable ids and does not duplicate them after hydration', async () => {
|
||
const projectPath = '/tmp/game-chat-runtime-message-hydration';
|
||
const runId = 'game-chat-runtime-message-hydration-run';
|
||
const events = [
|
||
gameChatRuntimeEvent({
|
||
sessionId: 'supervisor-session-active',
|
||
runId,
|
||
eventType: 'turn.progress',
|
||
summary: 'First visible runtime output',
|
||
updatedAt: 40,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
sessionId: 'supervisor-session-active',
|
||
runId,
|
||
eventType: 'observation',
|
||
summary: 'Second visible runtime output',
|
||
updatedAt: 50,
|
||
}),
|
||
];
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-runtime-message-hydration',
|
||
'game-chat-runtime-message-hydration',
|
||
);
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialRuntime: {
|
||
sessionId: 'supervisor-session-active',
|
||
runId,
|
||
status: 'running',
|
||
phase: 'execution',
|
||
recentEvents: events,
|
||
updatedAt: 60,
|
||
},
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: manifest.name,
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return { status: 'stopped', url: null, port: null, root: null };
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
const renderApp = () =>
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
let rendered = renderApp();
|
||
const runtimeEventAppends = () =>
|
||
invoke.mock.calls.filter(
|
||
([command, args]) =>
|
||
command === 'append_local_conversation_message' &&
|
||
args?.agentId === null &&
|
||
String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(runtimeEventAppends()).toHaveLength(2);
|
||
});
|
||
const firstRuntimeMessage = screen
|
||
.getByText(/First visible runtime output/)
|
||
.closest('p');
|
||
const secondRuntimeMessage = screen
|
||
.getByText(/Second visible runtime output/)
|
||
.closest('p');
|
||
expect(
|
||
firstRuntimeMessage?.querySelector('time')?.getAttribute('datetime'),
|
||
).toBe('1970-01-01T00:00:40.000Z');
|
||
expect(firstRuntimeMessage?.querySelector('time')?.textContent).toMatch(
|
||
/^\d{2}:\d{2}:\d{2}$/u,
|
||
);
|
||
expect(
|
||
secondRuntimeMessage?.querySelector('time')?.getAttribute('datetime'),
|
||
).toBe('1970-01-01T00:00:50.000Z');
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(0);
|
||
|
||
rendered.unmount();
|
||
rendered = renderApp();
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getByText(/First visible runtime output/).closest('p'),
|
||
).not.toBeNull();
|
||
expect(
|
||
screen.getByText(/Second visible runtime output/).closest('p'),
|
||
).not.toBeNull();
|
||
});
|
||
expect(runtimeEventAppends()).toHaveLength(2);
|
||
rendered.unmount();
|
||
});
|
||
|
||
it('keeps an unstructured image inspection neutral instead of presenting tool success as visual approval', () => {
|
||
const runId = 'game-chat-unstructured-image-inspection';
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
status: 'ok',
|
||
phase: 'tool-observation',
|
||
summary: 'image.inspect:ok · 视觉检查已完成,共分析 2 张图片',
|
||
detail: JSON.stringify({
|
||
inspectionKind: null,
|
||
validationProfile: null,
|
||
passed: null,
|
||
checks: null,
|
||
issues: null,
|
||
}),
|
||
updatedAt: 25,
|
||
}),
|
||
],
|
||
});
|
||
|
||
const progress = buildGameChatProgressEvidence(runtime, {}, null);
|
||
|
||
expect(progress?.evidence).toEqual([
|
||
expect.objectContaining({
|
||
label: '视觉分析完成',
|
||
tone: 'work',
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('presents an unstructured image inspection tool failure from its production summary', () => {
|
||
const runId = 'game-chat-unstructured-image-inspection-failure';
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
status: 'running',
|
||
phase: 'tool-observation',
|
||
summary: 'image.inspect:failed · 视觉模型调用失败',
|
||
detail: null,
|
||
updatedAt: 26,
|
||
}),
|
||
],
|
||
});
|
||
|
||
const progress = buildGameChatProgressEvidence(runtime, {}, null);
|
||
|
||
expect(progress?.evidence).toEqual([
|
||
expect.objectContaining({
|
||
label: '视觉分析未完成',
|
||
tone: 'fail',
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('uses structured image inspection verdicts for pass and fail presentation', () => {
|
||
for (const [passed, label, tone] of [
|
||
[true, '视觉检查通过', 'pass'],
|
||
[false, '视觉检查未通过', 'fail'],
|
||
] as const) {
|
||
const runId = `game-chat-structured-image-inspection-${passed}`;
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
status: passed ? 'ok' : 'failed',
|
||
phase: 'tool-observation',
|
||
summary: `image.inspect:${passed ? 'ok' : 'failed'} · UI 原型视觉检查${passed ? '已通过' : '未通过'}`,
|
||
detail: JSON.stringify({ passed }),
|
||
updatedAt: passed ? 26 : 27,
|
||
}),
|
||
],
|
||
});
|
||
|
||
expect(
|
||
buildGameChatProgressEvidence(runtime, {}, null)?.evidence,
|
||
).toEqual([expect.objectContaining({ label, tone })]);
|
||
}
|
||
});
|
||
|
||
it('requires an explicit playtest pass before presenting preview validation as passed', () => {
|
||
const runId = 'game-chat-preview-without-playtest-pass';
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
status: 'ok',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:ok · 浏览器验证已完成',
|
||
detail: JSON.stringify({
|
||
diagnostics: [],
|
||
diagnosticsCount: 0,
|
||
passed: true,
|
||
revision: 8,
|
||
}),
|
||
updatedAt: 26,
|
||
}),
|
||
],
|
||
});
|
||
|
||
const progress = buildGameChatProgressEvidence(runtime, {}, null);
|
||
|
||
expect(progress?.evidence).toEqual([
|
||
expect.objectContaining({
|
||
label: '试玩未通过',
|
||
tone: 'fail',
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('does not infer a preview pass from an ok summary when structured evidence is missing', () => {
|
||
const runId = 'game-chat-preview-without-structured-evidence';
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
status: 'ok',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:ok · 浏览器验证已完成',
|
||
detail: null,
|
||
updatedAt: 27,
|
||
}),
|
||
],
|
||
});
|
||
|
||
const progress = buildGameChatProgressEvidence(runtime, {}, null);
|
||
|
||
expect(progress?.evidence).toEqual([
|
||
expect.objectContaining({
|
||
label: '试玩未通过',
|
||
tone: 'fail',
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('derives a playable revision only from a passed preview validation with a positive integer revision', () => {
|
||
const runId = 'game-chat-playable-revision-run';
|
||
const invalidPlaytest = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: runId,
|
||
revision: 1,
|
||
updatedAt: 3000,
|
||
});
|
||
const invalidEvidence = [
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: invalidPlaytest.sessionId,
|
||
runId: invalidPlaytest.runId,
|
||
eventType: 'observation',
|
||
summary: 'preview.validate:failed',
|
||
detail: JSON.stringify({
|
||
passed: true,
|
||
playtestPassed: true,
|
||
revision: 8,
|
||
}),
|
||
updatedAt: 8000,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: invalidPlaytest.sessionId,
|
||
runId: invalidPlaytest.runId,
|
||
eventType: 'observation',
|
||
summary: 'preview.validate:ok · 验证未通过',
|
||
detail: JSON.stringify({
|
||
passed: false,
|
||
playtestPassed: false,
|
||
revision: 7,
|
||
}),
|
||
updatedAt: 7000,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: invalidPlaytest.sessionId,
|
||
runId: invalidPlaytest.runId,
|
||
eventType: 'observation',
|
||
summary: 'preview.validate:ok · 缺少 revision',
|
||
detail: JSON.stringify({ passed: true, playtestPassed: true }),
|
||
updatedAt: 6000,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: invalidPlaytest.sessionId,
|
||
runId: invalidPlaytest.runId,
|
||
eventType: 'observation',
|
||
summary: 'preview.validate:ok · revision 非正整数',
|
||
detail: JSON.stringify({
|
||
passed: true,
|
||
playtestPassed: true,
|
||
revision: 0,
|
||
}),
|
||
updatedAt: 5000,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: invalidPlaytest.sessionId,
|
||
runId: invalidPlaytest.runId,
|
||
eventType: 'observation',
|
||
summary: 'preview.validate:ok · revision 不是整数',
|
||
detail: JSON.stringify({
|
||
passed: true,
|
||
playtestPassed: true,
|
||
revision: 1.5,
|
||
}),
|
||
updatedAt: 4000,
|
||
}),
|
||
];
|
||
invalidPlaytest.recentEvents = invalidEvidence;
|
||
const runtimeWithOnlyInvalidEvidence = gameChatRuntimeState({
|
||
runId,
|
||
});
|
||
|
||
expect(
|
||
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
|
||
'preview-playtest': invalidPlaytest,
|
||
}),
|
||
).toBeNull();
|
||
|
||
const previewPlaytest = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: runId,
|
||
revision: 9,
|
||
updatedAt: 9000,
|
||
});
|
||
previewPlaytest.recentEvents?.push(
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: previewPlaytest.sessionId,
|
||
runId: previewPlaytest.runId,
|
||
eventType: 'observation',
|
||
status: 'completed',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:ok · revision 10 首次验证',
|
||
detail: JSON.stringify({
|
||
diagnosticsCount: 0,
|
||
passed: true,
|
||
playtestPassed: true,
|
||
revision: 10,
|
||
}),
|
||
updatedAt: 8500,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: previewPlaytest.sessionId,
|
||
runId: previewPlaytest.runId,
|
||
eventType: 'observation',
|
||
status: 'completed',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:ok · revision 10 最新验证',
|
||
detail: JSON.stringify({
|
||
diagnosticsCount: 0,
|
||
passed: true,
|
||
playtestPassed: true,
|
||
revision: 10,
|
||
}),
|
||
updatedAt: 8800,
|
||
}),
|
||
);
|
||
expect(
|
||
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
|
||
'preview-playtest': previewPlaytest,
|
||
}),
|
||
).toEqual({ runId, revision: 10, validatedAt: 8800 });
|
||
|
||
const staleParentPlaytest = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: 'different-parent-run',
|
||
revision: 11,
|
||
updatedAt: 11000,
|
||
});
|
||
expect(
|
||
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
|
||
'preview-playtest': staleParentPlaytest,
|
||
}),
|
||
).toBeNull();
|
||
|
||
const wrongSourcePlaytest = {
|
||
...gameChatPreviewPlaytestRuntime({
|
||
parentRunId: runId,
|
||
revision: 12,
|
||
updatedAt: 12000,
|
||
}),
|
||
source: 'agent-delegate' as const,
|
||
};
|
||
expect(
|
||
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
|
||
'preview-playtest': wrongSourcePlaytest,
|
||
}),
|
||
).toBeNull();
|
||
|
||
const passedRevision = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: runId,
|
||
revision: 20,
|
||
updatedAt: 20000,
|
||
});
|
||
const higherFailedRevision = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: runId,
|
||
revision: 21,
|
||
updatedAt: 21000,
|
||
});
|
||
higherFailedRevision.recentEvents = [
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: higherFailedRevision.sessionId,
|
||
runId: higherFailedRevision.runId,
|
||
eventType: 'observation',
|
||
status: 'failed',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:failed · revision 21',
|
||
detail: JSON.stringify({
|
||
passed: false,
|
||
playtestPassed: false,
|
||
revision: 21,
|
||
}),
|
||
updatedAt: 21000,
|
||
}),
|
||
];
|
||
expect(
|
||
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
|
||
'preview-playtest-passed': passedRevision,
|
||
'preview-playtest-higher-failed': higherFailedRevision,
|
||
}),
|
||
).toBeNull();
|
||
|
||
const sameRevisionLaterFailed = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: runId,
|
||
revision: 22,
|
||
updatedAt: 22000,
|
||
});
|
||
sameRevisionLaterFailed.recentEvents?.push(
|
||
gameChatRuntimeEvent({
|
||
agentId: 'preview-playtest',
|
||
taskId: 'preview-playtest',
|
||
sessionId: sameRevisionLaterFailed.sessionId,
|
||
runId: sameRevisionLaterFailed.runId,
|
||
eventType: 'observation',
|
||
status: 'failed',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:failed · revision 22 后续回归',
|
||
detail: JSON.stringify({
|
||
passed: false,
|
||
playtestPassed: false,
|
||
revision: 22,
|
||
}),
|
||
updatedAt: 22100,
|
||
}),
|
||
);
|
||
expect(
|
||
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
|
||
'preview-playtest': sameRevisionLaterFailed,
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('keeps game-chat status compact and shows the capped latest twenty events in runtime details', () => {
|
||
const runtime = gameChatRuntimeState({
|
||
runId: 'game-chat-events-run',
|
||
recentEvents: Array.from({ length: 23 }, (_, index) =>
|
||
gameChatRuntimeEvent({
|
||
runId: 'game-chat-events-run',
|
||
summary: `状态事件 ${index + 1}`,
|
||
updatedAt: index + 1,
|
||
}),
|
||
),
|
||
});
|
||
|
||
expect(runtime.recentEvents).toHaveLength(23);
|
||
|
||
renderGameChatStatus({ runtime });
|
||
|
||
const statusCard = screen.getByLabelText('最新状态');
|
||
expect(statusCard.textContent).not.toContain('状态事件 23');
|
||
expect(statusCard.textContent).not.toContain('疑似停滞');
|
||
expect(statusCard.textContent).not.toContain('已持续');
|
||
expect(statusCard.textContent).toContain('运行详情');
|
||
fireEvent.click(
|
||
within(statusCard).getByRole('button', { name: '运行详情' }),
|
||
);
|
||
|
||
const details = screen.getByRole('dialog', { name: '运行详情' });
|
||
const eventList = within(details).getByLabelText('最近运行活动');
|
||
expect(eventList.querySelectorAll(':scope > div')).toHaveLength(20);
|
||
const expandedEventTexts = Array.from(
|
||
eventList.querySelectorAll(':scope > div'),
|
||
(element) => element.textContent,
|
||
);
|
||
expect(expandedEventTexts[0]).toContain('状态事件 23');
|
||
expect(
|
||
expandedEventTexts.some((text) => text?.includes('状态事件 4')),
|
||
).toBe(true);
|
||
expect(
|
||
expandedEventTexts.some((text) => text?.includes('状态事件 3')),
|
||
).toBe(false);
|
||
expect(eventList.querySelectorAll('time')).toHaveLength(20);
|
||
});
|
||
|
||
it('keeps three stage records in the scrollable lane with the newest messages and timestamps visible after them', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-chat-conversation\.supervisor-chat-only-shell\s*\{[^}]*grid-template-rows:\s*64px auto minmax\(0, 1fr\) auto/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.supervisor-chat-only-message-list\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/s,
|
||
);
|
||
|
||
const updatedAt = Date.UTC(2026, 7, 5, 2, 55, 3);
|
||
renderGameChatStatus({
|
||
runtime: gameChatRuntimeState({ updatedAt }),
|
||
viewOverrides: {
|
||
visibleMessages: [
|
||
...Array.from({ length: 3 }, (_, index) => ({
|
||
role: 'assistant' as const,
|
||
text: `【Supervisor 阶段记录】\n阶段 ${index + 1}`,
|
||
messageId: `stage-${index + 1}`,
|
||
updatedAt: updatedAt + index,
|
||
})),
|
||
{
|
||
role: 'assistant',
|
||
text: '这是阶段卡片之后的最新持久消息',
|
||
messageId: 'latest-persisted',
|
||
updatedAt: updatedAt + 3,
|
||
},
|
||
],
|
||
transientReply: '这是当前仍在流式输出的新消息',
|
||
transientReplyUpdatedAt: updatedAt + 4,
|
||
},
|
||
});
|
||
|
||
const messageList = screen.getByLabelText('项目总控消息');
|
||
const messages = Array.from(
|
||
messageList.querySelectorAll<HTMLElement>(':scope > .message'),
|
||
);
|
||
expect(messages).toHaveLength(5);
|
||
expect(
|
||
messages
|
||
.slice(0, 3)
|
||
.every((message) =>
|
||
message.classList.contains('game-chat-stage-record'),
|
||
),
|
||
).toBe(true);
|
||
expect(messages[3]?.textContent).toContain('阶段卡片之后的最新持久消息');
|
||
expect(messages[4]?.textContent).toContain('当前仍在流式输出的新消息');
|
||
expect(messages[4]?.getAttribute('aria-label')).toBe(
|
||
'项目总控 Agent 实时回复',
|
||
);
|
||
expect(messages[4]?.querySelector('time')?.getAttribute('datetime')).toBe(
|
||
new Date(updatedAt + 4).toISOString(),
|
||
);
|
||
expect(messageList.querySelectorAll(':scope > .message time')).toHaveLength(
|
||
5,
|
||
);
|
||
});
|
||
|
||
it('falls back to unknown time for out-of-range message and stream timestamps', () => {
|
||
expect(() =>
|
||
renderGameChatStatus({
|
||
runtime: gameChatRuntimeState({ updatedAt: Number.MAX_VALUE }),
|
||
viewOverrides: {
|
||
visibleMessages: [
|
||
{
|
||
role: 'assistant',
|
||
text: '带无效时间戳的持久消息',
|
||
messageId: 'invalid-time-persisted',
|
||
updatedAt: Number.MAX_VALUE,
|
||
},
|
||
],
|
||
transientReply: '带无效时间戳的实时消息',
|
||
transientReplyUpdatedAt: Number.MAX_VALUE,
|
||
},
|
||
}),
|
||
).not.toThrow();
|
||
|
||
const messageTimes = screen
|
||
.getByLabelText('项目总控消息')
|
||
.querySelectorAll<HTMLElement>(':scope > .message time');
|
||
expect(messageTimes).toHaveLength(2);
|
||
for (const time of messageTimes) {
|
||
expect(time.textContent).toBe('时间未知');
|
||
expect(time.hasAttribute('datetime')).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('refreshes elapsed time every ten seconds and warns after five minutes without parent or child progress', () => {
|
||
vi.useFakeTimers();
|
||
const now = Date.UTC(2026, 7, 5, 2, 55, 3);
|
||
vi.setSystemTime(now);
|
||
try {
|
||
const runId = 'game-chat-stalled-runtime-run';
|
||
const startedAt = now - 9 * 60 * 1000 - 28 * 1000;
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
startedAt,
|
||
updatedAt: now - 6 * 60 * 1000,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'turn.progress',
|
||
summary: '事件窗口内最早进度',
|
||
updatedAt: now - 6 * 60 * 1000,
|
||
}),
|
||
],
|
||
});
|
||
const childRuntime = gameChatRuntimeState({
|
||
agentId: 'code-director',
|
||
taskId: 'code-director',
|
||
sessionId: 'game-chat-stalled-code-session',
|
||
runId: 'game-chat-stalled-code-run',
|
||
source: 'agent-ready-task-scheduler',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: runId,
|
||
updatedAt: now - 5 * 60 * 1000 - 10 * 1000,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
agentId: 'code-director',
|
||
taskId: 'code-director',
|
||
sessionId: 'game-chat-stalled-code-session',
|
||
runId: 'game-chat-stalled-code-run',
|
||
summary: '程序 Agent 最近进度',
|
||
updatedAt: now - 4 * 60 * 1000 - 55 * 1000,
|
||
}),
|
||
],
|
||
});
|
||
|
||
renderGameChatStatus({
|
||
runtime,
|
||
runtimeByAgentId: { 'code-director': childRuntime },
|
||
});
|
||
|
||
let status = screen.getByLabelText('最新状态');
|
||
expect(status.textContent).toContain('运行中');
|
||
expect(status.textContent).not.toContain('疑似停滞');
|
||
expect(status.textContent).toContain('已持续 9 分 28 秒');
|
||
expect(
|
||
within(status).getByTitle('最近运行活动时间').textContent,
|
||
).toContain('已持续 9 分 28 秒');
|
||
|
||
act(() => {
|
||
vi.advanceTimersByTime(10_000);
|
||
});
|
||
|
||
status = screen.getByLabelText('最新状态');
|
||
expect(status.textContent).toContain('运行中 · 疑似停滞');
|
||
expect(status.textContent).toContain('已持续 9 分 38 秒');
|
||
expect(status.textContent).toContain('5 分 5 秒无新进度');
|
||
} finally {
|
||
cleanup();
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it('does not report expected waits as stalled', () => {
|
||
const now = Date.UTC(2026, 7, 5, 2, 55, 3);
|
||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||
const runId = 'game-chat-expected-wait-run';
|
||
const staleAt = now - 10 * 60 * 1000;
|
||
const baseRuntime = gameChatRuntimeState({
|
||
runId,
|
||
updatedAt: staleAt,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'turn.started',
|
||
summary: '本轮开始',
|
||
updatedAt: staleAt,
|
||
}),
|
||
],
|
||
});
|
||
const pendingToolAction = {
|
||
actionId: 'action-1',
|
||
actionFingerprint: 'fingerprint-1',
|
||
tool: 'file.write',
|
||
inputSummary: '写入游戏代码',
|
||
reason: '需要确认',
|
||
requestedAt: staleAt,
|
||
};
|
||
const userInputRequest = {
|
||
schemaVersion: 'game-creator-agent-user-input.v1',
|
||
requestId: 'request-1',
|
||
agentId: 'project-supervisor',
|
||
taskId: 'project-supervisor',
|
||
sessionId: baseRuntime.sessionId,
|
||
runId,
|
||
actionId: 'action-2',
|
||
status: 'pending' as const,
|
||
questions: [],
|
||
allowFreeform: true,
|
||
responseId: null,
|
||
requestedAt: staleAt,
|
||
updatedAt: staleAt,
|
||
};
|
||
const cases: Array<{
|
||
label: string;
|
||
runtime: AgentRuntimeState;
|
||
runtimeByAgentId?: Record<string, AgentRuntimeState | undefined>;
|
||
viewOverrides?: Partial<
|
||
React.ComponentProps<typeof SupervisorChatOnlyView>
|
||
>;
|
||
}> = [
|
||
{
|
||
label: 'waiting-for-user-input status',
|
||
runtime: { ...baseRuntime, status: 'waiting-for-user-input' },
|
||
},
|
||
{
|
||
label: 'userInputRequest',
|
||
runtime: { ...baseRuntime, userInputRequest },
|
||
},
|
||
{
|
||
label: 'waiting-for-confirmation phase',
|
||
runtime: { ...baseRuntime, phase: 'waiting-for-confirmation' },
|
||
},
|
||
{
|
||
label: 'pendingToolAction',
|
||
runtime: { ...baseRuntime, pendingToolAction },
|
||
},
|
||
...[
|
||
'waiting-for-provider-retry',
|
||
'waiting-for-visual-asset',
|
||
'waiting-for-process-session',
|
||
'waiting-for-delegate-receipts',
|
||
'waiting-for-isolated-join',
|
||
'waiting-for-manifest-tasks',
|
||
'paused',
|
||
'pausing',
|
||
].map((phase) => ({
|
||
label: `${phase} phase`,
|
||
runtime: { ...baseRuntime, phase },
|
||
})),
|
||
{
|
||
label: 'pause-requested goal',
|
||
runtime: { ...baseRuntime, goalStatus: 'pause-requested' },
|
||
},
|
||
{
|
||
label: 'pendingConfirmation',
|
||
runtime: baseRuntime,
|
||
viewOverrides: {
|
||
pendingConfirmation: {
|
||
commandId: 'preview.start',
|
||
detail: '启动预览',
|
||
projectPath: '/tmp/game-chat-status',
|
||
},
|
||
},
|
||
},
|
||
{
|
||
label: 'pendingCommand',
|
||
runtime: baseRuntime,
|
||
viewOverrides: { pendingCommand: { id: 'preview.start' } },
|
||
},
|
||
{
|
||
label: 'child provider retry',
|
||
runtime: baseRuntime,
|
||
runtimeByAgentId: {
|
||
'code-director': gameChatRuntimeState({
|
||
agentId: 'code-director',
|
||
taskId: 'code-director',
|
||
sessionId: 'game-chat-waiting-code-session',
|
||
runId: 'game-chat-waiting-code-run',
|
||
source: 'agent-ready-task-scheduler',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: runId,
|
||
phase: 'waiting-for-provider-retry',
|
||
updatedAt: staleAt,
|
||
}),
|
||
},
|
||
},
|
||
];
|
||
|
||
for (const candidate of cases) {
|
||
renderGameChatStatus(candidate);
|
||
const text = screen.getByLabelText('最新状态').textContent ?? '';
|
||
expect(text, candidate.label).not.toContain('疑似停滞');
|
||
expect(text, candidate.label).not.toContain('无新进度');
|
||
cleanup();
|
||
}
|
||
});
|
||
|
||
it('still reports a stalled child when another child is in an expected wait', () => {
|
||
const now = Date.UTC(2026, 7, 5, 2, 55, 3);
|
||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||
const runId = 'game-chat-mixed-stall-run';
|
||
const staleAt = now - 10 * 60 * 1000;
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
updatedAt: staleAt,
|
||
});
|
||
const waitingChild = gameChatRuntimeState({
|
||
agentId: 'art-director',
|
||
taskId: 'art-director',
|
||
sessionId: 'game-chat-waiting-art-session',
|
||
runId: 'game-chat-waiting-art-run',
|
||
source: 'agent-ready-task-scheduler',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: runId,
|
||
phase: 'waiting-for-provider-retry',
|
||
updatedAt: now - 10 * 1000,
|
||
});
|
||
const stalledChild = gameChatRuntimeState({
|
||
agentId: 'code-prototype',
|
||
taskId: 'code-prototype',
|
||
sessionId: 'game-chat-stalled-code-session',
|
||
runId: 'game-chat-stalled-code-run',
|
||
source: 'agent-ready-task-scheduler',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: runId,
|
||
updatedAt: staleAt,
|
||
});
|
||
|
||
renderGameChatStatus({
|
||
runtime,
|
||
runtimeByAgentId: {
|
||
'art-director': waitingChild,
|
||
'code-prototype': stalledChild,
|
||
},
|
||
});
|
||
|
||
const text = screen.getByLabelText('最新状态').textContent ?? '';
|
||
expect(text).toContain('运行中 · 疑似停滞');
|
||
expect(text).toContain('10 分 0 秒无新进度');
|
||
});
|
||
|
||
it('freezes elapsed time when the run reaches a terminal state', () => {
|
||
vi.useFakeTimers();
|
||
const now = Date.UTC(2026, 7, 5, 2, 55, 3);
|
||
vi.setSystemTime(now);
|
||
try {
|
||
const runId = 'game-chat-terminal-duration-run';
|
||
const runtime = gameChatRuntimeState({
|
||
runId,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
updatedAt: now - 60 * 1000,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'turn.started',
|
||
summary: '本轮开始',
|
||
updatedAt: now - 10 * 60 * 1000,
|
||
}),
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'turn.completed',
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
summary: '本轮完成',
|
||
updatedAt: now - 60 * 1000,
|
||
}),
|
||
],
|
||
});
|
||
const childRuntime = gameChatRuntimeState({
|
||
agentId: 'code-director',
|
||
taskId: 'code-director',
|
||
sessionId: 'game-chat-terminal-duration-code-session',
|
||
runId: 'game-chat-terminal-duration-code-run',
|
||
source: 'agent-ready-task-scheduler',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: runId,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
updatedAt: now - 30 * 1000,
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
agentId: 'code-director',
|
||
taskId: 'code-director',
|
||
sessionId: 'game-chat-terminal-duration-code-session',
|
||
runId: 'game-chat-terminal-duration-code-run',
|
||
eventType: 'turn.completed',
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
summary: '子任务晚于父 Run 收口',
|
||
updatedAt: now - 30 * 1000,
|
||
}),
|
||
],
|
||
});
|
||
|
||
renderGameChatStatus({
|
||
runtime,
|
||
runtimeByAgentId: { 'code-director': childRuntime },
|
||
});
|
||
expect(screen.getByLabelText('最新状态').textContent).toContain(
|
||
'已持续 9 分 0 秒',
|
||
);
|
||
|
||
act(() => {
|
||
vi.advanceTimersByTime(60 * 60 * 1000);
|
||
});
|
||
expect(screen.getByLabelText('最新状态').textContent).toContain(
|
||
'已持续 9 分 0 秒',
|
||
);
|
||
} finally {
|
||
cleanup();
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it('hides internal loop iteration wording from game-chat latest status events', () => {
|
||
const runtime = gameChatRuntimeState({
|
||
runId: 'game-chat-loop-wording-run',
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId: 'game-chat-loop-wording-run',
|
||
eventType: 'turn.progress',
|
||
summary: 'Agent 已形成第 8 轮有效进度',
|
||
detail: '生成 Agent 工具计划(第 9 轮)',
|
||
updatedAt: 9000,
|
||
}),
|
||
],
|
||
});
|
||
|
||
renderGameChatStatus({ runtime });
|
||
|
||
expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u);
|
||
fireEvent.click(screen.getByRole('button', { name: '运行详情' }));
|
||
const eventList = screen.getByLabelText('最近运行活动');
|
||
expect(eventList.textContent).toContain('Agent 已形成本轮有效进度');
|
||
expect(eventList.textContent).toContain('生成 Agent 工具计划(本轮)');
|
||
expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u);
|
||
});
|
||
|
||
it('shows and archives the explicit mud point interruption from a failed art child runtime', () => {
|
||
const rootRunId = 'game-chat-mud-point-root-run';
|
||
const runtime = gameChatRuntimeState({
|
||
runId: rootRunId,
|
||
status: 'failed',
|
||
phase: 'failed',
|
||
error: '专业 Agent 执行失败',
|
||
updatedAt: 9100,
|
||
});
|
||
const artRuntime = gameChatRuntimeState({
|
||
agentId: 'art-asset-plan',
|
||
taskId: 'art-asset-plan',
|
||
sessionId: 'game-chat-mud-point-art-session',
|
||
runId: 'game-chat-mud-point-art-run',
|
||
source: 'agent-delegate',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: rootRunId,
|
||
status: 'failed',
|
||
phase: 'failed',
|
||
error:
|
||
'平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2;operationId=private-operation-id',
|
||
updatedAt: 9000,
|
||
});
|
||
const runtimeByAgentId = { 'art-asset-plan': artRuntime };
|
||
|
||
renderGameChatStatus({ runtime, runtimeByAgentId });
|
||
|
||
const status = screen.getByLabelText('最新状态').textContent ?? '';
|
||
expect(status).toContain(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE);
|
||
expect(status).not.toContain('operationId');
|
||
expect(status).not.toContain('private-operation-id');
|
||
|
||
const progress = buildGameChatProgressEvidence(
|
||
runtime,
|
||
runtimeByAgentId,
|
||
null,
|
||
);
|
||
if (!progress) {
|
||
throw new Error('missing failed game-chat progress fixture');
|
||
}
|
||
const stageRecord = formatGameChatStageRecord(runtime, progress, []);
|
||
expect(stageRecord).toContain(MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE);
|
||
expect(stageRecord).not.toContain('operationId');
|
||
expect(stageRecord).not.toContain('private-operation-id');
|
||
});
|
||
|
||
it('counts only the seven first-playable tasks in game-chat progress', () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-progress-total',
|
||
'game-chat-progress-total',
|
||
);
|
||
manifest.tasks = manifest.tasks.map((task) =>
|
||
isGameChatStageTask(task.id)
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
const runtime = gameChatRuntimeState({
|
||
runId: 'game-chat-progress-total-run',
|
||
status: 'running',
|
||
phase: 'execution',
|
||
updatedAt: 9000,
|
||
});
|
||
|
||
renderGameChatStatus({ runtime, manifest });
|
||
|
||
const status = screen.getByLabelText('最新状态');
|
||
expect(status.textContent).toContain('任务图 7/7');
|
||
expect(status.textContent).not.toContain('publish-strategy');
|
||
expect(status.textContent).not.toContain('publish-package');
|
||
fireEvent.click(within(status).getByRole('button', { name: '运行详情' }));
|
||
const progress = screen.getByLabelText('Supervisor 进度播报');
|
||
expect(progress.textContent).toContain('任务图 7/7');
|
||
});
|
||
|
||
it('keeps one live progress card while persisting every public game-chat output as a message', async () => {
|
||
const projectPath = '/tmp/game-chat-progress-broadcast';
|
||
const supervisorRunId = 'game-chat-progress-run';
|
||
const previewFailure = gameChatRuntimeEvent({
|
||
runId: supervisorRunId,
|
||
eventType: 'observation',
|
||
status: 'failed',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:failed',
|
||
detail: JSON.stringify({
|
||
diagnostics: [
|
||
{ message: '角色仍会穿过右侧墙体' },
|
||
'失败后重开按钮没有响应',
|
||
],
|
||
diagnosticsCount: 2,
|
||
passed: false,
|
||
playtestPassed: false,
|
||
revision: 7,
|
||
}),
|
||
updatedAt: 7100,
|
||
});
|
||
const initialSupervisorOverrides = {
|
||
runId: supervisorRunId,
|
||
source: 'project-supervisor-game-chat',
|
||
runProfile: 'autonomous-game-build' as const,
|
||
status: 'running',
|
||
phase: 'execution',
|
||
loopIteration: 4,
|
||
currentTask: '完成首版可运行原型',
|
||
currentAction: '核对首版试玩诊断',
|
||
planRevision: 1,
|
||
planSteps: [
|
||
{ index: 0, title: '完成基础玩法', status: 'completed' },
|
||
{ index: 1, title: '核对首版试玩诊断', status: 'in_progress' },
|
||
{ index: 2, title: '修复并复测', status: 'pending' },
|
||
],
|
||
activePlanStepIndex: 1,
|
||
recentEvents: [previewFailure],
|
||
updatedAt: 7200,
|
||
};
|
||
let professionalRuntimes: Array<Record<string, unknown>> = [];
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialRuntime: initialSupervisorOverrides,
|
||
runtimeMapLoader: async () => professionalRuntimes,
|
||
});
|
||
const initialSupervisor = harness.runtimeState(
|
||
initialSupervisorOverrides,
|
||
) as AgentRuntimeState;
|
||
const codePrototypeRuntime = harness.runtimeState({
|
||
agentId: 'code-prototype',
|
||
taskId: 'code-prototype',
|
||
sessionId: 'code-prototype-progress-session',
|
||
runId: 'code-prototype-progress-run',
|
||
source: 'agent-ready-task-scheduler',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: supervisorRunId,
|
||
delegationId: 'code-prototype-progress-delegation',
|
||
status: 'running',
|
||
phase: 'execution',
|
||
loopIteration: 2,
|
||
currentTask: '修复首版程序问题',
|
||
currentAction: '修复角色碰撞与重开逻辑',
|
||
planSteps: [
|
||
{
|
||
index: 0,
|
||
title: '修复角色碰撞与重开逻辑',
|
||
status: 'in_progress',
|
||
},
|
||
],
|
||
activePlanStepIndex: 0,
|
||
updatedAt: 7300,
|
||
}) as AgentRuntimeState;
|
||
professionalRuntimes = [codePrototypeRuntime];
|
||
harness.setRuntimeReader(async () => ({
|
||
...harness.runtimeResult(initialSupervisor),
|
||
recentEvents: initialSupervisor.recentEvents,
|
||
}));
|
||
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-progress-broadcast',
|
||
'game-chat-progress-broadcast',
|
||
);
|
||
manifest.tasks = manifest.tasks.map((task) => {
|
||
if (
|
||
['design-director', 'art-director', 'code-director'].includes(task.id)
|
||
) {
|
||
return { ...task, status: 'completed' as const };
|
||
}
|
||
if (task.id === 'code-prototype') {
|
||
return { ...task, status: 'running' as const };
|
||
}
|
||
return task;
|
||
});
|
||
let runtimeUpdateHandler:
|
||
| ((event: {
|
||
payload: {
|
||
projectPath: string;
|
||
agentId: string;
|
||
runId: string;
|
||
status: string;
|
||
phase: string;
|
||
runtime: Record<string, unknown>;
|
||
};
|
||
}) => void)
|
||
| null = null;
|
||
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;
|
||
}
|
||
return harness.listen(eventName, handler);
|
||
},
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-progress-broadcast',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const status = await screen.findByLabelText('最新状态');
|
||
expect(status.textContent).toContain('任务图 3/7 · 进行中 1 · 计划 1/3');
|
||
expect(status.textContent).toContain('核对首版试玩诊断');
|
||
expect(status.textContent).toContain('1 个专业 Agent 活跃');
|
||
const runtimeEventAppends = () =>
|
||
invoke.mock.calls.filter(
|
||
([command, args]) =>
|
||
command === 'append_local_conversation_message' &&
|
||
args?.agentId === null &&
|
||
String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'),
|
||
);
|
||
await waitFor(() => {
|
||
expect(runtimeEventAppends()).toHaveLength(1);
|
||
});
|
||
fireEvent.click(within(status).getByRole('button', { name: '运行详情' }));
|
||
const progress = await screen.findByLabelText('Supervisor 进度播报');
|
||
expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1);
|
||
expect(progress.getAttribute('data-runtime-owned')).toBe('true');
|
||
expect(progress.getAttribute('data-run-id')).toBe(supervisorRunId);
|
||
expect(within(progress).getByText('本轮生成进度')).not.toBeNull();
|
||
expect(within(progress).queryByText(/第 4 轮/u)).toBeNull();
|
||
expect(
|
||
within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 1/3'),
|
||
).not.toBeNull();
|
||
expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull();
|
||
expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull();
|
||
expect(
|
||
within(progress).getByText('程序原型 Agent · 修复角色碰撞与重开逻辑'),
|
||
).not.toBeNull();
|
||
expect(within(progress).getByText('试玩未通过')).not.toBeNull();
|
||
expect(
|
||
within(progress).getByText(
|
||
'revision 7 · 诊断 2 项 · 角色仍会穿过右侧墙体 · 失败后重开按钮没有响应',
|
||
),
|
||
).not.toBeNull();
|
||
const delegateDecision = gameChatRuntimeEvent({
|
||
runId: supervisorRunId,
|
||
eventType: 'action',
|
||
phase: 'tool-action',
|
||
summary: '调用工具 agent.delegate',
|
||
detail: 'code-prototype 根据试玩诊断返工碰撞与重开逻辑',
|
||
updatedAt: 7400,
|
||
});
|
||
const updatedSupervisor = harness.runtimeState({
|
||
...initialSupervisorOverrides,
|
||
loopIteration: 5,
|
||
planRevision: 2,
|
||
planSteps: [
|
||
{ index: 0, title: '完成基础玩法', status: 'completed' },
|
||
{ index: 1, title: '核对首版试玩诊断', status: 'completed' },
|
||
{ index: 2, title: '安排程序 Agent 返工', status: 'in_progress' },
|
||
],
|
||
activePlanStepIndex: 2,
|
||
recentEvents: [previewFailure, delegateDecision],
|
||
updatedAt: 7500,
|
||
}) as AgentRuntimeState;
|
||
const updateHandler = runtimeUpdateHandler;
|
||
if (!updateHandler) {
|
||
throw new Error('missing game creator runtime update listener');
|
||
}
|
||
act(() => {
|
||
updateHandler({
|
||
payload: {
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId: supervisorRunId,
|
||
status: 'running',
|
||
phase: 'execution',
|
||
runtime: {
|
||
...harness.runtimeResult(updatedSupervisor),
|
||
recentEvents: updatedSupervisor.recentEvents,
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(within(progress).getByText('本轮生成进度')).not.toBeNull();
|
||
expect(within(progress).queryByText(/第 5 轮/u)).toBeNull();
|
||
expect(
|
||
within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 2/3'),
|
||
).not.toBeNull();
|
||
expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull();
|
||
expect(within(progress).getByText('返工决定')).not.toBeNull();
|
||
expect(
|
||
within(progress).getByText(
|
||
'code-prototype 根据试玩诊断返工碰撞与重开逻辑',
|
||
),
|
||
).not.toBeNull();
|
||
});
|
||
expect(screen.getByLabelText('Supervisor 进度播报')).toBe(progress);
|
||
expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1);
|
||
await waitFor(() => {
|
||
expect(runtimeEventAppends()).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
it('renders registered image outcomes in one runtime-owned game-chat Supervisor card', async () => {
|
||
const projectPath = '/tmp/game-chat-result-images';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-result-images',
|
||
'game-chat-result-images',
|
||
);
|
||
manifest.assets.push(
|
||
{
|
||
id: 'art-spritesheet',
|
||
kind: 'art-spritesheet',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/art-spritesheet.png',
|
||
source: { kind: 'canvas', taskId: 'art-asset-plan' },
|
||
},
|
||
{
|
||
id: 'ui-prototype',
|
||
kind: 'ui-prototype',
|
||
mediaType: 'image/webp',
|
||
localPath: 'assets/ui-prototype.webp',
|
||
source: { kind: 'canvas', taskId: 'design-foundation' },
|
||
},
|
||
{
|
||
id: 'audio-manifest',
|
||
kind: 'audio',
|
||
mediaType: 'audio/mpeg',
|
||
localPath: 'assets/theme.mp3',
|
||
source: { kind: 'generated' },
|
||
},
|
||
{
|
||
id: 'invalid-image-path',
|
||
kind: 'reference',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/../outside.png',
|
||
source: { kind: 'generated' },
|
||
},
|
||
);
|
||
expect(collectGameChatResultImages(manifest)).toEqual([
|
||
{
|
||
key: 'art-spritesheet:assets/art-spritesheet.png',
|
||
label: '美术素材图集',
|
||
mediaType: 'image/png',
|
||
path: 'assets/art-spritesheet.png',
|
||
},
|
||
{
|
||
key: 'ui-prototype:assets/ui-prototype.webp',
|
||
label: '界面原型',
|
||
mediaType: 'image/webp',
|
||
path: 'assets/ui-prototype.webp',
|
||
},
|
||
]);
|
||
const pngDataUrl =
|
||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||
const webpDataUrl = 'data:image/webp;base64,UklGRgAAAABXRUJQVlA4';
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-result-images',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'read_local_project_image_preview') {
|
||
const relativePath = String(args?.relativePath ?? '');
|
||
const mediaType = relativePath.endsWith('.webp')
|
||
? 'image/webp'
|
||
: 'image/png';
|
||
return {
|
||
path: relativePath,
|
||
mediaType,
|
||
byteLen: 12,
|
||
dataUrl: mediaType === 'image/webp' ? webpDataUrl : pngDataUrl,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
const { unmount } = render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
fireEvent.click(
|
||
await screen.findByRole('button', { name: '运行详情 · 成果 2' }),
|
||
);
|
||
const imageCard = await screen.findByLabelText('Supervisor 成果图片');
|
||
expect(imageCard.getAttribute('data-runtime-owned')).toBe('true');
|
||
expect(screen.getAllByLabelText('Supervisor 成果图片')).toHaveLength(1);
|
||
expect(within(imageCard).getByText('2/2')).not.toBeNull();
|
||
expect(
|
||
within(imageCard)
|
||
.getByRole('img', {
|
||
name: '美术素材图集 · assets/art-spritesheet.png',
|
||
})
|
||
.getAttribute('src'),
|
||
).toBe(pngDataUrl);
|
||
expect(
|
||
within(imageCard)
|
||
.getByRole('img', {
|
||
name: '界面原型 · assets/ui-prototype.webp',
|
||
})
|
||
.getAttribute('src'),
|
||
).toBe(webpDataUrl);
|
||
const imageReadCalls = invoke.mock.calls.filter(
|
||
([command]) => command === 'read_local_project_image_preview',
|
||
);
|
||
expect(imageReadCalls).toHaveLength(2);
|
||
expect(imageReadCalls[0]?.[1]).toMatchObject({
|
||
projectPath,
|
||
relativePath: 'assets/art-spritesheet.png',
|
||
scopeId: expect.any(String),
|
||
requestId: expect.any(String),
|
||
});
|
||
expect(imageReadCalls[1]?.[1]).toMatchObject({
|
||
projectPath,
|
||
relativePath: 'assets/ui-prototype.webp',
|
||
scopeId: imageReadCalls[0]?.[1]?.scopeId,
|
||
requestId: expect.any(String),
|
||
});
|
||
expect(imageReadCalls[1]?.[1]?.requestId).not.toBe(
|
||
imageReadCalls[0]?.[1]?.requestId,
|
||
);
|
||
|
||
fireEvent.click(
|
||
within(imageCard).getByRole('button', {
|
||
name: '查看 美术素材图集',
|
||
}),
|
||
);
|
||
const viewer = screen.getByRole('dialog', {
|
||
name: '美术素材图集 图片查看器',
|
||
});
|
||
const scale = within(viewer).getByLabelText('图片缩放比例');
|
||
expect(scale.textContent).toBe('100%');
|
||
fireEvent.click(within(viewer).getByRole('button', { name: '放大图片' }));
|
||
expect(scale.textContent).toBe('125%');
|
||
const stage = within(viewer).getByLabelText('可缩放拖拽图片区域');
|
||
fireEvent.wheel(stage, { deltaY: -1 });
|
||
expect(scale.textContent).toBe('150%');
|
||
const dispatchPointer = (
|
||
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
||
clientX: number,
|
||
clientY: number,
|
||
) => {
|
||
const event = new Event(type, { bubbles: true });
|
||
Object.defineProperties(event, {
|
||
button: { value: 0 },
|
||
clientX: { value: clientX },
|
||
clientY: { value: clientY },
|
||
pointerId: { value: 1 },
|
||
});
|
||
fireEvent(stage, event);
|
||
};
|
||
dispatchPointer('pointerdown', 100, 80);
|
||
dispatchPointer('pointermove', 136, 104);
|
||
dispatchPointer('pointerup', 136, 104);
|
||
const viewerImage = within(viewer).getByRole('img', {
|
||
name: '美术素材图集 · assets/art-spritesheet.png',
|
||
});
|
||
expect(viewerImage.getAttribute('style')).toContain(
|
||
'translate(36px, 24px) scale(1.5)',
|
||
);
|
||
fireEvent.click(within(viewer).getByRole('button', { name: '复位图片' }));
|
||
expect(scale.textContent).toBe('100%');
|
||
expect(viewerImage.getAttribute('style')).toContain(
|
||
'translate(0px, 0px) scale(1)',
|
||
);
|
||
fireEvent.keyDown(window, { key: 'Escape' });
|
||
expect(
|
||
screen.queryByRole('dialog', {
|
||
name: '美术素材图集 图片查看器',
|
||
}),
|
||
).toBeNull();
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'append_local_conversation_message',
|
||
),
|
||
).toHaveLength(0);
|
||
unmount();
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'cancel_local_project_resource_preview_scope',
|
||
{ scopeId: imageReadCalls[0]?.[1]?.scopeId },
|
||
);
|
||
});
|
||
|
||
it('persists one terminal game-chat stage record and keeps it in the next round', async () => {
|
||
const projectPath = '/tmp/game-chat-stage-record';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-stage-record',
|
||
'game-chat-stage-record',
|
||
);
|
||
manifest.tasks = manifest.tasks.map((task) =>
|
||
isGameChatStageTask(task.id)
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
manifest.assets.push({
|
||
id: 'stage-art',
|
||
kind: 'art-spritesheet',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/art-spritesheet.png',
|
||
source: { kind: 'canvas', taskId: 'art-asset-plan' },
|
||
});
|
||
let runtimeUpdateHandler:
|
||
| ((event: {
|
||
payload: {
|
||
projectPath: string;
|
||
agentId: string;
|
||
runId: string;
|
||
status: string;
|
||
phase: string;
|
||
runtime: Record<string, unknown>;
|
||
};
|
||
}) => void)
|
||
| null = null;
|
||
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;
|
||
}
|
||
return harness.listen(eventName, handler);
|
||
},
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-stage-record',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'read_local_project_image_preview') {
|
||
return {
|
||
path: String(args?.relativePath ?? ''),
|
||
mediaType: 'image/png',
|
||
byteLen: 12,
|
||
dataUrl:
|
||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('游戏创作聊天');
|
||
const composer = within(surface).getByLabelText('项目总控对话内容');
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.change(composer, { target: { value: '完成这一轮并保留证据' } });
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) =>
|
||
command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
const startCall = invoke.mock.calls.find(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
);
|
||
const firstRunId = String(startCall?.[1]?.runId ?? '');
|
||
expect(firstRunId).not.toBe('');
|
||
const previewPassed = gameChatRuntimeEvent({
|
||
runId: firstRunId,
|
||
eventType: 'observation',
|
||
summary: 'preview.validate:ok · 浏览器验证已通过',
|
||
detail: JSON.stringify({
|
||
diagnosticsCount: 0,
|
||
passed: true,
|
||
playtestPassed: true,
|
||
revision: 9,
|
||
}),
|
||
updatedAt: 9100,
|
||
});
|
||
const rework = gameChatRuntimeEvent({
|
||
runId: firstRunId,
|
||
eventType: 'action',
|
||
summary: '调用工具 agent.delegate',
|
||
detail: '根据上一版试玩诊断安排程序 Agent 完成返工',
|
||
updatedAt: 9000,
|
||
});
|
||
const terminalRuntime = harness.runtimeState({
|
||
runId: firstRunId,
|
||
runProfile: 'autonomous-game-build',
|
||
status: 'idle',
|
||
phase: 'completed',
|
||
loopIteration: 6,
|
||
currentAction: '等待下一轮输入',
|
||
planSteps: [
|
||
{ index: 0, title: '完成返工', status: 'completed' },
|
||
{ index: 1, title: '通过试玩', status: 'completed' },
|
||
],
|
||
activePlanStepIndex: null,
|
||
recentEvents: [rework, previewPassed],
|
||
updatedAt: 9200,
|
||
});
|
||
harness.appendSupervisorMessage({
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: '第一轮已经完成。',
|
||
agentId: 'project-supervisor',
|
||
messageId: 'stage-record-final-assistant',
|
||
updatedAt: 9200,
|
||
});
|
||
const updateHandler = runtimeUpdateHandler;
|
||
if (!updateHandler) {
|
||
throw new Error('missing game creator runtime update listener');
|
||
}
|
||
act(() => {
|
||
updateHandler({
|
||
payload: {
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId: firstRunId,
|
||
status: 'idle',
|
||
phase: 'completed',
|
||
runtime: {
|
||
...harness.runtimeResult(terminalRuntime),
|
||
recentEvents: [rework, previewPassed],
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
const stageRecordText = await screen.findByText(
|
||
/【Supervisor 阶段记录】[\s\S]*本轮生成进度 · 本轮已完成/,
|
||
);
|
||
const stageRecord = stageRecordText.closest('p');
|
||
expect(stageRecord?.className).toContain('game-chat-stage-record');
|
||
expect(screen.queryByLabelText('Supervisor 进度播报')).toBeNull();
|
||
expect(stageRecordText.textContent).toContain('试玩通过:revision 9');
|
||
expect(stageRecordText.textContent).toContain(
|
||
'返工决定:根据上一版试玩诊断安排程序 Agent 完成返工',
|
||
);
|
||
expect(stageRecordText.textContent).toContain(
|
||
'成果图片:美术素材图集(assets/art-spritesheet.png)',
|
||
);
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command, args]) =>
|
||
command === 'append_local_conversation_message' &&
|
||
args?.agentId === null &&
|
||
String(
|
||
(args?.message as { content?: string } | undefined)?.content ??
|
||
'',
|
||
).startsWith('【Supervisor 阶段记录】'),
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
fireEvent.change(composer, { target: { value: '开始下一轮' } });
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) =>
|
||
command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(2);
|
||
});
|
||
expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1);
|
||
expect(screen.getByText(/试玩通过:revision 9/)).not.toBeNull();
|
||
});
|
||
|
||
it('defers the terminal game-chat stage record until the refreshed manifest is terminal', async () => {
|
||
const projectPath = '/tmp/game-chat-stage-record-manifest-race';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const pendingManifest = createGameCreationAppManifest(
|
||
'game-chat-stage-record-manifest-race',
|
||
'game-chat-stage-record-manifest-race',
|
||
);
|
||
const terminalManifest = createGameCreationAppManifest(
|
||
'game-chat-stage-record-manifest-race',
|
||
'game-chat-stage-record-manifest-race',
|
||
);
|
||
terminalManifest.tasks = terminalManifest.tasks.map((task) =>
|
||
task.id === 'preview-playtest'
|
||
? { ...task, status: 'failed' as const }
|
||
: [
|
||
'design-director',
|
||
'art-director',
|
||
'art-asset-plan',
|
||
'code-director',
|
||
'code-prototype',
|
||
'preview-readiness',
|
||
].includes(task.id)
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
let manifestReady = false;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-stage-record-manifest-race',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest: pendingManifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifestReady ? terminalManifest : pendingManifest;
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const composer = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||
const form = composer.form;
|
||
if (!form) {
|
||
throw new Error('missing game-chat composer form');
|
||
}
|
||
const stageRecordAppends = () =>
|
||
invoke.mock.calls.filter(
|
||
([command, args]) =>
|
||
command === 'append_local_conversation_message' &&
|
||
args?.agentId === null &&
|
||
isGameChatStageRecordMessage(
|
||
String(
|
||
(args?.message as { content?: string } | undefined)?.content ??
|
||
'',
|
||
),
|
||
),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(composer.disabled).toBe(false);
|
||
});
|
||
fireEvent.change(composer, { target: { value: 'manifest race' } });
|
||
fireEvent.submit(form);
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) =>
|
||
command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
const startCall = invoke.mock.calls.find(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
);
|
||
const runId = String(startCall?.[1]?.runId ?? '');
|
||
expect(runId).not.toBe('');
|
||
act(() => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
runId,
|
||
status: 'failed',
|
||
phase: 'failed',
|
||
currentAction: 'preview-playtest failed',
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
status: 'failed',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate failed',
|
||
detail: JSON.stringify({
|
||
diagnosticsCount: 1,
|
||
passed: false,
|
||
playtestPassed: false,
|
||
revision: 9,
|
||
}),
|
||
updatedAt: 9100,
|
||
}),
|
||
],
|
||
updatedAt: 9200,
|
||
}),
|
||
);
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(stageRecordAppends()).toHaveLength(0);
|
||
});
|
||
|
||
manifestReady = true;
|
||
fireEvent.change(composer, { target: { value: '/tasks' } });
|
||
fireEvent.submit(form);
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'get_local_game_manifest' &&
|
||
args?.commandId === 'task.list',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
await waitFor(() => {
|
||
expect(stageRecordAppends()).toHaveLength(1);
|
||
});
|
||
const stageRecord = String(
|
||
(stageRecordAppends()[0]?.[1] as { message?: { content?: string } })
|
||
?.message?.content ?? '',
|
||
);
|
||
expect(stageRecord).toContain('6/7');
|
||
});
|
||
|
||
it('archives a terminal game-chat run restored during initial hydration exactly once', async () => {
|
||
const projectPath = '/tmp/game-chat-stage-record-initial-terminal';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-stage-record-initial-terminal',
|
||
'game-chat-stage-record-initial-terminal',
|
||
);
|
||
manifest.tasks = manifest.tasks.map((task) =>
|
||
isGameChatStageTask(task.id)
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
const runId = 'game-chat-stage-record-initial-terminal-run';
|
||
const terminalRuntime = harness.runtimeState({
|
||
runId,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentAction: 'preview complete',
|
||
recentEvents: [
|
||
gameChatRuntimeEvent({
|
||
runId,
|
||
eventType: 'observation',
|
||
status: 'completed',
|
||
phase: 'tool-observation',
|
||
summary: 'preview.validate:ok · 试玩验证已通过',
|
||
detail: JSON.stringify({
|
||
diagnosticsCount: 0,
|
||
passed: true,
|
||
playtestPassed: true,
|
||
revision: 11,
|
||
}),
|
||
updatedAt: 9100,
|
||
}),
|
||
],
|
||
updatedAt: 9200,
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: manifest.name,
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'read_game_creator_agent_runtime') {
|
||
return harness.runtimeResult(terminalRuntime);
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const stageRecordAppends = () =>
|
||
invoke.mock.calls.filter(
|
||
([command, args]) =>
|
||
command === 'append_local_conversation_message' &&
|
||
args?.agentId === null &&
|
||
isGameChatStageRecordMessage(
|
||
String(
|
||
(args?.message as { content?: string } | undefined)?.content ??
|
||
'',
|
||
),
|
||
),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(stageRecordAppends()).toHaveLength(1);
|
||
});
|
||
expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1);
|
||
|
||
// A later refresh/runtime snapshot for the same run must not append again.
|
||
fireEvent.change(screen.getByRole('textbox'), {
|
||
target: { value: '/tasks' },
|
||
});
|
||
fireEvent.submit(
|
||
(screen.getByRole('textbox') as HTMLTextAreaElement).form!,
|
||
);
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'get_local_game_manifest' &&
|
||
args?.commandId === 'task.list',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
expect(stageRecordAppends()).toHaveLength(1);
|
||
});
|
||
|
||
it('does not duplicate a historical terminal game-chat stage record during restart hydration', async () => {
|
||
const projectPath = '/tmp/game-chat-stage-record-restart-hydration';
|
||
const runId = 'game-chat-stage-record-restart-run';
|
||
const terminalRuntime = gameChatRuntimeState({
|
||
sessionId: 'supervisor-session-active',
|
||
runId,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentAction: 'preview complete',
|
||
updatedAt: 9200,
|
||
});
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-stage-record-restart-hydration',
|
||
'game-chat-stage-record-restart-hydration',
|
||
);
|
||
manifest.tasks = manifest.tasks.map((task) =>
|
||
isGameChatStageTask(task.id)
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
const progress = buildGameChatProgressEvidence(
|
||
terminalRuntime,
|
||
{},
|
||
manifest,
|
||
);
|
||
if (!progress) {
|
||
throw new Error('missing terminal game-chat progress fixture');
|
||
}
|
||
const historicalStageRecord = formatGameChatStageRecord(
|
||
terminalRuntime,
|
||
progress,
|
||
collectGameChatResultImages(manifest),
|
||
);
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialRuntime: terminalRuntime,
|
||
projectMessages: [
|
||
{
|
||
role: 'assistant',
|
||
content: historicalStageRecord,
|
||
agentId: null,
|
||
messageId: 'historical-game-chat-stage-record',
|
||
updatedAt: 9201,
|
||
},
|
||
],
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: manifest.name,
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const stageRecordAppends = () =>
|
||
invoke.mock.calls.filter(
|
||
([command, args]) =>
|
||
command === 'append_local_conversation_message' &&
|
||
args?.agentId === null &&
|
||
isGameChatStageRecordMessage(
|
||
String(
|
||
(args?.message as { content?: string } | undefined)?.content ??
|
||
'',
|
||
),
|
||
),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1);
|
||
});
|
||
expect(stageRecordAppends()).toHaveLength(0);
|
||
|
||
// A terminal runtime event can race the hydration refresh. The historical
|
||
// record must remain the sole record and must not be appended again.
|
||
act(() => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
sessionId: terminalRuntime.sessionId,
|
||
runId,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentAction: 'preview complete',
|
||
updatedAt: 9300,
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1);
|
||
});
|
||
expect(stageRecordAppends()).toHaveLength(0);
|
||
});
|
||
|
||
it('starts and displays the first playable game-chat preview exactly once', async () => {
|
||
const projectPath = '/tmp/game-chat-auto-preview';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const pendingManifest = createGameCreationAppManifest(
|
||
'game-chat-auto-preview',
|
||
'game-chat-auto-preview',
|
||
);
|
||
const completedManifest = createGameCreationAppManifest(
|
||
'game-chat-auto-preview',
|
||
'game-chat-auto-preview',
|
||
);
|
||
completedManifest.tasks = completedManifest.tasks.map((task) =>
|
||
task.id === 'code-prototype'
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
let playableVersionReady = false;
|
||
let previewStarted = false;
|
||
let acceptedRunId = '';
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-auto-preview',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return previewStarted
|
||
? {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4311',
|
||
port: 4311,
|
||
root: `${projectPath}/game`,
|
||
}
|
||
: {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return playableVersionReady ? completedManifest : pendingManifest;
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
expect(args).toEqual({ projectPath, expectedRevision: 1 });
|
||
previewStarted = true;
|
||
return {
|
||
url: 'http://127.0.0.1:4311',
|
||
port: 4311,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
}
|
||
if (command === 'start_game_creator_supervisor_runtime_task') {
|
||
acceptedRunId = String(args?.runId ?? '');
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('游戏创作聊天');
|
||
const composer = within(surface).getByLabelText('项目总控对话内容');
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.change(composer, {
|
||
target: { value: '生成第一版可玩原型' },
|
||
});
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'start_game_creator_supervisor_runtime_task',
|
||
{
|
||
projectPath,
|
||
sessionId: harness.sessionId,
|
||
task: '生成第一版可玩原型',
|
||
runId: expect.stringMatching(/^project-supervisor-task-/),
|
||
runProfile: 'autonomous-game-build',
|
||
source: 'project-supervisor-game-chat',
|
||
},
|
||
);
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(0);
|
||
expect(screen.queryByLabelText('游戏运行')).toBeNull();
|
||
expect(within(surface).getByText('预览未启动')).not.toBeNull();
|
||
|
||
playableVersionReady = true;
|
||
harness.setProjectRevision(1);
|
||
const firstPlayableRevision = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: acceptedRunId,
|
||
revision: 1,
|
||
updatedAt: 3900,
|
||
});
|
||
await act(async () => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
runId: acceptedRunId,
|
||
runProfile: 'autonomous-game-build',
|
||
status: 'running',
|
||
phase: 'verification',
|
||
currentTask: '验证首个可玩版本',
|
||
currentGoal: '交付首个可玩版本',
|
||
currentAction: '可玩版本已经通过验证',
|
||
updatedAt: 4000,
|
||
}),
|
||
);
|
||
harness.emitAgentRuntime(firstPlayableRevision);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(
|
||
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
|
||
).not.toBeNull();
|
||
const previewFrame = screen.getByTitle(
|
||
'game-chat-auto-preview 游戏运行画面',
|
||
) as HTMLIFrameElement;
|
||
expect(previewFrame.src).toMatch(/^http:\/\/127\.0\.0\.1:4311\//);
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
expect(invoke).toHaveBeenCalledWith('append_local_permission_log', {
|
||
projectPath,
|
||
event: 'permission.confirm',
|
||
commandId: 'preview.start',
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
it('restores an authorized playable game-chat preview after restart without starting it twice', async () => {
|
||
const projectPath = '/tmp/game-chat-auto-preview-restart';
|
||
const runId = 'game-chat-auto-preview-restart-run';
|
||
const revision = 7;
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialProjectRevision: revision,
|
||
initialRuntime: {
|
||
sessionId: 'supervisor-session-active',
|
||
runId,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
updatedAt: 9200,
|
||
},
|
||
runtimeMapLoader: async () => [
|
||
gameChatPreviewPlaytestRuntime({
|
||
parentRunId: runId,
|
||
revision,
|
||
updatedAt: 9100,
|
||
}),
|
||
],
|
||
});
|
||
const manifest = createGameCreationAppManifest(
|
||
'game-chat-auto-preview-restart',
|
||
'game-chat-auto-preview-restart',
|
||
);
|
||
manifest.tasks = manifest.tasks.map((task) =>
|
||
task.id === 'code-prototype'
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
let previewStarted = false;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: manifest.name,
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision };
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return previewStarted
|
||
? {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4327',
|
||
port: 4327,
|
||
root: `${projectPath}/game`,
|
||
}
|
||
: { status: 'stopped', url: null, port: null, root: null };
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
expect(args).toEqual({ projectPath, expectedRevision: revision });
|
||
previewStarted = true;
|
||
return {
|
||
url: 'http://127.0.0.1:4327',
|
||
port: 4327,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.localStorage.setItem(
|
||
'genarrative.game-chat.auto-preview-authorization.v2',
|
||
JSON.stringify({
|
||
afterRevision: 0,
|
||
afterValidatedAt: 0,
|
||
authorizationId: 'restored-preview-authorization',
|
||
projectPath,
|
||
runId,
|
||
}),
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
|
||
const renderRelease = () =>
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
let rendered = renderRelease();
|
||
|
||
await waitFor(
|
||
() => {
|
||
expect(invoke.mock.calls).toContainEqual(
|
||
expect.arrayContaining(['start_local_game_preview']),
|
||
);
|
||
},
|
||
{ timeout: 5000 },
|
||
);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(screen.getByLabelText('游戏运行')).not.toBeNull();
|
||
expect(
|
||
window.localStorage.getItem(
|
||
'genarrative.game-chat.auto-preview-authorization.v2',
|
||
),
|
||
).toBeNull();
|
||
|
||
rendered.unmount();
|
||
rendered = renderRelease();
|
||
await waitFor(() => {
|
||
expect(screen.getByLabelText('游戏运行')).not.toBeNull();
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
rendered.unmount();
|
||
});
|
||
|
||
it('refreshes a running game-chat iframe after a later run completes without restarting the preview', async () => {
|
||
const projectPath = '/tmp/game-chat-preview-revision';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const playableManifest = createGameCreationAppManifest(
|
||
'game-chat-preview-revision',
|
||
'game-chat-preview-revision',
|
||
);
|
||
playableManifest.tasks = playableManifest.tasks.map((task) =>
|
||
task.id === 'code-prototype'
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
const acceptedRunIds: string[] = [];
|
||
let previewStarted = false;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-preview-revision',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return previewStarted
|
||
? {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4314',
|
||
port: 4314,
|
||
root: `${projectPath}/game`,
|
||
}
|
||
: {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return playableManifest;
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
expect(args).toEqual({ projectPath, expectedRevision: 1 });
|
||
previewStarted = true;
|
||
return {
|
||
url: 'http://127.0.0.1:4314',
|
||
port: 4314,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
}
|
||
if (command === 'start_game_creator_supervisor_runtime_task') {
|
||
acceptedRunIds.push(String(args?.runId ?? ''));
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('游戏创作聊天');
|
||
const composer = within(surface).getByLabelText('项目总控对话内容');
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.change(composer, {
|
||
target: { value: '生成第一版可玩原型' },
|
||
});
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
|
||
await waitFor(() => {
|
||
expect(acceptedRunIds).toHaveLength(1);
|
||
});
|
||
harness.setProjectRevision(1);
|
||
const firstPreviewPassed = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: acceptedRunIds[0],
|
||
revision: 1,
|
||
updatedAt: 3900,
|
||
});
|
||
await act(async () => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
runId: acceptedRunIds[0],
|
||
runProfile: 'autonomous-game-build',
|
||
status: 'running',
|
||
phase: 'verification',
|
||
currentTask: '验证第一版可玩原型',
|
||
currentGoal: '交付第一版可玩原型',
|
||
currentAction: '第一版已经通过浏览器验证',
|
||
updatedAt: 4000,
|
||
}),
|
||
);
|
||
harness.emitAgentRuntime(firstPreviewPassed);
|
||
await Promise.resolve();
|
||
});
|
||
const firstFrame = (await screen.findByTitle(
|
||
'game-chat-preview-revision 游戏运行画面',
|
||
{},
|
||
{ timeout: 3000 },
|
||
)) as HTMLIFrameElement;
|
||
const firstFrameUrl = firstFrame.src;
|
||
expect(acceptedRunIds).toHaveLength(1);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
|
||
await act(async () => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
runId: acceptedRunIds[0],
|
||
runProfile: 'autonomous-game-build',
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentTask: '第一版已经完成',
|
||
currentGoal: '第一版已经完成',
|
||
currentAction: '完成交付',
|
||
lastResponse: '第一版已经完成',
|
||
updatedAt: 5000,
|
||
}),
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
});
|
||
|
||
const previewStatusCallsBeforeRevision = invoke.mock.calls.filter(
|
||
([command]) => command === 'get_local_game_preview_status',
|
||
).length;
|
||
fireEvent.change(composer, {
|
||
target: { value: '更新第二版玩法和平衡性' },
|
||
});
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
await waitFor(() => {
|
||
expect(acceptedRunIds).toHaveLength(2);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'get_local_game_preview_status',
|
||
).length,
|
||
).toBeGreaterThan(previewStatusCallsBeforeRevision);
|
||
});
|
||
expect(screen.getByTitle('game-chat-preview-revision 游戏运行画面')).toBe(
|
||
firstFrame,
|
||
);
|
||
expect(firstFrame.src).toBe(firstFrameUrl);
|
||
|
||
harness.setProjectRevision(2);
|
||
const secondPreviewPassed = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: acceptedRunIds[1],
|
||
revision: 2,
|
||
updatedAt: 7900,
|
||
});
|
||
await act(async () => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
runId: acceptedRunIds[1],
|
||
runProfile: 'autonomous-game-build',
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentTask: '第二版已经完成',
|
||
currentGoal: '第二版已经完成',
|
||
currentAction: '完成第二版验证',
|
||
lastResponse: '第二版已经完成',
|
||
updatedAt: 8000,
|
||
}),
|
||
);
|
||
harness.emitAgentRuntime(secondPreviewPassed);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(
|
||
() => {
|
||
const refreshedFrame = screen.getByTitle(
|
||
'game-chat-preview-revision 游戏运行画面',
|
||
) as HTMLIFrameElement;
|
||
expect(
|
||
refreshedFrame !== firstFrame || refreshedFrame.src !== firstFrameUrl,
|
||
).toBe(true);
|
||
},
|
||
{ timeout: 3000 },
|
||
);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(2);
|
||
}, 10000);
|
||
|
||
it('keeps a same-run steer authorization until a newer revision refreshes the running iframe', async () => {
|
||
const projectPath = '/tmp/game-chat-same-run-steer-preview';
|
||
const driver = await renderGameChatAutoPreviewDriver({
|
||
projectPath,
|
||
port: 4381,
|
||
startPreview: async (args) => {
|
||
expect(args).toEqual({ projectPath, expectedRevision: 1 });
|
||
return {
|
||
url: 'http://127.0.0.1:4381',
|
||
port: 4381,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
},
|
||
});
|
||
|
||
await driver.submit('生成首个可玩版本');
|
||
await driver.emitValidation(1, 1000);
|
||
const firstFrame = (await screen.findByTitle(
|
||
'game-chat-same-run-steer-preview 游戏运行画面',
|
||
{},
|
||
{ timeout: 3000 },
|
||
)) as HTMLIFrameElement;
|
||
const firstFrameUrl = firstFrame.src;
|
||
await waitFor(() => {
|
||
expect(driver.readAuthorization()).toBeNull();
|
||
});
|
||
|
||
await driver.submit('保持同一 Run 并继续调整当前版本');
|
||
await waitFor(() => {
|
||
expect(driver.readAuthorization()).toMatchObject({
|
||
afterRevision: 1,
|
||
afterValidatedAt: 1000,
|
||
projectPath,
|
||
runId: driver.parentRunId,
|
||
authorizationId: expect.any(String),
|
||
});
|
||
});
|
||
const steerAuthorizationId = driver.readAuthorization()?.authorizationId;
|
||
const previewStatusReads = driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'get_local_game_preview_status',
|
||
).length;
|
||
await waitFor(
|
||
() => {
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'get_local_game_preview_status',
|
||
).length,
|
||
).toBeGreaterThan(previewStatusReads);
|
||
},
|
||
{ timeout: 3000 },
|
||
);
|
||
expect(driver.readAuthorization()?.authorizationId).toBe(
|
||
steerAuthorizationId,
|
||
);
|
||
expect(
|
||
screen.getByTitle('game-chat-same-run-steer-preview 游戏运行画面'),
|
||
).toBe(firstFrame);
|
||
expect(firstFrame.src).toBe(firstFrameUrl);
|
||
|
||
await driver.emitValidation(2, 2000);
|
||
await waitFor(
|
||
() => {
|
||
const refreshedFrame = screen.getByTitle(
|
||
'game-chat-same-run-steer-preview 游戏运行画面',
|
||
) as HTMLIFrameElement;
|
||
expect(
|
||
refreshedFrame !== firstFrame || refreshedFrame.src !== firstFrameUrl,
|
||
).toBe(true);
|
||
expect(driver.readAuthorization()).toBeNull();
|
||
},
|
||
{ timeout: 3000 },
|
||
);
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'steer_game_creator_agent_runtime_task',
|
||
),
|
||
).toHaveLength(1);
|
||
}, 10000);
|
||
|
||
it('waits for new validation evidence after a revision-drift start failure before retrying', async () => {
|
||
const projectPath = '/tmp/game-chat-preview-revision-drift';
|
||
let setProjectRevisionOnDrift: ((revision: number) => void) | null = null;
|
||
const driver = await renderGameChatAutoPreviewDriver({
|
||
projectPath,
|
||
port: 4382,
|
||
startPreview: async (args) => {
|
||
const expectedRevision = Number(args.expectedRevision);
|
||
if (expectedRevision === 1) {
|
||
setProjectRevisionOnDrift?.(2);
|
||
throw new Error(
|
||
'本地游戏项目已在验证后发生变化:expectedRevision=1 · currentRevision=2',
|
||
);
|
||
}
|
||
expect(expectedRevision).toBe(2);
|
||
return {
|
||
url: 'http://127.0.0.1:4382',
|
||
port: 4382,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
},
|
||
});
|
||
setProjectRevisionOnDrift = driver.harness.setProjectRevision;
|
||
|
||
await driver.submit('生成后验证 revision 漂移恢复');
|
||
await driver.emitValidation(1, 1000);
|
||
await waitFor(
|
||
() => {
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(driver.readAuthorization()).not.toBeNull();
|
||
},
|
||
{ timeout: 3000 },
|
||
);
|
||
await act(async () => {
|
||
driver.harness.emitAgentRuntime(
|
||
gameChatPreviewPlaytestRuntime({
|
||
parentRunId: driver.parentRunId,
|
||
revision: 1,
|
||
updatedAt: 1000,
|
||
}),
|
||
);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(driver.readAuthorization()).not.toBeNull();
|
||
|
||
await driver.emitValidation(2, 2000);
|
||
expect(
|
||
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
|
||
).not.toBeNull();
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(2);
|
||
expect(
|
||
driver.invoke.mock.calls
|
||
.filter(([command]) => command === 'start_local_game_preview')
|
||
.at(-1),
|
||
).toEqual([
|
||
'start_local_game_preview',
|
||
{ projectPath, expectedRevision: 2 },
|
||
]);
|
||
expect(driver.readAuthorization()).toBeNull();
|
||
}, 10000);
|
||
|
||
it('does not let a deferred policy result clear a replacement authorization', async () => {
|
||
await assertNewGameChatAuthorizationSupersedesDeferredAttempt('policy');
|
||
}, 10000);
|
||
|
||
it('atomically stops a stale deferred start without clearing its replacement authorization', async () => {
|
||
await assertNewGameChatAuthorizationSupersedesDeferredAttempt('start');
|
||
}, 10000);
|
||
|
||
it('allows a new authorization and later validation to retry the same revision after a terminal attempt', async () => {
|
||
const projectPath = '/tmp/game-chat-preview-same-revision-retry';
|
||
const driver = await renderGameChatAutoPreviewDriver({
|
||
projectPath,
|
||
port: 4383,
|
||
startPreview: async (args, callIndex) => {
|
||
expect(args).toEqual({ projectPath, expectedRevision: 1 });
|
||
if (callIndex === 1) {
|
||
throw new Error('模拟 preview.start 非瞬时失败');
|
||
}
|
||
return {
|
||
url: 'http://127.0.0.1:4383',
|
||
port: 4383,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
},
|
||
});
|
||
|
||
await driver.submit('生成同 revision 首次验证');
|
||
await driver.emitValidation(1, 1000);
|
||
await waitFor(
|
||
() => {
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(driver.readAuthorization()).toBeNull();
|
||
},
|
||
{ timeout: 3000 },
|
||
);
|
||
|
||
await driver.submit('同一 Run 新授权后重新验证同一 revision');
|
||
await waitFor(
|
||
() => {
|
||
expect(driver.readAuthorization()).toMatchObject({
|
||
afterRevision: 1,
|
||
afterValidatedAt: 1000,
|
||
authorizationId: expect.any(String),
|
||
});
|
||
},
|
||
{ timeout: 3000 },
|
||
);
|
||
await driver.emitValidation(1, 2000);
|
||
expect(
|
||
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
|
||
).not.toBeNull();
|
||
expect(
|
||
driver.invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(2);
|
||
expect(driver.readAuthorization()).toBeNull();
|
||
}, 10000);
|
||
|
||
it('retries the authorized game-chat preview after a transient project write lock', async () => {
|
||
const projectPath = '/tmp/game-chat-auto-preview-lock-retry';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const completedManifest = createGameCreationAppManifest(
|
||
'game-chat-auto-preview-lock-retry',
|
||
'game-chat-auto-preview-lock-retry',
|
||
);
|
||
completedManifest.tasks = completedManifest.tasks.map((task) =>
|
||
task.id === 'code-prototype'
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
let previewStarted = false;
|
||
let startAttempts = 0;
|
||
let acceptedRunId = '';
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-auto-preview-lock-retry',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return previewStarted
|
||
? {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4313',
|
||
port: 4313,
|
||
root: `${projectPath}/game`,
|
||
}
|
||
: {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return completedManifest;
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
expect(args).toEqual({ projectPath, expectedRevision: 3 });
|
||
startAttempts += 1;
|
||
if (startAttempts === 1) {
|
||
throw new Error(
|
||
`项目正在被其他写操作占用:${projectPath}/.agent/project.lock`,
|
||
);
|
||
}
|
||
previewStarted = true;
|
||
return {
|
||
url: 'http://127.0.0.1:4313',
|
||
port: 4313,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
}
|
||
if (command === 'start_game_creator_supervisor_runtime_task') {
|
||
acceptedRunId = String(args?.runId ?? '');
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('游戏创作聊天');
|
||
const composer = within(surface).getByLabelText('项目总控对话内容');
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.change(composer, {
|
||
target: { value: '生成后自动预览并处理瞬时写锁' },
|
||
});
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
|
||
await waitFor(() => {
|
||
expect(acceptedRunId).toMatch(/^project-supervisor-task-/);
|
||
});
|
||
harness.setProjectRevision(3);
|
||
const playableRevision = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: acceptedRunId,
|
||
revision: 3,
|
||
updatedAt: 3900,
|
||
});
|
||
await act(async () => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
runId: acceptedRunId,
|
||
runProfile: 'autonomous-game-build',
|
||
status: 'running',
|
||
phase: 'verification',
|
||
updatedAt: 4000,
|
||
}),
|
||
);
|
||
harness.emitAgentRuntime(playableRevision);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(
|
||
await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }),
|
||
).not.toBeNull();
|
||
expect(startAttempts).toBe(2);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(2);
|
||
});
|
||
|
||
it('adopts the accepted game-chat run when start initially returns the previous idle state', async () => {
|
||
const projectPath = '/tmp/game-chat-accepted-run';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const completedManifest = createGameCreationAppManifest(
|
||
'game-chat-accepted-run',
|
||
'game-chat-accepted-run',
|
||
);
|
||
completedManifest.tasks = completedManifest.tasks.map((task) =>
|
||
task.id === 'code-prototype'
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
const previousIdleRuntime = harness.runtimeState({
|
||
runId: 'game-chat-previous-idle',
|
||
status: 'idle',
|
||
phase: 'idle',
|
||
updatedAt: 1000,
|
||
});
|
||
let acceptedRunId = '';
|
||
let postStartRuntimeReads = 0;
|
||
let allowCompletion = false;
|
||
let previewStarted = false;
|
||
const acceptedRunEvent = gameChatRuntimeEvent({
|
||
runId: 'placeholder',
|
||
summary: '新受理 Run 正在执行',
|
||
updatedAt: 4000,
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-accepted-run',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return previewStarted
|
||
? {
|
||
status: 'running',
|
||
url: 'http://127.0.0.1:4312',
|
||
port: 4312,
|
||
root: `${projectPath}/game`,
|
||
}
|
||
: {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return completedManifest;
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
expect(args).toEqual({ projectPath, expectedRevision: 4 });
|
||
previewStarted = true;
|
||
return {
|
||
url: 'http://127.0.0.1:4312',
|
||
port: 4312,
|
||
root: `${projectPath}/game`,
|
||
};
|
||
}
|
||
if (command === 'start_game_creator_supervisor_runtime_task') {
|
||
acceptedRunId = String(args?.runId ?? '');
|
||
return {
|
||
...harness.runtimeResult(previousIdleRuntime),
|
||
acceptedRunId,
|
||
};
|
||
}
|
||
if (command === 'read_game_creator_agent_runtime' && acceptedRunId) {
|
||
postStartRuntimeReads += 1;
|
||
const state = harness.runtimeState({
|
||
runId: acceptedRunId,
|
||
runProfile: 'autonomous-game-build',
|
||
status: allowCompletion ? 'completed' : 'running',
|
||
phase: allowCompletion ? 'completed' : 'execution',
|
||
currentTask: '生成 accepted run 首版游戏',
|
||
currentGoal: '完成 accepted run 可玩原型',
|
||
currentAction: allowCompletion ? '完成交付' : '执行当前计划',
|
||
waitingOn: allowCompletion ? '' : '专业任务收束',
|
||
nextStep: allowCompletion ? '' : '继续执行',
|
||
lastResponse: allowCompletion ? 'accepted run 已完成' : null,
|
||
updatedAt: allowCompletion ? 5000 : 4000,
|
||
});
|
||
return {
|
||
...harness.runtimeResult(state),
|
||
recentEvents: [
|
||
{
|
||
...acceptedRunEvent,
|
||
runId: acceptedRunId,
|
||
status: allowCompletion ? 'completed' : 'running',
|
||
phase: allowCompletion ? 'completed' : 'execution',
|
||
summary: allowCompletion
|
||
? '新受理 Run 已完成'
|
||
: acceptedRunEvent.summary,
|
||
updatedAt: allowCompletion ? 5000 : 4000,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('游戏创作聊天');
|
||
const composer = within(surface).getByLabelText('项目总控对话内容');
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.change(composer, {
|
||
target: { value: '生成并接管真实受理 Run' },
|
||
});
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
|
||
await waitFor(() => {
|
||
expect(acceptedRunId).toMatch(/^project-supervisor-task-/);
|
||
expect(postStartRuntimeReads).toBeGreaterThan(0);
|
||
expect(screen.getByLabelText('最新状态').textContent).toContain(
|
||
'生成 accepted run 首版游戏',
|
||
);
|
||
});
|
||
expect(screen.queryByText('等待新的运行事件')).toBeNull();
|
||
fireEvent.click(screen.getByRole('button', { name: '运行详情' }));
|
||
await waitFor(
|
||
() => {
|
||
expect(
|
||
within(screen.getByLabelText('最近运行活动')).getByText(
|
||
/新受理 Run 正在执行/,
|
||
),
|
||
).not.toBeNull();
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
harness.setProjectRevision(4);
|
||
await act(async () => {
|
||
harness.emitAgentRuntime(
|
||
gameChatPreviewPlaytestRuntime({
|
||
parentRunId: acceptedRunId,
|
||
revision: 4,
|
||
updatedAt: 4100,
|
||
}),
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
expect(await screen.findByLabelText('游戏运行')).not.toBeNull();
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
const startCallIndex = invoke.mock.calls.findIndex(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
);
|
||
const acceptedReadCallIndex = invoke.mock.calls.findIndex(
|
||
([command], index) =>
|
||
index > startCallIndex && command === 'read_game_creator_agent_runtime',
|
||
);
|
||
const manifestCallIndex = invoke.mock.calls.findIndex(
|
||
([command], index) =>
|
||
index > startCallIndex && command === 'get_local_game_manifest',
|
||
);
|
||
expect(acceptedReadCallIndex).toBeGreaterThan(startCallIndex);
|
||
expect(manifestCallIndex).toBeGreaterThan(acceptedReadCallIndex);
|
||
|
||
allowCompletion = true;
|
||
await waitFor(
|
||
() => {
|
||
expect(screen.getByLabelText('最新状态').textContent).toContain(
|
||
'本轮已完成',
|
||
);
|
||
expect(screen.getByLabelText('最近运行活动').textContent).toContain(
|
||
'新受理 Run 已完成',
|
||
);
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
it('honors an explicit preview.start deny after a playable revision is verified', async () => {
|
||
const projectPath = '/tmp/game-chat-preview-denied';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const completedManifest = createGameCreationAppManifest(
|
||
'game-chat-preview-denied',
|
||
'game-chat-preview-denied',
|
||
);
|
||
completedManifest.tasks = completedManifest.tasks.map((task) =>
|
||
task.id === 'code-prototype'
|
||
? { ...task, status: 'completed' as const }
|
||
: task,
|
||
);
|
||
let acceptedRunId = '';
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'game-chat-preview-denied',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return {
|
||
status: 'stopped',
|
||
url: null,
|
||
port: null,
|
||
root: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return completedManifest;
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return {
|
||
path: '.agent/policy.json',
|
||
policy: {
|
||
deniedCommands: ['preview.start'],
|
||
confirmCommands: [],
|
||
},
|
||
};
|
||
}
|
||
if (command === 'start_game_creator_supervisor_runtime_task') {
|
||
acceptedRunId = String(args?.runId ?? '');
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
gameChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('游戏创作聊天');
|
||
const composer = within(surface).getByLabelText('项目总控对话内容');
|
||
await waitFor(() => {
|
||
expect((composer as HTMLTextAreaElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.change(composer, { target: { value: '生成但不要自动启动' } });
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
|
||
await waitFor(() => {
|
||
expect(acceptedRunId).toMatch(/^project-supervisor-task-/);
|
||
});
|
||
harness.setProjectRevision(5);
|
||
const deniedPlayableRevision = gameChatPreviewPlaytestRuntime({
|
||
parentRunId: acceptedRunId,
|
||
revision: 5,
|
||
updatedAt: 3900,
|
||
});
|
||
await act(async () => {
|
||
harness.emitRuntime(
|
||
harness.runtimeState({
|
||
runId: acceptedRunId,
|
||
runProfile: 'autonomous-game-build',
|
||
status: 'running',
|
||
phase: 'verification',
|
||
updatedAt: 4000,
|
||
}),
|
||
);
|
||
harness.emitAgentRuntime(deniedPlayableRevision);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(
|
||
() => {
|
||
expect(
|
||
within(surface).getByLabelText('最新状态').textContent,
|
||
).toContain('项目权限策略拒绝执行:preview.start');
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(0);
|
||
expect(screen.queryByLabelText('游戏运行')).toBeNull();
|
||
});
|
||
|
||
it('keeps game-chat full-width before preview and switches to desktop 2:1 or mobile vertical layout', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
|
||
expect(styles).toMatch(
|
||
/\.game-chat-shell\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-chat-shell\.has-preview\s*\{[^}]*grid-template-columns:\s*minmax\(0, 2fr\) minmax\(320px, 1fr\)/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/@media \(max-width: 760px\)[\s\S]*?\.game-chat-shell\.has-preview\s*\{[^}]*grid-template-rows:\s*minmax\(220px, 42dvh\) minmax\(0, 1fr\)[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/,
|
||
);
|
||
});
|
||
|
||
it('loads and continues the active Project Supervisor Session in the standalone chat surface', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-game';
|
||
const historyMessage = '已持久化的项目总控历史';
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
expectedRunProfile: 'standard',
|
||
supervisorMessages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: historyMessage,
|
||
agentId: 'project-supervisor',
|
||
messageId: 'supervisor-chat-only-history',
|
||
updatedAt: 2000,
|
||
},
|
||
],
|
||
});
|
||
window.__TAURI__ = {
|
||
core: { invoke: harness.invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
const messageList = within(surface).getByLabelText('项目总控消息');
|
||
expect(await within(messageList).findByText(historyMessage)).not.toBeNull();
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'list_game_creator_agent_sessions',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
},
|
||
);
|
||
expect(harness.invoke).toHaveBeenCalledWith('read_local_conversation', {
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
sessionId: harness.sessionId,
|
||
});
|
||
expect(
|
||
within(surface).getByRole('button', { name: '设置' }),
|
||
).not.toBeNull();
|
||
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
|
||
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
|
||
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
|
||
|
||
fireEvent.change(within(surface).getByLabelText('项目总控对话内容'), {
|
||
target: { value: '继续完成可玩原型' },
|
||
});
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
|
||
await waitFor(() => {
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'start_game_creator_supervisor_runtime_task',
|
||
{
|
||
projectPath,
|
||
sessionId: harness.sessionId,
|
||
task: '继续完成可玩原型',
|
||
runId: expect.stringMatching(/^project-supervisor-task-/),
|
||
runProfile: 'standard',
|
||
source: 'project-supervisor-gui',
|
||
},
|
||
);
|
||
});
|
||
expect(harness.invoke).not.toHaveBeenCalledWith(
|
||
'create_game_creator_agent_session',
|
||
expect.anything(),
|
||
);
|
||
expect(harness.invoke).not.toHaveBeenCalledWith(
|
||
'chat_with_game_creator_agent',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it('keeps an unsent standalone Project Supervisor draft across window navigation reloads', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-draft';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
window.__TAURI__ = {
|
||
core: { invoke: harness.invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
const renderSupervisorChat = () =>
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
renderSupervisorChat();
|
||
const firstSurface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
await within(firstSurface).findByLabelText('项目总控消息');
|
||
fireEvent.change(within(firstSurface).getByLabelText('项目总控对话内容'), {
|
||
target: { value: '这条草稿还没有发送' },
|
||
});
|
||
|
||
cleanup();
|
||
renderSupervisorChat();
|
||
|
||
const restoredInput = await screen.findByLabelText('项目总控对话内容');
|
||
expect(restoredInput).toHaveProperty('value', '这条草稿还没有发送');
|
||
});
|
||
|
||
it('shows and handles runtime recovery confirmation in the standalone Project Supervisor chat', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-resume';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
throw new Error('项目权限策略要求用户确认:agent.resume');
|
||
}
|
||
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
const detail = await within(surface).findByText(
|
||
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
|
||
);
|
||
expect(within(surface).queryByText(/项目总控 Agent 恢复失败/)).toBeNull();
|
||
const confirmation = detail.closest('.pending-command');
|
||
expect(confirmation).not.toBeNull();
|
||
|
||
fireEvent.click(
|
||
within(confirmation as HTMLElement).getByRole('button', {
|
||
name: '确认',
|
||
}),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'confirm_resume_game_creator_agent_runtime_tasks',
|
||
{ projectPath },
|
||
);
|
||
expect(
|
||
within(surface).queryByText(
|
||
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
|
||
),
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
it('confirms and rejects pending actions in the standalone Project Supervisor chat', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-confirmation';
|
||
const runId = 'supervisor-chat-only-confirmation-run';
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialRuntime: {
|
||
runId,
|
||
status: 'waiting-for-confirmation',
|
||
phase: 'waiting-for-confirmation',
|
||
pendingToolAction: {
|
||
actionId: 'standalone-action-confirm',
|
||
actionFingerprint: 'standalone-fingerprint-confirm',
|
||
tool: 'file.write',
|
||
inputSummary: 'game/index.html',
|
||
reason: null,
|
||
requestedAt: 3000,
|
||
},
|
||
},
|
||
});
|
||
harness.setConfirmRuntime(
|
||
harness.runtimeState({
|
||
runId,
|
||
status: 'waiting-for-confirmation',
|
||
phase: 'waiting-for-confirmation',
|
||
pendingToolAction: {
|
||
actionId: 'standalone-action-reject',
|
||
actionFingerprint: 'standalone-fingerprint-reject',
|
||
tool: 'command.exec',
|
||
inputSummary: 'npm test',
|
||
reason: null,
|
||
requestedAt: 4000,
|
||
},
|
||
}),
|
||
);
|
||
harness.setRejectRuntime(
|
||
harness.runtimeState({
|
||
runId,
|
||
status: 'running',
|
||
phase: 'planning',
|
||
pendingToolAction: null,
|
||
}),
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke: harness.invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
let pendingAction =
|
||
await within(surface).findByLabelText('项目总控 Agent 待确认动作');
|
||
expect(within(pendingAction).getByText('file.write')).not.toBeNull();
|
||
expect(within(pendingAction).getByText('game/index.html')).not.toBeNull();
|
||
fireEvent.click(
|
||
within(pendingAction).getByRole('button', { name: '确认' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'confirm_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId,
|
||
actionId: 'standalone-action-confirm',
|
||
note: '用户已确认待执行工具动作',
|
||
},
|
||
);
|
||
});
|
||
|
||
pendingAction =
|
||
await within(surface).findByLabelText('项目总控 Agent 待确认动作');
|
||
expect(within(pendingAction).getByText('command.exec')).not.toBeNull();
|
||
expect(within(pendingAction).getByText('npm test')).not.toBeNull();
|
||
fireEvent.click(
|
||
within(pendingAction).getByRole('button', { name: '拒绝' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'reject_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId,
|
||
actionId: 'standalone-action-reject',
|
||
note: '用户已拒绝待执行工具动作',
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
it('answers structured questions in the standalone Project Supervisor chat', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-user-input';
|
||
const sessionId = 'supervisor-chat-only-user-input-session';
|
||
const runId = 'supervisor-chat-only-user-input-run';
|
||
const request = agentRuntimeUserInputRequest({
|
||
agentId: 'project-supervisor',
|
||
sessionId,
|
||
runId,
|
||
});
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
sessionId,
|
||
initialRuntime: {
|
||
runId,
|
||
status: 'waiting-for-user-input',
|
||
phase: 'waiting-for-user-input',
|
||
currentTask: '准备首版角色规范图',
|
||
currentAction: '等待用户补充关键信息',
|
||
waitingOn: '你的澄清回答',
|
||
nextStep: '提交全部回答后继续同一 Run',
|
||
userInputRequest: request,
|
||
updatedAt: 6000,
|
||
},
|
||
});
|
||
window.__TAURI__ = {
|
||
core: { invoke: harness.invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
const card = await within(surface).findByLabelText('Needs input');
|
||
expect(within(card).getByText('1. 美术方向')).not.toBeNull();
|
||
expect(
|
||
within(card).getByText('首版角色规范图采用哪种美术方向?'),
|
||
).not.toBeNull();
|
||
expect(within(card).getByText('优先验证轮廓与动作可读性。')).not.toBeNull();
|
||
expect(
|
||
(
|
||
within(surface).getByLabelText(
|
||
'项目总控对话内容',
|
||
) as HTMLTextAreaElement
|
||
).disabled,
|
||
).toBe(true);
|
||
|
||
fireEvent.click(within(card).getByRole('button', { name: /像素风/ }));
|
||
fireEvent.click(within(card).getByRole('button', { name: '提交回答' }));
|
||
await waitFor(() => {
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'answer_game_creator_agent_runtime_user_input',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId,
|
||
actionId: request.actionId,
|
||
requestId: request.requestId,
|
||
responseId: expect.stringMatching(/^app-user-input-/),
|
||
answers: { visual_direction: '像素风' },
|
||
},
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(within(surface).queryByLabelText('Needs input')).toBeNull();
|
||
});
|
||
});
|
||
|
||
it('opens an existing project into the active Supervisor Session, restores history, then starts and steers the same run', async () => {
|
||
const projectPath = '/tmp/launcher-supervisor-game';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-supervisor-game',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
supervisorMessages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: '已恢复的项目总控历史',
|
||
agentId: 'project-supervisor',
|
||
messageId: 'launcher-supervisor-history',
|
||
updatedAt: 2000,
|
||
},
|
||
],
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-supervisor-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||
target: { value: projectPath },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '打开' }));
|
||
|
||
const supervisorSurface = await screen.findByLabelText('项目总控对话');
|
||
expect(
|
||
await within(supervisorSurface).findByText('已恢复的项目总控历史'),
|
||
).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith('list_game_creator_agent_sessions', {
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
});
|
||
expect(invoke).toHaveBeenCalledWith('read_local_conversation', {
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
sessionId: supervisorHarness.sessionId,
|
||
});
|
||
expect(screen.getByLabelText('专业 Agent 协作状态')).not.toBeNull();
|
||
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
|
||
expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull();
|
||
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
|
||
'game-creator-agent-progress',
|
||
expect.any(Function),
|
||
);
|
||
const policyReadCountBeforeChat = invoke.mock.calls.filter(
|
||
([command]) => command === 'read_project_permission_policy',
|
||
).length;
|
||
|
||
fireEvent.change(screen.getByLabelText('项目需求'), {
|
||
target: { value: '先完成正式客户端玩法拆解' },
|
||
});
|
||
fireEvent.click(
|
||
within(supervisorSurface).getByRole('button', { name: '发送' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'start_game_creator_supervisor_runtime_task',
|
||
{
|
||
projectPath,
|
||
sessionId: supervisorHarness.sessionId,
|
||
task: '先完成正式客户端玩法拆解',
|
||
runId: expect.stringMatching(/^project-supervisor-task-/),
|
||
runProfile: 'autonomous-game-build',
|
||
source: 'project-supervisor-gui',
|
||
},
|
||
);
|
||
});
|
||
const startCall = invoke.mock.calls.find(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
);
|
||
const runId = String(startCall?.[1]?.runId ?? '');
|
||
await waitFor(() => {
|
||
expect(
|
||
(
|
||
within(supervisorSurface).getByRole('button', {
|
||
name: '发送',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(false);
|
||
});
|
||
|
||
fireEvent.change(screen.getByLabelText('项目需求'), {
|
||
target: { value: '补充:优先复用现有素材' },
|
||
});
|
||
fireEvent.click(
|
||
within(supervisorSurface).getByRole('button', { name: '发送' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'steer_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
sessionId: supervisorHarness.sessionId,
|
||
runId,
|
||
steerId: expect.stringMatching(/^project-supervisor-steer-/),
|
||
instruction: '补充:优先复用现有素材',
|
||
runProfile: 'autonomous-game-build',
|
||
source: 'project-supervisor-gui',
|
||
},
|
||
);
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'read_project_permission_policy',
|
||
),
|
||
).toHaveLength(policyReadCountBeforeChat);
|
||
});
|
||
|
||
it('keeps the workbench professional Agent status truthful through polling without Tauri events', async () => {
|
||
const projectPath = '/tmp/launcher-runtime-status-game';
|
||
const supervisorRunId = 'supervisor-runtime-status-run';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-runtime-status-game',
|
||
);
|
||
expect(manifest.tasks.every((task) => task.status === 'pending')).toBe(
|
||
true,
|
||
);
|
||
|
||
let exposeProfessionalRuntimes = false;
|
||
let failProfessionalRuntimeReads = false;
|
||
let failProfessionalConversationReads = false;
|
||
let designConversationMode: 'initial' | 'updated' = 'initial';
|
||
let failProfessionalAction = false;
|
||
let failProfessionalRetry = true;
|
||
let professionalRuntimeReadCount = 0;
|
||
let professionalConversationReadCount = 0;
|
||
let releaseProfessionalRetry: (() => void) | null = null;
|
||
let professionalRuntimes: Array<Record<string, unknown>> = [];
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialRuntime: {
|
||
runId: supervisorRunId,
|
||
status: 'running',
|
||
phase: 'waiting-for-delegate-receipts',
|
||
currentTask: '协调专业 Agent 完成首版原型',
|
||
currentAction: '等待专业 Agent 返回真实 Runtime 状态',
|
||
waitingOn: '专业 Agent 回执',
|
||
updatedAt: 5000,
|
||
},
|
||
runtimeMapLoader: async () => {
|
||
professionalRuntimeReadCount += 1;
|
||
if (failProfessionalRuntimeReads) {
|
||
throw new Error('模拟专业 Runtime 瞬时读取失败');
|
||
}
|
||
return exposeProfessionalRuntimes ? professionalRuntimes : [];
|
||
},
|
||
});
|
||
professionalRuntimes = [
|
||
supervisorHarness.runtimeState({
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'design-runtime-status-session',
|
||
runId: 'design-runtime-status-run',
|
||
source: 'agent-delegate',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: supervisorRunId,
|
||
delegationId: 'design-runtime-status-delegation',
|
||
status: 'failed',
|
||
phase: 'failed',
|
||
currentTask: '拆解首版玩法',
|
||
currentAction: '策划 Runtime 已失败',
|
||
error:
|
||
'agentLlm.design-director 后台 Agent 工具计划调用 LLM 失败:kind=transport fingerprint=internal-error-fingerprint chars=545',
|
||
updatedAt: 6100,
|
||
}),
|
||
supervisorHarness.runtimeState({
|
||
agentId: 'art-director',
|
||
taskId: 'art-director',
|
||
sessionId: 'art-runtime-status-session',
|
||
runId: 'art-runtime-status-run',
|
||
source: 'agent-delegate',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: supervisorRunId,
|
||
delegationId: 'art-runtime-status-delegation',
|
||
status: 'waiting-for-confirmation',
|
||
phase: 'waiting-for-confirmation',
|
||
currentTask: '写入美术计划',
|
||
currentAction: '等待确认写入美术计划',
|
||
waitingOn: '用户确认 file.write',
|
||
pendingToolAction: {
|
||
actionId: 'art-file-write-action',
|
||
actionFingerprint: 'art-file-write-fingerprint',
|
||
tool: 'file.write',
|
||
inputSummary: 'path=assets/art-plan.md · contentChars=7325',
|
||
reason: null,
|
||
requestedAt: 6200,
|
||
},
|
||
updatedAt: 6200,
|
||
}),
|
||
supervisorHarness.runtimeState({
|
||
agentId: 'code-director',
|
||
taskId: 'code-director',
|
||
sessionId: 'code-runtime-status-session',
|
||
runId: 'code-runtime-status-run',
|
||
source: 'agent-delegate-retry',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: supervisorRunId,
|
||
delegationId: 'code-runtime-status-delegation',
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentTask: '完成程序实现拆解',
|
||
currentAction: '程序方案已完成',
|
||
updatedAt: 6300,
|
||
}),
|
||
supervisorHarness.runtimeState({
|
||
agentId: 'art-polish',
|
||
taskId: 'art-polish',
|
||
sessionId: 'stale-art-session',
|
||
runId: 'stale-art-run',
|
||
source: 'agent-delegate',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: 'stale-supervisor-run',
|
||
delegationId: 'stale-art-delegation',
|
||
status: 'running',
|
||
phase: 'action',
|
||
currentTask: '旧父 Run 的美术任务不得显示',
|
||
currentAction: '旧父 Run 的状态不得显示',
|
||
updatedAt: 9999,
|
||
}),
|
||
];
|
||
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-runtime-status-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (
|
||
command === 'read_local_conversation' &&
|
||
args?.agentId === 'code-director'
|
||
) {
|
||
professionalConversationReadCount += 1;
|
||
if (failProfessionalConversationReads) {
|
||
throw new Error('模拟专业成果持久对话瞬时读取失败');
|
||
}
|
||
return {
|
||
path: `${projectPath}/.agent/conversations/agents/code-director.jsonl`,
|
||
agentId: 'code-director',
|
||
sessionId: 'code-runtime-status-session',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: '已完成程序实现拆解,并给出可执行的模块边界。',
|
||
agentId: 'code-director',
|
||
messageId:
|
||
'agent-finalization-11111111111111111111111111111111',
|
||
updatedAt: 6300,
|
||
},
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: '新一轮程序任务失败,这不是可交付成果。',
|
||
agentId: 'code-director',
|
||
messageId: null,
|
||
updatedAt: 6400,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (
|
||
command === 'read_local_conversation' &&
|
||
args?.agentId === 'design-director'
|
||
) {
|
||
professionalConversationReadCount += 1;
|
||
if (failProfessionalConversationReads) {
|
||
throw new Error('模拟专业成果持久对话瞬时读取失败');
|
||
}
|
||
const designFinalization =
|
||
designConversationMode === 'updated'
|
||
? {
|
||
content: '更新后的策划历史成果,替换同 Agent 的旧回执。',
|
||
messageId:
|
||
'agent-finalization-33333333333333333333333333333333',
|
||
updatedAt: 7200,
|
||
}
|
||
: {
|
||
content: '上一轮已经完成并验收的策划成果。',
|
||
messageId:
|
||
'agent-finalization-22222222222222222222222222222222',
|
||
updatedAt: 5900,
|
||
};
|
||
return {
|
||
path: `${projectPath}/.agent/conversations/agents/design-director.jsonl`,
|
||
agentId: 'design-director',
|
||
sessionId: 'design-runtime-status-session',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: designFinalization.content,
|
||
agentId: 'design-director',
|
||
messageId: designFinalization.messageId,
|
||
updatedAt: designFinalization.updatedAt,
|
||
},
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: '当前新一轮策划失败,不得覆盖旧成果。',
|
||
agentId: 'design-director',
|
||
messageId: null,
|
||
updatedAt: 6100,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (
|
||
command === 'read_local_conversation' &&
|
||
args?.agentId === 'art-asset-plan'
|
||
) {
|
||
professionalConversationReadCount += 1;
|
||
if (failProfessionalConversationReads) {
|
||
throw new Error('模拟专业成果持久对话瞬时读取失败');
|
||
}
|
||
return {
|
||
path: `${projectPath}/.agent/conversations/agents/art-asset-plan.jsonl`,
|
||
agentId: 'art-asset-plan',
|
||
sessionId: 'art-history-session',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: '历史美术资源计划回执,尚不等于图片资产。',
|
||
agentId: 'art-asset-plan',
|
||
messageId:
|
||
'agent-finalization-44444444444444444444444444444444',
|
||
updatedAt: 5800,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (
|
||
command === 'read_local_conversation' &&
|
||
args?.agentId === 'balance-seed'
|
||
) {
|
||
professionalConversationReadCount += 1;
|
||
if (failProfessionalConversationReads) {
|
||
throw new Error('模拟专业成果持久对话瞬时读取失败');
|
||
}
|
||
return {
|
||
path: `${projectPath}/.agent/conversations/agents/balance-seed.jsonl`,
|
||
agentId: 'balance-seed',
|
||
sessionId: 'balance-history-session',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: '历史数值方案回执。',
|
||
agentId: 'balance-seed',
|
||
messageId:
|
||
'agent-finalization-55555555555555555555555555555555',
|
||
updatedAt: 5700,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (command === 'confirm_retry_game_creator_agent_runtime_task') {
|
||
if (failProfessionalRetry) {
|
||
throw new Error(
|
||
'agentLlm.design-director retry失败:kind=transport fingerprint=private-retry-fingerprint chars=256',
|
||
);
|
||
}
|
||
await new Promise<void>((resolve) => {
|
||
releaseProfessionalRetry = resolve;
|
||
});
|
||
return supervisorHarness.runtimeResult(
|
||
supervisorHarness.runtimeState({
|
||
agentId: String(args?.agentId ?? ''),
|
||
taskId: String(args?.agentId ?? ''),
|
||
sessionId: 'design-runtime-status-session',
|
||
runId: String(args?.nextRunId ?? ''),
|
||
source: 'agent-delegate-retry',
|
||
parentAgentId: 'project-supervisor',
|
||
parentRunId: supervisorRunId,
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentTask: '在当前项目重试策划任务',
|
||
currentAction: '重新生成 Agent 工具计划',
|
||
updatedAt: 7000,
|
||
}),
|
||
);
|
||
}
|
||
if (
|
||
failProfessionalAction &&
|
||
command === 'confirm_game_creator_agent_runtime_task'
|
||
) {
|
||
throw new Error(
|
||
'agentLlm.art-director 工具确认失败:kind=transport fingerprint=private-action-fingerprint chars=384',
|
||
);
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||
target: { value: projectPath },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '打开' }));
|
||
|
||
expect(await screen.findByLabelText('项目总控对话')).not.toBeNull();
|
||
await waitFor(() => {
|
||
expect(professionalRuntimeReadCount).toBeGreaterThan(0);
|
||
});
|
||
expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull();
|
||
const supervisorStatusPanel = screen.getByLabelText('项目总控 Agent 状态');
|
||
supervisorStatusPanel.scrollTop = 120;
|
||
|
||
const readCountBeforeExpose = professionalRuntimeReadCount;
|
||
exposeProfessionalRuntimes = true;
|
||
const professionalList = await screen.findByLabelText(
|
||
'专业 Agent 实时状态',
|
||
{},
|
||
{ timeout: 2500 },
|
||
);
|
||
expect(professionalRuntimeReadCount).toBeGreaterThan(readCountBeforeExpose);
|
||
expect(
|
||
within(professionalList).getByText('策划 Agent 服务连接失败,请稍后重试'),
|
||
).not.toBeNull();
|
||
expect(professionalList.textContent).not.toContain('fingerprint');
|
||
expect(professionalList.textContent).not.toContain('chars=545');
|
||
expect(professionalList.textContent).not.toContain('contentChars');
|
||
expect(supervisorStatusPanel.scrollTop).toBe(0);
|
||
expect(
|
||
within(professionalList).getByText('等待确认后继续工作'),
|
||
).not.toBeNull();
|
||
expect(within(professionalList).getByText('本轮工作已完成')).not.toBeNull();
|
||
expect(
|
||
within(professionalList).queryByText('旧父 Run 的状态不得显示'),
|
||
).toBeNull();
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith('read_local_conversation', {
|
||
projectPath,
|
||
agentId: 'code-director',
|
||
});
|
||
});
|
||
const codeAgentCard = within(professionalList)
|
||
.getByText('程序 Agent')
|
||
.closest('article')!;
|
||
const resultButton = await within(codeAgentCard).findByRole('button', {
|
||
name: '查看成果',
|
||
});
|
||
fireEvent.click(resultButton);
|
||
const resultDialog = await screen.findByRole('dialog', {
|
||
name: '程序 Agent 文本回执',
|
||
});
|
||
expect(resultDialog.textContent).toContain(
|
||
'已完成程序实现拆解,并给出可执行的模块边界。',
|
||
);
|
||
fireEvent.click(within(resultDialog).getByRole('button', { name: '关闭' }));
|
||
const documentResources = screen.getByLabelText('文档');
|
||
expect(
|
||
within(documentResources).getByRole('button', {
|
||
name: '打开资源详情:文档 程序 Agent 文本回执',
|
||
}),
|
||
).not.toBeNull();
|
||
const designReceiptCard = within(documentResources).getByRole('button', {
|
||
name: '打开资源详情:文档 策划 Agent 文本回执',
|
||
});
|
||
expect(designReceiptCard).not.toBeNull();
|
||
expect(
|
||
within(documentResources).getByRole('button', {
|
||
name: '打开资源详情:文档 美术资源计划 Agent 文本回执',
|
||
}),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(documentResources).getByRole('button', {
|
||
name: '打开资源详情:文档 数值 Agent 文本回执',
|
||
}),
|
||
).not.toBeNull();
|
||
expect(documentResources.textContent).not.toContain(
|
||
'当前新一轮策划失败,不得覆盖旧成果。',
|
||
);
|
||
fireEvent.click(designReceiptCard);
|
||
const designReceiptFocus = await screen.findByRole('region', {
|
||
name: '策划 Agent 文本回执',
|
||
});
|
||
expect(
|
||
within(designReceiptFocus).getByText('历史成果 · 策划 Agent'),
|
||
).not.toBeNull();
|
||
fireEvent.click(
|
||
within(designReceiptFocus).getByRole('button', { name: '收起资源' }),
|
||
);
|
||
|
||
failProfessionalConversationReads = true;
|
||
const conversationReadCountBeforeFailure =
|
||
professionalConversationReadCount;
|
||
professionalRuntimes = professionalRuntimes.map((runtime) =>
|
||
runtime.agentId === 'design-director'
|
||
? { ...runtime, updatedAt: 7100 }
|
||
: runtime,
|
||
);
|
||
await waitFor(
|
||
() => {
|
||
expect(professionalConversationReadCount).toBeGreaterThan(
|
||
conversationReadCountBeforeFailure,
|
||
);
|
||
expect(
|
||
within(screen.getByLabelText('文档')).getByRole('button', {
|
||
name: '打开资源详情:文档 策划 Agent 文本回执',
|
||
}),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(screen.getByLabelText('文档')).getByRole('button', {
|
||
name: '打开资源详情:文档 程序 Agent 文本回执',
|
||
}),
|
||
).not.toBeNull();
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
failProfessionalConversationReads = false;
|
||
designConversationMode = 'updated';
|
||
const conversationReadCountBeforeUpdate = professionalConversationReadCount;
|
||
professionalRuntimes = professionalRuntimes.map((runtime) =>
|
||
runtime.agentId === 'design-director'
|
||
? { ...runtime, updatedAt: 7200 }
|
||
: runtime,
|
||
);
|
||
await waitFor(
|
||
() => {
|
||
expect(professionalConversationReadCount).toBeGreaterThan(
|
||
conversationReadCountBeforeUpdate,
|
||
);
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
const updatedDesignCard = await waitFor(() => {
|
||
const card = within(screen.getByLabelText('文档')).getByRole('button', {
|
||
name: '打开资源详情:文档 策划 Agent 文本回执',
|
||
});
|
||
expect(card.getAttribute('data-resource-id')).toContain(
|
||
'agent-finalization-33333333333333333333333333333333',
|
||
);
|
||
return card;
|
||
});
|
||
fireEvent.click(updatedDesignCard);
|
||
const updatedDesignDialog = await screen.findByRole('region', {
|
||
name: '策划 Agent 文本回执',
|
||
});
|
||
expect(
|
||
within(updatedDesignDialog).getByText(
|
||
'更新后的策划历史成果,替换同 Agent 的旧回执。',
|
||
),
|
||
).not.toBeNull();
|
||
fireEvent.click(
|
||
within(updatedDesignDialog).getByRole('button', { name: '收起资源' }),
|
||
);
|
||
|
||
const dock = screen.getByLabelText('子 Agent 状态栏');
|
||
await waitFor(() => {
|
||
expect(
|
||
within(
|
||
within(dock).getByRole('article', { name: /策划 Agent/ }),
|
||
).getByText('失败'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(
|
||
within(dock).getByRole('article', { name: /美术 Agent/ }),
|
||
).getByText('待确认'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(
|
||
within(dock).getByRole('article', { name: /程序 Agent/ }),
|
||
).getByText('已完成'),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
const artPendingAction =
|
||
within(professionalList).getByLabelText('美术 Agent待确认动作');
|
||
failProfessionalAction = true;
|
||
fireEvent.click(
|
||
within(artPendingAction).getByRole('button', { name: '确认' }),
|
||
);
|
||
expect(
|
||
await screen.findByText('专业 Agent 服务连接失败,请稍后重试'),
|
||
).not.toBeNull();
|
||
expect(screen.queryByText(/private-action-fingerprint/)).toBeNull();
|
||
failProfessionalAction = false;
|
||
fireEvent.click(
|
||
within(artPendingAction).getByRole('button', { name: '确认' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'confirm_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'art-director',
|
||
runId: 'art-runtime-status-run',
|
||
actionId: 'art-file-write-action',
|
||
note: '用户已确认专业 Agent 待执行工具动作',
|
||
},
|
||
);
|
||
});
|
||
|
||
failProfessionalRuntimeReads = true;
|
||
const readCountBeforeFailure = professionalRuntimeReadCount;
|
||
await waitFor(
|
||
() => {
|
||
expect(professionalRuntimeReadCount).toBeGreaterThan(
|
||
readCountBeforeFailure,
|
||
);
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
expect(
|
||
within(
|
||
within(dock).getByRole('article', { name: /策划 Agent/ }),
|
||
).getByText('失败'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(
|
||
within(dock).getByRole('article', { name: /美术 Agent/ }),
|
||
).getByText('待确认'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(
|
||
within(dock).getByRole('article', { name: /程序 Agent/ }),
|
||
).getByText('已完成'),
|
||
).not.toBeNull();
|
||
|
||
let retryButton = within(professionalList).getByRole('button', {
|
||
name: '在当前项目重试',
|
||
});
|
||
fireEvent.click(retryButton);
|
||
expect(
|
||
await within(retryButton.closest('article')!).findByText(
|
||
'策划 Agent 服务连接失败,请稍后重试',
|
||
),
|
||
).not.toBeNull();
|
||
expect(retryButton.closest('article')?.textContent).not.toContain(
|
||
'private-retry-fingerprint',
|
||
);
|
||
failProfessionalRetry = false;
|
||
retryButton = within(professionalList).getByRole('button', {
|
||
name: '在当前项目重试',
|
||
});
|
||
fireEvent.click(retryButton);
|
||
const retryingButton = within(professionalList).getByRole('button', {
|
||
name: '正在提交重试…',
|
||
});
|
||
expect(retryingButton.hasAttribute('disabled')).toBe(true);
|
||
expect(
|
||
within(retryingButton.closest('article')!).getByRole('status')
|
||
.textContent,
|
||
).toBe('正在提交重试…');
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'confirm_retry_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'design-director',
|
||
runId: 'design-runtime-status-run',
|
||
nextRunId: expect.stringMatching(/^project-professional-retry-/),
|
||
},
|
||
);
|
||
});
|
||
releaseProfessionalRetry?.();
|
||
await within(professionalList).findByText(
|
||
'重试请求已受理,正在同步新一轮状态',
|
||
);
|
||
|
||
failProfessionalRuntimeReads = false;
|
||
exposeProfessionalRuntimes = false;
|
||
const readCountBeforeAuthoritativeEmpty = professionalRuntimeReadCount;
|
||
await waitFor(
|
||
() => {
|
||
expect(professionalRuntimeReadCount).toBeGreaterThan(
|
||
readCountBeforeAuthoritativeEmpty,
|
||
);
|
||
expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull();
|
||
expect(
|
||
within(
|
||
within(dock).getByRole('article', { name: /策划 Agent/ }),
|
||
).getByText('等待中'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(screen.getByLabelText('文档')).getByRole('button', {
|
||
name: '打开资源详情:文档 策划 Agent 文本回执',
|
||
}),
|
||
).not.toBeNull();
|
||
},
|
||
{ timeout: 2500 },
|
||
);
|
||
}, 10_000);
|
||
|
||
it('does not render a ready Supervisor stream twice when its assistant is already in restored history', async () => {
|
||
const projectPath = '/tmp/launcher-supervisor-finalizing';
|
||
const reply = '已经落盘的唯一项目总控回复';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-supervisor-finalizing',
|
||
);
|
||
const runId = 'supervisor-finalizing-run';
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
supervisorMessages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: reply,
|
||
agentId: 'project-supervisor',
|
||
messageId: 'launcher-supervisor-final-assistant',
|
||
updatedAt: 7000,
|
||
},
|
||
],
|
||
initialRuntime: {
|
||
runId,
|
||
status: 'running',
|
||
phase: 'finalizing',
|
||
loopIteration: 1,
|
||
appliedSteerCursor: 0,
|
||
updatedAt: 7000,
|
||
},
|
||
initialResponseStream: projectSupervisorResponseStream({
|
||
runId,
|
||
sequence: 8,
|
||
accumulatedText: reply,
|
||
status: 'ready',
|
||
}),
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-supervisor-finalizing',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||
target: { value: projectPath },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '打开' }));
|
||
|
||
const supervisorSurface = await screen.findByLabelText('项目总控对话');
|
||
expect(await within(supervisorSurface).findByText(reply)).not.toBeNull();
|
||
expect(within(supervisorSurface).getAllByText(reply)).toHaveLength(1);
|
||
expect(
|
||
within(supervisorSurface).queryByLabelText('项目总控 Agent 实时回复'),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('rejects launcher project paths with control characters before Tauri calls', () => {
|
||
const invoke = vi.fn(async () => undefined);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.change(screen.getByLabelText('项目目录'), {
|
||
target: { value: '/tmp/bad\u0007path' },
|
||
});
|
||
fireEvent.click(screen.getAllByRole('button', { name: '新建项目' })[0]);
|
||
|
||
expect(screen.getByText('项目目录不能包含控制字符')).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('opens a selected Godot project as the active root without initializing the web layout', async () => {
|
||
const projectPath = '/tmp/existing-godot-project';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'existing-godot-project',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
expectedRunProfile: 'standard',
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'pick_local_project_directory') {
|
||
return projectPath;
|
||
}
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: false,
|
||
isGodotProject: true,
|
||
projectName: null,
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'import_local_godot_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '打开 Godot 项目' }));
|
||
|
||
const surface = await screen.findByLabelText('项目总控对话');
|
||
expect(invoke).toHaveBeenCalledWith('import_local_godot_project', {
|
||
projectPath,
|
||
projectId: 'local-project-draft',
|
||
name: 'existing-godot-project',
|
||
});
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'init_local_game_project',
|
||
),
|
||
).toBe(false);
|
||
|
||
const composer = within(surface).getByLabelText('项目需求');
|
||
fireEvent.change(composer, { target: { value: '修改玩家移动脚本' } });
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'start_game_creator_supervisor_runtime_task',
|
||
expect.objectContaining({
|
||
projectPath,
|
||
runProfile: 'standard',
|
||
task: '修改玩家移动脚本',
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
export function registerProjectWorkbenchNavigationTests() {
|
||
it('keeps project switching inside the single-window client flow', async () => {
|
||
const invoke = vi.fn(async () => undefined);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '切换项目' }));
|
||
|
||
expect(screen.getByText('请回到首页的项目组切换项目。')).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'open_game_creator_launcher_window',
|
||
);
|
||
|
||
submitChat('/switch-project');
|
||
|
||
expect(
|
||
screen.getAllByText('请回到首页的项目组切换项目。').length,
|
||
).toBeGreaterThanOrEqual(2);
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'open_game_creator_launcher_window',
|
||
);
|
||
});
|
||
|
||
it('opens the current project directory from the main project window', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'chat_with_game_creator_agent') {
|
||
return {
|
||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'chat_with_game_creator_role_agent') {
|
||
return {
|
||
replyText: mockRoleAgentReply(),
|
||
};
|
||
}
|
||
if (command === 'append_local_conversation_message') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'open_local_project_directory') {
|
||
return undefined;
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('已打开:authorized-game');
|
||
const revealButtons = screen.getAllByRole('button', { name: '显示目录' });
|
||
fireEvent.click(revealButtons[revealButtons.length - 1]);
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith('open_local_project_directory', {
|
||
projectPath: '/tmp/authorized-game',
|
||
});
|
||
});
|
||
expect(await screen.findByText('已打开项目目录。')).not.toBeNull();
|
||
|
||
submitChat('/open-project');
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'open_local_project_directory',
|
||
),
|
||
).toHaveLength(2);
|
||
});
|
||
expect(
|
||
screen.getAllByText('已打开项目目录。').length,
|
||
).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it('fills an asset registration draft from recent project files without registering immediately', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
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 === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error('missing trace');
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return {
|
||
projectPath: String(args?.projectPath ?? ''),
|
||
files: [
|
||
{
|
||
path: 'assets/uploads/hero.png',
|
||
kind: 'file',
|
||
size: 4,
|
||
modifiedAt: 1700000001,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (command === 'register_local_asset') {
|
||
throw new Error('should only fill the chat draft');
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
await act(async () => {
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
});
|
||
|
||
await screen.findByText('已打开:authorized-game');
|
||
invoke.mockClear();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '文件' }));
|
||
|
||
expect(await screen.findByText(/本地项目文件:/)).not.toBeNull();
|
||
fireEvent.click(
|
||
within(screen.getByLabelText('最近项目文件')).getByRole('button', {
|
||
name: '登记资产 assets/uploads/hero.png',
|
||
}),
|
||
);
|
||
await waitFor(() =>
|
||
expect(document.activeElement).toBe(screen.getByLabelText('创作想法')),
|
||
);
|
||
|
||
expect(screen.getByLabelText('创作想法')).toHaveProperty(
|
||
'value',
|
||
'/asset-register assets/uploads/hero.png image image/png',
|
||
);
|
||
expect(
|
||
screen.queryByText('asset.register · assets/uploads/hero.png'),
|
||
).toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'register_local_asset',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
}
|
||
|
||
export function registerProjectAgentStatusTests() {
|
||
it('shows runtime status and recent tasks in the main agent status list', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const runtimeTask = {
|
||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'runtime-design-director-1',
|
||
source: 'agent-background-task',
|
||
task: '排队补齐世界观拆解',
|
||
status: 'pending',
|
||
phase: 'queued',
|
||
currentAction: '等待当前任务完成',
|
||
error: null,
|
||
updatedAt: 10,
|
||
};
|
||
const runtimeState = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'runtime-design-director-1',
|
||
source: 'agent-background-task',
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentTask: '拆解关卡节奏',
|
||
currentGoal: '补齐第一关节奏目标',
|
||
currentAction: '整理目标和约束',
|
||
waitingOn: 'Agent 输出计划或回复',
|
||
loopIteration: 2,
|
||
maxLoopIterations: 3,
|
||
toolActionBudget: 3,
|
||
plan: ['读取项目上下文'],
|
||
planSteps: [
|
||
{
|
||
index: 0,
|
||
title: '读取项目上下文',
|
||
status: 'active',
|
||
detail: '正在整理目标和约束',
|
||
updatedAt: 11,
|
||
},
|
||
],
|
||
activePlanStepIndex: 0,
|
||
observations: ['已创建本轮 Agent Runtime run。'],
|
||
taskQueue: {
|
||
total: 2,
|
||
pending: 1,
|
||
running: 1,
|
||
completed: 0,
|
||
failed: 0,
|
||
latestRunId: 'runtime-design-director-1',
|
||
updatedAt: 10,
|
||
},
|
||
allowedTools: ['conversation.read'],
|
||
lastResponse: null,
|
||
error: null,
|
||
updatedAt: 10,
|
||
};
|
||
const completedRuntimeState = {
|
||
...runtimeState,
|
||
status: 'idle',
|
||
phase: 'completed',
|
||
currentAction: '等待下一轮输入',
|
||
waitingOn: '开发者下一轮输入',
|
||
nextStep: '等待下一轮输入',
|
||
taskQueue: {
|
||
total: 2,
|
||
pending: 1,
|
||
running: 0,
|
||
completed: 1,
|
||
failed: 0,
|
||
latestRunId: 'runtime-design-director-1',
|
||
updatedAt: 12,
|
||
},
|
||
lastResponse: '已补齐第一关节奏目标。',
|
||
updatedAt: 12,
|
||
};
|
||
const completedRuntimeTask = {
|
||
...runtimeTask,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentAction: '等待下一轮输入',
|
||
updatedAt: 12,
|
||
};
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [
|
||
{
|
||
state: runtimeState,
|
||
sessionPath:
|
||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||
eventPath:
|
||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||
taskPath:
|
||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||
taskQueue: runtimeState.taskQueue,
|
||
recentEvents: [],
|
||
recentTasks: [runtimeTask],
|
||
},
|
||
];
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
let runtimeUpdateHandler:
|
||
((event: { payload: Record<string, unknown> }) => void) | null = null;
|
||
const listen = vi.fn(
|
||
async (
|
||
eventName: string,
|
||
handler: (event: { payload: Record<string, unknown> }) => void,
|
||
) => {
|
||
if (eventName === 'game-creator-agent-progress') {
|
||
return () => {};
|
||
}
|
||
if (eventName === 'game-creator-agent-runtime-update') {
|
||
runtimeUpdateHandler = handler;
|
||
return () => {
|
||
if (runtimeUpdateHandler === handler) {
|
||
runtimeUpdateHandler = null;
|
||
}
|
||
};
|
||
}
|
||
throw new Error(`unexpected listen ${eventName}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke }, event: { listen } };
|
||
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
await waitFor(() => {
|
||
const designCard = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
expect(designCard.textContent).toContain(
|
||
'Runtime:running / planning · Loop 2/3 · 整理目标和约束 · 等待 Agent 输出计划或回复 · 下一步 等待 Agent 输出计划或回复 · run runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain('当前目标:补齐第一关节奏目标');
|
||
expect(designCard.textContent).toContain('当前任务:拆解关卡节奏');
|
||
expect(designCard.textContent).toContain(
|
||
'当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'任务队列:pending 1 · running 1 · waiting 0 · needsInput 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:pending / queued · 排队补齐世界观拆解',
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'resume_game_creator_agent_runtime_tasks',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
await act(async () => {
|
||
runtimeUpdateHandler?.({
|
||
payload: {
|
||
projectPath: '/tmp/authorized-game',
|
||
agentId: 'design-director',
|
||
runId: 'runtime-design-director-1',
|
||
status: 'idle',
|
||
phase: 'completed',
|
||
runtime: {
|
||
state: completedRuntimeState,
|
||
sessionPath:
|
||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||
eventPath:
|
||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||
taskPath:
|
||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||
taskQueue: completedRuntimeState.taskQueue,
|
||
recentEvents: [],
|
||
recentTasks: [completedRuntimeTask],
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const designCard = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
expect(designCard.textContent).toContain(
|
||
'Runtime:idle / completed · Loop 2/3 · 等待下一轮输入 · 等待 开发者下一轮输入 · 下一步 等待下一轮输入 · run runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'任务队列:pending 1 · running 0 · waiting 0 · needsInput 0 · cancelled 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:completed / completed · 排队补齐世界观拆解',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('lets the developer schedule ready manifest tasks into agent runtimes', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const runtimeTask = {
|
||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'ready-design-director-1',
|
||
source: 'agent-ready-task-scheduler',
|
||
task: '执行 Ready 任务:拆解创作方向',
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentAction: '准备读取项目上下文',
|
||
error: null,
|
||
updatedAt: 20,
|
||
};
|
||
const runtimeState = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'ready-design-director-1',
|
||
source: 'agent-ready-task-scheduler',
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentTask: '执行 Ready 任务:拆解创作方向',
|
||
currentGoal: '把 ready 任务投递给对应 Agent',
|
||
currentAction: '准备读取项目上下文',
|
||
waitingOn: 'Agent 输出计划或回复',
|
||
loopIteration: 1,
|
||
maxLoopIterations: 3,
|
||
toolActionBudget: 3,
|
||
plan: ['读取项目上下文'],
|
||
planSteps: [],
|
||
activePlanStepIndex: null,
|
||
observations: [],
|
||
taskQueue: {
|
||
total: 1,
|
||
pending: 0,
|
||
running: 1,
|
||
completed: 0,
|
||
failed: 0,
|
||
latestRunId: 'ready-design-director-1',
|
||
updatedAt: 20,
|
||
},
|
||
allowedTools: ['conversation.read'],
|
||
lastResponse: null,
|
||
error: null,
|
||
updatedAt: 20,
|
||
};
|
||
const runtimeResult = {
|
||
state: runtimeState,
|
||
sessionPath:
|
||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||
eventPath:
|
||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||
taskPath:
|
||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||
taskQueue: runtimeState.taskQueue,
|
||
recentEvents: [],
|
||
recentTasks: [runtimeTask],
|
||
};
|
||
let scheduled = false;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return scheduled ? [runtimeResult] : [];
|
||
}
|
||
if (command === 'schedule_game_creator_agent_ready_tasks') {
|
||
scheduled = true;
|
||
return [runtimeResult];
|
||
}
|
||
if (command === 'append_local_conversation_message') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const scheduleButton = await screen.findByRole('button', {
|
||
name: '调度 Ready',
|
||
});
|
||
await waitFor(() => {
|
||
expect((scheduleButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
|
||
fireEvent.click(scheduleButton);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'schedule_game_creator_agent_ready_tasks' &&
|
||
args?.projectPath === '/tmp/authorized-game' &&
|
||
args?.limit === 16,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
expect(await screen.findAllByText('已调度 1 个 Ready 任务。')).toHaveLength(
|
||
2,
|
||
);
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
await waitFor(() => {
|
||
const designCard = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
expect(designCard.textContent).toContain('Runtime:running / planning');
|
||
expect(designCard.textContent).toContain(
|
||
'当前目标:把 ready 任务投递给对应 Agent',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:running / planning · 执行 Ready 任务:拆解创作方向',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('keeps ready task scheduling out of the normal user window', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
|
||
expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull();
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'schedule_game_creator_agent_ready_tasks',
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('ignores stale agent conversation reads after switching agents', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseOldConversation: (() => void) | null = null;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (args?.agentId === 'design-director') {
|
||
return await new Promise((resolve) => {
|
||
releaseOldConversation = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '旧 Agent 慢速消息',
|
||
agentId: 'design-director',
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
|
||
agentId: args?.agentId,
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '新 Agent 历史消息',
|
||
agentId: String(args?.agentId ?? ''),
|
||
updatedAt: 2,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: `/tmp/authorized-game/memory/agents/${String(
|
||
args?.taskId ?? '',
|
||
)}.md`,
|
||
content:
|
||
args?.taskId === 'art-director'
|
||
? '新 Agent 私有记忆'
|
||
: '旧 Agent 私有记忆',
|
||
exists: true,
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
const designAgentButton = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
await waitFor(() => {
|
||
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.click(designAgentButton);
|
||
await waitFor(() => {
|
||
expect(releaseOldConversation).not.toBeNull();
|
||
});
|
||
fireEvent.click(
|
||
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
|
||
);
|
||
|
||
expect(await screen.findByText('新 Agent 历史消息')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain(
|
||
'新 Agent 私有记忆',
|
||
);
|
||
await act(async () => {
|
||
releaseOldConversation?.();
|
||
});
|
||
|
||
expect(screen.queryByText('旧 Agent 慢速消息')).toBeNull();
|
||
expect(screen.getByText('新 Agent 历史消息')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 私有记忆').textContent).not.toContain(
|
||
'旧 Agent 私有记忆',
|
||
);
|
||
});
|
||
|
||
it('ignores stale agent conversation saves after switching agents', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseOldSave: (() => void) | null = null;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (args?.agentId === 'art-director') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
|
||
agentId: 'art-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '新 Agent 留存消息',
|
||
agentId: 'art-director',
|
||
updatedAt: 2,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: `/tmp/authorized-game/memory/agents/${String(
|
||
args?.taskId ?? '',
|
||
)}.md`,
|
||
content: '',
|
||
exists: false,
|
||
};
|
||
}
|
||
if (command === 'append_local_conversation_message') {
|
||
const message = args?.message as {
|
||
role: 'user' | 'assistant';
|
||
content: string;
|
||
agentId: string | null;
|
||
};
|
||
return await new Promise((resolve) => {
|
||
releaseOldSave = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: message.role,
|
||
content: message.content,
|
||
agentId: message.agentId,
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
const designAgentButton = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
await waitFor(() => {
|
||
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.click(designAgentButton);
|
||
const input = await screen.findByLabelText('Agent 对话内容');
|
||
fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } });
|
||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||
await screen.findByText('正在保存用户消息');
|
||
await waitFor(() => {
|
||
expect(releaseOldSave).not.toBeNull();
|
||
});
|
||
fireEvent.click(
|
||
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
|
||
);
|
||
|
||
expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull();
|
||
await act(async () => {
|
||
releaseOldSave?.();
|
||
});
|
||
|
||
expect(screen.getByText('新 Agent 留存消息')).not.toBeNull();
|
||
expect(screen.queryByText('旧 Agent 保存回包')).toBeNull();
|
||
expect(screen.queryByText(/已保存 1 条/)).toBeNull();
|
||
});
|
||
|
||
it('ignores stale agent reads after closing the agent dialog', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseConversation: (() => void) | null = null;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
return await new Promise((resolve) => {
|
||
releaseConversation = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '关闭后不该写入界面状态',
|
||
agentId: 'design-director',
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: '/tmp/authorized-game/memory/agents/design/director.md',
|
||
content: '关闭后不该读取私有记忆',
|
||
exists: true,
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
await waitFor(() => {
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '刷新 Agent',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(false);
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
|
||
await waitFor(() => {
|
||
expect(releaseConversation).not.toBeNull();
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||
expect(screen.queryByLabelText('Agent 对话')).toBeNull();
|
||
|
||
await act(async () => {
|
||
releaseConversation?.();
|
||
});
|
||
|
||
expect(screen.queryByText('关闭后不该写入界面状态')).toBeNull();
|
||
expect(screen.queryByText('conversation.read')).toBeNull();
|
||
expect(screen.queryByText('memory.agent.read')).toBeNull();
|
||
});
|
||
|
||
it('updates the open agent dialog when agent status is refreshed', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let runReadCount = 0;
|
||
const makeTrace = (withAgentStep: boolean) =>
|
||
({
|
||
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
runId: 'run-open-agent-refresh',
|
||
commandId: 'game.generate_draft',
|
||
status: 'running',
|
||
passes: 1,
|
||
maxPasses: 3,
|
||
toolCallCount: 1,
|
||
maxToolCalls: 128,
|
||
stopReason: 'running',
|
||
goal: '做一个厨房弹幕游戏',
|
||
coordination: 'Planner',
|
||
steps: withAgentStep
|
||
? [
|
||
{
|
||
pass: 1,
|
||
agent: 'Planner',
|
||
phase: 'plan',
|
||
taskId: 'design-director',
|
||
group: 'design',
|
||
role: 'Director',
|
||
status: 'running',
|
||
inputPaths: ['memory/session.md'],
|
||
outputPaths: ['.agent/spec.md'],
|
||
summary: '刷新后的拆解方向',
|
||
toolCalls: [
|
||
{
|
||
toolId: 'llm.planner.refresh',
|
||
status: 'ok',
|
||
inputPaths: ['memory/session.md'],
|
||
outputPaths: ['.agent/spec.md'],
|
||
summary: '刷新后工具调用',
|
||
},
|
||
...Array.from({ length: 5 }, (_, index) => ({
|
||
toolId: `llm.extra.${index + 1}`,
|
||
status: 'ok',
|
||
inputPaths: [],
|
||
outputPaths: [],
|
||
summary: `额外工具调用 ${index + 1}`,
|
||
})),
|
||
],
|
||
},
|
||
]
|
||
: [],
|
||
artifacts: [],
|
||
taskGraph: {
|
||
goal: '做一个厨房弹幕游戏',
|
||
readyTaskIds: [],
|
||
activeTaskIds: withAgentStep ? ['design-director'] : [],
|
||
carriedTaskIds: [],
|
||
repairFocus: [],
|
||
repairRoutes: [],
|
||
tasks: createGameCreationAppSeedTasks(),
|
||
},
|
||
passPlans: [],
|
||
nextStep: 'continue',
|
||
error: null,
|
||
updatedAt: runReadCount,
|
||
}) satisfies GameCreationAgentRunTrace;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: args?.agentId
|
||
? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl'
|
||
: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: args?.agentId ?? null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: '/tmp/authorized-game/memory/agents/design/director.md',
|
||
content: '',
|
||
exists: false,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
runReadCount += 1;
|
||
return {
|
||
path: String(args?.relativePath ?? ''),
|
||
absolutePath: `${String(args?.projectPath ?? '')}/${String(
|
||
args?.relativePath ?? '',
|
||
)}`,
|
||
content: JSON.stringify(makeTrace(runReadCount > 1)),
|
||
};
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('已打开:authorized-game');
|
||
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
|
||
expect(await screen.findByLabelText('Agent 对话')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'暂无最近运行证据',
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
|
||
|
||
expect(await screen.findByText('刷新后的拆解方向')).not.toBeNull();
|
||
await waitFor(() => {
|
||
expect(screen.getByLabelText('Agent 对话').textContent).toContain(
|
||
'刷新后的拆解方向',
|
||
);
|
||
});
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'in: memory/session.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'out: .agent/spec.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'tool: llm.planner.refresh · ok · 刷新后工具调用',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'tool: llm.planner.refresh · ok · 刷新后工具调用 · in memory/session.md · out .agent/spec.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'还有 1 个工具调用',
|
||
);
|
||
});
|
||
|
||
it('confirms before refreshing agents when trace read policy requires it', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const trace: GameCreationAgentRunTrace = {
|
||
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
runId: 'run-agent-refresh-confirm',
|
||
commandId: 'game.generate_draft',
|
||
status: 'running',
|
||
passes: 1,
|
||
maxPasses: 3,
|
||
toolCallCount: 1,
|
||
maxToolCalls: 128,
|
||
stopReason: 'running',
|
||
goal: '做一个厨房弹幕游戏',
|
||
coordination: 'Planner',
|
||
steps: [],
|
||
artifacts: [],
|
||
taskGraph: {
|
||
goal: '做一个厨房弹幕游戏',
|
||
readyTaskIds: [],
|
||
activeTaskIds: [],
|
||
carriedTaskIds: [],
|
||
repairFocus: [],
|
||
repairRoutes: [],
|
||
tasks: createGameCreationAppSeedTasks(),
|
||
},
|
||
passPlans: [],
|
||
nextStep: 'continue',
|
||
error: null,
|
||
updatedAt: 1,
|
||
};
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return {
|
||
path: '.agent/policy.json',
|
||
policy: {
|
||
deniedCommands: [],
|
||
confirmCommands: ['agent.trace_read'],
|
||
},
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
return {
|
||
path: '.agent/run.latest.json',
|
||
absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`,
|
||
content: JSON.stringify(trace),
|
||
};
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
|
||
invoke.mockClear();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
|
||
|
||
expect(await screen.findByText('agent.trace_read')).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'read_local_project_file',
|
||
expect.anything(),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
|
||
projectPath: '/tmp/authorized-game',
|
||
relativePath: '.agent/run.latest.json',
|
||
commandId: 'agent.trace_read',
|
||
});
|
||
});
|
||
});
|
||
|
||
it('cancels agent run trace refresh policy confirmation from the panel', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return {
|
||
path: '.agent/policy.json',
|
||
policy: {
|
||
deniedCommands: [],
|
||
confirmCommands: ['agent.trace_read'],
|
||
},
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error('should wait for trace confirmation');
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
|
||
invoke.mockClear();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
|
||
|
||
const traceReadCommand = await screen.findByText('agent.trace_read');
|
||
fireEvent.click(
|
||
within(
|
||
traceReadCommand.closest('.pending-command') as HTMLElement,
|
||
).getByRole('button', { name: '取消' }),
|
||
);
|
||
|
||
expect(
|
||
await screen.findByText('run: 已取消读取 Agent trace'),
|
||
).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'read_local_project_file',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
}
|