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(() => { 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; queueMicrotask(() => this.onopen?.(new Event('open'))); } close(): void { this.readyState = LocalEventSource.CLOSED; } } Object.defineProperty(window, 'EventSource', { configurable: true, writable: true, value: LocalEventSource, }); }); } async function installCodingFirstChatHost( electronApp: ElectronApplication, hostConnection: HostConnection, featureComplete = false, ): Promise { await electronApp.evaluate(async (_, payload) => { const { connection, featureComplete } = payload; const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); type MainState = { captured: CapturedRequest[]; conversationCreated: boolean; releaseSnapshot: (() => void) | null; snapshotPending: boolean; }; const mainGlobal = globalThis as typeof globalThis & { __makelorePiFirstChatE2E?: MainState; }; const state: MainState = { captured: [], conversationCreated: false, releaseSnapshot: null, snapshotPending: false, }; mainGlobal.__makelorePiFirstChatE2E = state; const now = '2026-08-24T00:00:00.000Z'; const project = { id: 'project-pi-first-chat', name: 'PI first chat', createdAt: now, updatedAt: now, lastOpenedAt: now, }; const configuredModel = { accountId: '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', legacyConversationNotice: featureComplete ? 'pending' : 'none', 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 snapshot = { schemaVersion: 1, conversation: { id: conversation.id, projectId: project.id, agentId: agent.id, title: conversation.title, model: { model: configuredModel, modelResolution: 'resolved' }, }, nodes: featureComplete ? [ { 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: 'Browser ready', status: 'complete' }], details: { schema: 'agent-browser.v1', action: 'status' }, }, ] : [], 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: 'confirm', title: '允许继续?', message: '确认当前实现方向。', 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, }, }, nodes: [], run: { status: 'idle' }, queue: { items: [] }, pendingInteractions: [], worker: { status: 'error', generation: 1, error: { code: 'CODING_RUNTIME_START_FAILED', message: 'Worker stopped', recoverable: true }, }, }; const respond = (json: unknown, status = 200) => ({ ok: true, data: { status, ok: status >= 200 && status < 300, json }, }); 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: 'account-e2e', vendorId: 'custom', label: 'E2E account', authMode: 'api_key', model: 'model-a', fallbackModels: ['model-b'], 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/legacy-conversation-notice/acknowledge' && method === 'POST') { return respond({ snapshot: { project, config: { ...config, legacyConversationNotice: 'acknowledged' }, knowledgeFiles: [], }, }); } if (path === `/api/coding/projects/conversations?projectId=${project.id}`) { return respond({ conversations: featureComplete ? [conversation, secondConversation] : state.conversationCreated ? [conversation] : [], }); } if (path === '/api/coding/projects/conversations' && method === 'POST') { state.conversationCreated = true; return respond({ conversation }, 201); } if (path === `/api/coding/conversations/${conversation.id}/snapshot`) { if (!featureComplete) { state.snapshotPending = true; await new Promise((resolve) => { state.releaseSnapshot = resolve; }); state.snapshotPending = false; } return respond({ snapshot }); } if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) { return respond({ snapshot: secondSnapshot }); } 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: 'prompt', }, }, 202); } if (/^\/api\/coding\/conversations\/[^/]+\/(abort|compact|recover)$/.test(path) && method === 'POST') { return respond({}); } if (/^\/api\/coding\/conversations\/[^/]+\/model$/.test(path) && method === 'POST') { 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') { return respond({ conversation: { ...conversation, ...(body?.title ? { title: body.title } : {}), unread: body?.unread === true, archivedAt: body?.archived === true ? now : null } }); } if (/^\/api\/coding\/interactions\/[^/]+\/respond$/.test(path) && method === 'POST') { 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 }); } 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?.(); }); } 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 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'); } finally { await releaseSnapshot(electronApp); } }); test('PI feature UI isolates Conversations and exposes queue, interaction, model, subagent, and project tools', 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-header')).toContainText('生成中'); await expect(page.getByTestId('coding-conversation-header')).toContainText('队列 1'); await expect(page.getByText('并行子任务')).toBeVisible(); await expect(page.getByText('系统会自动重试。')).toBeVisible(); await page.locator('[data-node-id="browser-tool-e2e"] > summary').click(); await expect(page.getByText('浏览器操作 · status')).toBeVisible(); await expect(page.getByText('允许继续?')).toBeVisible(); await expect(page.getByTestId('legacy-conversation-notice')).toBeVisible(); await page.getByTestId('legacy-conversation-notice').getByRole('button', { name: '知道了' }).click(); await expect(page.getByTestId('legacy-conversation-notice')).toHaveCount(0); await page.getByRole('button', { name: '确认', exact: true }).click(); const mode = page.getByRole('combobox', { name: '消息发送方式' }); await mode.selectOption('follow-up'); await expect(mode).toHaveValue('follow-up'); const model = page.getByRole('combobox', { name: '当前对话模型' }); await model.selectOption(JSON.stringify(['account-e2e', 'model-b'])); await page.getByRole('button', { name: '中止' }).click(); await page.getByRole('button', { name: '打开编程工具' }).click(); await expect(page.getByRole('dialog')).toContainText('编程工具'); await page.getByRole('tab', { name: '命令' }).click(); await page.getByRole('button', { name: /\/review/ }).click(); await expect(page.getByRole('textbox')).toHaveValue('/review '); await page.getByRole('button', { name: 'Second Conversation' }).first().click(); await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation'); await expect(page.getByRole('combobox', { name: '当前对话模型' })).toHaveValue( JSON.stringify(['account-e2e', 'model-b']), ); await expect(page.getByRole('textbox')).toHaveValue(''); await page.getByRole('button', { name: '恢复' }).click(); await page.getByRole('button', { name: '整理上下文' }).click(); await page.getByRole('button', { name: '打开编程工具' }).click(); await page.getByRole('tab', { name: '技能' }).click(); await expect(page.getByRole('dialog')).toContainText('Research'); 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'))).toBe(true); expect(state.captured.some((request) => request.path.endsWith('/commands'))).toBe(true); 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(true); expect(state.captured.some((request) => request.path.includes('legacy-conversation-notice'))).toBe(true); expect(state.captured.every((request) => !request.path.includes('/api/opencode/share'))).toBe(true); } finally { await releaseSnapshot(electronApp); } });