/* * 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 { 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 { 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 { 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 { 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 { 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 { await page.evaluate((frames) => new Promise((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('[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('[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('[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(selector); const list = root?.querySelector('.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('[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('[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); });