源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user