Files
maka/apps/desktop/e2e/streaming-remount.spec.ts
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

357 lines
14 KiB
TypeScript

/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
FAKE_HOLD_OPEN_PROMPT,
FAKE_HOLD_OPEN_REWRITE_PROMPT,
FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT,
} from '@maka/runtime/test-only/fake-backend';
import type { Locator } from '@playwright/test';
import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures';
interface SessionObservationLatchWindow extends Window {
/** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */
makaE2eLatch?: {
rejectNextSessionObservation(message: string): void;
};
}
function sessionRow(sidebar: Locator, sessionId: string): Locator {
return sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`);
}
async function steerActiveTurn(composer: Locator, text: string): Promise<void> {
// Mid-turn steering is Shift+Enter: the one Send stays Send, and the shifted
// submit hands the draft to the active Turn once.
await composer.fill(text);
await composer.press('Shift+Enter');
}
test('a failed first observation seed reconnects to the live Turn', async ({ window: page }) => {
const latchInstalled = await page.evaluate(() => {
const latch = (window as SessionObservationLatchWindow).makaE2eLatch;
if (!latch) return false;
latch.rejectNextSessionObservation('forced first observation failure');
return true;
});
expect(latchInstalled, 'the preload E2E latch is installed').toBe(true);
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_HOLD_OPEN_PROMPT);
await composer.press('Enter');
await expect(page.locator('.maka-bubble-streaming')).toContainText(
'Fake backend waiting',
);
await page.getByRole('button', { name: '停止' }).click();
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
});
test('remounting a live surface leaves accumulated output settled', async ({
window: page,
}) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false);
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_HOLD_OPEN_REWRITE_PROMPT);
await composer.press('Enter');
const accumulatedOutput = 'prefix sk-123456789012345';
const liveBubble = page.locator('.maka-bubble-streaming');
await expect(liveBubble).toContainText(accumulatedOutput);
const sidebar = page.getByRole('navigation', { name: '任务列表' });
await ensureSidebarExpanded(page);
await sidebar.getByRole('button', { name: '扩展' }).click();
await expect(page.locator('[data-module="skills"]')).toBeVisible();
await expect(liveBubble).toHaveCount(0);
// Return through the task row the product exposes. Module navigation can
// preserve either sidebar state, so restore it only when it is collapsed.
await ensureSidebarExpanded(page);
const currentTaskRow = sidebar.locator(
'[data-maka-contract="session-row"] [aria-current="page"]',
);
await expect(currentTaskRow).toHaveCount(1);
await currentTaskRow.click();
await expect(liveBubble).toHaveCount(1);
await expect(liveBubble).toContainText(accumulatedOutput);
expect((await liveBubble.textContent())?.split(accumulatedOutput)).toHaveLength(2);
expect(
await liveBubble.evaluate(
(element) =>
element
.getAnimations({ subtree: true })
.filter((animation) => animation.playState !== 'finished').length,
),
).toBe(0);
await liveBubble.evaluate((element) => {
const observed = { texts: [] as string[] };
(window as typeof window & { __makaStreamingRemountObserved?: typeof observed })
.__makaStreamingRemountObserved = observed;
new MutationObserver(() => {
observed.texts.push(element.textContent ?? '');
}).observe(element, { childList: true, characterData: true, subtree: true });
});
const steering = 'trigger rewrite after returning to this conversation';
await steerActiveTurn(composer, steering);
const finalText = 'prefix <redacted> NEW streamed after the remount';
await expect(liveBubble).toContainText(finalText);
const observed = await page.evaluate(() => (
window as typeof window & {
__makaStreamingRemountObserved?: {
texts: string[];
};
}
).__makaStreamingRemountObserved);
expect(observed?.texts.some((text) => text.includes('<redacted>') && !text.includes(finalText)))
.toBe(true);
});
test('keeps a completed reply after an interrupted turn and conversation remount', async ({
window: page,
}) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false);
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('temporary conversation');
await composer.press('Enter');
await expect(page.getByRole('log')).toContainText('Fake backend received: temporary conversation');
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
const sidebar = page.getByRole('navigation', { name: '任务列表' });
await page.getByRole('button', { name: '展开侧边栏' }).click();
await expect(page.locator('[data-agents-page]')).toHaveAttribute(
'data-sidebar-state',
'expanded',
);
const temporarySessionId = await sidebar
.locator('[data-session-id]:has([aria-current="page"])')
.getAttribute('data-session-id');
expect(temporarySessionId).toBeTruthy();
await composer.fill('draft before starting the interrupted conversation');
await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
await expect(composer).toHaveText('');
await composer.fill(FAKE_HOLD_OPEN_PROMPT);
await composer.press('Enter');
await expect(page.locator('.maka-bubble-streaming')).toContainText(
'Fake backend waiting',
);
const originalSessionId = await sidebar
.locator('[data-session-id]:has([aria-current="page"])')
.getAttribute('data-session-id');
expect(originalSessionId).toBeTruthy();
expect(originalSessionId).not.toBe(temporarySessionId);
await page.getByRole('button', { name: '停止' }).click();
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
await expect.poll(
() => page.evaluate(async (sessionId) => (
(await window.maka.sessions.list()).find((session) => session.id === sessionId)
?.runningTurnIds?.length ?? 0
), originalSessionId!),
{ timeout: 20_000 },
).toBe(0);
await composer.fill(FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT);
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({
timeout: 20_000,
});
await composer.press('Enter');
await expect(page.locator('.maka-user-message', {
hasText: FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT,
})).toBeVisible();
await expect(page.getByRole('button', { name: '停止' })).toBeVisible({
timeout: 20_000,
});
const steering = 'use the detailed response';
const completedReply = 'Large response complete.';
await steerActiveTurn(composer, steering);
await expect(page.getByRole('log')).toContainText(completedReply);
await expect(page.getByRole('button', { name: '停止' })).toHaveCount(0, {
timeout: 20_000,
});
await expect(page.locator('.maka-bubble-streaming')).toHaveCount(0, {
timeout: 20_000,
});
const temporarySessionRow = sessionRow(sidebar, temporarySessionId!);
await temporarySessionRow.click();
await expect(temporarySessionRow.locator('[aria-current="page"]')).toHaveCount(1, {
timeout: 20_000,
});
await expect(page.getByRole('log')).toContainText('Fake backend received: temporary conversation');
const originalSessionRow = sessionRow(sidebar, originalSessionId!);
await originalSessionRow.click();
await expect(originalSessionRow.locator('[aria-current="page"]')).toHaveCount(1, {
timeout: 20_000,
});
await expect(page.getByRole('log')).toContainText(completedReply);
});
test('returning to a live conversation settles output accumulated while away', async ({
window: page,
}) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false);
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_HOLD_OPEN_PROMPT);
await composer.press('Enter');
const accumulatedOutput = 'Fake backend waiting for the test to stop the Turn.';
const liveBubble = page.locator('.maka-bubble-streaming');
await expect(liveBubble).toContainText(accumulatedOutput);
const sidebar = page.getByRole('navigation', { name: '任务列表' });
await page.getByRole('button', { name: '展开侧边栏' }).click();
await expect(page.locator('[data-agents-page]')).toHaveAttribute(
'data-sidebar-state',
'expanded',
);
const originalSessionId = await sidebar.locator('[data-session-id]').first()
.getAttribute('data-session-id');
expect(originalSessionId).toBeTruthy();
await composer.fill('draft before switching conversations');
await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
await expect(composer).toHaveText('');
await composer.fill('temporary second conversation');
await composer.press('Enter');
await expect(page.getByRole('log')).toContainText(
'Fake backend received: temporary second conversation',
);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
const backgroundSteering = 'background output accumulated while away';
await page.evaluate(
({ sessionId, steering }) => new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
unsubscribe();
reject(new Error('Timed out waiting for background stream output'));
}, 10_000);
const unsubscribe = window.maka.sessions.subscribeEvents(sessionId, (event) => {
if (event.type !== 'text_delta' || !event.text.includes(steering)) return;
window.clearTimeout(timeout);
unsubscribe();
resolve();
});
// Runtime Host decides what this Message becomes; the test only needs it
// to reach the running Turn, so anything short of an accepted admission
// fails closed rather than waiting out the timeout.
void window.maka.sessions
.submitMessage(sessionId, 'current_turn', {
messageId: crypto.randomUUID(),
text: steering,
})
.then((result) => {
if (result.ok) return;
window.clearTimeout(timeout);
unsubscribe();
reject(new Error(`Runtime Host refused the steering Message: ${result.reason}`));
})
.catch((error) => {
window.clearTimeout(timeout);
unsubscribe();
reject(error);
});
}),
{ sessionId: originalSessionId!, steering: backgroundSteering },
);
await page.evaluate(() => {
// Painted frames only. A MutationObserver on `document.body` fires on every
// shell mutation during the remount, including React commit intermediates
// that never paint. Those samples made this assertion machine-load
// dependent (#3061): the same restore passed in isolation and failed when
// the rest of the file had already warmed the compositor.
const observed = {
texts: [] as string[],
maxActiveAnimations: 0,
stop() {},
};
let stopped = false;
const sample = () => {
if (stopped) return;
const bubble = document.querySelector<HTMLElement>('.maka-bubble-streaming');
if (bubble) {
const text = bubble.textContent ?? '';
if (observed.texts.at(-1) !== text) observed.texts.push(text);
observed.maxActiveAnimations = Math.max(
observed.maxActiveAnimations,
bubble
.getAnimations({ subtree: true })
.filter((animation) => animation.playState !== 'finished').length,
);
}
window.requestAnimationFrame(sample);
};
observed.stop = () => {
stopped = true;
};
(
window as typeof window & {
__makaBackgroundRestoreObserved?: typeof observed;
}
).__makaBackgroundRestoreObserved = observed;
window.requestAnimationFrame(sample);
});
await sessionRow(sidebar, originalSessionId!).click();
await liveBubble.waitFor({ state: 'attached' });
await expect(liveBubble).toContainText(backgroundSteering);
expect((await liveBubble.textContent())?.split(accumulatedOutput)).toHaveLength(2);
// Playwright's toContainText is a DOM check. Stop on the next animation
// frame so the already-queued sample() records that settled paint first.
const backgroundRestoreObserved = await page.evaluate(
() =>
new Promise<{
texts: string[];
maxActiveAnimations: number;
} | undefined>((resolve) => {
window.requestAnimationFrame(() => {
const observed = (
window as typeof window & {
__makaBackgroundRestoreObserved?: {
texts: string[];
maxActiveAnimations: number;
stop(): void;
};
}
).__makaBackgroundRestoreObserved;
observed?.stop();
resolve(observed);
});
}),
);
expect(
backgroundRestoreObserved?.texts.some((text) => text.includes(backgroundSteering)),
).toBe(true);
expect(backgroundRestoreObserved?.texts.some((text) =>
text.includes('background output') && !text.includes(backgroundSteering)
)).toBe(false);
expect(backgroundRestoreObserved?.maxActiveAnimations).toBe(0);
});