feat: implement PI core chat timeline
This commit is contained in:
286
tests/e2e/pi-coding-first-chat.spec.ts
Normal file
286
tests/e2e/pi-coding-first-chat.spec.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type { ElectronApplication, Page } from 'playwright-core';
|
||||
import { expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
type CapturedRequest = {
|
||||
path: string;
|
||||
method: string;
|
||||
body?: Record<string, unknown>;
|
||||
at: number;
|
||||
};
|
||||
|
||||
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): Promise<void> {
|
||||
await electronApp.evaluate(async () => {
|
||||
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; 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;
|
||||
state.captured.push({ path, method, ...(body ? { body } : {}), at: Date.now() });
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
await installCodingFirstChatHost(electronApp);
|
||||
let page = await getStableWindow(electronApp);
|
||||
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);
|
||||
|
||||
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.getByText('1 条消息已被本地 Agent 接收。')).toBeVisible();
|
||||
} finally {
|
||||
await releaseSnapshot(electronApp);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user