Files
makelore/tests/e2e/pi-coding-first-chat.spec.ts
T
brother7 efff238a75
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
Merge remote main preserving project chats and reply recovery
2026-09-28 20:51:33 +08:00

2578 lines
142 KiB
TypeScript

import type { ElectronApplication, Page } from 'playwright-core';
import { appendFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import type { NativeAgentBrowserFixture } from './fixtures/native-agent-browser';
import type { TeacherCatalog, TeacherDefinition } from '../../shared/coding-teacher';
import { expect, getStableWindow, test } from './fixtures/electron';
type CapturedRequest = {
path: string;
method: string;
body?: Record<string, unknown>;
byteLength?: number;
contentType?: string;
at: number;
};
type HostConnection = {
baseUrl: string;
token: string;
};
type ConsultationReply = {
response: string;
suggestedQuestions: string[];
replyParseError?: string;
unparsedResponse?: string;
};
type TeacherCatalogFixture = {
nextTeacherReplyIncomplete?: boolean;
teacherCatalog: TeacherCatalog;
teacherCatalogFailure: boolean;
holdTeacherCatalog: boolean;
releaseTeacherCatalog: (() => void) | null;
};
async function disableCodingEventSource(page: Page): Promise<void> {
await page.addInitScript(() => {
const sources = new Set<LocalEventSource>();
type TrackedWindow = Window & {
__makeloreCodingEventSources?: LocalEventSource[];
};
const trackedWindow = window as TrackedWindow;
trackedWindow.__makeloreCodingEventSources = [];
class LocalEventSource extends EventTarget {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
readonly CONNECTING = LocalEventSource.CONNECTING;
readonly OPEN = LocalEventSource.OPEN;
readonly CLOSED = LocalEventSource.CLOSED;
readonly url: string;
readonly withCredentials = false;
readyState = LocalEventSource.OPEN;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
constructor(url: string) {
super();
this.url = url;
sources.add(this);
trackedWindow.__makeloreCodingEventSources?.push(this);
queueMicrotask(() => this.onopen?.(new Event('open')));
}
close(): void {
this.readyState = LocalEventSource.CLOSED;
sources.delete(this);
}
}
Object.defineProperty(window, 'EventSource', {
configurable: true,
writable: true,
value: LocalEventSource,
});
Object.defineProperty(window, '__makeloreEmitCodingEvent', {
configurable: true,
value(type: string, payload: unknown) {
for (const source of sources) {
source.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
}
},
});
});
}
async function emitCodingEvent(page: Page, type: string, payload: unknown): Promise<void> {
await page.evaluate(({ eventType, eventPayload }) => {
const testWindow = window as typeof window & {
__makeloreEmitCodingEvent?: (type: string, payload: unknown) => void;
};
testWindow.__makeloreEmitCodingEvent?.(eventType, eventPayload);
}, { eventType: type, eventPayload: payload });
}
async function installCodingFirstChatHost(
electronApp: ElectronApplication,
hostConnection: HostConnection,
featureComplete = false,
managedCapabilities = false,
removedModel = false,
audioPreview?: { executionId: string; path: string; dataUrl: string },
multipleProjects = false,
consultationReply?: ConsultationReply,
): Promise<void> {
await electronApp.evaluate(async (_, payload) => {
const { connection, featureComplete, managedCapabilities, removedModel, audioPreview, multipleProjects, consultationReply } = payload;
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = TeacherCatalogFixture & {
captured: CapturedRequest[];
conversationCreated: boolean;
interactionAnswered: boolean;
abortRequested: boolean;
releaseSnapshot: (() => void) | null;
snapshotPending: boolean;
snapshotSettled: boolean;
workReady?: boolean;
workFailed?: boolean;
workGeneration?: number;
};
const mainGlobal = globalThis as typeof globalThis & {
__makelorePiFirstChatE2E?: MainState;
__makeloreNativeBrowserE2E?: NativeAgentBrowserFixture;
};
const state: MainState = {
captured: [],
conversationCreated: false,
interactionAnswered: false,
abortRequested: false,
releaseSnapshot: null,
snapshotPending: false,
snapshotSettled: false,
teacherCatalog: { items: [] },
teacherCatalogFailure: false,
holdTeacherCatalog: false,
releaseTeacherCatalog: null,
};
mainGlobal.__makelorePiFirstChatE2E = state;
const now = '2026-08-24T00:00:00.000Z';
const metadata = new Map<string, Record<string, unknown>>();
const project = {
id: 'project-pi-first-chat',
name: 'PI first chat',
createdAt: now,
updatedAt: now,
lastOpenedAt: now,
};
const otherProject = { ...project, id: 'project-pi-other', name: 'Another project' };
let activeProject = project;
const configuredModel = {
accountId: managedCapabilities ? 'niancode-user-models' : 'account-e2e',
modelId: 'model-a',
thinkingLevel: 'off',
};
const agent = {
id: 'builder',
avatarId: 'avatar-01',
roleName: '实现者',
name: 'Builder',
builtIn: false,
enabled: true,
model: configuredModel,
modelResolution: 'resolved',
skillIds: [],
responsibility: {
mission: 'Implement',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: true,
createdAt: now,
updatedAt: now,
};
const config = {
schemaVersion: 2,
projectType: 'custom',
initialized: true,
agents: [agent],
knowledgeDirectory: 'knowledge',
createdAt: now,
updatedAt: now,
};
const conversation = {
id: 'conversation-pi-first-chat',
agentId: agent.id,
title: '新对话',
model: configuredModel,
modelResolution: 'resolved',
archivedAt: null,
unread: false,
createdAt: now,
updatedAt: now,
};
const secondConversation = {
...conversation,
id: 'conversation-pi-second',
title: 'Second Conversation',
updatedAt: '2026-08-23T23:59:00.000Z',
model: featureComplete
? { accountId: 'account-e2e', modelId: 'model-b', thinkingLevel: 'low' }
: null,
modelResolution: featureComplete ? 'resolved' : 'required',
};
const historyConversation = {
...conversation,
id: 'conversation-pi-history',
title: 'History and quota',
updatedAt: '2026-08-23T23:58:00.000Z',
};
const snapshot = {
schemaVersion: 1,
conversation: {
id: conversation.id,
projectId: project.id,
agentId: agent.id,
title: conversation.title,
model: { model: configuredModel, modelResolution: 'resolved' },
},
nodes: featureComplete ? [
{
kind: 'message',
id: 'message-user-e2e',
sourceEntryId: 'entry-user-e2e',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'message-user-e2e:content:0', text: 'Durable user fork source', status: 'complete' }],
},
{
kind: 'message',
id: 'message-assistant-e2e',
sourceEntryId: 'entry-assistant-e2e',
role: 'assistant',
status: 'streaming',
blocks: [
{
kind: 'thinking',
id: 'message-assistant-e2e:thinking:0',
text: [
'Inspecting the project before responding.',
'Checking the runtime boundary and current configuration.',
'Preparing the smallest safe next step.',
].join('\n\n'),
status: 'streaming',
},
{ kind: 'text', id: 'message-assistant-e2e:content:0', text: 'Durable assistant response', status: 'complete' },
],
},
{
kind: 'subagent',
id: 'subagent-e2e',
runId: 'run-e2e-feature',
details: {
schema: 'subagent.v1',
dispatchId: 'dispatch-e2e',
mode: 'parallel',
tasks: [
{ taskId: 'task-reader', agentId: 'reader', toolProfile: 'read-only', status: 'complete', summary: 'Read complete' },
{ taskId: 'task-builder', agentId: 'builder', toolProfile: 'coding', status: 'running' },
],
},
},
{
kind: 'compaction',
id: 'compaction-e2e',
runId: 'run-e2e-feature',
source: 'automatic',
status: 'error',
willRetry: true,
summary: '保留了本轮关键上下文。',
},
{
kind: 'tool',
id: 'browser-tool-e2e',
toolCallId: 'browser-call-e2e',
toolName: 'agent_browser',
title: '浏览器状态',
inputText: 'status',
status: 'complete',
output: [{
kind: 'text',
id: 'browser-output-e2e',
text: `${'Checking browser runtime state and the latest navigation checkpoint. '.repeat(8)}Browser ready`,
status: 'complete',
}],
details: { schema: 'agent-browser.v1', action: 'status' },
},
{
kind: 'tool',
id: 'changes-tool-e2e',
toolCallId: 'changes-call-e2e',
toolName: 'changed_file',
title: '记录文件更改',
inputText: 'src/app.ts',
status: 'complete',
output: [],
details: { schema: 'changed-file.v1', paths: ['src/app.ts'] },
},
] : [],
run: featureComplete ? { status: 'running', runId: 'run-e2e-feature', mode: 'prompt' } : { status: 'idle' },
queue: { items: featureComplete ? [{ id: 'queue-e2e', clientRequestId: 'request-queued', mode: 'follow-up', text: 'Queued follow-up', attachmentIds: [] }] : [] },
context: featureComplete
? { usedTokens: 256, contextWindow: 4096, compaction: 'idle' }
: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
pendingInteractions: featureComplete ? [{
id: 'interaction-e2e',
conversationId: conversation.id,
runId: 'run-e2e-feature',
kind: 'select',
title: '允许继续?',
message: '确认当前实现方向。',
options: [
{ id: 'interaction-e2e:option:0', label: '按当前方向继续' },
{ id: 'interaction-e2e:option:1', label: '重新调整方向' },
],
status: 'pending',
}] : [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 0 },
};
const secondSnapshot = {
...snapshot,
conversation: {
...snapshot.conversation,
id: secondConversation.id,
title: secondConversation.title,
model: {
model: secondConversation.model,
modelResolution: secondConversation.modelResolution,
...(featureComplete
? { availableThinkingLevels: ['off', 'low', 'medium', 'high'] }
: {}),
},
},
nodes: featureComplete ? [
{
kind: 'message',
id: 'message-second-user-e2e',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'message-second-user-e2e:content:0', text: '检查第二个项目', status: 'complete' }],
},
{
kind: 'boundary',
id: 'boundary-second-start-e2e',
runId: 'run-second-e2e',
boundary: 'turn-start',
},
{
kind: 'message',
id: 'message-second-work-e2e',
role: 'assistant',
status: 'complete',
stopReason: 'tool-use',
blocks: [
{ kind: 'thinking', id: 'message-second-work-e2e:thinking:0', text: '读取配置并核对入口。', status: 'complete' },
{ kind: 'text', id: 'message-second-work-e2e:content:0', text: '我先读取配置。', status: 'complete' },
],
},
{
kind: 'tool',
id: 'tool-second-e2e',
toolCallId: 'tool-call-second-e2e',
toolName: 'agent_browser',
title: '检查网站',
inputText: 'status',
status: 'error',
output: [{ kind: 'text', id: 'tool-second-e2e:output:0', text: 'Bridge request failed', status: 'complete' }],
details: { schema: 'agent-browser.v1', action: 'status' },
},
{
kind: 'boundary',
id: 'boundary-second-end-e2e',
runId: 'run-second-e2e',
boundary: 'turn-end',
},
{
kind: 'message',
id: 'message-second-final-e2e',
role: 'assistant',
status: 'complete',
stopReason: 'stop',
blocks: [{
kind: 'text',
id: 'message-second-final-e2e:content:0',
text: [
'## 检查结论',
'',
'结论:第二个项目配置正常。',
'',
'文件位置:`~/Desktop/123/guiyang/index.html`',
'',
'查看方式:打开 `guiyang/index.html`,或访问 `http://localhost:8642/index.html`。',
'',
'| 检查项 | 状态 |',
'| --- | --- |',
'| 项目配置 | 正常 |',
'',
'[查看项目文档](https://example.com/docs)',
].join('\n'),
status: 'complete',
}],
},
] : [],
run: featureComplete ? {
status: 'idle',
runId: 'run-second-e2e',
mode: 'prompt',
startedAt: 1_000,
settledAt: 5_000,
terminalReason: 'completed',
} : { status: 'idle' },
queue: { items: [] },
pendingInteractions: [],
worker: {
status: 'error',
generation: 1,
error: { code: 'CODING_RUNTIME_START_FAILED', message: 'Worker stopped', recoverable: true },
},
};
const historySnapshot = {
...snapshot,
conversation: {
...snapshot.conversation,
id: historyConversation.id,
title: historyConversation.title,
},
nodes: [
...Array.from({ length: 150 }, (_, index) => ({
kind: 'message',
id: `history-message-${index}`,
role: 'user',
status: 'complete',
blocks: [{
kind: 'text',
id: `history-message-${index}:content:0`,
text: `History message ${index}`,
status: 'complete',
}],
})),
{
kind: 'notice',
id: 'history-quota-notice',
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED',
level: 'error',
message: '词元点数余额不足,请充值后重试。',
},
],
run: { status: 'idle' },
queue: { items: [] },
pendingInteractions: [],
worker: { status: 'ready', generation: 1 },
};
const respond = (json: unknown, status = 200) => ({
ok: true,
data: { status, ok: status >= 200 && status < 300, json },
});
const browserSnapshot = (overrides: Record<string, unknown> = {}) => ({
browserId: null,
projectId: project.id,
projectPath: null,
state: 'closed',
generation: 0,
url: '',
title: '',
visible: false,
bounds: null,
canGoBack: false,
canGoForward: false,
eventCursor: 0,
...overrides,
});
const teacherDefinition: TeacherDefinition = {schema_version:1,teacher_id:'coding-teacher',name:'代码智能体',description:'一起发现代码里的小奥秘,把想法变成作品。',avatar_id:'avatar-01',welcome_message:'一起理解代码',suggested_questions:['解释当前代码'],system_prompt:'教学',skills:[],model:{model_id:'model-a',reasoning_choice:{mode:'default'}},limits:{max_input_tokens:8000,max_output_tokens:1500}};
state.teacherCatalog.items = [
{ teacher_id: 'teacher-code', version: 1, is_default: true, definition: { ...teacherDefinition, config_id: 'teacher-code', runtime: 'yuxi', system_prompt: '', yuxi: { agent_slug: 'code', agent_version: 2 } } },
{ teacher_id: 'teacher-algorithm', version: 9, is_default: false, definition: { ...teacherDefinition, config_id: 'teacher-algorithm', runtime: 'yuxi', name: '朋友', description: '陪你听听玩家的想法,让作品更有趣。', system_prompt: '', yuxi: { agent_slug: 'algorithm', agent_version: 4 } } },
];
const consultationTopics: Record<string, Record<string, unknown>> = {};
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (
_event,
request: {
path?: string;
method?: string;
headers?: Record<string, string>;
body?: unknown;
},
) => {
const path = request.path ?? '';
const method = request.method ?? 'GET';
if (audioPreview && path === '/api/coding/game-audio/' + audioPreview.executionId + '/outputs/0') {
return respond({ path: audioPreview.path, dataUrl: audioPreview.dataUrl });
}
const body = typeof request.body === 'string' && request.body
? JSON.parse(request.body) as Record<string, unknown>
: undefined;
const binaryBody = request.body instanceof ArrayBuffer
? new Uint8Array(request.body)
: ArrayBuffer.isView(request.body)
? new Uint8Array(
request.body.buffer,
request.body.byteOffset,
request.body.byteLength,
)
: undefined;
state.captured.push({
path,
method,
...(body ? { body } : {}),
...(binaryBody ? { byteLength: binaryBody.byteLength } : {}),
...(request.headers?.['Content-Type']
? { contentType: request.headers['Content-Type'] }
: {}),
at: Date.now(),
});
if (path === '/api/coding/attachments'
|| /^\/api\/coding\/attachments\/[^/]+\/content$/.test(path)) {
const response = await fetch(`${connection.baseUrl}${path}`, {
method,
headers: {
Authorization: `Bearer ${connection.token}`,
...(request.headers ?? {}),
},
...(binaryBody ? { body: binaryBody } : {}),
});
const responseContentType = response.headers.get('content-type') ?? '';
if (responseContentType.includes('application/json')) {
return {
ok: true,
data: {
status: response.status,
ok: response.ok,
json: await response.json(),
transport: 'loopback',
},
};
}
return {
ok: true,
data: {
status: response.status,
ok: response.ok,
bytes: new Uint8Array(await response.arrayBuffer()),
contentType: responseContentType.split(';', 1)[0]?.trim(),
transport: 'loopback',
},
};
}
if (path.endsWith('/teacher-check-in') && method === 'POST') {
const published = state.teacherCatalog.items.find((item) => item.is_default) ?? state.teacherCatalog.items[0];
const current = consultationTopics.agent ?? {schemaVersion:1,revision:0,id:'agent-topic',accountId:'e2e',projectId:project.id,sourceConversationId:'project',definition:published?.definition ?? teacherDefinition,version:published?.version ?? 1,createdAt:now,updatedAt:now,requests:[]};
consultationTopics.agent = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,intent:'check-in',text:'',references:[],sourceConversationId:body!.sourceConversationId,createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'你刚才想加排行榜,我们一起想想比什么更有意思?'}]};
return respond({topic:consultationTopics.agent});
}
if (path === '/api/coding/teacher/teachers') {
if (state.holdTeacherCatalog) {
await new Promise<void>((resolveCatalog) => { state.releaseTeacherCatalog = resolveCatalog; });
state.releaseTeacherCatalog = null;
state.holdTeacherCatalog = false;
}
if (state.teacherCatalogFailure) return respond({ error: 'Catalog unavailable' }, 503);
return respond(state.teacherCatalog);
}
if (path === '/api/coding/teacher/config') return respond({enabled:true,revision:1,published_version:1,definition:teacherDefinition});
const agentChat = path.match(/^\/api\/coding\/projects\/([^/?]+)\/agent-conversations\/([^/?]+)(?:\/(messages|seen))?(?:\?.*)?$/);
if (agentChat) {
const projectId = agentChat[1], agentId = agentChat[2], action = agentChat[3], chatKey = projectId + ':' + agentId;
const selected = state.teacherCatalog.items.find(item => item.teacher_id === agentId)!;
const current = consultationTopics[chatKey] ?? {schemaVersion:1,revision:0,id:'chat-' + chatKey,accountId:'e2e',projectId,sourceConversationId:'project',definition:selected?.definition ?? teacherDefinition,version:selected?.version ?? 1,createdAt:now,updatedAt:now,requests:[],conversation:{agentId,segmentTurns:0,discussions:{}}};
if (!action && method === 'GET') return respond({topic: consultationTopics[chatKey] ?? null, before:null});
if (action === 'seen') {
consultationTopics[chatKey] = {...current, revision:Number(current.revision)+1,conversation:{...current.conversation as object,seenThrough:body!.requestId}};
return respond(consultationTopics[chatKey]);
}
if (action === 'messages' && method === 'POST') {
const suggestions = body!.intent === 'suggestions';
const response = suggestions ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[chatKey] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:new Date().toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
const last = (consultationTopics[chatKey].requests as Record<string, unknown>[]).at(-1)!;
Object.assign(last, consultationReply);
if (state.nextTeacherReplyIncomplete) {
state.nextTeacherReplyIncomplete = false;
Object.assign(last, { response: '它早就不是', replyIncomplete: true, replyParseError: '格式不完整',
unparsedResponse: '{"reply":"它早就不是"刚搭好架子' });
}
return respond(consultationTopics[chatKey],202);
}
}
const consultationPath = path.match(/\/(agent|teacher)-topics(?:\/|$)/)?.[1];
if (consultationPath) {
const topicBase = `/${consultationPath}-topics`;
const topicId = `${consultationPath}-topic`;
const currentTopic = consultationTopics[consultationPath];
if (path.endsWith(topicBase) && method === 'GET') return respond({items:currentTopic?[{id:currentTopic.id,title:'智能体话题',teacherId:(currentTopic.definition as {config_id?:string}).config_id}]:[],lastSelectedTopicId:currentTopic?.id??null});
if (path.endsWith(topicBase) && method === 'POST') {
const selected = state.teacherCatalog.items.find(item => item.version === body?.teacherVersion) ?? state.teacherCatalog.items[0];
consultationTopics[consultationPath]={schemaVersion:1,revision:0,id:topicId,accountId:'e2e',projectId:project.id,sourceConversationId:conversation.id,definition:selected.definition,version:selected.version,createdAt:now,updatedAt:now,requests:[]};
return respond(consultationTopics[consultationPath],201);
}
if (path.endsWith(`${topicBase}/${topicId}/messages`) && method === 'POST') {
const suggestionRequest = body!.intent === 'suggestions';
const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[consultationPath]={...currentTopic,revision:Number(currentTopic?.revision??0)+1,requests:[...(currentTopic?.requests as unknown[]??[]),{id:body!.requestId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response:teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
if (state.nextTeacherReplyIncomplete) {
state.nextTeacherReplyIncomplete = false;
const reply = (consultationTopics[consultationPath].requests as Record<string, unknown>[]).at(-1)!;
Object.assign(reply, { response: '它早就不是', replyIncomplete: true, replyParseError: '格式不完整',
sourceConversationId: body!.sourceConversationId, unparsedResponse: '{"reply":"它早就不是"刚搭好架子' });
}
return respond(consultationTopics[consultationPath],202);
}
if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic);
}
if (path === '/api/coding/projects') {
return respond({ projects: multipleProjects ? [project, otherProject] : [project], activeProjectId: activeProject.id });
}
if (path === '/api/coding/projects/active' && method === 'POST') {
activeProject = body!.projectId === otherProject.id ? otherProject : project;
return respond({ project: activeProject });
}
if (multipleProjects && path === `/api/coding/projects/config?projectId=${otherProject.id}`)
return respond({ snapshot: { project: otherProject, config, knowledgeFiles: [] } });
if (multipleProjects && path === `/api/coding/projects/conversations?projectId=${otherProject.id}`)
return respond({ conversations: [{ ...secondConversation, id: 'conversation-other-project', title: 'Other project conversation' }] });
if (multipleProjects && path === '/api/coding/conversations/conversation-other-project/snapshot')
return respond({ snapshot: { ...secondSnapshot, conversation: { ...secondSnapshot.conversation,
id: 'conversation-other-project', projectId: otherProject.id } } });
if (path === '/api/provider-accounts') {
return respond([{
id: managedCapabilities ? 'niancode-user-models' : 'account-e2e',
vendorId: 'custom',
label: 'E2E account',
authMode: 'api_key',
model: removedModel ? 'model-b' : 'model-a',
fallbackModels: removedModel ? [] : ['model-b'],
...(managedCapabilities ? { metadata: { worksSquareModelCapabilitiesV2: {
schemaVersion: 2, fetchedAt: now, refreshStatus: 'fresh', models: {
'model-a': { inputModalities: ['text'], outputModalities: ['text'],
reasoning: { supported: true, canDisable: true, defaultEnabled: true,
effortValues: ['xhigh', 'medium', 'low'], defaultEffort: 'xhigh', controlFormat: 'qwen', budget: null },
limits: null, resolutionStatus: 'ready', issues: [], updatedAt: now },
},
} } } : {}),
enabled: true,
isDefault: true,
createdAt: now,
updatedAt: now,
}]);
}
if (path === '/api/provider-accounts/key-info') return respond([]);
if (path === '/api/provider-vendors') return respond([{ id: 'custom', name: 'Custom' }]);
if (path === '/api/provider-accounts/default') return respond({ accountId: 'account-e2e' });
if (path === `/api/coding/projects/config?projectId=${project.id}`) {
return respond({ snapshot: { project, config, knowledgeFiles: [] } });
}
if (path === `/api/coding/projects/conversations?projectId=${project.id}`) {
return respond({
conversations: (featureComplete
? [conversation, secondConversation, historyConversation]
: state.conversationCreated
? [conversation]
: []).map((item) => ({ ...item, ...metadata.get(item.id) })),
});
}
if (path.startsWith('/api/agent-browser/') && mainGlobal.__makeloreNativeBrowserE2E) {
return respond(await mainGlobal.__makeloreNativeBrowserE2E.route(path, body));
}
if (path.startsWith('/api/agent-browser/state?')) {
return respond({ success: true, browser: browserSnapshot() });
}
if (path === '/api/agent-browser/ensure-work' && method === 'POST') {
if (state.workFailed) return respond({ success: true, status: 'failed', message: '作品还没有打开,我可以检查启动情况,再帮你打开。' });
return respond(state.workReady === false
? { success: true, status: 'starting', conversation }
: { success: true, status: 'ready', browser: browserSnapshot({ browserId: 'browser-e2e', state: 'attached', generation: state.workGeneration ?? 1, url: 'http://127.0.0.1:4173/' }) });
}
if ((path === '/api/agent-browser/open' || path === '/api/agent-browser/navigate') && method === 'POST') {
return respond({
success: true,
browser: browserSnapshot({
browserId: 'browser-e2e',
state: 'attached',
generation: 1,
url: body?.url,
visible: true,
bounds: body?.bounds,
}),
});
}
if (path === '/api/agent-browser/present' && method === 'POST') {
return respond({
success: true,
browser: browserSnapshot({
browserId: 'browser-e2e',
state: 'attached',
generation: state.workGeneration ?? 1,
url: 'http://127.0.0.1:4173/',
visible: body?.visible === true,
bounds: body?.bounds ?? null,
}),
});
}
if (path === '/api/agent-browser/diagnostics' && method === 'POST') {
return respond({ success: true, browser: browserSnapshot() });
}
if (path === '/api/agent-browser/cdp/events' && method === 'POST') {
return respond({
success: true,
page: { events: [], nextCursor: 0, hasMore: false },
});
}
if (path === '/api/agent-browser/close' && method === 'POST') {
return respond({ success: true, browser: browserSnapshot() });
}
if (path === '/api/coding/projects/conversations' && method === 'POST') {
state.conversationCreated = true;
return respond({ conversation }, 201);
}
if (path === `/api/coding/conversations/${conversation.id}/snapshot`) {
if (removedModel && conversation.model.modelId === 'model-a') {
return respond({ success: false, code: 'CODING_MODEL_UNAVAILABLE', error: '所选模型当前不可用,请重新选择。' }, 409);
}
if (!featureComplete) {
state.snapshotPending = true;
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
state.snapshotPending = false;
}
const posted = state.captured.find((request) => (
request.path === `/api/coding/conversations/${conversation.id}/prompt` && request.method === 'POST'
));
if (!featureComplete && posted?.body) {
const refs = posted.body.attachments as Array<{ attachmentId: string }>;
return respond({ snapshot: {
...snapshot,
nodes: [{ kind: 'message', id: 'entry:sent-image', sourceEntryId: 'sent-image',
role: 'user', status: 'complete', blocks: [
{ kind: 'text', id: 'entry:sent-image:content:0', text: posted.body.text, status: 'complete' },
...refs.map(({ attachmentId }, index) => ({
kind: 'image', id: `entry:sent-image:image:${index}`, attachmentId, mime: 'image/png',
})),
] }],
cursor: { workerGeneration: 1, seq: 1 },
} });
}
const currentSnapshot = state.snapshotSettled
? {
...snapshot,
run: {
status: 'idle',
runId: 'run-e2e-feature',
mode: 'prompt',
startedAt: 1_000,
settledAt: 2_000,
terminalReason: 'aborted',
},
queue: { items: [] },
pendingInteractions: [],
worker: { status: 'stopped', generation: 1 },
cursor: { workerGeneration: 1, seq: 1 },
}
: snapshot;
return respond({
snapshot: state.abortRequested
? {
...currentSnapshot,
nodes: currentSnapshot.nodes.map((node) => {
if (node.kind === 'message' && node.role === 'assistant') {
return {
...node,
status: 'aborted',
blocks: node.blocks.map((block) => ({ ...block, status: 'complete' })),
};
}
if (node.kind === 'subagent') {
return {
...node,
details: {
...node.details,
tasks: node.details.tasks.map((task) => (
task.status === 'running' ? { ...task, status: 'aborted' } : task
)),
},
};
}
return node;
}),
run: {
status: 'idle',
runId: 'run-e2e-feature',
mode: 'prompt',
settledAt: 12_000,
terminalReason: 'aborted',
},
queue: { items: [] },
pendingInteractions: [],
cursor: { workerGeneration: 1, seq: 1 },
}
: state.interactionAnswered
? { ...currentSnapshot, pendingInteractions: [] }
: currentSnapshot,
});
}
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
return respond({ snapshot: secondSnapshot });
}
if (path === `/api/coding/conversations/${historyConversation.id}/snapshot`) {
return respond({ snapshot: historySnapshot });
}
if (path === `/api/coding/conversations/${conversation.id}/prompt` && method === 'POST') {
return respond({
acceptance: {
accepted: true,
conversationId: conversation.id,
clientRequestId: body?.clientRequestId,
runId: 'run-e2e-1',
mode: body?.mode ?? 'prompt',
},
}, 202);
}
if (/^\/api\/coding\/conversations\/[^/]+\/abort$/.test(path) && method === 'POST') {
state.abortRequested = true;
return respond({});
}
if (/^\/api\/coding\/conversations\/[^/]+\/(compact|recover)$/.test(path) && method === 'POST') {
return respond({});
}
if (/^\/api\/coding\/conversations\/[^/]+\/model$/.test(path) && method === 'POST') {
if (removedModel) {
conversation.model = body?.model as typeof configuredModel;
snapshot.conversation.model.model = conversation.model;
}
return respond({ model: { model: body?.model, modelResolution: 'resolved' } });
}
if (/^\/api\/coding\/conversations\/[^/]+\/thinking$/.test(path) && method === 'POST') {
return respond({ model: snapshot.conversation.model });
}
if (/^\/api\/coding\/conversations\/[^/]+\/fork$/.test(path) && method === 'POST') {
return respond({ conversation: { ...conversation, id: 'conversation-forked', title: 'Feature UI branch' } }, 201);
}
if (/^\/api\/coding\/conversations\/[^/]+$/.test(path) && method === 'PATCH') {
const target = [conversation, secondConversation, historyConversation].find((item) => path.endsWith('/' + item.id)) ?? conversation;
const updated = { ...target, ...metadata.get(target.id),
...(body?.title ? { title: body.title } : {}),
...(typeof body?.unread === 'boolean' ? { unread: body.unread } : {}),
...(typeof body?.archived === 'boolean' ? { archivedAt: body.archived ? now : null } : {}),
};
metadata.set(target.id, updated);
return respond({ conversation: updated });
}
if (/^\/api\/coding\/interactions\/[^/]+\/respond$/.test(path) && method === 'POST') {
state.interactionAnswered = true;
return respond({});
}
if (/^\/api\/coding\/conversations\/[^/]+\/changes$/.test(path)) {
return respond({ changes: { conversationId: conversation.id, runId: 'run-e2e-feature', git: true, baselineHead: 'head-e2e', files: [{ path: 'src/app.ts', status: 'modified', diff: '+feature UI' }] } });
}
if (path === '/api/coding/files/status') return respond({ files: [{ path: 'src/app.ts', name: 'app.ts', type: 'file', status: 'modified' }] });
if (path.startsWith('/api/coding/files/content?')) return respond({ file: { path: 'src/app.ts', content: 'export const app = true;', truncated: false } });
if (path.startsWith('/api/coding/files/find?')) return respond({ files: [{ path: 'src/app.ts', name: 'app.ts', type: 'file' }] });
if (path.startsWith('/api/coding/skills')) return respond({ skills: [{ id: 'research', name: 'Research', description: 'Inspect sources', selected: true }] });
if (/^\/api\/coding\/conversations\/[^/]+\/commands$/.test(path)) return respond({ commands: [{ name: 'review', title: 'Review', description: 'Review changes', source: 'makelore' }] });
if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } });
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
});
}, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview, multipleProjects, consultationReply });
}
test('saved Game Audio offers click-only playable local preview in Electron', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(app);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
const executionId = '00000000-0000-4000-8000-000000000601';
const relative = 'assets/generated/game-audio/' + executionId + '/sound-1.wav';
// Synthetic quarter-second PCM sample, never a claimed Meowa output.
const wav = Buffer.alloc(4044);
wav.write('RIFF', 0); wav.writeUInt32LE(4036, 4); wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(4000, 40);
await installCodingFirstChatHost(app, connection, true, false, false, {
executionId, path: relative, dataUrl: 'data:audio/wav;base64,' + wav.toString('base64'),
});
await settleSnapshot(app);
await disableCodingEventSource(page);
await page.reload();
page = await getStableWindow(app);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByTestId('coding-conversation-header')).toBeVisible();
const snapshot = await page.evaluate(async () => {
const response = await window.electron.ipcRenderer.invoke('hostapi:fetch', {
path: '/api/coding/conversations/conversation-pi-first-chat/snapshot', method: 'GET',
}) as { data: { json: { snapshot: Record<string, unknown> } } };
return response.data.json.snapshot;
});
const details = {
schema: 'makelore-capability.v1', plugin_id: 'makelore.game-audio', plugin_version: '1.0.0',
capability_id: 'game-audio.sound', operation: 'generate', request_id: 'pi:r:t',
success: true, status: 200, code: null, error: null, retryable: false, payload_schema: 'game-audio.v1',
billing: { mode: 'platform_metered', status: 'settled', reserved_points: '0.04',
actual_points: '0.04', usage_amount: 4, unit: 'half_second' },
data: { executionId, logicalOperationId: 'pi:r:t', projectId: 'project-e2e',
providerStatus: 'succeeded', deliveryStatus: 'saved', phase: 'saved', outputCount: 1,
files: [{ index: 0, path: relative, bytes: wav.length, mimeType: 'audio/wav' }] },
};
await emitCodingEvent(page, 'snapshot', {
type: 'snapshot', conversationId: 'conversation-pi-first-chat', workerGeneration: 1, seq: 10,
snapshot: { ...snapshot, cursor: { workerGeneration: 1, seq: 10 }, nodes: [{
kind: 'tool', id: 'audio-preview', toolCallId: 'audio-preview', toolName: 'game_sound_generate',
title: '生成游戏音效', status: 'complete', inputText: '', output: [], details,
}] },
});
await expect(page.getByTestId('tool-progress-preview')).toContainText('游戏音频 · 已保存');
await page.getByTestId('coding-process-group').locator('summary').first().click();
await page.locator('[data-node-id="audio-preview"] > summary').click();
await expect(page.locator('audio')).toHaveCount(0);
await page.getByRole('button', { name: '试听 sound-1.wav' }).click();
const player = page.getByLabel('试听 sound-1.wav', { exact: true });
await expect(player).toHaveAttribute('controls', '');
expect(await player.getAttribute('autoplay')).toBeNull();
await expect.poll(() => player.evaluate((element) => (element as HTMLAudioElement).duration)).toBe(0.25);
expect(await player.evaluate((element) => (element as HTMLAudioElement).paused)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('audio-preview.png') });
});
async function readState(electronApp: ElectronApplication): Promise<{
captured: CapturedRequest[];
snapshotPending: boolean;
}> {
return await electronApp.evaluate(() => {
const mainGlobal = globalThis as typeof globalThis & {
__makelorePiFirstChatE2E?: {
captured: CapturedRequest[];
snapshotPending: boolean;
};
};
return structuredClone({
captured: mainGlobal.__makelorePiFirstChatE2E?.captured ?? [],
snapshotPending: mainGlobal.__makelorePiFirstChatE2E?.snapshotPending ?? false,
});
});
}
async function releaseSnapshot(electronApp: ElectronApplication): Promise<void> {
await electronApp.evaluate(() => {
const mainGlobal = globalThis as typeof globalThis & {
__makelorePiFirstChatE2E?: { releaseSnapshot: (() => void) | null };
};
mainGlobal.__makelorePiFirstChatE2E?.releaseSnapshot?.();
});
}
async function settleSnapshot(electronApp: ElectronApplication): Promise<void> {
await electronApp.evaluate(() => {
const mainGlobal = globalThis as typeof globalThis & {
__makelorePiFirstChatE2E?: { snapshotSettled: boolean };
};
if (mainGlobal.__makelorePiFirstChatE2E) {
mainGlobal.__makelorePiFirstChatE2E.snapshotSettled = true;
}
});
}
test('conversation menus rename, archive and restore without selecting or stopping the target', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const sidebar = page.getByTestId('project-conversations');
const header = page.getByTestId('coding-conversation-header');
await expect(header).toContainText('新对话');
await sidebar.getByRole('button', { name: '对话操作:Second Conversation' }).focus();
await page.keyboard.press('Enter');
await page.getByRole('menuitem', { name: '重命名', exact: true }).click();
const title = page.getByRole('textbox', { name: '对话标题' });
await expect(title).toHaveValue('Second Conversation');
await title.fill('重命名后的会话');
await title.press('Enter');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(header).toContainText('新对话');
await sidebar.getByRole('button', { name: '对话操作:重命名后的会话' }).focus();
await page.keyboard.press('Enter');
await page.getByRole('menuitem', { name: '归档', exact: true }).click();
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toHaveCount(0);
await sidebar.getByRole('button', { name: '查看归档对话' }).click();
await sidebar.getByRole('button', { name: '重命名后的会话', exact: true }).click();
await expect(page.getByRole('button', { name: '恢复对话', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '发送', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: '恢复对话', exact: true }).click();
await expect(header).toContainText('重命名后的会话');
await expect(sidebar.getByRole('button', { name: '查看归档对话' })).toBeVisible();
const requests = (await readState(electronApp)).captured;
expect(requests.some((request) => request.path.endsWith('/abort'))).toBe(false);
expect(requests.filter((request) => request.method === 'PATCH' && request.path.endsWith('/conversation-pi-second'))
.map((request) => request.body)).toEqual([
{ title: '重命名后的会话' }, { archived: true }, { archived: false },
]);
await page.screenshot({ path: 'test-results/coding-session-controls.png' });
} finally { await releaseSnapshot(electronApp); }
});
test('a removed saved model can be replaced after snapshot preparation fails', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true, false, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByText('所选模型当前不可用,请重新选择。', { exact: true })).toBeVisible();
const settings = page.getByRole('button', { name: /模型与思考设置/ });
await expect(settings).toBeEnabled();
await settings.click();
await page.getByRole('menuitem', { name: '模型 model-a', exact: true }).click();
await expect(page.getByRole('menuitemradio', { name: 'model-a(当前不可用)' })).toBeDisabled();
await page.getByRole('menuitemradio', { name: 'model-b', exact: true }).click();
await expect(settings).toContainText('model-b');
await expect(page.getByText('所选模型当前不可用,请重新选择。', { exact: true })).toHaveCount(0);
await expect(page.getByText('Durable user fork source', { exact: true })).toBeVisible();
await page.getByTestId('coding-process-group').locator('summary').first().click();
await expect(page.getByText('Durable assistant response', { exact: true })).toBeVisible();
const requests = (await readState(electronApp)).captured;
expect(requests.filter(request => request.path.endsWith('/model') && request.method === 'POST').map(request => request.body)).toEqual([
{ model: { accountId: 'account-e2e', modelId: 'model-b', thinkingLevel: 'off' } },
]);
expect(requests.some(request => request.path.endsWith('/prompt') || request.path.endsWith('/abort'))).toBe(false);
});
test('managed capabilities expose native xhigh and block unsupported image input', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByRole('button', { name: '添加图片', exact: true })).toBeDisabled();
const controls = page.getByTestId('coding-composer-runtime-controls');
await expect(controls).toContainText('模型默认');
await controls.getByRole('button').click();
await page.getByRole('menuitem', { name: /推理强度/ }).click();
await expect(page.getByRole('menuitemradio', { name: '模型默认', exact: true })).toBeVisible();
await expect(page.getByRole('menuitemradio', { name: '关闭思考', exact: true })).toBeVisible();
await page.getByRole('menuitemradio', { name: 'xhigh', exact: true }).click();
await expect.poll(async () => (await readState(electronApp)).captured.find(
request => request.path.endsWith('/thinking') && request.method === 'POST')?.body?.reasoningChoice,
).toEqual({ mode: 'enabled', effort: 'xhigh' });
} finally { await releaseSnapshot(electronApp); }
});
test('first PI Conversation is editable under 500 ms and submits before runtime Snapshot', async ({
launchElectronApp,
}) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const hostConnection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, hostConnection);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.evaluate(() => {
performance.mark('pi-first-chat-start');
window.location.hash = '/chat';
});
await expect(page.getByRole('button', { name: 'Builder', exact: true })).toHaveCount(0);
const builderConversations = page.getByRole('group', { name: '项目会话' });
await expect(builderConversations).toBeVisible();
await expect(builderConversations.getByRole('button', { name: '新对话', exact: true })).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item => item.path === '/api/coding/projects/conversations' && item.method === 'POST')).toHaveLength(0);
const composer = page.getByRole('textbox');
await expect(composer).toBeEnabled();
const editableMs = await page.evaluate(() => (
performance.now() - performance.getEntriesByName('pi-first-chat-start').at(-1)!.startTime
));
expect(editableMs).toBeLessThan(500);
const performanceFragment = process.env.MAKELORE_PI_PERF_COMPOSER_FRAGMENT;
if (performanceFragment) {
await appendFile(performanceFragment, `${JSON.stringify({ editableMs })}\n`);
}
const pixelPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
const largePng = Buffer.concat([pixelPng, Buffer.alloc(1024 * 1024)]);
await page.getByTestId('coding-file-attachment-input').setInputFiles({
name: 'large.png',
mimeType: 'image/png',
buffer: largePng,
});
await composer.fill('Build the first PI scene');
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled();
await page.getByTestId('coding-message-composer').evaluate(
(form: HTMLFormElement) => form.requestSubmit(),
);
await expect.poll(async () => {
const state = await readState(electronApp);
return {
snapshotPending: state.snapshotPending,
promptPosted: state.captured.some((request) => (
request.path === '/api/coding/conversations/conversation-pi-first-chat/prompt'
&& request.method === 'POST'
)),
};
}).toEqual({ snapshotPending: true, promptPosted: true });
await expect(
page.getByTestId('coding-conversation-timeline').getByText('Build the first PI scene'),
).toBeVisible();
await expect(page.getByRole('img', { name: '对话图片附件' })).toBeVisible();
await expect(page.getByText(/条消息已被本地 Agent 接收/)).toHaveCount(0);
const state = await readState(electronApp);
const uploads = state.captured.filter((request) => (
request.path === '/api/coding/attachments' && request.method === 'POST'
));
expect(uploads).toHaveLength(1);
expect(uploads[0]).toMatchObject({
byteLength: largePng.byteLength,
contentType: 'image/png',
});
expect(state.captured.some((request) => (
/^\/api\/coding\/attachments\/[^/]+\/content$/.test(request.path)
&& request.method === 'GET'
))).toBe(true);
const prompt = state.captured.find((request) => (
request.path === '/api/coding/conversations/conversation-pi-first-chat/prompt'
));
expect(prompt?.body?.attachments).toEqual([
{ attachmentId: expect.any(String) },
]);
expect(JSON.stringify(prompt?.body)).not.toContain('data:image');
await releaseSnapshot(electronApp);
const persistedMessage = page.locator('[data-node-id="entry:sent-image"]');
await expect(persistedMessage).toBeVisible();
await expect(persistedMessage.getByRole('img', { name: '对话图片附件' })).toBeVisible();
await expect(persistedMessage.getByRole('button', { name: '从这里创建新对话分支' })).toBeVisible();
// A full renderer reload discards its optimistic state and blob URLs.
await page.reload();
page = await getStableWindow(electronApp);
await expect.poll(async () => (await readState(electronApp)).snapshotPending).toBe(true);
await releaseSnapshot(electronApp);
await expect(page.locator('[data-node-id="entry:sent-image"]').getByRole('img', {
name: '对话图片附件',
})).toBeVisible();
} finally {
await releaseSnapshot(electronApp);
}
});
test('hidden Conversation badge ignores process failures until interaction or task settlement', async ({
launchElectronApp,
}) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const hostConnection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, hostConnection, true);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/chat'; });
const conversations = page.getByRole('group', { name: '项目会话' });
const firstConversation = conversations.getByRole('button', { name: '新对话', exact: true });
const secondConversation = conversations.getByRole('button', { name: 'Second Conversation' });
const secondUnreadBadge = secondConversation.locator('[aria-label="未读"]');
await expect(page.getByTestId('coding-conversation-header')).toContainText('新对话');
await expect(secondUnreadBadge).toHaveCount(0);
await emitCodingEvent(page, 'patch-batch', {
type: 'patch-batch',
conversationId: 'conversation-pi-second',
workerGeneration: 1,
fromSeq: 1,
toSeq: 3,
items: [
{
seq: 1,
at: 1_001,
runId: 'run-process-e2e',
patch: {
op: 'run.state',
run: { status: 'running', runId: 'run-process-e2e', mode: 'prompt', startedAt: 1_001 },
},
},
{
seq: 2,
at: 1_002,
runId: 'run-process-e2e',
patch: {
op: 'message.upsert',
node: {
kind: 'message',
id: 'message-process-e2e',
role: 'assistant',
status: 'streaming',
blocks: [{ kind: 'thinking', id: 'thinking-process-e2e', text: 'Checking', status: 'streaming' }],
},
},
},
{
seq: 3,
at: 1_003,
runId: 'run-process-e2e',
patch: {
op: 'tool.upsert',
node: {
kind: 'tool',
id: 'tool-process-e2e',
toolCallId: 'tool-call-process-e2e',
toolName: 'bash',
title: 'Run command',
inputText: 'false',
status: 'error',
output: [{ kind: 'text', id: 'tool-output-process-e2e', text: 'Command failed', status: 'complete' }],
},
},
},
],
});
await expect(secondUnreadBadge).toHaveCount(0);
await emitCodingEvent(page, 'patch-batch', {
type: 'patch-batch',
conversationId: 'conversation-pi-second',
workerGeneration: 1,
fromSeq: 4,
toSeq: 4,
items: [{
seq: 4,
at: 1_004,
runId: 'run-process-e2e',
patch: {
op: 'interaction.upsert',
interaction: {
id: 'interaction-process-e2e',
conversationId: 'conversation-pi-second',
runId: 'run-process-e2e',
kind: 'confirm',
title: 'Allow this action?',
status: 'pending',
},
},
}],
});
await expect(secondUnreadBadge).toHaveCount(1);
await secondConversation.click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation');
await expect(secondUnreadBadge).toHaveCount(0);
await firstConversation.click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('新对话');
await emitCodingEvent(page, 'patch-batch', {
type: 'patch-batch',
conversationId: 'conversation-pi-second',
workerGeneration: 1,
fromSeq: 5,
toSeq: 5,
items: [{
seq: 5,
at: 1_005,
runId: 'run-process-e2e',
patch: {
op: 'interaction.remove',
interactionId: 'interaction-process-e2e',
},
}],
});
await expect(secondUnreadBadge).toHaveCount(0);
await emitCodingEvent(page, 'patch-batch', {
type: 'patch-batch',
conversationId: 'conversation-pi-second',
workerGeneration: 1,
fromSeq: 6,
toSeq: 6,
items: [{
seq: 6,
at: 1_006,
runId: 'run-process-e2e',
patch: {
op: 'run.state',
run: {
status: 'idle',
runId: 'run-process-e2e',
mode: 'prompt',
startedAt: 1_001,
settledAt: 1_006,
terminalReason: 'completed',
},
},
}],
});
await expect(secondUnreadBadge).toHaveCount(1);
} finally {
await releaseSnapshot(electronApp);
}
});
test('foreground focus rehydrates a terminal Snapshot after lifecycle sleep', async ({
launchElectronApp,
}) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const hostConnection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, hostConnection, true);
await disableCodingEventSource(page);
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/chat'; });
const processGroup = page.getByTestId('coding-process-group');
await expect(processGroup).toHaveAttribute('data-process-state', 'active');
await expect(processGroup.locator('summary').first()).toContainText('处理中');
const snapshotPath = '/api/coding/conversations/conversation-pi-first-chat/snapshot';
const stateBeforeSleep = await readState(electronApp);
const snapshotCallsBeforeSleep = stateBeforeSleep.captured.filter((request) => (
request.path === snapshotPath
)).length;
await settleSnapshot(electronApp);
await electronApp.evaluate(({ BrowserWindow }) => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('lifecycle:sleep');
}
});
await expect.poll(async () => page.evaluate(() => {
const trackedWindow = window as typeof window & {
__makeloreCodingEventSources?: Array<{ readyState: number }>;
};
return trackedWindow.__makeloreCodingEventSources?.at(-1)?.readyState ?? -1;
})).toBe(2);
await page.evaluate(() => window.dispatchEvent(new FocusEvent('focus')));
await expect(processGroup).toHaveAttribute('data-process-state', 'settled');
await expect(processGroup.locator('summary').first()).not.toContainText('处理中');
await expect.poll(async () => {
const state = await readState(electronApp);
return state.captured.filter((request) => request.path === snapshotPath).length;
}).toBeGreaterThan(snapshotCallsBeforeSleep);
const stateAfterFocus = await readState(electronApp);
expect(stateAfterFocus.captured.some((request) => (
request.path === '/api/coding/conversations/conversation-pi-first-chat/prompt'
))).toBe(false);
});
test('Windows Code titlebar keeps browser actions clear of the logo and window controls', async ({
launchElectronApp,
}) => {
test.skip(process.platform !== 'win32', 'Windows custom titlebar only');
const electronApp = await launchElectronApp({ skipSetup: true });
const page = await getStableWindow(electronApp);
const hostConnection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, hostConnection, true);
await disableCodingEventSource(page);
try {
await page.reload();
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('coding-conversation-header')).toBeVisible();
const header = page.getByTestId('coding-conversation-header');
const assertChromeClear = async () => {
const logo = await page.getByTestId('titlebar-logo').boundingBox();
expect(logo).not.toBeNull();
for (const button of await header.getByRole('button').all()) {
const bounds = await button.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(logo!.x);
}
const minimize = page.getByTitle('Minimize');
const minimizeBounds = await minimize.boundingBox();
expect(minimizeBounds).not.toBeNull();
expect(logo!.x + logo!.width).toBeLessThanOrEqual(minimizeBounds!.x);
// The conversation header must not intercept clicks in the window controls.
for (const title of ['Minimize', 'Maximize', 'Close']) {
await page.getByTitle(title, { exact: true }).click({ trial: true });
}
};
for (const { width, zoom } of [
{ width: 1280, zoom: 1 },
{ width: 1024, zoom: 1 },
{ width: 1280, zoom: 1.25 },
]) {
await electronApp.evaluate(({ BrowserWindow }, dimensions) => {
const window = BrowserWindow.getAllWindows()[0];
window.setSize(dimensions.width, 800);
window.webContents.setZoomFactor(dimensions.zoom);
}, { width, zoom });
await expect.poll(async () => page.evaluate(() => window.innerWidth)).toBe(Math.round(width / zoom));
await assertChromeClear();
if (width === 1280 && zoom === 1) {
await page.screenshot({ path: test.info().outputPath('windows-code-titlebar.png') });
}
await header.getByRole('button', { name: '打开开发浏览器' }).click();
await expect(page.getByTestId('agent-browser-panel')).toBeVisible();
await assertChromeClear();
await header.getByRole('button', { name: '关闭开发浏览器' }).click();
await expect(page.getByTestId('agent-browser-panel')).toHaveCount(0);
}
await page.getByTestId('titlebar-sidebar-toggle').click();
await assertChromeClear();
await settleSnapshot(electronApp);
await page.evaluate(() => window.dispatchEvent(new FocusEvent('focus')));
await expect(header.getByRole('button', { name: '中止', exact: true })).toHaveCount(0);
await assertChromeClear();
await page.getByTitle('Maximize', { exact: true }).click();
await expect(page.getByTitle('Restore', { exact: true })).toBeVisible();
await page.getByTitle('Restore', { exact: true }).click();
await expect(page.getByTitle('Maximize', { exact: true })).toBeVisible();
} finally {
await releaseSnapshot(electronApp);
}
});
test('PI feature UI isolates Conversations and exposes queue, interaction, model, and subagent state', async ({
launchElectronApp,
}) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const hostConnection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, hostConnection, true);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByTestId('coding-conversation-sidebar-titlebar')).toBeVisible();
await expect(page.getByTestId('coding-conversation-header-titlebar')).toBeVisible();
const titlebarBounds = await Promise.all([
page.getByTestId('coding-conversation-sidebar-titlebar').boundingBox(),
page.getByTestId('coding-conversation-header-titlebar').boundingBox(),
]);
expect(titlebarBounds.every((bounds) => bounds?.y === 0 && bounds.height === 40)).toBe(true);
const logoBounds = await page.getByTestId('titlebar-logo').boundingBox();
expect(logoBounds).not.toBeNull();
if (await page.evaluate(() => window.electron.platform === 'win32')) {
const minimizeBounds = await page.getByTitle('Minimize').boundingBox();
expect(minimizeBounds).not.toBeNull();
expect(logoBounds!.x + logoBounds!.width).toBeLessThanOrEqual(minimizeBounds!.x);
} else {
const windowWidth = await page.evaluate(() => window.innerWidth);
expect(Math.abs((logoBounds!.x + logoBounds!.width) - windowWidth)).toBeLessThanOrEqual(1);
}
const conversationHeader = page.getByTestId('coding-conversation-header');
await expect(conversationHeader).toContainText('新对话');
await expect(conversationHeader).not.toContainText('Pi ·');
await expect(conversationHeader).not.toContainText('队列 1');
await expect(page.getByTestId('coding-conversation-sidebar-titlebar')).not.toContainText('Pi 本地对话');
await expect(conversationHeader.getByRole('button', { name: '创建分支' })).toHaveCount(0);
await expect(conversationHeader.getByRole('button', { name: /标为(已读|未读)/ })).toHaveCount(0);
await expect(conversationHeader.getByRole('button', { name: '归档' })).toHaveCount(0);
await expect(conversationHeader.getByRole('button', { name: '智能体设置' })).toHaveCount(0);
await expect(conversationHeader.getByRole('button', { name: '打开编程工具' })).toHaveCount(0);
const browserToggle = conversationHeader.getByRole('button', { name: '打开开发浏览器' });
await expect(browserToggle).toBeVisible();
if (process.platform === 'win32') {
const browserBounds = await browserToggle.boundingBox();
expect(browserBounds).not.toBeNull();
expect(browserBounds!.x + browserBounds!.width).toBeLessThanOrEqual(logoBounds!.x);
}
await browserToggle.click();
await expect(page.getByTestId('agent-browser-panel')).toBeVisible();
await expect(page.getByTestId('agent-browser-diagnostics')).toHaveCount(0);
await expect.poll(async () => {
const state = await readState(electronApp);
return state.captured.some((request) => request.path.startsWith('/api/agent-browser/state?'));
}).toBe(true);
expect((await readState(electronApp)).captured.some(
(request) => request.path === '/api/agent-browser/diagnostics',
)).toBe(false);
await page.getByRole('button', { name: '关闭开发浏览器' }).last().click();
await expect(page.getByTestId('agent-browser-panel')).toHaveCount(0);
await expect(page.getByRole('button', { name: '项目设置' })).toHaveCount(1);
await expect(page.getByTestId('project-conversations')).toBeVisible();
const activeProcess = page.getByTestId('coding-process-group');
await expect(activeProcess).toHaveAttribute('open', '');
await expect(activeProcess.locator('summary').first()).toContainText('处理中');
const activeThinking = page.getByLabel('思考过程');
await expect(activeThinking).toContainText('Inspecting the project before responding.');
await expect(activeThinking).not.toContainText('Preparing the smallest safe next step.');
await expect(activeThinking).toHaveAttribute('data-collapsed-lines', '1');
await expect(activeThinking).toHaveAttribute('data-expanded', 'false');
await expect(activeThinking).toHaveAttribute('data-streaming', 'true');
const activeThinkingPreview = activeThinking.getByTestId('process-progress-preview');
await expect(activeThinkingPreview).toHaveAttribute('data-progress-origin', 'head');
await expect(activeThinkingPreview).toHaveAttribute('data-progress-alignment', 'left');
await expect(activeThinkingPreview).toHaveAttribute('data-progress-update-motion', 'none');
await expect(activeThinkingPreview).not.toHaveAttribute('data-roll-revision');
await expect(activeThinkingPreview.locator('.streaming-progress-roll')).toHaveCount(0);
await expect(activeThinkingPreview).toHaveAttribute('data-progress-shimmer', 'true');
await expect(activeThinkingPreview).toHaveCSS('text-align', 'left');
const activeShimmer = activeThinkingPreview.locator('.streaming-progress-shimmer');
await expect(activeShimmer).toBeVisible();
expect(await activeShimmer.evaluate((element) => (
getComputedStyle(element).animationName
))).toContain('makelore-progress-shimmer');
const collapsedThinkingBounds = await activeThinking.boundingBox();
expect(collapsedThinkingBounds).not.toBeNull();
expect(collapsedThinkingBounds!.height).toBeLessThanOrEqual(24);
const thinkingToggle = page.getByRole('button', { name: '展开思考详情' });
await expect(thinkingToggle).toHaveAttribute('aria-expanded', 'false');
await thinkingToggle.click();
await expect(activeThinking).toHaveAttribute('data-expanded', 'true');
await expect(activeThinking).toContainText('Inspecting the project before responding.');
await expect(activeThinking.getByTestId('assistant-markdown'))
.toHaveClass(/text-muted-foreground/);
await expect.poll(async () => (
(await activeThinking.boundingBox())?.height ?? 0
)).toBeGreaterThan(collapsedThinkingBounds!.height);
await page.getByRole('button', { name: '收起思考详情' }).click();
await expect(activeThinking).toHaveAttribute('data-expanded', 'false');
const activeCommentary = page.locator('[data-node-kind="assistant-commentary"]');
await expect(activeCommentary).toHaveCount(1);
await expect(activeCommentary).toHaveClass(/text-foreground/);
const activeCommentaryPreview = activeCommentary.getByTestId('process-progress-preview');
await expect(activeCommentaryPreview).toContainText('Durable assistant response');
await expect(activeCommentaryPreview).toHaveAttribute('data-progress-origin', 'head');
await expect(activeCommentaryPreview).toHaveClass(/text-foreground/);
await expect(activeCommentaryPreview).not.toHaveClass(/text-muted-foreground/);
await expect(activeCommentaryPreview).toHaveAttribute('data-progress-shimmer', 'false');
await expect(activeCommentaryPreview).toHaveAttribute('data-progress-update-motion', 'none');
await expect(activeCommentaryPreview.locator('.streaming-progress-shimmer')).toHaveCount(0);
await expect(activeCommentaryPreview.locator('.streaming-progress-roll')).toHaveCount(0);
await page.getByRole('button', { name: '展开过程说明' }).click();
await expect(activeCommentary.getByTestId('assistant-markdown')).toHaveClass(/text-foreground/);
await expect(activeCommentary.getByTestId('assistant-markdown'))
.not.toHaveClass(/text-muted-foreground/);
await page.getByRole('button', { name: '收起过程说明' }).click();
await expect(page.locator('[data-node-id="subagent-e2e"] > summary')).toContainText('并行子任务');
await page.locator('[data-node-id="compaction-e2e"] > summary').click();
await expect(page.getByText('系统会自动重试。')).toBeVisible();
const compactTool = page.locator('[data-node-id="browser-tool-e2e"]');
const compactToolSummary = compactTool.locator('> summary');
await expect(compactTool).not.toHaveAttribute('open', '');
await expect(compactToolSummary).toHaveAttribute('data-collapsed-lines', '1');
await expect(compactTool.getByTestId('tool-progress-preview'))
.toContainText('Checking browser runtime state');
await expect(compactTool.getByTestId('tool-progress-preview')).not.toContainText('Browser ready');
await expect(compactTool.getByTestId('tool-progress-preview'))
.toHaveAttribute('data-progress-origin', 'head');
const toolProgressViewport = compactTool.getByTestId('tool-progress-preview');
await expect(toolProgressViewport).toHaveAttribute('data-progress-alignment', 'left');
await expect(toolProgressViewport).toHaveAttribute('data-progress-shimmer', 'false');
await expect(toolProgressViewport.locator('.streaming-progress-shimmer')).toHaveCount(0);
await expect(toolProgressViewport).toHaveCSS('text-align', 'left');
const toolProgressScroll = await toolProgressViewport.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollLeft: element.scrollLeft,
scrollWidth: element.scrollWidth,
}));
expect(toolProgressScroll.scrollWidth).toBeGreaterThan(toolProgressScroll.clientWidth);
expect(Math.abs(toolProgressScroll.scrollLeft)).toBeLessThanOrEqual(2);
const compactToolBounds = await compactToolSummary.boundingBox();
expect(compactToolBounds).not.toBeNull();
expect(compactToolBounds!.height).toBeLessThanOrEqual(33);
await compactToolSummary.click();
await expect(page.getByText('浏览器操作 · status')).toBeVisible();
await expect(page.getByText('允许继续?')).toBeVisible();
const changesSummary = page.getByRole('button', { name: '查看 1 个文件的更改' });
await expect(changesSummary).toContainText('1 个文件已更改');
await expect(changesSummary).toContainText('+1');
await changesSummary.click();
await expect(page.getByText('src/app.ts')).toBeVisible();
await expect(page.getByTestId('legacy-conversation-notice')).toHaveCount(0);
const interactionPanel = page.getByTestId('coding-interactions');
await interactionPanel.getByRole('textbox', { name: '允许继续?的其他回答' })
.fill('先补充错误处理再继续');
await interactionPanel.getByRole('button', { name: '提交回答' }).click();
await expect(interactionPanel).toHaveCount(0);
await page.getByTestId('coding-message-composer').getByRole('textbox').fill('排到下一轮验证');
const mode = page.getByRole('combobox', { name: '消息发送方式' });
await mode.selectOption('follow-up');
await expect(mode).toHaveValue('follow-up');
await page.getByRole('button', { name: '发送', exact: true }).click();
await expect.poll(async () => (await readState(electronApp)).captured.some((request) => (
request.path.endsWith('/prompt') && request.body?.mode === 'follow-up'
))).toBe(true);
await expect(page.getByTestId('coding-file-attachment-input')).toBeEnabled();
await expect(page.getByText(/条消息已被本地 Agent 接收/)).toHaveCount(0);
await expect(page.getByTestId('coding-message-queue')).toBeVisible();
const composer = page.getByTestId('coding-message-composer');
await expect(composer).not.toContainText('项目内可写');
await expect(composer.getByRole('button', { name: '整理上下文' })).toHaveCount(0);
await expect(composer.getByRole('button', { name: '语音输入' })).toBeVisible();
const runtimeSettings = composer.getByRole('button', { name: /模型与思考设置/ });
await expect(runtimeSettings).toHaveClass(/bg-transparent/);
await expect(runtimeSettings).not.toHaveClass(/bg-surface-subtle\/75/);
await expect(runtimeSettings).toBeDisabled();
await page.getByRole('button', { name: '中止', exact: true }).click();
await expect(page.getByRole('button', { name: '中止', exact: true })).toHaveCount(0);
await expect(composer.getByRole('button', { name: '中止生成' })).toHaveCount(0);
await expect(runtimeSettings).toBeEnabled();
await expect(page.getByTestId('coding-message-queue')).toHaveCount(0);
await composer.getByRole('textbox').fill('继续');
await expect(composer.getByRole('button', { name: '发送', exact: true })).toBeEnabled();
await composer.getByRole('textbox').press('Enter');
await expect.poll(async () => (await readState(electronApp)).captured.some((request) => (
request.path.endsWith('/prompt')
&& request.body?.mode === 'prompt'
&& request.body?.text === '继续'
))).toBe(true);
const builderConversations = page.getByRole('group', { name: '项目会话' });
await expect(builderConversations).toBeVisible();
const forkActions = page.getByRole('button', { name: '从这里创建新对话分支' });
await expect(forkActions).toHaveCount(1);
await forkActions.click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('Feature UI branch');
await expect(builderConversations.getByRole('button', { name: 'Feature UI branch' })).toBeVisible();
await expect(page.getByText('本地编程运行时暂时不可用')).toHaveCount(0);
await builderConversations.getByRole('button', { name: /^新对话/ }).click();
await expect(page.getByText('Durable user fork source')).toBeVisible();
await page.getByTestId('coding-process-group').locator('summary').first().click();
await expect(page.getByText('Durable assistant response')).toBeVisible();
await expect(page.getByRole('button', { name: '从这里创建新对话分支' })).toHaveCount(1);
await builderConversations.getByRole('button', { name: 'Second Conversation' }).click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation');
const completedProcess = page.getByTestId('coding-process-group');
await expect(completedProcess).not.toHaveAttribute('open', '');
await expect(completedProcess.locator('summary').first()).toContainText('已处理 4 秒');
await expect(completedProcess.locator('summary').first()).not.toContainText('处理失败');
await expect(page.getByRole('heading', { level: 2, name: '检查结论' })).toBeVisible();
await expect(page.getByText('结论:第二个项目配置正常。')).toBeVisible();
await expect(page.getByRole('table')).toContainText('项目配置');
await expect(page.getByRole('link', { name: '查看项目文档' }))
.toHaveAttribute('href', 'https://example.com/docs');
const localReplyLink = page.getByRole('button', {
name: '用默认浏览器打开 guiyang/index.html',
});
await expect(localReplyLink).toBeVisible();
await expect(localReplyLink).toHaveClass(/text-brand/);
await expect(localReplyLink).not.toHaveClass(/bg-/);
const localhostReplyLink = page.getByRole('link', {
name: 'http://localhost:8642/index.html',
});
await expect(localhostReplyLink)
.toHaveAttribute('href', 'http://localhost:8642/index.html');
await expect(localhostReplyLink).toHaveClass(/text-brand/);
await expect(localhostReplyLink).not.toHaveClass(/bg-/);
await expect(page.getByText('开始新一轮')).toHaveCount(0);
await expect(page.getByText('本轮已结算')).toHaveCount(0);
await completedProcess.locator('summary').first().click();
await expect(page.getByLabel('思考过程')).toContainText('读取配置并核对入口。');
const completedCommentary = page.locator(
'[data-node-id="message-second-work-e2e"][data-node-kind="assistant-commentary"]',
);
await expect(completedCommentary.getByTestId('process-progress-preview'))
.toContainText('我先读取配置。');
await expect(completedCommentary.getByTestId('process-progress-preview'))
.toHaveClass(/text-foreground/);
await expect(completedCommentary.getByTestId('process-progress-preview'))
.not.toHaveClass(/text-muted-foreground/);
await expect(page.locator('[data-node-id="tool-second-e2e"] > summary')).toContainText('失败');
const secondRuntimeSettings = composer.getByRole('button', { name: /模型与思考设置/ });
await expect(secondRuntimeSettings).toBeEnabled();
await expect(secondRuntimeSettings).toContainText('model-b');
await secondRuntimeSettings.click();
await page.getByRole('menuitem', { name: '推理强度 低' }).click();
await expect(page.getByRole('menuitemradio', { name: '关闭' })).toBeVisible();
await expect(page.getByRole('menuitemradio', { name: '低' })).toBeVisible();
await expect(page.getByRole('menuitemradio', { name: '中等' })).toBeVisible();
await expect(page.getByRole('menuitemradio', { name: '高' })).toBeVisible();
await expect(page.getByRole('menuitemradio', { name: '极简' })).toHaveCount(0);
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
await page.evaluate(() => {
const trackedWindow = window as typeof window & {
__makeloreReconnectObserver?: MutationObserver;
__makeloreSawReconnectBanner?: boolean;
};
trackedWindow.__makeloreSawReconnectBanner = false;
trackedWindow.__makeloreReconnectObserver = new MutationObserver(() => {
if (document.body.textContent?.includes('正在重新连接本地 Agent')) {
trackedWindow.__makeloreSawReconnectBanner = true;
}
});
trackedWindow.__makeloreReconnectObserver.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
});
await secondRuntimeSettings.click();
await page.getByRole('menuitem', { name: '模型 model-b' }).click();
await page.getByRole('menuitemradio', { name: 'model-a' }).click();
await expect(secondRuntimeSettings).toContainText('model-b');
expect(await page.evaluate(() => {
const trackedWindow = window as typeof window & {
__makeloreReconnectObserver?: MutationObserver;
__makeloreSawReconnectBanner?: boolean;
};
trackedWindow.__makeloreReconnectObserver?.disconnect();
return trackedWindow.__makeloreSawReconnectBanner ?? false;
})).toBe(false);
await expect(page.getByRole('textbox')).toHaveValue('');
await page.getByRole('button', { name: '恢复' }).click();
await builderConversations.getByRole('button', { name: 'History and quota' }).click();
const historyProcess = page.getByTestId('coding-process-group');
await expect(historyProcess.locator('summary').first())
.toContainText('词元点数余额不足,请充值后重试。');
await expect(page.getByText('History message 0')).toHaveCount(0);
await expect(page.getByText('History message 31')).toHaveCount(1);
const historyTimeline = page.getByTestId('coding-conversation-timeline');
const beforeHeight = await historyTimeline.evaluate((element) => {
element.scrollTop = 0;
const height = element.scrollHeight;
element.dispatchEvent(new Event('scroll', { bubbles: true }));
return height;
});
await expect(page.getByText('History message 0')).toHaveCount(1);
const anchoredScroll = await historyTimeline.evaluate((element) => ({
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
}));
expect(anchoredScroll.scrollTop).toBeGreaterThan(0);
expect(Math.abs(
anchoredScroll.scrollTop - (anchoredScroll.scrollHeight - beforeHeight),
)).toBeLessThanOrEqual(2);
await expect(page.getByText(/编程工具|分享|取消分享|回滚|恢复回滚|待办|全局运行时/)).toHaveCount(0);
const state = await readState(electronApp);
expect(state.captured.some((request) => request.path.endsWith('/model') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/abort') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => (
request.path.includes('/interactions/')
&& request.path.endsWith('/respond')
&& request.body?.value === '先补充错误处理再继续'
))).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/commands'))).toBe(false);
expect(state.captured.some((request) => request.path.endsWith('/changes'))).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/recover') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/compact') && request.method === 'POST')).toBe(false);
expect(state.captured.some((request) => (
request.path === '/api/coding/conversations/conversation-pi-first-chat/fork'
&& request.method === 'POST'
&& request.body?.sourceEntryId === 'entry-user-e2e'
))).toBe(true);
expect(state.captured.some((request) => request.path.includes('legacy-conversation-notice'))).toBe(false);
expect(state.captured.every((request) => !request.path.includes('/api/opencode/share'))).toBe(true);
} finally {
await releaseSnapshot(electronApp);
}
});
test('project consultations preserve student drafts and switch between work and chat without submitting advice', async ({launchElectronApp}) => {
const electronApp=await launchElectronApp({skipSetup:true});let page=await getStableWindow(electronApp);
const connection=await page.evaluate(async()=>({token:await window.electron.ipcRenderer.invoke('hostapi:token') as string,baseUrl:await window.electron.ipcRenderer.invoke('hostapi:base-url') as string}));
await installCodingFirstChatHost(electronApp,connection,true);await settleSnapshot(electronApp);await disableCodingEventSource(page);
try {
await page.reload();page=await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();await page.evaluate(()=>{window.location.hash='/chat';});
await expect(page.getByTestId('project-conversations')).toBeVisible();
await expect(page.getByTestId('sidebar')).toHaveCSS('width', '171px');
const rail = page.getByTestId('sidebar');
const railResizer = page.getByRole('separator', { name: '调整左侧功能栏宽度' });
const handle = (await railResizer.boundingBox())!;
await page.mouse.move(handle.x + handle.width / 2, handle.y + 80);
await page.mouse.down();
await page.mouse.move(handle.x + 180, handle.y + 80, { steps: 8 });
await page.mouse.up();
await expect(rail).toHaveCSS('width', '256px');
await expect(page.getByTestId('titlebar-sidebar-surface')).toHaveCSS('width', '256px');
await expect(page.getByTestId('coding-conversation-header-titlebar')).toHaveCSS('left', '256px');
await railResizer.focus();
await railResizer.press('Home');
await expect(rail).toHaveCSS('width', '128px');
await railResizer.press('End');
await expect(rail).toHaveCSS('width', '256px');
await page.getByTestId('titlebar-sidebar-toggle').click();
await expect(railResizer).toHaveCount(0);
await page.getByTestId('titlebar-sidebar-toggle').click();
await expect(rail).toHaveCSS('width', '256px');
await page.reload(); page = await getStableWindow(electronApp);
await expect(rail).toHaveCSS('width', '256px');
await railResizer.dblclick({ position: { x: 3, y: 80 } });
await expect(rail).toHaveCSS('width', '171px');
await expect(page.getByTestId('coding-conversation-sidebar')).toHaveCount(0);
await expect(page.getByRole('tab',{name:'操作对话',exact:true})).toHaveAttribute('aria-selected','true');
await expect(page.getByRole('button',{name:/项目共识|记一下|记入共识/})).toHaveCount(0);
const composer=page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await composer.fill('保留我的草稿');
await page.getByRole('tab',{name:'作品',exact:true}).click();
await expect(page.getByRole('tabpanel',{name:'作品',exact:true})).toBeVisible();
await expect(composer).toBeHidden();
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
await expect(composer).toHaveValue('保留我的草稿');
await page.getByRole('button',{name:'与代码智能体聊天',exact:true}).hover();
await expect(page.getByTestId('teacher-introduction')).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
await page.getByRole('button',{name:'与朋友聊天',exact:true}).hover();
await expect(page.getByTestId('teacher-introduction')).toHaveCount(1);
await expect(page.getByTestId('teacher-introduction')).toContainText('陪你听听玩家的想法,让作品更有趣。');
await expect(page.getByTestId('teacher-chat-panel')).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
const teacher=page.getByTestId('teacher-chat-panel');
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
await expect(teacher).toHaveAccessibleName('朋友咨询');
await expect(teacher.locator('header')).toHaveCount(0);
await expect(teacher.getByText('陪你听听玩家的想法,让作品更有趣。')).toHaveCount(0);
await expect(page.getByRole('tab',{name:'作品',exact:true})).toHaveAttribute('aria-selected','true');
await expect(page.getByRole('tabpanel',{name:'作品',exact:true})).toBeVisible();
await expect(composer).toBeHidden();
await expect(composer).toHaveValue('保留我的草稿');
await expect(teacher.getByText('一起理解代码')).toBeVisible();
await expect(teacher.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '与代码智能体聊天' })).toBeVisible();
await expect(page.getByRole('button', { name: '与朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#coding-consultation-dock')).toHaveCSS('width', '508px');
await expect(teacher.locator('form .consultation-hint')).toBeVisible();
await expect(teacher.locator('form .consultation-hint')).toHaveText('继续看看👀');
await expect(teacher.getByText('把你的困惑说出来就好')).toHaveCount(0);
await expect(teacher.getByText('当前项目',{exact:true})).toHaveCount(0);
await expect(teacher.getByRole('button',{name:'解释当前代码',exact:true})).toBeVisible();
expect((await readState(electronApp)).captured.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('还没说完的困惑');
await expect(teacher.locator('form .consultation-hint')).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('');
await teacher.locator('form .consultation-hint').click();
await expect(teacher.getByText('我们可以从你最近试过的地方聊起。')).toBeVisible();
await expect(teacher.getByTestId('teacher-discussion')).toHaveCount(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('还没说完的困惑');
await expect(teacher.locator('form .consultation-hint')).toHaveCount(0);
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
expect((await readState(electronApp)).captured.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(1);
await teacher.getByRole('button',{name:'怎样观察别人玩游戏?',exact:true}).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByRole('button',{name:'带回主会话草稿'})).toHaveCount(0);
await expect(teacher.getByRole('button',{name:'我去试一试'})).toHaveCount(0);
await expect(teacher.getByRole('button',{name:'复制',exact:true})).toHaveCount(0);
await expect(composer).toHaveValue('保留我的草稿');
await expect(teacher.getByRole('button',{name:'我也说不清,你带我看看',exact:true})).toHaveCount(0);
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
await expect(composer).toBeVisible();
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(teacher).toHaveCount(0);
await expect(page.getByRole('button',{name:'与朋友聊天',exact:true})).toBeFocused();
await page.getByRole('button', { name: '与代码智能体聊天' }).click();
await expect(teacher).toHaveAccessibleName('代码智能体咨询');
await expect(teacher.getByRole('textbox', { name: '向智能体提问' })).toHaveValue('');
await teacher.getByRole('textbox', { name: '向智能体提问' }).fill('另一个智能体的草稿');
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
// Moving inside the clicked entry must not schedule its introduction again.
const friendEntry = (await page.getByRole('button', { name: '与朋友聊天', exact: true }).boundingBox())!;
await page.mouse.move(friendEntry.x + friendEntry.width / 2 + 2, friendEntry.y + friendEntry.height / 2);
await page.waitForTimeout(300);
await expect(page.getByTestId('teacher-introduction')).toHaveCount(0);
const requests=(await readState(electronApp)).captured;
expect(requests.filter(item=>item.path.endsWith('/prompt')&&item.method==='POST')).toHaveLength(0);
const consultationRequests=requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST');
expect(consultationRequests).toHaveLength(2);
expect(requests.filter(item => item.path.endsWith('/agent-topics') && item.method === 'POST')).toHaveLength(0);
expect(consultationRequests.every(item => item.path === '/api/coding/projects/project-pi-first-chat/agent-conversations/teacher-algorithm/messages')).toBe(true);
await expect(teacher.getByRole('button', { name: '智能体新话题' })).toHaveCount(0);
await expect(teacher.getByRole('combobox')).toHaveCount(0);
for (const request of consultationRequests) {
expect(request.body).not.toHaveProperty('presentation');
expect(request.body).not.toHaveProperty('discussion');
}
await expect(teacher).toHaveAccessibleName('朋友咨询');
expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'继续看看👀'});
expect(consultationRequests[1].body).toMatchObject({text:'怎样观察别人玩游戏?'});
expect(consultationRequests[1].body?.intent).toBeUndefined();
await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')});
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
} finally {await releaseSnapshot(electronApp);}
});
test('incomplete consultation replies retry only on click and preserve both drafts', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(app);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(app, connection, true);
await settleSnapshot(app);
await disableCodingEventSource(page);
await app.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.nextTeacherReplyIncomplete = true;
});
try {
await page.reload();
page = await getStableWindow(app);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const mainDraft = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await mainDraft.fill('主对话草稿');
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
await draft.fill('现在项目是什么阶段?');
await teacher.getByRole('button', { name: '提问', exact: true }).click();
await expect(teacher.getByRole('status')).toHaveText('这次回复未能完整显示');
await expect(teacher.getByTestId('teacher-reply')).toHaveCount(0);
await expect(teacher.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
const sends = async () => (await readState(app)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST');
expect(await sends()).toHaveLength(1);
await draft.fill('咨询里还没写完的草稿');
await teacher.getByRole('button', { name: '重新回答' }).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(draft).toHaveValue('咨询里还没写完的草稿');
await expect(mainDraft).toHaveValue('主对话草稿');
const inputs = await sends();
expect(inputs).toHaveLength(2);
expect(inputs[1].path).toBe(inputs[0].path);
expect(inputs[1].body).toEqual({ ...inputs[0].body, requestId: expect.any(String) });
expect(inputs[1].body!.requestId).not.toBe(inputs[0].body!.requestId);
} finally { await releaseSnapshot(app); }
});
test('project consultations show reply protocol errors without exposing rejected shortcuts', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
const response = '可以先观察玩家在哪里停下来,再聊聊你想改的地方。';
const quickReplies = ['看懂规则|怎样观察玩家?', '想想作用|我想聊聊按钮。', '试试结果|怎样看出变化?', '<button>原始选项</button> **不要渲染**'];
const replyParseError = '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。';
const unparsedResponse = '\n' + JSON.stringify({ reply: response, quickReplies }, null, 2) + '\n';
await installCodingFirstChatHost(electronApp, connection, true, false, false, undefined, false, {
response, suggestedQuestions: [], replyParseError, unparsedResponse,
});
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByTestId('project-conversations')).toBeVisible();
const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await composer.fill('保留操作草稿');
await page.getByRole('button', { name: '与朋友聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
const teacherComposer = teacher.getByRole('textbox', { name: '向智能体提问' });
await teacherComposer.fill('还没说完的困惑');
await teacher.getByRole('button', { name: '提问', exact: true }).click();
const reply = teacher.locator('.consultation-messages > div').filter({ has: page.getByText(response, { exact: true }) });
await expect(reply.getByText(response, { exact: true })).toBeVisible();
await expect(reply.getByRole('status')).toHaveText('回答正文已保留,部分附加内容未能显示。');
await expect(reply.getByRole('status')).toBeVisible();
await expect(teacher.locator('.consultation-follow-up')).toHaveCount(0);
for (const question of quickReplies) {
await expect(teacher.getByRole('button', { name: question, exact: true })).toHaveCount(0);
}
const raw = reply.locator('details').filter({ has: page.getByText('查看收到的原始内容', { exact: true }) });
await expect(raw).not.toHaveAttribute('open');
await expect(raw.locator('pre')).toBeHidden();
await raw.locator('summary').click();
await expect(raw.locator('pre')).toBeVisible();
expect(await raw.locator('pre').textContent()).toBe(unparsedResponse);
await expect(raw.locator('button, strong')).toHaveCount(0);
await expect(teacherComposer).toHaveValue('');
await expect(composer).toHaveValue('保留操作草稿');
const requests = (await readState(electronApp)).captured;
expect(requests.filter(item => item.path.endsWith('/messages') && item.method === 'POST')).toHaveLength(1);
expect(requests.filter(item => item.path.endsWith('/prompt') && item.method === 'POST')).toHaveLength(0);
} finally {
await releaseSnapshot(electronApp);
}
});
test('the same delivered agent keeps separate project chats and drafts across project switches and reload', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true, false, false, undefined, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByTestId('project-conversations')).toBeVisible();
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
const panel = page.getByTestId('teacher-chat-panel');
const input = panel.getByRole('textbox', { name: '向智能体提问' });
await input.fill('天气项目的独立问题');
await panel.getByRole('button', { name: '提问', exact: true }).click();
await expect(panel.getByText('天气项目的独立问题', { exact: true })).toBeVisible();
await input.fill('天气项目还未发送');
await page.getByRole('button', { name: '进入项目 Another project', exact: true }).click();
await expect(page.getByTestId('project-conversations').getByText('Other project conversation', { exact: true })).toBeVisible();
// Switching a project may close the consultation dock; reopening reads this project's chat only.
if (!await panel.isVisible()) await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
await expect(input).toHaveValue('');
await expect(panel.getByText('天气项目的独立问题', { exact: true })).toHaveCount(0);
await input.fill('游戏项目的独立问题');
await panel.getByRole('button', { name: '提问', exact: true }).click();
await expect(panel.getByText('游戏项目的独立问题', { exact: true })).toBeVisible();
await input.fill('游戏项目还未发送');
await page.getByRole('button', { name: '进入项目 PI first chat', exact: true }).click();
if (!await panel.isVisible()) await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
await expect(input).toHaveValue('天气项目还未发送');
await expect(panel.getByText('天气项目的独立问题', { exact: true })).toBeVisible();
await expect(panel.getByText('游戏项目的独立问题', { exact: true })).toHaveCount(0);
await page.reload(); page = await getStableWindow(electronApp);
if (!await panel.isVisible()) await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
await expect(input).toHaveValue('天气项目还未发送');
await expect(panel.getByText('天气项目的独立问题', { exact: true })).toBeVisible();
await expect(panel.getByRole('combobox')).toHaveCount(0);
const sends = (await readState(electronApp)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST');
expect(sends.map(item => item.body?.projectId)).toEqual(['project-pi-first-chat', 'project-pi-other']);
expect(sends[0].path).toBe('/api/coding/projects/project-pi-first-chat/agent-conversations/teacher-code/messages');
expect(sends[1].path).toBe('/api/coding/projects/project-pi-other/agent-conversations/teacher-code/messages');
await page.screenshot({ path: test.info().outputPath('project-scoped-agent-chat.png') });
} finally { await releaseSnapshot(electronApp); }
});
test('manual agent refresh updates the catalog and retries failures without replacing the ongoing chat or drafts', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByTestId('project-conversations')).toBeVisible();
const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await composer.fill('主会话还没发出的想法');
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
const teacher = page.getByTestId('teacher-chat-panel');
await teacher.getByRole('button', { name: '解释当前代码', exact: true }).click();
await teacher.getByRole('button', { name: '提问', exact: true }).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
await draft.fill('智能体聊天里还没说完的话');
const before = (await readState(electronApp)).captured.length;
await electronApp.evaluate(() => {
const state = (globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture }).__makelorePiFirstChatE2E;
const original = state.teacherCatalog.items[0];
state.teacherCatalog.items = [
{ ...original, version: 2, definition: { ...original.definition, name: '新版代码智能体', description: '更新后的编程伙伴介绍。' } },
state.teacherCatalog.items[1],
{ teacher_id: 'teacher-science', version: 15, is_default: false,
definition: { ...original.definition, config_id: 'teacher-science', name: '科学智能体', welcome_message: '一起探索科学', description: '刚刚下发的新智能体。' } },
];
state.holdTeacherCatalog = true;
});
const refresh = page.getByRole('button', { name: '刷新智能体', exact: true });
await refresh.click();
await expect(refresh).toBeDisabled();
await expect(page.getByRole('status').filter({ hasText: '正在刷新' })).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect.poll(() => electronApp.evaluate(() => Boolean(
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.releaseTeacherCatalog,
))).toBe(true);
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.releaseTeacherCatalog?.();
});
await expect(refresh).toBeEnabled();
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await expect(page.getByRole('button', { name: '与科学智能体聊天', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '与新版代码智能体聊天', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: '与代码智能体聊天', exact: true })).toHaveCount(0);
// The contact uses its current name while history and all unsent text stay in the same chat.
await expect(teacher).toHaveAccessibleName('新版代码智能体咨询');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(composer).toHaveValue('主会话还没发出的想法');
const refreshedRequests = (await readState(electronApp)).captured.slice(before);
expect(refreshedRequests.filter((request) => request.path === '/api/coding/teacher/teachers')).toHaveLength(1);
expect(refreshedRequests.filter((request) => request.path === '/api/coding/teacher/config')).toHaveLength(1);
expect(refreshedRequests.filter((request) => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(request.path) && request.method === 'POST')).toHaveLength(0);
expect(refreshedRequests.filter((request) => /\/agent-topics(?:\/|$)/.test(request.path))).toHaveLength(0);
await page.screenshot({ path: test.info().outputPath('manual-agent-refresh.png') });
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.teacherCatalogFailure = true;
});
await refresh.click();
await expect(page.getByRole('status').filter({ hasText: '刷新失败,请重试' })).toBeVisible();
await expect(refresh).toBeEnabled();
await expect(page.getByRole('button', { name: '与科学智能体聊天', exact: true })).toBeVisible();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
.__makelorePiFirstChatE2E.teacherCatalogFailure = false;
});
await refresh.click();
await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible();
await page.getByRole('button', { name: '与科学智能体聊天', exact: true }).click();
await expect(teacher).toHaveAccessibleName('科学智能体咨询');
await expect(teacher.getByText('一起探索科学')).toBeVisible();
await page.getByRole('button', { name: '与新版代码智能体聊天', exact: true }).click();
await expect(draft).toHaveValue('智能体聊天里还没说完的话');
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(composer).toHaveValue('主会话还没发出的想法');
const finalRequests = (await readState(electronApp)).captured.slice(before);
expect(finalRequests.filter((request) => /\/(messages|teacher-check-in|prompt)$/.test(request.path) && request.method === 'POST')).toHaveLength(0);
} finally {
await electronApp.evaluate(() => {
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E?: TeacherCatalogFixture })
.__makelorePiFirstChatE2E?.releaseTeacherCatalog?.();
}).catch(() => undefined);
await releaseSnapshot(electronApp);
}
});
test('settled prompt releases the composer without a client receipt and keeps the next draft', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const composer = page.getByTestId('coding-message-composer');
const input = composer.getByRole('textbox');
const send = composer.getByRole('button', { name: '发送', exact: true });
await input.fill('hello');
await send.click();
await expect.poll(async () => (await readState(electronApp)).captured.filter((item) => item.path.endsWith('/prompt') && item.method === 'POST').length).toBe(1);
await expect(page.getByTestId('coding-file-attachment-input')).toBeEnabled();
await input.fill('我的游戏在哪里哦');
await expect(send).toBeDisabled();
await expect(page.getByText(/条消息已被本地 Agent 接收/)).toHaveCount(0);
const snapshot = {
schemaVersion: 1,
conversation: { id: 'conversation-pi-first-chat', projectId: 'project-pi-first-chat', agentId: 'builder', title: '新对话', model: { model: { accountId: 'account-e2e', modelId: 'model-a', thinkingLevel: 'off' }, modelResolution: 'resolved' } },
nodes: [{ kind: 'message', id: 'durable-hello', sourceEntryId: 'durable-hello', role: 'user', status: 'complete', blocks: [{ kind: 'text', id: 'durable-hello:text', text: 'hello', status: 'complete' }] }],
run: { status: 'idle', runId: 'run-e2e-feature', mode: 'prompt', terminalReason: 'completed', settledAt: 2000 },
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 2 },
queue: { items: [] }, context: { usedTokens: 0, contextWindow: 4096, compaction: 'idle' }, pendingInteractions: [],
};
await emitCodingEvent(page, 'snapshot', { type: 'snapshot', conversationId: snapshot.conversation.id, workerGeneration: 1, seq: 2, snapshot });
// An older run's completion is not permission to send the accepted prompt twice.
await expect(send).toBeDisabled();
await emitCodingEvent(page, 'snapshot', { type: 'snapshot', conversationId: snapshot.conversation.id, workerGeneration: 1, seq: 3, snapshot: { ...snapshot, run: { ...snapshot.run, runId: 'run-e2e-1' }, cursor: { workerGeneration: 1, seq: 3 } } });
await expect(send).toBeEnabled();
await expect(input).toHaveValue('我的游戏在哪里哦');
await expect(page.getByText('正在提交…', { exact: true })).toHaveCount(0);
await expect(page.getByTestId('coding-conversation-timeline').getByText('hello', { exact: true })).toHaveCount(1);
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/prompt') && item.method === 'POST')).toHaveLength(1);
} finally { await releaseSnapshot(electronApp); }
});
test('teacher avatar consumes the welcome bubble through close, reopen and reload without a model request', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const teacherButton = page.getByRole('button', { name: '与代码智能体聊天', exact: true });
const invitation = page.getByTestId('teacher-invitation');
const teacher = page.getByTestId('teacher-chat-panel');
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
await teacherButton.click();
await expect(teacher).toBeVisible();
await expect(teacher.getByText('一起理解代码', { exact: true })).toBeVisible();
await expect(invitation).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('teacher-avatar-consumed-welcome.png') });
await teacherButton.click();
await expect(teacher).toHaveCount(0);
await expect(invitation).toHaveCount(0);
await teacherButton.click();
await expect(teacher).toBeVisible();
await expect(invitation).toHaveCount(0);
await teacherButton.click();
await expect(teacher).toHaveCount(0);
await page.reload();
await expect(teacherButton).toBeVisible();
await expect(invitation).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
} finally { await releaseSnapshot(electronApp); }
});
test('teacher stays in the top-right header without legacy check-ins or taking over the student draft', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
await page.clock.install();
try {
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const header = page.getByTestId('coding-conversation-header-titlebar');
const companion = page.getByTestId('teacher-companion');
const teacherButton = companion.getByRole('button', { name: '与代码智能体聊天', exact: true });
await expect(teacherButton).toHaveText('代码智能体');
await expect(teacherButton).toBeVisible();
await expect(teacherButton.locator('img')).toHaveAttribute('src', /avatar-01/);
await expect(page.getByRole('checkbox', { name: '智能体偶尔来看看' })).toHaveCount(0);
await expect(page.getByTestId('teacher-companion')).toHaveCount(1);
const invitation = companion.getByTestId('teacher-invitation');
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
await expect(invitation).toContainText('一起理解代码');
await expect(invitation).toBeInViewport({ ratio: 1 });
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
await page.screenshot({ path: test.info().outputPath('teacher-entry-greeting.png') });
await expect(page.getByRole('button', { name: /举手/ })).toHaveCount(0);
const headerBounds = await header.boundingBox();
const teacherBounds = await teacherButton.boundingBox();
expect(headerBounds).not.toBeNull();
expect(teacherBounds).not.toBeNull();
expect(teacherBounds!.x).toBeGreaterThan(headerBounds!.x + headerBounds!.width / 2);
expect(teacherBounds!.y).toBeGreaterThanOrEqual(headerBounds!.y);
expect(teacherBounds!.y + teacherBounds!.height).toBeLessThanOrEqual(headerBounds!.y + headerBounds!.height + 1);
const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await composer.fill('我还在写自己的想法');
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(teacherButton).toBeVisible();
await teacherButton.hover();
await page.clock.runFor(250);
const introduction = page.getByTestId('teacher-introduction');
await expect(introduction).toContainText('一起发现代码里的小奥秘,把想法变成作品。');
await expect(introduction).toBeInViewport({ ratio: 1 });
const [introductionBounds, introductionViewportBounds] = await Promise.all([
introduction.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox(),
]);
// Native WebContentsView must leave room for the portaled introduction too.
expect(introductionBounds).not.toBeNull();
expect(introductionViewportBounds).not.toBeNull();
expect(introductionViewportBounds!.y).toBeGreaterThanOrEqual(introductionBounds!.y + introductionBounds!.height);
await page.keyboard.press('Escape');
await expect(introduction).toHaveCount(0);
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
expect((await readState(electronApp)).captured.filter(item => /\/(messages|teacher-check-in)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await composer.focus();
await page.clock.fastForward(300_000);
await expect(invitation).toContainText('一起理解代码');
await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome');
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
await expect(invitation).toBeInViewport({ ratio: 1 });
const presence = companion.getByTestId('teacher-presence');
await expect(presence.getByRole('button', { name: '打开这条智能体消息' })).toBeVisible();
await expect(presence.getByRole('button', { name: '打开这条智能体消息' }).locator('img')).toHaveAttribute('src', await teacherButton.locator('img').getAttribute('src') as string);
const [invitationBounds, companionBounds, avatarBounds, presenceBounds, speakerBounds] = await Promise.all([
invitation.boundingBox(), companion.boundingBox(), teacherButton.locator('img').boundingBox(), presence.boundingBox(), presence.getByRole('button', { name: '打开这条智能体消息' }).boundingBox(),
]);
expect(invitationBounds).not.toBeNull();
expect(companionBounds).not.toBeNull();
expect(avatarBounds).not.toBeNull();
expect(invitationBounds!.y).toBeGreaterThanOrEqual(avatarBounds!.y + avatarBounds!.height);
expect(Math.abs(presenceBounds!.x + presenceBounds!.width - companionBounds!.x - companionBounds!.width)).toBeLessThanOrEqual(2);
expect(speakerBounds!.x - invitationBounds!.x - invitationBounds!.width).toBeLessThanOrEqual(10);
await expect(composer).toBeFocused();
await expect(composer).toHaveValue('我还在写自己的想法');
await expect(page.getByRole('tab', { name: '操作对话', exact: true })).toHaveAttribute('aria-selected', 'true');
await page.screenshot({ path: test.info().outputPath('teacher-published-welcome.png') });
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(invitation).toBeInViewport({ ratio: 1 });
const [workInvitationBounds, browserViewportBounds] = await Promise.all([
presence.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox(),
]);
expect(workInvitationBounds).not.toBeNull();
expect(browserViewportBounds).not.toBeNull();
// The native view uses this rectangle and cannot be covered by a DOM z-index.
expect(browserViewportBounds!.y).toBeGreaterThanOrEqual(workInvitationBounds!.y + workInvitationBounds!.height);
const workPane = page.getByRole('tabpanel', { name: '作品', exact: true });
const workPanel = page.getByTestId('agent-browser-panel');
// Account for existing DOM toolbars instead of reserving a full bubble row.
expect((await workPanel.boundingBox())!.y - (await workPane.boundingBox())!.y).toBeLessThan(65);
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await expect(composer).toHaveValue('我还在写自己的想法');
await page.clock.fastForward(300_000);
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
await invitation.getByRole('button', { name: '和智能体聊聊' }).click();
await expect(invitation).toHaveCount(0);
await expect(presence).toHaveCount(0);
await expect(teacherButton).toBeVisible();
await expect(page.getByRole('tab', { name: '作品', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: '作品', exact: true })).toBeVisible();
const teacher = page.getByTestId('teacher-chat-panel');
await expect(teacher.getByText('一起理解代码')).toBeVisible();
await expect(teacher.getByRole('button', { name: '智能体新话题' })).toHaveCount(0);
await expect(teacher.getByRole('combobox')).toHaveCount(0);
await expect(teacher.locator('.consultation-user')).toHaveCount(0);
const teacherInput = teacher.getByRole('textbox', { name: '向智能体提问' });
await expect(teacherInput).toBeFocused();
await teacherInput.fill('我想比谁更了解自己的宠物');
await teacherInput.press('Escape');
await expect(teacher).toHaveCount(0);
await expect(teacherButton).toBeFocused();
await teacherButton.click();
await expect(teacherInput).toHaveValue('我想比谁更了解自己的宠物');
await teacherInput.fill('');
// Delivered-agent chats must not restart the legacy paid check-in timer.
await page.clock.runFor(32);
await page.clock.fastForward(300_000);
await page.clock.runFor(15_000);
await expect(invitation).toHaveCount(0);
await expect(teacher).toBeVisible();
expect((await readState(electronApp)).captured.filter((item) => item.path.endsWith('/teacher-check-in'))).toHaveLength(0);
await expect.poll(async () => (await workPanel.boundingBox())!.y - (await workPane.boundingBox())!.y).toBe(20);
await page.screenshot({ path: test.info().outputPath('teacher-open-continuous-chat.png') });
await expect(composer).toHaveValue('我还在写自己的想法');
expect((await readState(electronApp)).captured.filter((item) => /\/(messages|prompt)$/.test(item.path) && item.method === 'POST')).toHaveLength(0);
} finally { await releaseSnapshot(electronApp); }
});
test('work tab prepares the page automatically and keeps the student draft through repeated switches', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await electronApp.evaluate(() => { (globalThis as unknown as { __makelorePiFirstChatE2E: { workReady: boolean } }).__makelorePiFirstChatE2E.workReady = false; });
await disableCodingEventSource(page);
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const composer = page.getByTestId('coding-message-composer').getByRole('textbox');
await composer.fill('还没有发出的想法');
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(page.getByTestId('agent-browser-diagnostics')).toHaveCount(0);
await expect(page.getByRole('button', { name: '展开调试面板' })).toHaveCount(0);
await expect(page.getByRole('status').filter({ hasText: '正在打开你的作品' })).toBeVisible();
await page.getByRole('button', { name: '回到操作对话', exact: true }).click();
await expect(composer).toHaveValue('还没有发出的想法');
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(page.getByText('正在打开你的作品…', { exact: true })).toBeVisible();
await electronApp.evaluate(() => { (globalThis as unknown as { __makelorePiFirstChatE2E: { workReady: boolean } }).__makelorePiFirstChatE2E.workReady = true; });
await expect(page.getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/');
await expect(page.getByTestId('agent-browser-panel')).toHaveAccessibleName('作品预览');
await expect(page.getByRole('heading', { name: '作品预览', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '收起作品', exact: true })).toHaveCount(0);
await expect(page.getByTestId('agent-browser-viewport')).toBeVisible();
await expect(page.getByText('正在打开你的作品…', { exact: true })).toHaveCount(0);
// A manual address open must not permanently disable recovery checks.
await page.getByRole('textbox', { name: '网页地址' }).fill('http://127.0.0.1:4173/');
await page.getByRole('textbox', { name: '网页地址' }).press('Enter');
await expect(page.getByRole('button', { name: '刷新网页', exact: true })).toBeEnabled();
// Background sleep disposes the native view. Returning to the already-open
// Work tab must recover without another click or another prompt identity.
const opensBeforeSleep = (await readState(electronApp)).captured.filter((item) => item.path === '/api/agent-browser/ensure-work').length;
await electronApp.evaluate(({ BrowserWindow }) => {
(globalThis as unknown as { __makelorePiFirstChatE2E: { workGeneration: number } }).__makelorePiFirstChatE2E.workGeneration = 2;
BrowserWindow.getAllWindows()[0].webContents.send('agent-browser:state', {
browserId: null, projectId: 'project-pi-first-chat', projectPath: null, state: 'closed',
generation: 1, url: '', title: '', visible: false, bounds: null,
canGoBack: false, canGoForward: false, eventCursor: 0,
});
});
// Recover from the closed state itself, even if a focus event already happened.
await expect.poll(async () => (await readState(electronApp)).captured.filter((item) => item.path === '/api/agent-browser/ensure-work').length).toBeGreaterThan(opensBeforeSleep);
await expect(page.getByRole('button', { name: '刷新网页', exact: true })).toBeEnabled();
await expect(page.getByText('正在打开你的作品…', { exact: true })).toHaveCount(0);
await expect(page.getByText('开发浏览器已关闭。', { exact: true })).toHaveCount(0);
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await expect(composer).toHaveValue('还没有发出的想法');
const requests = (await readState(electronApp)).captured;
const opens = requests.filter((request) => request.path === '/api/agent-browser/ensure-work');
expect(opens.length).toBeGreaterThanOrEqual(2);
expect(new Set(opens.map((request) => request.body?.request_id)).size).toBe(1);
expect(requests.filter((request) => request.path.endsWith('/prompt'))).toHaveLength(0);
});
test('agent browser continues in the chat tab without repeatedly selecting Work', async ({ launchElectronApp }) => {
const require = createRequire(resolve('package.json'));
const { build } = createRequire(require.resolve('vite/package.json'))('esbuild') as typeof import('esbuild');
const bundlePath = test.info().outputPath('native-agent-browser.cjs');
await build({
entryPoints: [resolve('tests/e2e/fixtures/native-agent-browser.ts')],
outfile: bundlePath, bundle: true, platform: 'node', format: 'cjs', external: ['electron'],
});
let pageLoads = 0;
const server = createServer((_request, response) => {
pageLoads += 1;
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end('<html><body><h1>Browser background test</h1><script>console.log("page-ready")</script></body></html>');
});
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Missing loopback port');
const url = 'http://127.0.0.1:' + address.port + '/';
const electronApp = await launchElectronApp({ skipSetup: true });
try {
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const composer = page.getByTestId('coding-message-composer').getByRole('textbox');
await composer.fill('继续对话,不要抢走标签');
await electronApp.evaluate(({ app, BrowserWindow }, input) => {
const { NativeAgentBrowserFixture } = process.mainModule!.require(input.bundlePath) as typeof import('./fixtures/native-agent-browser');
(globalThis as typeof globalThis & { __makeloreNativeBrowserE2E?: NativeAgentBrowserFixture }).__makeloreNativeBrowserE2E =
new NativeAgentBrowserFixture(BrowserWindow.getAllWindows()[0], app.getPath('userData'), input.url);
}, { bundlePath, url });
const execute = async (input: Record<string, unknown>) => await electronApp.evaluate(async (_electron, command) => {
return await (globalThis as typeof globalThis & { __makeloreNativeBrowserE2E: NativeAgentBrowserFixture }).__makeloreNativeBrowserE2E.execute(command);
}, input);
const opened = await execute({ action: 'open', url });
expect(opened.value.visible).toBe(true);
expect(opened.showRequests).toBe(1);
await expect(page.getByRole('tab', { name: '作品', exact: true })).toHaveAttribute('aria-selected', 'true');
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await expect.poll(async () => (await execute({ action: 'status' })).value.visible).toBe(false);
await composer.focus();
const generation = opened.value.generation;
for (let attempt = 0; attempt < 3; attempt += 1) {
// Include a new URL: open may navigate, but must not reveal the native view.
const reused = await execute({ action: 'open', url: url + '?attempt=' + attempt });
expect(reused.value).toMatchObject({ browserId: opened.value.browserId, generation, visible: false });
expect(reused.showRequests).toBe(1);
const evaluated = await execute({
action: 'send_cdp', method: 'Runtime.evaluate',
params: { expression: 'document.querySelector("h1").textContent', returnByValue: true },
});
expect(evaluated.value).toMatchObject({ kind: 'inline', value: { result: { value: 'Browser background test' } } });
await expect(page.getByRole('tab', { name: '操作对话', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(composer).toHaveValue('继续对话,不要抢走标签');
}
const loadsBeforeReload = pageLoads;
await execute({ action: 'navigate', navigation: 'reload' });
await expect.poll(() => pageLoads).toBeGreaterThan(loadsBeforeReload);
const events = await execute({ action: 'read_events', after: 0 });
expect(events.value.events.some((event: { method: string }) => event.method === 'Runtime.consoleAPICalled')).toBe(true);
const screenshot = await execute({ action: 'send_cdp', method: 'Page.captureScreenshot' });
expect(screenshot.value).toMatchObject({ attachmentId: expect.any(String), mime: 'image/png' });
expect(screenshot.showRequests).toBe(1);
expect((await execute({ action: 'status' })).value.visible).toBe(false);
await expect(composer).toHaveValue('继续对话,不要抢走标签');
await expect(composer).toBeFocused();
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect.poll(async () => (await execute({ action: 'status' })).value.visible).toBe(true);
expect((await execute({ action: 'status' })).value).toMatchObject({ browserId: opened.value.browserId, generation });
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await expect.poll(async () => (await execute({ action: 'status' })).value.visible).toBe(false);
await execute({ action: 'close' });
expect((await execute({ action: 'status' })).value.state).toBe('closed');
} finally {
await electronApp.evaluate(async () => {
const mainGlobal = globalThis as typeof globalThis & { __makeloreNativeBrowserE2E?: NativeAgentBrowserFixture };
await mainGlobal.__makeloreNativeBrowserE2E?.dispose();
delete mainGlobal.__makeloreNativeBrowserE2E;
});
await new Promise<void>((done, reject) => server.close((error) => error ? reject(error) : done()));
}
});
test('work failure offers a useful recovery and retains the student draft after opening', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
await installCodingFirstChatHost(electronApp, connection, true);
await settleSnapshot(electronApp);
await electronApp.evaluate(() => { (globalThis as unknown as { __makelorePiFirstChatE2E: { workFailed: boolean } }).__makelorePiFirstChatE2E.workFailed = true; });
await disableCodingEventSource(page);
await page.reload(); page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
await composer.fill('我还没有写完的想法');
await page.getByRole('tab', { name: '作品', exact: true }).click();
await expect(page.getByRole('button', { name: '帮我检查并打开' })).toBeVisible();
await expect(page.getByText('本次处理失败。', { exact: true })).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('work-actionable-recovery.png') });
const first = (await readState(electronApp)).captured.filter((item) => item.path === '/api/agent-browser/ensure-work');
expect(first).toHaveLength(1);
await electronApp.evaluate(() => { (globalThis as unknown as { __makelorePiFirstChatE2E: { workFailed: boolean } }).__makelorePiFirstChatE2E.workFailed = false; });
await page.getByRole('button', { name: '帮我检查并打开' }).click();
await expect(page.getByRole('button', { name: '帮我检查并打开' })).toHaveCount(0);
await expect(page.getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/');
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
await expect(composer).toHaveValue('我还没有写完的想法');
const requests = (await readState(electronApp)).captured;
const opens = requests.filter((item) => item.path === '/api/agent-browser/ensure-work');
expect(new Set(opens.map((item) => item.body?.request_id)).size).toBe(2);
expect(requests.filter((item) => item.path.endsWith('/prompt'))).toHaveLength(0);
});