Files
makelore/tests/e2e/pi-coding-first-chat.spec.ts

388 lines
13 KiB
TypeScript

import type { ElectronApplication, Page } from 'playwright-core';
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;
};
async function disableCodingEventSource(page: Page): Promise<void> {
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,
): Promise<void> {
await electronApp.evaluate(async (_, connection) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = {
captured: CapturedRequest[];
releaseSnapshot: (() => void) | null;
snapshotPending: boolean;
};
const mainGlobal = globalThis as typeof globalThis & {
__makelorePiFirstChatE2E?: MainState;
};
const state: MainState = {
captured: [],
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 legacyProject = { ...project, path: 'D:/e2e/pi-first-chat' };
const agent = {
id: 'builder',
avatarId: 'avatar-01',
roleName: '实现者',
name: 'Builder',
builtIn: false,
enabled: true,
model: null,
modelResolution: 'required',
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: 'none',
createdAt: now,
updatedAt: now,
};
const legacyConfig = {
schemaVersion: 1,
projectType: 'custom',
initialized: true,
defaultModel: null,
agents: [{ ...agent, model: null }],
knowledgeDirectory: 'knowledge',
createdAt: now,
updatedAt: now,
};
const conversation = {
id: 'conversation-pi-first-chat',
agentId: agent.id,
title: '新对话',
model: null,
modelResolution: 'required',
archivedAt: null,
unread: false,
createdAt: now,
updatedAt: now,
};
const snapshot = {
schemaVersion: 1,
conversation: {
id: conversation.id,
projectId: project.id,
agentId: agent.id,
title: conversation.title,
model: { model: null, modelResolution: 'required' },
},
nodes: [],
run: { status: 'idle' },
queue: { items: [] },
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
pendingInteractions: [],
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 0 },
};
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<string, string>;
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<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 === '/api/coding/projects') {
return respond({ projects: [project], activeProjectId: project.id });
}
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: [] });
}
if (path === '/api/coding/projects/conversations' && method === 'POST') {
return respond({ conversation }, 201);
}
if (path === `/api/coding/conversations/${conversation.id}/snapshot`) {
state.snapshotPending = true;
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
state.snapshotPending = false;
return respond({ snapshot });
}
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 (path.startsWith('/api/opencode/status')) {
return respond({ state: 'stopped', port: null, url: null });
}
if (path === '/api/opencode/projects' || path.startsWith('/api/opencode/projects?')) {
return respond({ projects: [legacyProject], activeProject: legacyProject });
}
if (path === '/api/opencode/projects/active') {
return respond({ projects: [legacyProject], activeProject: legacyProject });
}
if (path.startsWith('/api/opencode/projects/config?')) {
return respond({ status: 'valid', config: legacyConfig, knowledgeFiles: [] });
}
if (path.startsWith('/api/opencode/projects/conversations?')) {
return respond({
state: { schemaVersion: 1, sessions: [], updatedAt: now },
});
}
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
});
}, hostConnection);
}
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?.();
});
}
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 = '/opencode-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 pixelPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
await page.getByTestId('coding-file-attachment-input').setInputFiles({
name: 'pixel.png',
mimeType: 'image/png',
buffer: pixelPng,
});
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: pixelPng.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(state.captured)).not.toContain(pixelPng.toString('base64'));
} finally {
await releaseSnapshot(electronApp);
}
});