Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 12 additions & 21 deletions apps/desktop/e2e/native-transcript-perf.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ interface StressSample extends BrowserCounters {
firstTurnId: string | null;
lastTurnId: string | null;
mountedTurns: number;
positionWindow: number;
gapRows: number;
}

interface StressSweep {
Expand Down Expand Up @@ -203,26 +205,6 @@ async function returnToLatest(page: Page): Promise<void> {
else await page.locator('.maka-prompt-rail-tick').last().click({ force: true });
}

async function traverseFullHistoryAndReturnToTail(page: Page): Promise<void> {
for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) {
const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id');
if (firstBefore?.endsWith('-1')) break;
await page.evaluate((selector) => {
const root = document.querySelector<HTMLElement>(selector);
if (!root) throw new Error('the chat scroll container is missing');
root.scrollTop = 0;
root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }));
}, SCROLLER);
await expect.poll(async () =>
page.locator('[data-turn-id]').first().getAttribute('data-turn-id'),
).not.toBe(firstBefore);
}
await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1);
await returnToLatest(page);
await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
.toHaveCount(1);
}

async function measureSessionSwitch(page: Page): Promise<number> {
await ensureSidebarExpanded(page);
const rows = page.locator('.maka-session-row');
Expand Down Expand Up @@ -273,14 +255,14 @@ performanceTest('warm native transcript scroll metrics', async ({ promptRailWind
const cdp = await page.context().newCDPSession(page);
await cdp.send('Performance.enable');
await prepareFrameRecorder(page);
await traverseFullHistoryAndReturnToTail(page);
await moveToTail(page);

// Warm Chromium, React and the transcript path in both directions before sampling.
await scrollGesture(page, -600, 120);
await scrollGesture(page, 600, 120);
await moveToTail(page);
await collectGarbage(cdp);
await page.waitForTimeout(100);
await page.evaluate(() => new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
));
Expand Down Expand Up @@ -341,6 +323,9 @@ stressTest('600+ Turn repeated paging keeps the active range on a memory plateau
firstTurnId: await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'),
lastTurnId: await page.locator('[data-turn-id]').last().getAttribute('data-turn-id'),
mountedTurns: await page.locator('[data-turn-id]').count(),
positionWindow: Number(await page.locator('[data-position-source-count]').first()
.getAttribute('data-position-source-count')),
gapRows: await page.locator('.maka-transcript-gap-row').count(),
...counters,
};
return sample;
Expand Down Expand Up @@ -422,10 +407,14 @@ stressTest('600+ Turn repeated paging keeps the active range on a memory plateau

const allSamples = sweeps.flatMap((sweep) => sweep.samples);
const mountedMax = Math.max(...allSamples.map((sample) => sample.mountedTurns));
const positionWindowMax = Math.max(...allSamples.map((sample) => sample.positionWindow));
const gapRowsMax = Math.max(...allSamples.map((sample) => sample.gapRows));
console.log(`TRANSCRIPT_STRESS ${JSON.stringify({
fixtureTurns: PROMPT_RAIL_PROMPT_COUNT,
sweeps,
mountedMax,
positionWindowMax,
gapRowsMax,
nodeMin: Math.min(...allSamples.map((sample) => sample.nodes)),
nodeMax: Math.max(...allSamples.map((sample) => sample.nodes)),
nodeMaxSecondToFirstRatio,
Expand All @@ -434,6 +423,8 @@ stressTest('600+ Turn repeated paging keeps the active range on a memory plateau
expect(mountedMax).toBeLessThanOrEqual(
transcriptContract.DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS,
);
expect(positionWindowMax).toBeLessThanOrEqual(128);
expect(gapRowsMax).toBeLessThanOrEqual(2);
expect(nodeMaxSecondToFirstRatio).toBeLessThanOrEqual(
1 + SECONDARY_RESOURCE_GROWTH_RATIO,
);
Expand Down
119 changes: 116 additions & 3 deletions apps/desktop/e2e/partial-history-notice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
*/

import type { Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { FAKE_STREAM_UNTIL_STEERING_PROMPT } from '@maka/runtime/test-only/fake-backend';
import { COMPOSER_INPUT, expect, test } from './fixtures';

const NOTICE = '.maka-transcript-history-controls';

Expand Down Expand Up @@ -62,18 +63,90 @@ test('partial history is a quiet reading-column control with neutral rail ticks'
await page.setViewportSize({ width: 1_400, height: 800 });
await expect(page.locator(NOTICE)).toHaveCount(0);

const composer = page.locator(COMPOSER_INPUT);
const settlingPrompt = FAKE_STREAM_UNTIL_STEERING_PROMPT;
const settlingSteering = 'finish the active overlay';
await composer.fill(settlingPrompt);
await composer.press('Enter');
const liveBubble = page.locator('.maka-bubble-streaming');
await expect(liveBubble).toBeVisible();
const activeTurn = liveBubble.locator('xpath=ancestor::*[@data-transcript-turn-id][1]');
const activeTurnId = await activeTurn.getAttribute('data-transcript-turn-id');
if (!activeTurnId) throw new Error('the live Turn is missing its transcript identity');
await expect(page.locator('.maka-chat-message-list')).toHaveAttribute(
'data-position-source-count',
'9',
);
await expect(activeTurn.locator('[data-turn-status="running"]')).toHaveCount(1);

const firstPrompt = page.locator(
'.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]',
);
await expect(firstPrompt).toBeVisible();
await firstPrompt.click();

const logicalRows = () => page.locator(
'.maka-chat-message-list .maka-transcript-turn, .maka-chat-message-list .maka-transcript-gap-row',
).evaluateAll((rows) => rows.map((row) =>
row.getAttribute('data-transcript-turn-id') ?? `gap:${row.getAttribute('data-transcript-gap')}`));
await expect.poll(async () => {
const rows = await logicalRows();
const activeIndex = rows.indexOf(activeTurnId);
return {
activeAtEnd: activeIndex === rows.length - 1,
activeStreaming: await liveBubble.count() === 1,
hasFirst: rows.includes('turn-partial-history-1'),
};
}).toEqual({
activeAtEnd: true,
activeStreaming: true,
hasFirst: true,
});

const activeHistoryState = await page.locator('.maka-chat-message-list').evaluate((list, id) => {
const rows = [...list.querySelectorAll<HTMLElement>(
'.maka-transcript-turn, .maka-transcript-gap-row',
)];
const rowIds = rows.map((row) =>
row.dataset.transcriptTurnId ?? `gap:${row.dataset.transcriptGap}`);
const firstIndex = rowIds.indexOf('turn-partial-history-1');
const activeIndex = rowIds.indexOf(id);
return {
rowIds,
activeIndex,
firstIndex,
gapBetween: rowIds.slice(firstIndex + 1, activeIndex).some((row) => row.startsWith('gap:')),
turnSourceCount: list.dataset.turnSourceCount,
positionSourceCount: list.dataset.positionSourceCount,
gaps: rows.flatMap((row) => row.dataset.transcriptGap ? [{
direction: row.dataset.transcriptGap,
text: row.textContent?.replace(/\s+/g, ' ').trim(),
}] : []),
};
}, activeTurnId);
expect(activeHistoryState.gapBetween).toBe(true);

const notice = page.locator(NOTICE);
await expect(notice).toBeVisible();
await expect(notice).toContainText('正在查看较早的消息');
await expect(notice.getByRole('button', { name: '返回最新消息' })).toBeVisible();
await expect(notice).not.toContainText(/保存|加载/);

const oldRows = await logicalRows();
expect(oldRows[0]).toBe('turn-partial-history-1');
expect(oldRows.at(-1)).toBe(activeTurnId);
const activeIndex = oldRows.indexOf(activeTurnId);
expect(oldRows.slice(1, activeIndex).some((row) => row.startsWith('gap:'))).toBe(true);
const loadedHistoricalTurns = new Set(oldRows.filter((row) => row.startsWith('turn-')));
const missingHistoricalTurns = Array.from({ length: 8 }, (_, index) =>
`turn-partial-history-${index + 1}`).filter((turnId) => !loadedHistoricalTurns.has(turnId));
expect(missingHistoricalTurns.length).toBeGreaterThan(0);
await expect(
page.locator(`[data-transcript-turn-id=${JSON.stringify(activeTurnId)}]`),
).toHaveCount(1);
const loadGap = page.getByRole('button', { name: '载入这段内容' }).first();
await expect(loadGap).toBeVisible();

const regular = await noticePresentation(page);
expect(regular).toEqual({
backgroundColor: 'rgba(0, 0, 0, 0)',
Expand Down Expand Up @@ -129,9 +202,49 @@ test('partial history is a quiet reading-column control with neutral rail ticks'
expect(narrow.fitsViewport).toBe(true);
expect(narrow.hasHorizontalOverflow).toBe(false);

await loadGap.click();
await expect.poll(async () => {
const loaded = await page.locator('[data-transcript-turn-id]').evaluateAll((turns) =>
turns.map((turn) => turn.getAttribute('data-transcript-turn-id')));
return missingHistoricalTurns.filter((turnId) => loaded.includes(turnId)).length;
}).toBeGreaterThan(0);
await expect(liveBubble).toBeVisible();
await notice.getByRole('button', { name: '返回最新消息' }).click();
const historicalTail = page.locator('[data-turn-id="turn-partial-history-8"]');
await expect(historicalTail).toBeVisible({ timeout: 20_000 });
await expect(historicalTail.locator('[data-turn-status="failed"]')).toHaveCount(0);
await expect(notice).toHaveCount(0);
await expect(
page.locator('[data-turn-id="turn-partial-history-8"]'),
).toBeVisible();
page.locator(`[data-transcript-turn-id=${JSON.stringify(activeTurnId)}]`),
).toHaveCount(1);

const scroller = page.locator('[data-chat-scroll-container="true"]');
const distanceFromBottom = () => scroller.evaluate((element) =>
Math.abs(element.scrollHeight - element.scrollTop - element.clientHeight));
expect(await distanceFromBottom()).toBeLessThanOrEqual(2);

await composer.fill(settlingSteering);
await composer.press('Shift+Enter');
await expect(liveBubble).toHaveCount(0, { timeout: 30_000 });
const settledTurn = page.locator(
`[data-transcript-turn-id=${JSON.stringify(activeTurnId)}]`,
);
await expect(settledTurn).toHaveCount(1);
const userBubbleTexts = await settledTurn.locator(
'.maka-chat-message-bubble-user',
).allTextContents();
expect(userBubbleTexts.filter((text) => text.includes(settlingPrompt))).toHaveLength(1);
expect(userBubbleTexts.filter((text) => text.includes(settlingSteering))).toHaveLength(1);
const assistantBubble = settledTurn.locator('.maka-chat-message-bubble-assistant');
await expect(assistantBubble).toHaveCount(1);
await expect(assistantBubble).toContainText(
`Acknowledged steering: ${settlingSteering}`,
);
const tailTurnIds = await page.locator('[data-transcript-turn-id]').evaluateAll((turns) =>
turns.map((turn) => turn.getAttribute('data-transcript-turn-id')));
expect(tailTurnIds.filter((turnId) => turnId === activeTurnId)).toHaveLength(1);
expect(tailTurnIds.indexOf('turn-partial-history-8')).toBeLessThan(
tailTurnIds.indexOf(activeTurnId),
);
expect(await distanceFromBottom()).toBeLessThanOrEqual(2);
});
9 changes: 8 additions & 1 deletion apps/desktop/e2e/transcript-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ async function sendPrompt(page: Page, text: string): Promise<void> {
await composer.fill(text);
// Switching Session or model restarts asynchronous send admission.
await awaitSendReady(page);
await composer.press('Enter');
// This suite measures transcript scrolling, not keyboard submission. A
// transient editor popup deliberately consumes Enter while it closes, so
// use the form's real submit control and let Playwright await actionability.
await page.locator('.maka-composer button[type="submit"]').click();
}

/** Answered turns, so a second send can be waited for without a stale match. */
Expand Down Expand Up @@ -309,6 +312,10 @@ test('switching Sessions restores a Turn anchor while a tail Session follows bac
await modelSwitcher.click();
await page.getByRole('menuitemradio', { name: 'glm-4.5', exact: true }).click();
await expect(modelSwitcher).toContainText('glm-4.5');
await expect.poll(() => page.evaluate(async (sessionId) =>
(await window.maka.sessions.list()).find((session) => session.id === sessionId)?.model,
tailSessionId), { message: 'the model change reaches the authoritative Session catalog' })
.toBe('glm-4.5');

await page.evaluate((sessionId) => {
const state = { complete: false, unsubscribe: () => undefined };
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,43 @@ describe('app shell session UI state controller', () => {
assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' });
});

it('consumes a search target once so later range changes do not restore it', async () => {
let residentSequence: number | null = 5;
const loadedSequences: number[] = [];
const controller = {
store: {
range: () => ({ sessionId: 'session' }),
sequenceForTurn: () => residentSequence,
newestDurableUserSequence: () => 5,
snapshot: () => ({ messages: [] }),
},
ready: async () => undefined,
loadAround: async (sequence: number) => {
loadedSequences.push(sequence);
},
};
const restore = (nonce: number) => transcriptReadingPosition.restoreRange({
sessionId: 'session',
searchTarget: { sessionId: 'session', turnId: 'turn', sequence: 5, nonce },
controller,
isCurrent: () => true,
setMessages: () => undefined,
setReadingAnchor: () => undefined,
onError: (error: unknown) => assert.fail(String(error)),
});

restore(1);
await new Promise<void>((resolve) => setImmediate(resolve));
residentSequence = null;
restore(1);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(loadedSequences, []);

restore(2);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(loadedSequences, [5]);
});

it('keeps the synchronous live-turn ref aligned with reducer updates', () => {
const controller = createAppShellSessionUiStateController();
const projection = armLiveTurn('turn-1');
Expand Down
Loading