1533 lines
66 KiB
TypeScript
1533 lines
66 KiB
TypeScript
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<string, unknown>;
|
|
byteLength?: number;
|
|
contentType?: string;
|
|
at: number;
|
|
};
|
|
|
|
type HostConnection = {
|
|
baseUrl: string;
|
|
token: string;
|
|
};
|
|
|
|
async function disableCodingEventSource(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
const sources = new Set<LocalEventSource>();
|
|
type TrackedWindow = Window & {
|
|
__makeloreCodingEventSources?: LocalEventSource[];
|
|
};
|
|
const trackedWindow = window as TrackedWindow;
|
|
trackedWindow.__makeloreCodingEventSources = [];
|
|
class LocalEventSource extends EventTarget {
|
|
static readonly CONNECTING = 0;
|
|
static readonly OPEN = 1;
|
|
static readonly CLOSED = 2;
|
|
readonly CONNECTING = LocalEventSource.CONNECTING;
|
|
readonly OPEN = LocalEventSource.OPEN;
|
|
readonly CLOSED = LocalEventSource.CLOSED;
|
|
readonly url: string;
|
|
readonly withCredentials = false;
|
|
readyState = LocalEventSource.OPEN;
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
|
|
constructor(url: string) {
|
|
super();
|
|
this.url = url;
|
|
sources.add(this);
|
|
trackedWindow.__makeloreCodingEventSources?.push(this);
|
|
queueMicrotask(() => this.onopen?.(new Event('open')));
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = LocalEventSource.CLOSED;
|
|
sources.delete(this);
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(window, 'EventSource', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: LocalEventSource,
|
|
});
|
|
Object.defineProperty(window, '__makeloreEmitCodingEvent', {
|
|
configurable: true,
|
|
value(type: string, payload: unknown) {
|
|
for (const source of sources) {
|
|
source.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
|
|
}
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
async function emitCodingEvent(page: Page, type: string, payload: unknown): Promise<void> {
|
|
await page.evaluate(({ eventType, eventPayload }) => {
|
|
const testWindow = window as typeof window & {
|
|
__makeloreEmitCodingEvent?: (type: string, payload: unknown) => void;
|
|
};
|
|
testWindow.__makeloreEmitCodingEvent?.(eventType, eventPayload);
|
|
}, { eventType: type, eventPayload: payload });
|
|
}
|
|
|
|
async function installCodingFirstChatHost(
|
|
electronApp: ElectronApplication,
|
|
hostConnection: HostConnection,
|
|
featureComplete = false,
|
|
managedCapabilities = false,
|
|
): Promise<void> {
|
|
await electronApp.evaluate(async (_, payload) => {
|
|
const { connection, featureComplete, managedCapabilities } = 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 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<string, unknown> = {}) => ({
|
|
browserId: null,
|
|
projectId: project.id,
|
|
projectPath: null,
|
|
state: 'closed',
|
|
generation: 0,
|
|
url: '',
|
|
title: '',
|
|
visible: false,
|
|
bounds: null,
|
|
canGoBack: false,
|
|
canGoForward: false,
|
|
eventCursor: 0,
|
|
...overrides,
|
|
});
|
|
|
|
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/provider-accounts') {
|
|
return respond([{
|
|
id: managedCapabilities ? 'niancode-user-models' : 'account-e2e',
|
|
vendorId: 'custom',
|
|
label: 'E2E account',
|
|
authMode: 'api_key',
|
|
model: 'model-a',
|
|
fallbackModels: ['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]
|
|
: [],
|
|
});
|
|
}
|
|
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 (!featureComplete) {
|
|
state.snapshotPending = true;
|
|
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
|
|
state.snapshotPending = false;
|
|
}
|
|
const posted = state.captured.find((request) => (
|
|
request.path === `/api/coding/conversations/${conversation.id}/prompt` && request.method === 'POST'
|
|
));
|
|
if (!featureComplete && posted?.body) {
|
|
const refs = posted.body.attachments as Array<{ attachmentId: string }>;
|
|
return respond({ snapshot: {
|
|
...snapshot,
|
|
nodes: [{ kind: 'message', id: 'entry:sent-image', sourceEntryId: 'sent-image',
|
|
role: 'user', status: 'complete', blocks: [
|
|
{ kind: 'text', id: 'entry:sent-image:content:0', text: posted.body.text, status: 'complete' },
|
|
...refs.map(({ attachmentId }, index) => ({
|
|
kind: 'image', id: `entry:sent-image:image:${index}`, attachmentId, mime: 'image/png',
|
|
})),
|
|
] }],
|
|
cursor: { workerGeneration: 1, seq: 1 },
|
|
} });
|
|
}
|
|
const currentSnapshot = state.snapshotSettled
|
|
? {
|
|
...snapshot,
|
|
run: {
|
|
status: 'idle',
|
|
runId: 'run-e2e-feature',
|
|
mode: 'prompt',
|
|
startedAt: 1_000,
|
|
settledAt: 2_000,
|
|
terminalReason: 'aborted',
|
|
},
|
|
queue: { items: [] },
|
|
pendingInteractions: [],
|
|
worker: { status: 'stopped', generation: 1 },
|
|
cursor: { workerGeneration: 1, seq: 1 },
|
|
}
|
|
: snapshot;
|
|
return respond({
|
|
snapshot: state.abortRequested
|
|
? {
|
|
...currentSnapshot,
|
|
nodes: currentSnapshot.nodes.map((node) => {
|
|
if (node.kind === 'message' && node.role === 'assistant') {
|
|
return {
|
|
...node,
|
|
status: 'aborted',
|
|
blocks: node.blocks.map((block) => ({ ...block, status: 'complete' })),
|
|
};
|
|
}
|
|
if (node.kind === 'subagent') {
|
|
return {
|
|
...node,
|
|
details: {
|
|
...node.details,
|
|
tasks: node.details.tasks.map((task) => (
|
|
task.status === 'running' ? { ...task, status: 'aborted' } : task
|
|
)),
|
|
},
|
|
};
|
|
}
|
|
return node;
|
|
}),
|
|
run: {
|
|
status: 'idle',
|
|
runId: 'run-e2e-feature',
|
|
mode: 'prompt',
|
|
settledAt: 12_000,
|
|
terminalReason: 'aborted',
|
|
},
|
|
queue: { items: [] },
|
|
pendingInteractions: [],
|
|
cursor: { workerGeneration: 1, seq: 1 },
|
|
}
|
|
: state.interactionAnswered
|
|
? { ...currentSnapshot, pendingInteractions: [] }
|
|
: currentSnapshot,
|
|
});
|
|
}
|
|
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
|
|
return respond({ snapshot: secondSnapshot });
|
|
}
|
|
if (path === `/api/coding/conversations/${historyConversation.id}/snapshot`) {
|
|
return respond({ snapshot: historySnapshot });
|
|
}
|
|
if (path === `/api/coding/conversations/${conversation.id}/prompt` && method === 'POST') {
|
|
return respond({
|
|
acceptance: {
|
|
accepted: true,
|
|
conversationId: conversation.id,
|
|
clientRequestId: body?.clientRequestId,
|
|
runId: 'run-e2e-1',
|
|
mode: body?.mode ?? 'prompt',
|
|
},
|
|
}, 202);
|
|
}
|
|
if (/^\/api\/coding\/conversations\/[^/]+\/abort$/.test(path) && method === 'POST') {
|
|
state.abortRequested = true;
|
|
return respond({});
|
|
}
|
|
if (/^\/api\/coding\/conversations\/[^/]+\/(compact|recover)$/.test(path) && method === 'POST') {
|
|
return respond({});
|
|
}
|
|
if (/^\/api\/coding\/conversations\/[^/]+\/model$/.test(path) && method === 'POST') {
|
|
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') {
|
|
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 });
|
|
}
|
|
|
|
async function readState(electronApp: ElectronApplication): Promise<{
|
|
captured: CapturedRequest[];
|
|
snapshotPending: boolean;
|
|
}> {
|
|
return await electronApp.evaluate(() => {
|
|
const mainGlobal = globalThis as typeof globalThis & {
|
|
__makelorePiFirstChatE2E?: {
|
|
captured: CapturedRequest[];
|
|
snapshotPending: boolean;
|
|
};
|
|
};
|
|
return structuredClone({
|
|
captured: mainGlobal.__makelorePiFirstChatE2E?.captured ?? [],
|
|
snapshotPending: mainGlobal.__makelorePiFirstChatE2E?.snapshotPending ?? false,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function releaseSnapshot(electronApp: ElectronApplication): Promise<void> {
|
|
await electronApp.evaluate(() => {
|
|
const mainGlobal = globalThis as typeof globalThis & {
|
|
__makelorePiFirstChatE2E?: { releaseSnapshot: (() => void) | null };
|
|
};
|
|
mainGlobal.__makelorePiFirstChatE2E?.releaseSnapshot?.();
|
|
});
|
|
}
|
|
|
|
async function settleSnapshot(electronApp: ElectronApplication): Promise<void> {
|
|
await electronApp.evaluate(() => {
|
|
const mainGlobal = globalThis as typeof globalThis & {
|
|
__makelorePiFirstChatE2E?: { snapshotSettled: boolean };
|
|
};
|
|
if (mainGlobal.__makelorePiFirstChatE2E) {
|
|
mainGlobal.__makelorePiFirstChatE2E.snapshotSettled = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
test('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);
|
|
}
|
|
});
|