Skip to content
Open
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
218 changes: 122 additions & 96 deletions apps/desktop/e2e/partial-history-notice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,121 +17,147 @@
* under the License.
*/

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';

async function waitForPaint(page: Page): Promise<void> {
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}));
}

async function noticePresentation(page: Page) {
return page.locator(NOTICE).evaluate((notice) => {
const style = getComputedStyle(notice);
const box = notice.getBoundingClientRect();
const composer = document.querySelector('.maka-composer-astryx');
if (!composer) throw new Error('the composer is missing');
const composerBox = composer.getBoundingClientRect();
return {
backgroundColor: style.backgroundColor,
borderWidths: [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
],
display: style.display,
flexWrap: style.flexWrap,
justifyContent: style.justifyContent,
widthDelta: Math.abs(box.width - composerBox.width),
centerDelta: Math.abs(
(box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2,
),
fitsViewport: box.left >= 0 && box.right <= document.documentElement.clientWidth,
hasHorizontalOverflow: notice.scrollWidth > notice.clientWidth,
};
});
}

test('partial history is a quiet reading-column control with neutral rail ticks', async ({
test('partial history uses the dock arrow to return to the real tail', async ({
partialHistoryWindow: page,
}) => {
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 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 regular = await noticePresentation(page);
expect(regular).toEqual({
backgroundColor: 'rgba(0, 0, 0, 0)',
borderWidths: ['0px', '0px', '0px', '0px'],
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
widthDelta: expect.any(Number),
centerDelta: expect.any(Number),
fitsViewport: true,
hasHorizontalOverflow: false,
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,
});
expect(regular.widthDelta).toBeLessThanOrEqual(1);
expect(regular.centerDelta).toBeLessThanOrEqual(1);

await page.mouse.move(0, 0);
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
const railPresentation = await page.evaluate(() => {
const ticks = [...document.querySelectorAll<HTMLElement>('.maka-prompt-rail-tick')];
const presentation = (tick: HTMLElement) => {
const bar = tick.querySelector<HTMLElement>('.maka-prompt-rail-tick-bar');
if (!bar) throw new Error('a prompt rail tick is missing its bar');
const style = getComputedStyle(bar);
return {
backgroundColor: style.backgroundColor,
borderStyle: style.borderStyle,
borderWidth: style.borderWidth,
boxShadow: style.boxShadow,
};
};
const neutralPaint = ticks
.filter((tick) => tick.dataset.active !== 'true' && !tick.matches(':hover'))
.map(presentation);
const residentStyleRules = [...document.styleSheets].flatMap((sheet) =>
[...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident'))
);
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 {
residentAttributeCount: document.querySelectorAll('[data-resident]').length,
residentStyleRuleCount: residentStyleRules.length,
neutralTickCount: neutralPaint.length,
neutralPaintCount: new Set(neutralPaint.map((paint) => JSON.stringify(paint))).size,
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(),
}] : []),
};
});
expect(railPresentation.residentAttributeCount).toBe(0);
expect(railPresentation.residentStyleRuleCount).toBe(0);
expect(railPresentation.neutralTickCount).toBeGreaterThan(1);
expect(railPresentation.neutralPaintCount).toBe(1);
}, activeTurnId);
expect(activeHistoryState.gapBetween).toBe(true);

await page.setViewportSize({ width: 520, height: 720 });
await waitForPaint(page);
const narrow = await noticePresentation(page);
expect(narrow.centerDelta).toBeLessThanOrEqual(1);
expect(narrow.fitsViewport).toBe(true);
expect(narrow.hasHorizontalOverflow).toBe(false);
await expect(page.locator(NOTICE)).toHaveCount(0);
const returnToTail = page.getByRole('button', { name: '滚动主对话到底部' });
await expect.poll(() => returnToTail.evaluate((button) => {
const style = getComputedStyle(button);
return style.pointerEvents !== 'none' && Number(style.opacity) > 0.5;
})).toBe(true);

await notice.getByRole('button', { name: '返回最新消息' }).click();
await expect(notice).toHaveCount(0);
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();

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();
// This spec owns the button's semantic route. Pointer geometry is covered by
// transcript-scroll.spec.ts in the same real Electron shell.
await returnToTail.dispatchEvent('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(page.locator(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);
});
Loading