Files
maka/apps/desktop/e2e/transcript-scroll.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

585 lines
25 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 { expect, test, COMPOSER_INPUT } from './fixtures';
import type { Page } from '@playwright/test';
/**
* Where the transcript is looking, in a real Chromium with a real scroller.
*
* Two rounds of this work shipped green and wrong, both times because the
* instrument could not see the property being claimed: a CLS measurement is
* blind to scroll position, and a linkedom harness decides the effect ordering
* its own assertions then confirm. Nothing below reads a ref or a flag — each
* test states where an element or the viewport ended up, and the app has to put
* it there.
*
* Positions are asserted against an element or against the scroller's own end,
* never as a pixel delta: a delta is satisfiable by two wrongs (the content
* grew by as much as the view moved), which is the bug class that produced the
* `scrollHeight`-difference compensation this replaces.
*/
const SCROLLER = '[data-chat-scroll-container="true"]';
const REGENERATE = /^重新生成回答/;
/** Astryx's dock affordance, relabelled by `ChatSurfaceLayout`. */
const SCROLL_TO_BOTTOM = /^滚动主对话到底部$/;
/** Sixty lines: more than one viewport once the fake backend echoes it back. */
const LONG_PROMPT = Array.from(
{ length: 60 },
(_, index) => `第 ${index} 行:这一段用来把转录推过滚动视口的高度。`,
).join('\n');
function distanceToTail(page: Page): Promise<number> {
return scrollMetrics(page).then((metrics) => metrics.distance);
}
/**
* The distance plus the three numbers it came from. A failure that reports only
* the distance cannot say whether the transcript grew past the reader or the
* viewport shrank under them, and those have different causes.
*/
function scrollMetrics(page: Page): Promise<{
distance: number;
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}> {
return page.evaluate((selector) => {
const root = document.querySelector(selector);
if (!root) throw new Error('the chat scroll container is missing');
return {
distance: Math.round(root.scrollHeight - root.scrollTop - root.clientHeight),
scrollTop: Math.round(root.scrollTop),
scrollHeight: root.scrollHeight,
clientHeight: root.clientHeight,
};
}, SCROLLER);
}
/**
* Whether the dock affordance is actually offered. It is always in the DOM —
* Astryx toggles opacity and pointer-events — so presence proves nothing and
* `toBeVisible` passes on the transparent one.
*/
function scrollButtonOffered(page: Page): Promise<boolean> {
return page.evaluate((name) => {
const button = [...document.querySelectorAll('button')].find(
(candidate) => candidate.getAttribute('aria-label') === name
|| candidate.textContent?.trim() === name,
);
if (!button) throw new Error(`the "${name}" affordance is missing`);
const style = getComputedStyle(button);
return style.pointerEvents !== 'none' && Number(style.opacity) > 0.5;
}, '滚动主对话到底部');
}
function turnTop(page: Page, turnId: string): Promise<number> {
return page.evaluate((id) => {
const turn = document.querySelector(`[data-turn-id="${CSS.escape(id)}"]`);
if (!turn) throw new Error(`turn ${id} is not mounted`);
return Math.round(turn.getBoundingClientRect().top);
}, turnId);
}
/**
* Sample the tail through the frames a growing transcript produces.
*
* Read at the start of each frame, which is one frame behind the pin: the
* content commits, the next frame's layout delivers the resize, and the write
* lands before that frame paints. So the view can only ever be behind by what
* arrived since the last delivery — never more, and never cumulatively. That is
* what `worstLag` against `worstFrameGrowth` states, and it is a property no
* fixed pixel budget can express: a transcript that stopped following instead
* falls behind by the whole of `grewBy`.
*/
function measureTailLag(page: Page, frames: number): Promise<{
worstLag: number;
worstFrameGrowth: number;
grewBy: number;
viewportHeight: number;
}> {
return page.evaluate(([selector, frameCount]) => new Promise<{
worstLag: number;
worstFrameGrowth: number;
grewBy: number;
viewportHeight: number;
}>((resolve) => {
const root = document.querySelector(selector as string);
if (!root) throw new Error('the chat scroll container is missing');
const startedAt = root.scrollHeight;
let previousScrollHeight = startedAt;
let worstLag = 0;
let worstFrameGrowth = 0;
let left = frameCount as number;
const tick = (): void => {
const settledTail = previousScrollHeight - root.clientHeight;
worstLag = Math.max(worstLag, Math.abs(root.scrollTop - settledTail));
worstFrameGrowth = Math.max(worstFrameGrowth, root.scrollHeight - previousScrollHeight);
previousScrollHeight = root.scrollHeight;
// Stops on the content, not on a frame count: when the answer starts
// arriving is the backend's business, and a fixed window can expire
// before it does.
const enough = root.scrollHeight - startedAt > root.clientHeight;
if (enough || --left <= 0) {
resolve({
worstLag: Math.round(worstLag),
worstFrameGrowth: Math.round(worstFrameGrowth),
grewBy: Math.round(root.scrollHeight - startedAt),
viewportHeight: root.clientHeight,
});
} else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}), [SCROLLER, frames] as const);
}
async function sendPrompt(page: Page, text: string): Promise<void> {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(text);
await composer.press('Enter');
}
/** Answered turns, so a second send can be waited for without a stale match. */
function answeredTurns(page: Page) {
return page.getByRole('button', { name: REGENERATE });
}
async function scrollTranscriptTo(page: Page, top: number): Promise<void> {
await page.evaluate(([selector, position]) => {
const root = document.querySelector(selector as string);
if (!root) throw new Error('the chat scroll container is missing');
root.scrollTop = position as number;
}, [SCROLLER, top] as const);
await waitForPaintedFrames(page);
}
async function waitForPaintedFrames(page: Page, count = 3): Promise<void> {
await page.evaluate((frames) => new Promise<void>((resolve) => {
const tick = (left: number) => {
if (left <= 0) {
resolve();
return;
}
requestAnimationFrame(() => tick(left - 1));
};
tick(frames);
}), count);
}
test('a streaming answer keeps the viewport at the tail', async ({ window: page }) => {
// A full fake-backend turn, streamed nine characters at a time.
test.slow();
await page.setViewportSize({ width: 900, height: 700 });
await sendPrompt(page, LONG_PROMPT);
// Measured through the stream, not only at the end: the failure this guards
// against is the tail slipping away *while* content arrives, which a single
// reading afterwards cannot tell apart from a view dragged back at the last
// delta.
const lag = await measureTailLag(page, 1_200);
await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 });
// The samples have to have covered more than a viewport of real growth, or
// every reading above is a stationary transcript and proves nothing.
expect(lag.grewBy).toBeGreaterThan(lag.viewportHeight);
expect(lag.worstLag).toBeLessThanOrEqual(lag.worstFrameGrowth + 8);
const settled = await scrollMetrics(page);
expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4);
expect(await scrollButtonOffered(page)).toBe(false);
});
/**
* The turn wrappers are not the transcript. It also renders the optimistic user
* message, the no-tail live fallback and orphaned conversation items outside
* them, so a growth signal that watches turns has a blind spot the size of
* whatever else gets rendered next.
*
* Grown here rather than by sending a Follow Up: whether the optimistic message
* is ever on screen is the host's timing, and it was measured both appearing
* and being overtaken by its own answer within the same fixture. What is under
* test is not that message — it is that `scrollHeight` growing anywhere is
* enough, which is a property of the scroller and needs no help from the
* transcript to state.
*/
test('content that grows outside the turn wrappers is followed too', async ({
window: page,
}) => {
test.slow();
await page.setViewportSize({ width: 900, height: 700 });
await sendPrompt(page, LONG_PROMPT);
await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 });
expect(await distanceToTail(page)).toBeLessThanOrEqual(4);
const outsideTurnWrapper = await page.evaluate(() => {
const list = document.querySelector('.maka-chat-message-list');
if (!list) throw new Error('the transcript content box is missing');
const grown = document.createElement('div');
grown.dataset.outsideTurnGrowth = 'true';
grown.style.height = '600px';
list.append(grown);
return grown.closest('[data-transcript-turn-id]') === null;
});
await waitForPaintedFrames(page);
// Outside a wrapper is what makes this the uncovered path: growth inside one
// is what every other test in this file already exercises.
expect(outsideTurnWrapper, 'the injected box landed inside a turn wrapper').toBe(true);
const settled = await scrollMetrics(page);
expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4);
});
test('content that arrives after the reader scrolls up does not pull them back', async ({
window: page,
}) => {
test.slow();
await page.setViewportSize({ width: 900, height: 700 });
await sendPrompt(page, LONG_PROMPT);
await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 });
const transcript = page.locator('.maka-chat-message-list');
await transcript.hover();
await page.mouse.wheel(0, -500);
await waitForPaintedFrames(page);
const before = await distanceToTail(page);
expect(before).toBeGreaterThan(100);
expect(await scrollButtonOffered(page)).toBe(true);
const anchorTurnId = await page.evaluate(() => {
const turn = document.querySelector<HTMLElement>('[data-turn-id]');
const turnId = turn?.dataset.turnId;
if (!turnId) throw new Error('the transcript has no mounted turn');
return turnId;
});
const anchorTop = await turnTop(page, anchorTurnId);
await sendPrompt(page, LONG_PROMPT);
await expect(answeredTurns(page)).toHaveCount(2, { timeout: 30_000 });
await waitForPaintedFrames(page);
// The turn the reader was on is still where it was. Everything that arrived,
// arrived below them.
expect(Math.abs((await turnTop(page, anchorTurnId)) - anchorTop)).toBeLessThanOrEqual(4);
expect(await distanceToTail(page)).toBeGreaterThan(before);
});
test('a gesture a nested scroller consumed does not release the tail', async ({
window: page,
}) => {
test.slow();
await page.setViewportSize({ width: 900, height: 700 });
await sendPrompt(page, LONG_PROMPT);
await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 });
const settled = await scrollMetrics(page);
expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4);
// A real scroller inside the transcript, standing in for a tool-output box
// (`.maka-tool-output-body`, `max-height: 256px; overflow-y: auto`) or a pty
// terminal. Built here rather than fixtured because what is under test is
// Chromium's scroll chain, which does not care where the element came from,
// and no fixture reliably produces an output tall enough to overflow.
const nested = await page.evaluate(() => {
const turns = document.querySelectorAll<HTMLElement>('[data-turn-id]');
const turn = turns[turns.length - 1];
if (!turn) throw new Error('the transcript has no mounted turn');
const box = document.createElement('div');
box.dataset.nestedScroller = 'true';
box.style.cssText = 'max-height:120px;overflow-y:auto';
const filler = document.createElement('div');
filler.style.height = '2000px';
box.append(filler);
turn.append(box);
// Away from both ends, so scrolling up inside it never reaches a boundary
// and never chains to the transcript.
box.scrollTop = 600;
return box.scrollTop;
});
// Appending is growth like any other, so the pin brings the new box into
// view — which also keeps Playwright's hover from scrolling to reach it.
await waitForPaintedFrames(page);
expect(await distanceToTail(page)).toBeLessThanOrEqual(4);
// The real input pipeline, over the nested element: the gesture crosses the
// transcript on its way up the tree, the nested element consumes it, and the
// transcript never moves — so no `scroll` follows. A tail-follow that watches
// gestures reads this as the reader leaving; one that watches position cannot
// see it at all. Astryx's stock predicate is the former, and its
// `animatingRef` was measured sitting at `true` on a resting transcript, so
// an upward wheel here released the tail with nothing having scrolled.
await page.locator('[data-nested-scroller="true"]').hover();
await page.mouse.wheel(0, -400);
await waitForPaintedFrames(page);
const nestedAfter = await page.evaluate(
() => document.querySelector<HTMLElement>('[data-nested-scroller="true"]')?.scrollTop ?? -1,
);
// The nested box moved, which is what makes this a gesture the transcript
// never saw. Without this the test would pass on a wheel that did nothing.
expect(nestedAfter).toBeLessThan(nested);
expect(await distanceToTail(page)).toBeLessThanOrEqual(4);
// The touch equivalent, which no synthetic-free path can produce here.
await page.evaluate(() => {
const target = document.querySelector('[data-turn-id]');
if (!target) throw new Error('the transcript has no mounted turn');
target.dispatchEvent(new Event('touchmove', { bubbles: true }));
});
await waitForPaintedFrames(page);
// Following is unharmed: a whole further answer lands and the tail is still
// under the reader. A release would have left them a screen and a half up,
// with no gesture of their own to explain it.
await sendPrompt(page, LONG_PROMPT);
await expect(answeredTurns(page)).toHaveCount(2, { timeout: 30_000 });
await waitForPaintedFrames(page);
expect(await distanceToTail(page)).toBeLessThanOrEqual(4);
expect(await scrollButtonOffered(page)).toBe(false);
});
test('a nested scroller near the history boundary does not request an earlier range', async ({
promptRailWindow: page,
}) => {
await page.setViewportSize({ width: 900, height: 1500 });
await waitForPaintedFrames(page, 6);
const metrics = await scrollMetrics(page);
expect(metrics.scrollTop).toBeLessThanOrEqual(Math.max(640, metrics.clientHeight * 2));
expect(metrics.distance).toBeLessThanOrEqual(4);
const nestedBefore = await page.evaluate((selector) => {
const root = document.querySelector<HTMLElement>(selector);
const list = root?.querySelector<HTMLElement>('.maka-chat-message-list');
if (!root || !list) throw new Error('the active transcript range is missing');
const box = document.createElement('div');
box.dataset.nestedHistoryScroller = 'true';
box.style.cssText = [
'position:fixed',
'top:160px',
'left:160px',
'width:240px',
'height:120px',
'overflow-y:auto',
'z-index:9999',
].join(';');
const filler = document.createElement('div');
filler.style.height = '2000px';
box.append(filler);
// A Turn uses `content-visibility:auto`, whose paint containment prevents
// a fixed descendant from reliably winning hit testing over sibling Turns
// on Linux/Xvfb. Keep the fixture inside the transcript event path without
// putting it inside the product containment boundary being tested.
list.append(box);
box.scrollTop = 600;
return box.scrollTop;
}, SCROLLER);
const nested = page.locator('[data-nested-history-scroller="true"]');
const box = await nested.boundingBox();
if (!box) throw new Error('the nested history scroller is not rendered');
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
expect(await page.evaluate(({ x, y }) =>
document.elementFromPoint(x, y)?.closest('[data-nested-history-scroller="true"]') !== null,
point)).toBe(true);
await page.mouse.move(point.x, point.y);
await page.mouse.wheel(0, -400);
await waitForPaintedFrames(page);
const nestedAfter = await page.evaluate(() =>
document.querySelector<HTMLElement>('[data-nested-history-scroller="true"]')?.scrollTop ?? -1,
);
expect(nestedAfter).toBeLessThan(nestedBefore);
await page.evaluate(() => {
const list = document.querySelector('.maka-chat-message-list');
if (!list) throw new Error('the transcript content box is missing');
const grown = document.createElement('div');
grown.style.height = '600px';
list.append(grown);
});
await waitForPaintedFrames(page, 6);
expect(await distanceToTail(page)).toBeLessThanOrEqual(4);
});
test('the dock affordance returns the reader to the tail', async ({ window: page }) => {
test.slow();
await page.setViewportSize({ width: 900, height: 700 });
await sendPrompt(page, LONG_PROMPT);
await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 });
await scrollTranscriptTo(page, 0);
// Offered at all is the assertion: with Astryx's scroll layer off, its
// `isScrolledUp` never updates again, so the stock button would stay
// transparent forever. This one reads Maka's pin.
expect(await scrollButtonOffered(page)).toBe(true);
await page.getByRole('button', { name: SCROLL_TO_BOTTOM }).click();
await waitForPaintedFrames(page);
expect(await distanceToTail(page)).toBeLessThanOrEqual(4);
expect(await scrollButtonOffered(page)).toBe(false);
});
test('earlier history lands above the turn the reader is on', async ({
promptRailWindow: page,
}) => {
const firstLoadedTurn = () => page
.locator('[data-transcript-turn-id]')
.first()
.getAttribute('data-transcript-turn-id');
const firstBefore = await firstLoadedTurn();
// Just short of the band that asks for more, so the active range has painted
// turns around the reader before the load starts. Landing straight on zero
// leaves no visible turn above the load boundary to identify as the anchor.
await page.evaluate((selector) => {
const root = document.querySelector(selector);
if (!root) throw new Error('the chat scroll container is missing');
root.scrollTop = Math.max(640, root.clientHeight * 2) + 400;
}, SCROLLER);
await waitForPaintedFrames(page, 6);
// The move that asks for earlier history, and the reading of where the
// reader is, in one task. Keep the reader near the active range's head: an
// anchor near its tail can already have a complete bounded range around it,
// so a valid load has no reason to move the first resident Turn.
const anchor = await page.evaluate((selector) => {
const root = document.querySelector(selector);
if (!root) throw new Error('the chat scroll container is missing');
root.scrollTop = Math.min(300, root.scrollHeight - root.clientHeight);
const rootTop = root.getBoundingClientRect().top;
const turn = [...root.querySelectorAll<HTMLElement>('[data-turn-id]')].find(
(candidate) => candidate.getBoundingClientRect().bottom > rootTop,
);
const turnId = turn?.dataset.turnId;
if (!turn || !turnId) throw new Error('no turn is on screen');
const anchor = { turnId, top: Math.round(turn.getBoundingClientRect().top) };
root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }));
return anchor;
}, SCROLLER);
await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore);
await waitForPaintedFrames(page);
// The turns that arrived went above the reader, and the reader did not go
// with them. Asserting the element rather than a `scrollTop` delta is the
// point: a compensation computed from `scrollHeight` satisfies the delta
// while putting the reader somewhere else entirely.
await expect.poll(
async () => Math.abs((await turnTop(page, anchor.turnId)) - anchor.top),
).toBeLessThanOrEqual(4);
});
test('history asked for at the very top of the scroller still lands above the reader', async ({
promptRailWindow: page,
}) => {
const firstLoadedTurn = () => page
.locator('[data-transcript-turn-id]')
.first()
.getAttribute('data-transcript-turn-id');
const firstBefore = await firstLoadedTurn();
// The one position where the browser declines to anchor, and the one the
// wheel-to-load path puts the reader in.
await page.evaluate((selector) => {
const root = document.querySelector(selector);
if (!root) throw new Error('the chat scroll container is missing');
root.scrollTop = 0;
}, SCROLLER);
await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore);
await waitForPaintedFrames(page);
// Anchoring resumes at an offset of one pixel, so the offset itself is the
// evidence: left at zero the browser holds the scroller at the top and every
// turn that arrives pushes the reader's content down the viewport instead.
const offset = await page.evaluate((selector) => {
const root = document.querySelector(selector);
if (!root) throw new Error('the chat scroll container is missing');
return root.scrollTop;
}, SCROLLER);
expect(offset).toBeGreaterThanOrEqual(1);
});
/**
* A transcript shorter than about three viewports has its tail inside the band
* that asks for earlier history, so "near the start" cannot mean the reader
* wants it.
*
* The other half of that rule — a wheel the scroller cannot act on releases the
* pin, because it is the reader asking for what is above them — has no assertion
* here, and not for want of trying. Its only observable consequence is that a
* later arrival does not take the reader back down, and in this fixture the
* reader who asks is already at the tail: anchoring holds them at the same
* distance from it, the session takes no new turns, and a viewport change moves
* them the same way pinned or not. An assertion that passes either way is worse
* than none. `transcript-scroll-authority.test.ts` covers the state machine it
* turns on.
*/
test('following the tail does not ask for the history above it', async ({
promptRailWindow: page,
}) => {
const firstLoadedTurn = () => page
.locator('[data-transcript-turn-id]')
.first()
.getAttribute('data-transcript-turn-id');
const firstBefore = await firstLoadedTurn();
// Tall enough that the tail sits inside `max(640, clientHeight * 2)`. The
// resize itself is a growth signal, so the pin writes the tail and that write
// dispatches the scroll event this test is about.
await page.setViewportSize({ width: 900, height: 1500 });
await waitForPaintedFrames(page, 6);
const settled = await scrollMetrics(page);
expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4);
expect(
settled.scrollTop,
`the tail must be inside the load band for this test to mean anything: ${JSON.stringify(settled)}`,
).toBeLessThanOrEqual(Math.max(640, settled.clientHeight * 2));
// Nothing arrived that the reader did not ask for.
await waitForPaintedFrames(page, 12);
expect(await firstLoadedTurn()).toBe(firstBefore);
});
test('a wheel a short scroller cannot act on still asks for history', async ({
promptRailWindow: page,
}) => {
const firstLoadedTurn = () => page
.locator('[data-transcript-turn-id]')
.first()
.getAttribute('data-transcript-turn-id');
const firstBefore = await firstLoadedTurn();
await page.setViewportSize({ width: 900, height: 4000 });
await waitForPaintedFrames(page, 6);
const asked = await scrollMetrics(page);
expect(asked.distance, JSON.stringify(asked)).toBeLessThanOrEqual(4);
// Dispatched rather than driven, and that is the point: the case is a wheel
// the scroller cannot act on — already at zero, or too short to move — where
// no scroll follows and the authority never learns the reader asked. A real
// `mouse.wheel` would scroll, and the scroll alone would carry the request.
await page.evaluate((selector) => {
const root = document.querySelector(selector);
if (!root) throw new Error('the chat scroll container is missing');
root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }));
}, SCROLLER);
await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore);
});