Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
27 changes: 20 additions & 7 deletions apps/desktop/e2e/partial-history-notice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { expect, test } from './fixtures';

const GAP = '.maka-transcript-gap-row';
const TURN = '.maka-transcript-turn';
/** Turns the partial-history fixture seeds. */
const PARTIAL_HISTORY_TURN_COUNT = 18;

test('bounded transcript ranges expose only their truthful boundary gaps', async ({
partialHistoryWindow: page,
Expand All @@ -44,14 +46,20 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async

const firstTurn = page.locator('[data-turn-id="turn-partial-history-1"]');
await expect(firstTurn).toBeVisible();
await expect(firstTurn).toHaveAttribute('data-search-highlight', 'true');
// Where the jump landed, read from the reading position rather than from
// `data-search-highlight`: that highlight clears itself 2.2s after the
// command lands, so waiting for the Turn to mount and then asserting it
// fails whenever loading the page around it takes longer than the flash —
// measured here as a 3s pass turning into an 18s timeout under load.
await expect(oldestPrompt).toHaveAttribute('data-active', 'true');
await expect(olderGap).toHaveCount(0);
await expect(newerGap).toBeVisible();
await expect(newerGap.getByRole('button', {
name: /^(?:加载较新消息|Load newer messages)$/,
})).toBeVisible();
await expect(page.locator(GAP)).toHaveCount(1);
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);
// A jump lands on its own page, not on the whole history.
expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT);

const loadNewer = newerGap.getByRole('button', {
name: /^(?:加载较新消息|Load newer messages)$/,
Expand All @@ -64,21 +72,26 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async

await loadNewer.click();
await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible();
await expect(olderGap).toBeVisible();
// Paging newer used to push the oldest Turn out of a Host-bounded range and
// put an older gap back. The Renderer owns the window now and keeps what the
// reader can still reach, so the only truthful boundary is still the newer one.
await expect(olderGap).toHaveCount(0);
await expect(newerGap).toBeVisible();
await expect(page.locator(GAP)).toHaveCount(1);
await expect(loadNewer).toBeEnabled();
await expect(oldestPrompt).toBeVisible();
await expect(page.locator(GAP)).toHaveCount(2);
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);

const returnToLatest = page.getByRole('button', {
name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/,
});
await expect(returnToLatest).toBeVisible();
await returnToLatest.click();

await expect(page.locator('[data-turn-id="turn-partial-history-18"]')).toBeVisible();
// Reading the tail page and rebuilding the window around it is slower than
// the paging above, and measured past the suite's 10s expect timeout here.
await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`))
.toBeVisible({ timeout: 30_000 });
await expect(newerGap).toHaveCount(0);
await expect(oldestPrompt).toBeVisible();
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);
expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT);
});
239 changes: 229 additions & 10 deletions apps/desktop/e2e/transcript-scroll-cost.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,34 @@

import type { CDPSession, Page } from '@playwright/test';
import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers';
import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract';
import { expect, test } from './fixtures';

const SCROLLER = '[data-chat-scroll-container="true"]';
const TURN = '.maka-transcript-turn';

/**
* The mounted range is now a band of pixels, not a Host constant: useChatScroll
* keeps the Turns within four screens of the reader and drops what sits beyond
* six, so what bounds this count is the viewport these tests set (700px) and
* how tall a fixture Turn is — no number the Main tail cache owns.
*
* Generous on purpose. The property worth guarding is that paging through 120
* Turns stops adding Turns; a range that kept everything it paged in would
* mount all 120, and a band that quietly doubled would pass no threshold that
* left this much room.
*/
const MOUNTED_TURNS_MAX = 40;

/**
* How far a page boundary is allowed to move the reader, in CSS pixels.
*
* Not a tolerance for "close enough" motion: scroll anchoring corrects in whole
* device pixels while these are read as fractional CSS pixels, so a correct
* frame lands within a pixel of zero and a frame that lost the reader lands a
* Turn away — hundreds.
*/
const DISPLACEMENT_MAX_PX = 2;

declare global {
interface Window {
__makaTranscriptCost?: {
Expand All @@ -51,9 +73,29 @@ declare global {
skipped: WeakSet<Element>;
skippedCount: number;
};
__makaTranscriptDisplacement?: {
boundaries: TranscriptBoundary[];
peakMounted: number;
stop(): void;
};
}
}

/**
* One frame where the mounted range changed: a page installed, or the band
* trimmed, or both.
*/
interface TranscriptBoundary {
readonly firstBefore: string;
readonly firstAfter: string;
readonly mountedBefore: number;
readonly mountedAfter: number;
/** Turns present in both frames, so a reader position can be compared. */
readonly carried: number;
readonly worstTurnId: string | null;
readonly worstPx: number;
}

/**
* Real wheel input at the centre of the scroller. Relative by construction: a
* wheel tick asks the compositor to move by a delta from wherever the scroller
Expand Down Expand Up @@ -125,6 +167,100 @@ async function observe(page: Page): Promise<void> {
});
}

/**
* Watch every frame for a change in the mounted range, and measure what that
* change did to the reader.
*
* A Turn the reader can still see is at `top` in the viewport and at
* `top + scrollTop` in the document. Between two frames with no input, its
* document position must not move, so `Δtop + ΔscrollTop` is zero — whatever
* the Renderer installed above it, the browser's scroll anchoring absorbed. A
* page that displaces the reader breaks that sum by however tall the rows it
* added or dropped were.
*
* Sampled per frame rather than per gesture: the frame that installs a page is
* the only one where the reader can be lost, and a per-gesture reading would
* subtract the reader's own scrolling back out and see nothing.
*/
async function observeDisplacement(page: Page): Promise<void> {
await page.evaluate((scrollerSelector) => {
const scroller = document.querySelector(scrollerSelector);
if (!scroller) throw new Error('the chat scroll container is missing');
const read = () => {
const tops = new Map<string, number>();
for (const turn of document.querySelectorAll<HTMLElement>('[data-turn-id]')) {
const turnId = turn.dataset.turnId;
if (turnId) tops.set(turnId, turn.getBoundingClientRect().top);
}
return { scrollTop: scroller.scrollTop, tops, key: [...tops.keys()].join(',') };
};
const state: { boundaries: unknown[]; peakMounted: number; stop(): void } = {
boundaries: [],
peakMounted: 0,
stop: () => { running = false; },
};
let running = true;
let previous = read();
// The last frame before the range started changing. Held across a run of
// changing frames so the measurement spans settled state to settled state:
// scroll anchoring corrects after layout, so a reading taken inside the
// change would report a correction that never reached the screen.
let settled: ReturnType<typeof read> | null = null;
let peakMounted = 0;
const tick = (): void => {
if (!running) return;
const current = read();
peakMounted = Math.max(peakMounted, current.tops.size);
state.peakMounted = peakMounted;
if (current.key !== previous.key) {
if (!settled) settled = previous;
} else if (settled) {
const before = settled;
settled = null;
const scrolled = current.scrollTop - before.scrollTop;
let carried = 0;
let worstPx = 0;
let worstTurnId: string | null = null;
for (const [turnId, top] of current.tops) {
const wasAt = before.tops.get(turnId);
if (wasAt === undefined) continue;
carried += 1;
const displaced = Math.abs(top - wasAt + scrolled);
Comment thread
Astro-Han marked this conversation as resolved.
Outdated
if (displaced > worstPx) {
worstPx = displaced;
worstTurnId = turnId;
}
}
state.boundaries.push({
firstBefore: before.key.split(',')[0] ?? '',
firstAfter: current.key.split(',')[0] ?? '',
mountedBefore: before.tops.size,
mountedAfter: current.tops.size,
carried,
worstTurnId,
worstPx,
});
}
previous = current;
requestAnimationFrame(tick);
};
window.__makaTranscriptDisplacement = state as never;
requestAnimationFrame(tick);
}, SCROLLER);
}

async function displacement(page: Page): Promise<{
boundaries: readonly TranscriptBoundary[];
peakMounted: number;
}> {
return page.evaluate(() => {
const state = window.__makaTranscriptDisplacement;
if (!state) throw new Error('the transcript displacement probe is missing');
state.stop();
return { boundaries: state.boundaries, peakMounted: state.peakMounted };
});
}

interface CostSample {
transitionRuns: number;
animationStarts: number;
Expand All @@ -149,7 +285,32 @@ async function sample(page: Page): Promise<CostSample> {
});
}

/**
* A transcript opened at its tail keeps fetching older history until two
* screens of it sit above the reader, and trims what falls outside the band it
* retains, so the mounted rows churn for as long as that runs. Wait for the
* window to stop moving before touching a row: a locator resolved mid-churn
* points at an element the Renderer has already unmounted.
*/
async function settled(page: Page): Promise<void> {
const mounted = async (): Promise<string> => page.evaluate(() => {
const turns = document.querySelectorAll('[data-turn-id]');
return `${turns.length}:${turns[0]?.getAttribute('data-turn-id')}`;
});
let previous = await mounted();
await expect
.poll(async () => {
await page.waitForTimeout(250);
const current = await mounted();
const stable = current === previous;
previous = current;
return stable;
})
.toBe(true);
}

async function moveToTail(page: Page): Promise<void> {
await settled(page);
await page.locator(TURN).last().scrollIntoViewIfNeeded();
await page.evaluate(() => new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
Expand Down Expand Up @@ -232,9 +393,10 @@ test('the browser skips the Turns the reader has scrolled past', async ({

/**
* The bound the Desktop transcript is built on: paging back through a history
* far longer than the active range mounts a bounded number of Turns, not a
* growing one. Sampled at every page rather than only at the end, because a
* range that overshoots and is trimmed afterwards is the regression.
* far longer than the retained band mounts a bounded number of Turns, not a
* growing one. Sampled at every page rather than only at the end, because the
* regression is a range that grows while the reader travels and is only trimmed
* once they stop.
*/
test('paging back through the whole history keeps the mounted range bounded', async ({
promptRailWindow: page,
Expand Down Expand Up @@ -267,14 +429,71 @@ test('paging back through the whole history keeps the mounted range bounded', as

expect(pages).toBeGreaterThan(0);
await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1');
expect(mountedMax).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS);
expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);

// Coming back from the far end is a range reload, not a scroll: the Host
// resolves a new window around the tail and the renderer mounts it. The
// suite's 10s expect timeout is sized for UI that is already on screen, and
// this step measured past it on a loaded CI runner.
// Coming back from the far end reads the tail page and rebuilds the window
// around it, so it is slower than the scrolling above. The suite's 10s expect
// timeout is sized for UI that is already on screen, and this step measured
// past it on a loaded CI runner.
await returnToLatest(page);
await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
.toHaveCount(1, { timeout: 30_000 });
expect(await turns.count()).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS);
expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);
});

/**
* The scenario #5163 was reported from: quit Desktop, start it again, open a
* long Session, and scroll upward through history without stopping. The reader
* perceives stalls or jumps around range boundaries.
*
* The tests above establish that paging works and stays bounded. Neither says
* where the reader ended up while a page was installing, which is the whole of
* what that report is about. This one measures it: every frame the mounted
* range changes, whatever Turn the reader can still see must hold its document
* position.
*
* Displacement in pixels rather than frame timings on purpose — see this file's
* header for what happened to the timing assertions this suite replaced. A
* stall and a jump have the same cause here (a page boundary that moves
* content out from under the reader) and only one of them can be asserted
* without a clock.
*/
test('paging back never moves the reader at a range boundary', async ({
promptRailWindow: page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1_000, height: 700 });
await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
.toHaveCount(1);
const cdp = await page.context().newCDPSession(page);
const turns = page.locator('[data-turn-id]');
await moveToTail(page);
await observeDisplacement(page);

for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) {
const firstBefore = await turns.first().getAttribute('data-turn-id');
if (firstBefore === 'turn-prompt-rail-1') break;
await expect
.poll(async () => {
await wheel(page, cdp, { ticks: 12, deltaY: -120 });
return turns.first().getAttribute('data-turn-id');
})
.not.toBe(firstBefore);
}

const { boundaries, peakMounted } = await displacement(page);
// The probe has to have seen the thing it measures: a run that paged nothing,
// or one where every boundary replaced the range wholesale and carried no
// Turn across, proves nothing about the reader.
expect(boundaries.length).toBeGreaterThan(0);
expect(boundaries.filter((boundary) => boundary.carried > 0).length).toBeGreaterThan(0);

const displaced = boundaries.filter((boundary) => boundary.worstPx > DISPLACEMENT_MAX_PX);
expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`)
.toEqual([]);
// Sampled per frame, not per gesture: the bound above is read once the range
// has stopped moving, so a page that mounts the whole answer and trims it on
// a later frame passes it while costing the reader a full layout of every
// Turn it installed.
expect(peakMounted).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);
});
Loading
Loading