import type { ElectronApplication, Page } from 'playwright-core'; import { appendFile } from 'node:fs/promises'; import { expect, getStableWindow, test } from './fixtures/electron'; type CapturedRequest = { path: string; method: string; body?: Record; byteLength?: number; contentType?: string; at: number; }; type HostConnection = { baseUrl: string; token: string; }; async function disableCodingEventSource(page: Page): Promise { await page.addInitScript(() => { const sources = new Set(); 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 { 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, ): Promise { await electronApp.evaluate(async (_, payload) => { const { connection, featureComplete, managedCapabilities, removedModel } = payload; const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); type MainState = { captured: CapturedRequest[]; conversationCreated: boolean; interactionAnswered: boolean; abortRequested: boolean; releaseSnapshot: (() => void) | null; snapshotPending: boolean; snapshotSettled: boolean; }; const mainGlobal = globalThis as typeof globalThis & { __makelorePiFirstChatE2E?: MainState; }; const state: MainState = { captured: [], conversationCreated: false, interactionAnswered: false, abortRequested: false, releaseSnapshot: null, snapshotPending: false, snapshotSettled: false, }; mainGlobal.__makelorePiFirstChatE2E = state; const now = '2026-08-24T00:00:00.000Z'; const metadata = new Map>(); const project = { id: 'project-pi-first-chat', name: 'PI first chat', createdAt: now, updatedAt: now, lastOpenedAt: now, }; 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 = {}) => ({ browserId: null, projectId: project.id, projectPath: null, state: 'closed', generation: 0, url: '', title: '', visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0, ...overrides, }); ipcMain.removeHandler('hostapi:fetch'); ipcMain.handle('hostapi:fetch', async ( _event, request: { path?: string; method?: string; headers?: Record; body?: unknown; }, ) => { const path = request.path ?? ''; const method = request.method ?? 'GET'; const body = typeof request.body === 'string' && request.body ? JSON.parse(request.body) as Record : 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 === '/api/coding/projects') { return respond({ projects: [project], activeProjectId: project.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/state?')) { return respond({ success: true, browser: browserSnapshot() }); } if (path === '/api/agent-browser/open' && 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: 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((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 }); } 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 { await electronApp.evaluate(() => { const mainGlobal = globalThis as typeof globalThis & { __makelorePiFirstChatE2E?: { releaseSnapshot: (() => void) | null }; }; mainGlobal.__makelorePiFirstChatE2E?.releaseSnapshot?.(); }); } async function settleSnapshot(electronApp: ElectronApplication): Promise { 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('coding-conversation-sidebar'); 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: '已归档(1)' }).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.getByText('最近对话', { exact: true })).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'; }); const builderButton = page.getByRole('button', { name: 'Builder', exact: true }); await expect(builderButton).toHaveAttribute('aria-expanded', 'true'); const builderConversations = page.getByRole('group', { name: 'Builder 的对话' }); await expect(builderConversations).toBeVisible(); await expect(builderConversations.getByRole('button', { name: '新对话', exact: true })).toBeVisible(); 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('1 条消息已被本地 Agent 接收。')).toBeVisible(); 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: 'Builder 的对话' }); 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')).toHaveAttribute('data-state', 'closed'); 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('coding-conversation-sidebar')).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: 'Builder 的对话' }); 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); } });