merge: integrate remote main
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-03 17:19:21 +08:00
58 changed files with 3989 additions and 199 deletions

View File

@@ -18,6 +18,12 @@ type HostConnection = {
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;
@@ -35,11 +41,14 @@ async function disableCodingEventSource(page: Page): Promise<void> {
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);
}
}
@@ -48,9 +57,26 @@ async function disableCodingEventSource(page: Page): Promise<void> {
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,
@@ -66,6 +92,7 @@ async function installCodingFirstChatHost(
abortRequested: boolean;
releaseSnapshot: (() => void) | null;
snapshotPending: boolean;
snapshotSettled: boolean;
};
const mainGlobal = globalThis as typeof globalThis & {
__makelorePiFirstChatE2E?: MainState;
@@ -77,6 +104,7 @@ async function installCodingFirstChatHost(
abortRequested: false,
releaseSnapshot: null,
snapshotPending: false,
snapshotSettled: false,
};
mainGlobal.__makelorePiFirstChatE2E = state;
const now = '2026-08-24T00:00:00.000Z';
@@ -145,6 +173,12 @@ async function installCodingFirstChatHost(
: 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: {
@@ -217,7 +251,7 @@ async function installCodingFirstChatHost(
output: [{
kind: 'text',
id: 'browser-output-e2e',
text: `${'Checking browser runtime state and the latest navigation checkpoint. '.repeat(5)}Browser ready`,
text: `${'Checking browser runtime state and the latest navigation checkpoint. '.repeat(8)}Browser ready`,
status: 'complete',
}],
details: { schema: 'agent-browser.v1', action: 'status' },
@@ -355,6 +389,39 @@ async function installCodingFirstChatHost(
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 },
@@ -455,7 +522,7 @@ async function installCodingFirstChatHost(
if (path === `/api/coding/projects/conversations?projectId=${project.id}`) {
return respond({
conversations: featureComplete
? [conversation, secondConversation]
? [conversation, secondConversation, historyConversation]
: state.conversationCreated
? [conversation]
: [],
@@ -471,11 +538,28 @@ async function installCodingFirstChatHost(
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
state.snapshotPending = false;
}
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
? {
...snapshot,
nodes: snapshot.nodes.map((node) => {
...currentSnapshot,
nodes: currentSnapshot.nodes.map((node) => {
if (node.kind === 'message' && node.role === 'assistant') {
return {
...node,
@@ -508,13 +592,16 @@ async function installCodingFirstChatHost(
cursor: { workerGeneration: 1, seq: 1 },
}
: state.interactionAnswered
? { ...snapshot, pendingInteractions: [] }
: snapshot,
? { ...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: {
@@ -590,6 +677,17 @@ async function releaseSnapshot(electronApp: ElectronApplication): Promise<void>
});
}
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('first PI Conversation is editable under 500 ms and submits before runtime Snapshot', async ({
launchElectronApp,
}) => {
@@ -684,6 +782,217 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
}
});
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('PI feature UI isolates Conversations and exposes queue, interaction, model, and subagent state', async ({
launchElectronApp,
}) => {
@@ -736,13 +1045,13 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
await expect(activeProcess).toHaveAttribute('open', '');
await expect(activeProcess.locator('summary').first()).toContainText('处理中');
const activeThinking = page.getByLabel('思考过程');
await expect(activeThinking).toContainText('Preparing the smallest safe next step.');
await expect(activeThinking).not.toContainText('Inspecting the project before responding.');
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-tail', 'true');
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');
@@ -774,6 +1083,7 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
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');
@@ -792,9 +1102,11 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
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('Browser ready');
await expect(compactTool.getByTestId('tool-progress-preview'))
.toHaveAttribute('data-progress-tail', 'true');
.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');
@@ -806,10 +1118,7 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
scrollWidth: element.scrollWidth,
}));
expect(toolProgressScroll.scrollWidth).toBeGreaterThan(toolProgressScroll.clientWidth);
expect(Math.abs(
toolProgressScroll.scrollLeft
- (toolProgressScroll.scrollWidth - toolProgressScroll.clientWidth),
)).toBeLessThanOrEqual(2);
expect(Math.abs(toolProgressScroll.scrollLeft)).toBeLessThanOrEqual(2);
const compactToolBounds = await compactToolSummary.boundingBox();
expect(compactToolBounds).not.toBeNull();
expect(compactToolBounds!.height).toBeLessThanOrEqual(33);
@@ -941,6 +1250,29 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
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);