From 01d63006a6896220a76b32c0293dedd7f4adec87 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 15:36:05 +0800 Subject: [PATCH] test(desktop): remove the layout-tier E2E specs that block main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five of the last six CI runs on main were red, and three of them were one of these specs sampling transcript geometry before it settled. Four of the five open flake reports live in two files: #4648, #4688 and #4707 in transcript-scroll.spec.ts, #4675 in prompt-rail.spec.ts. Fixing them one at a time has not worked — #4620 fixed the same failure mode in the same file that #4648 then reported somewhere else in it. These 22 tests need a real layout engine, not Electron. They assert scroll offsets and bounding boxes against a compositor that settles on its own schedule, so under four-worker Xvfb load they read a scroller mid-pin and block changes that never touched scrolling. retries: 0 is why this is visible and stays that way; the tests are what is in the wrong place. streaming-remount.spec.ts's interrupted-turn remount test (#4664) goes with them for a different reason: it is not a timing artifact. The bubble mounts as historical while the Turn is live, which is a product race the test found and cannot itself fix. #4664 stays open as a product bug. Removes what that leaves unreachable: promptRailMotionWindow had no other caller. The scrollMotion fixture option below it keeps its unit coverage and no E2E consumer until this coverage is rebuilt one tier down. Refs #4727 Generated-by: Claude Code --- apps/desktop/e2e/fixtures.ts | 28 +- apps/desktop/e2e/prompt-rail.spec.ts | 622 -------------- apps/desktop/e2e/streaming-remount.spec.ts | 88 -- apps/desktop/e2e/transcript-scroll.spec.ts | 774 ------------------ .../src/main/e2e-fixture/scenarios-chat.ts | 4 +- .../src/renderer/scroll-motion-policy.ts | 7 +- .../transcript-scroll-authority.test.ts | 6 +- 7 files changed, 11 insertions(+), 1518 deletions(-) delete mode 100644 apps/desktop/e2e/prompt-rail.spec.ts delete mode 100644 apps/desktop/e2e/transcript-scroll.spec.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 02130965c3..53020800d5 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -572,7 +572,6 @@ type E2eTestFixtures = { railRenderWindow: Page; promptRailWindow: Page; partialHistoryWindow: Page; - promptRailMotionWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; @@ -741,8 +740,9 @@ export const test = base.extend({ await use({ app, page, viewport }); }); }, { scope: 'worker' }], - // A multi-prompt transcript for the prompt anchor rail. Shown, because every - // assertion in prompt-rail.spec.ts is geometry the compositor has to settle. + // A multi-prompt transcript. Shown, because the perf suite that measures it + // reads real frame pacing, and a throttled compositor paces nothing a user + // would see. promptRailWindow: async ({ promptRailWorker }, use) => { await setPromptRailWindowVisible(promptRailWorker, true); try { @@ -763,28 +763,6 @@ export const test = base.extend({ showWindow: true, }, use); }, - // The same transcript, scrolling the way the shipped app scrolls. Separate - // from `promptRailWindow` because it is only the jump that needs a scroll - // still in flight, and paying for one everywhere costs several seconds per - // window and settles less predictably. - promptRailMotionWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - // The transcript and the fixture attributes arrive on two unordered - // async paths: `runDeferredStartupRefreshes` fires `refreshSessions()` - // and `applyE2eFixture()` side by side, and only the second one — after - // its `e2eFixture.getState()` IPC resolves — writes - // `data-maka-scroll-motion`. A turn can therefore paint while the - // document still says nothing about scroll motion. Requiring both in one - // selector is what makes "this window scrolls smoothly" true by the time - // a test body reads it. - readinessSelector: 'html[data-maka-scroll-motion="smooth"] [data-turn-id]', - e2eFixtureScenario: 'chat-prompt-rail', - locale: 'zh', - showWindow: true, - scrollMotion: 'smooth', - }, use); - }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — // the connection type whose detail page owns the custom request headers // editor. Shown, because what this window is for is a rendered box diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts deleted file mode 100644 index 7af51a35c2..0000000000 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ /dev/null @@ -1,622 +0,0 @@ -/* - * 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 { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; -import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract'; -import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; -import type { Page } from '@playwright/test'; - -const MAX_PROMPT_RAIL_TICKS = 64; - -/** - * The prompt anchor rail (#563) has failed three times in a row in the same - * way: the code kept working and the pixels stopped. #2161 pinned it against - * `.maka-chat-shell` while Astryx's ChatLayout owned the scroll container, so - * the rail laid out across the whole conversation and scrolled off screen. - * #2338 parked it under macOS's overlay scrollbar, which takes no layout space - * but still swallows the pointer, so every tick rendered and none could be - * clicked. #2580 moved the tick onto Astryx's `Button`, whose label span put - * the tick's bar back into normal flow — an inline box takes no width or - * height, so the bars computed to 0x0 and the rail shipped invisible in 0.1.9 - * and 0.1.10. - * - * None of the three is visible to a static read of the CSS, and none is - * reachable from jsdom, which has no layout. Only a real scroller with a real - * transcript can see them, so this file keeps one test per failure and nothing - * else. It is deliberately much smaller than the suite deleted in #2462. - * - * Two boundaries worth knowing before adding to it: - * - e2e-fixture renders carry `data-maka-e2e-fixture`, and `base.css` gives - * that `animation: none` plus a 0.01ms transition cap, so a fixture's state - * never depends on when it settles. Assert the end states motion resolves - * to; there is no way to assert that anything animated. - * - the reachability test is load-bearing on macOS only. Linux's in-flow - * scrollbar moves the content column left instead of overlaying it, so the - * #2338 regression goes green on CI. Run this spec on macOS before merging - * anything that touches the rail's right edge. - */ - -const RAIL_PROBE = `(() => { - const scroller = document.querySelector('[data-chat-scroll-container="true"]'); - const rail = document.querySelector('.maka-prompt-rail'); - if (!scroller || !rail) return null; - const s = scroller.getBoundingClientRect(); - const r = rail.getBoundingClientRect(); - // Astryx renders the composer dock as the scroll container's last child; - // the rail measures it the same way, for want of a published hook. - const dock = scroller.lastElementChild.getBoundingClientRect(); - return { - // Positive on all four = the rail's box is inside the scrollport and clear - // of the band the sticky dock occupies at the bottom. - insetTop: Math.round(r.top - s.top), - insetBottom: Math.round(s.bottom - r.bottom), - insetRight: Math.round(s.right - r.right), - dockClearance: Math.round(dock.top - r.bottom), - railWidth: Math.round(r.width), - scrollTop: Math.round(scroller.scrollTop), - }; -})()`; - -interface RailProbe { - insetTop: number; - insetBottom: number; - insetRight: number; - dockClearance: number; - railWidth: number; - scrollTop: number; -} - -function probeRail(page: Page): Promise { - return page.evaluate(RAIL_PROBE) as Promise; -} - -async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promise { - await page.evaluate((where) => { - const scroller = document.querySelector('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('the chat scroll container is missing'); - scroller.scrollTop = where === 'top' ? 0 : scroller.scrollHeight; - }, position); - await notifyTranscriptScrolled(page); - await waitForPaintedFrames(page); -} - -async function scrollTranscriptAwayFromTail(page: Page): Promise { - await page.evaluate(() => { - const root = document.querySelector('[data-chat-scroll-container="true"]'); - if (!root) throw new Error('the chat scroll container is missing'); - const historyLoadBand = Math.max(640, root.clientHeight * 2); - root.scrollTop = Math.min( - root.scrollHeight - root.clientHeight - 100, - historyLoadBand + 200, - ); - root.dispatchEvent(new Event('scroll')); - }); - await waitForPaintedFrames(page); -} - -async function waitForPaintedFrames(page: Page, count = 2): Promise { - await page.evaluate((frames) => new Promise((resolve) => { - const tick = (left: number) => { - if (left <= 0) { - resolve(); - return; - } - requestAnimationFrame(() => tick(left - 1)); - }; - tick(frames); - }), count); -} - -function notifyTranscriptScrolled(page: Page): Promise { - return page.evaluate(() => { - const root = document.querySelector('[data-chat-scroll-container="true"]'); - if (!root) throw new Error('the chat scroll container is missing'); - root.dispatchEvent(new Event('scroll')); - }); -} - -interface ActivePromptRailSnapshot { - currentIds: string[]; - expectedId: string | null; - sourceTurnId: string | null; -} - -async function activePromptRailSnapshot(page: Page): Promise { - return page.evaluate(async ({ promptCount }) => { - const root = document.querySelector('[data-chat-scroll-container="true"]'); - if (!root) throw new Error('the chat scroll container is missing'); - const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; - const currentIds = ticks - .filter((tick) => tick.getAttribute('aria-current') === 'true') - .map((tick) => tick.dataset.promptTurnId ?? ''); - const rootBounds = root.getBoundingClientRect(); - const atEnd = root.scrollHeight - root.scrollTop - root.clientHeight <= 2; - const turns = [...root.querySelectorAll('[data-transcript-turn-id]')] - .map((turn) => ({ - element: turn, - id: turn.dataset.transcriptTurnId ?? '', - index: Number(turn.dataset.transcriptTurnId?.split('-').at(-1)) - 1, - })) - .filter((turn) => turn.id.length > 0 && Number.isFinite(turn.index)); - const readingBandTurns = turns - .filter(({ element }) => { - const bounds = element.getBoundingClientRect(); - return bounds.bottom > rootBounds.top - && bounds.top < rootBounds.top + rootBounds.height * 0.34; - }) - .sort((left, right) => left.index - right.index); - const scrollportTurns = turns - .filter(({ element }) => { - const bounds = element.getBoundingClientRect(); - return bounds.bottom > rootBounds.top && bounds.top < rootBounds.bottom; - }) - .sort((left, right) => left.index - right.index); - const sourceTurn = atEnd - ? turns.reduce( - (latest, turn) => latest === null || turn.index > latest.index ? turn : latest, - null, - ) - : readingBandTurns[0] ?? scrollportTurns[0] ?? null; - const expectedRailIndex = sourceTurn === null || ticks.length === 0 - ? null - : Math.round( - sourceTurn.index * (ticks.length - 1) / (promptCount - 1), - ); - const expectedId = expectedRailIndex === null - ? null - : ticks[expectedRailIndex]?.dataset.promptTurnId ?? null; - return { - currentIds, - expectedId, - sourceTurnId: sourceTurn?.id ?? null, - }; - }, { promptCount: PROMPT_RAIL_PROMPT_COUNT }); -} - -async function expectPromptRailMatchesReadingPosition(page: Page): Promise { - let lastSnapshot: ActivePromptRailSnapshot | null = null; - try { - await expect.poll(async () => { - lastSnapshot = await activePromptRailSnapshot(page); - return lastSnapshot.expectedId !== null - && lastSnapshot.currentIds.length === 1 - && lastSnapshot.currentIds[0] === lastSnapshot.expectedId; - }, { message: 'the one current tick maps from the Turn being read' }).toBe(true); - } catch { - throw new Error(`the prompt rail did not settle on the reading position: ${JSON.stringify(lastSnapshot)}`); - } - const snapshot = await activePromptRailSnapshot(page); - expect(snapshot.expectedId, `no visible Turn in ${JSON.stringify(snapshot)}`).not.toBeNull(); - expect(snapshot.currentIds).toEqual([snapshot.expectedId]); -} - -async function scrollTranscriptThroughHistory(page: Page): Promise { - for (let pageIndex = 0; pageIndex < PROMPT_RAIL_PROMPT_COUNT; pageIndex += 1) { - const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); - if (firstBefore === 'turn-prompt-rail-1') return; - await page.evaluate(() => { - const root = document.querySelector('[data-chat-scroll-container="true"]'); - if (!root) throw new Error('the chat scroll container is missing'); - root.scrollTop = 0; - root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); - root.dispatchEvent(new Event('scroll')); - }); - await expect.poll(async () => - page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), { - message: `history loads before ${firstBefore}`, - timeout: 20_000, - }).not.toBe(firstBefore); - await waitForPaintedFrames(page); - await expectPromptRailMatchesReadingPosition(page); - } - throw new Error('the first prompt did not enter the active transcript range'); -} - -test('every tick paints a bar with a real box', async ({ promptRailWindow: page }) => { - // Measured over ALL ticks, not a sample: a helper that skips what it cannot - // evaluate creates its blind spot exactly where a regression lives. - const bars = await page.evaluate(() => - [...document.querySelectorAll('.maka-prompt-rail-tick-bar')].map((bar) => { - const box = bar.getBoundingClientRect(); - return { width: Math.round(box.width), height: Math.round(box.height) }; - }), - ); - - expect(bars).toHaveLength(Math.min(PROMPT_RAIL_PROMPT_COUNT, MAX_PROMPT_RAIL_TICKS)); - // #2580 shipped bars at 0x0 — present in the DOM, painting nothing. - expect(Math.min(...bars.map((bar) => bar.width))).toBeGreaterThan(0); - expect(Math.min(...bars.map((bar) => bar.height))).toBeGreaterThan(0); -}); - -test('the rail stays inside the scrollport at both scroll extremes', async ({ - promptRailWindow: page, -}) => { - const scroll = await page.evaluate(() => { - const scroller = document.querySelector('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('the chat scroll container is missing'); - return { height: scroller.scrollHeight, client: scroller.clientHeight }; - }); - // Without an overflowing transcript the rail has nothing to be pinned - // against and the rest of this test proves nothing. - expect(scroll.height).toBeGreaterThan(scroll.client); - - for (const position of ['top', 'bottom'] as const) { - await scrollTranscriptTo(page, position); - // The bottom is where it bites: a sticky offset is clamped by its - // containing block, and the chat shell ends a dock-height above the - // scrollport's bottom edge (CI caught -62px insetTop that way). - await expect - .poll(async () => { - const probe = await probeRail(page); - if (!probe) return null; - return { - insetTop: probe.insetTop >= 0, - insetBottom: probe.insetBottom >= 0, - insetRight: probe.insetRight >= 0, - dockClearance: probe.dockClearance >= 0, - }; - }, { message: `rail geometry at the ${position} of the transcript` }) - .toEqual({ - insetTop: true, - insetBottom: true, - insetRight: true, - dockClearance: true, - }); - } -}); - -test('the pointer is always on a tick while it travels down the rail', async ({ - promptRailWindow: page, -}) => { - // The hover falloff reads which tick the pointer entered. A gap between the - // hit boxes is a band where it is over the rail and over no tick, so the - // effect drops out and picks up again every few pixels of travel. Walked a - // pixel at a time rather than sampled between two ticks: a single midpoint - // would pass on a rail whose gaps sat anywhere else. - const travel = await page.evaluate(() => { - const bars = [...document.querySelectorAll('.maka-prompt-rail-tick-bar')]; - if (bars.length < 2) throw new Error('the prompt rail needs at least two ticks'); - const first = bars[0]!.getBoundingClientRect(); - const last = bars[bars.length - 1]!.getBoundingClientRect(); - const x = Math.round(first.left + first.width / 2); - const misses: number[] = []; - for (let y = Math.round(first.top + first.height / 2); y <= Math.round(last.top + last.height / 2); y += 1) { - const found = document.elementFromPoint(x, y); - if (!found?.closest('.maka-prompt-rail-tick')) misses.push(y); - } - return { misses: misses.length, span: Math.round(last.bottom - first.top) }; - }); - - expect(travel.span).toBeGreaterThan(0); - expect(travel.misses).toBe(0); -}); - -test('the first click of a session lands on its prompt and holds', async ({ - promptRailMotionWindow: page, -}) => { - // Clicked before anything else touches the transcript, which is the case - // that used to fail: the head of a 30-prompt session is not mounted yet, so - // the jump has to mount it, and the fill that follows changes scrollHeight - // under Astryx's auto-follow lock. The lock ignores scroll-ups that arrive - // with a changed height, so it stayed on and pulled the transcript back to - // the bottom — the click looked dead until the reader scrolled by hand. - // - // This window is the only one in the suite that scrolls smoothly, which is - // why it is its own fixture. Every capture otherwise collapses scroll - // motion, and a jump that finishes in one frame is never in flight long - // enough to meet the lock at all. `emulateMedia` cannot arrange it: the - // collapse is keyed on `data-maka-e2e-fixture`, not on the media query. - expect(await page.evaluate(() => document.documentElement.dataset.makaScrollMotion)).toBe( - 'smooth', - ); - - // The first tick's turn, named rather than inferred: the first mounted - // `[data-turn-id]` is whatever the tail window happens to hold, and at the - // opening scroll position its top is already above the scrollport, which - // passes an upper-bound-only check without the jump doing anything at all. - const targetTurnId = 'turn-prompt-rail-1'; - await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); - - const landing = async () => - page.evaluate((turnId) => { - const scroller = document.querySelector('[data-chat-scroll-container="true"]')!; - const turn = document.querySelector(`[data-turn-id="${turnId}"]`); - if (!turn) return null; - const tick = document.querySelector('.maka-prompt-rail-tick'); - return { - offset: Math.round( - turn.getBoundingClientRect().top - scroller.getBoundingClientRect().top, - ), - tickIsCurrent: tick?.getAttribute('aria-current') === 'true', - }; - }, targetTurnId); - - // Bounded on both sides: below is the turn never arriving, above is it - // arriving and then being pulled off the top of the scrollport. - await expect - .poll(async () => { - const offset = (await landing())?.offset; - return offset === undefined ? Number.POSITIVE_INFINITY : Math.abs(offset); - }, { message: 'the clicked prompt reaches the top' }) - .toBeLessThan(24); - expect((await landing())?.tickIsCurrent).toBe(true); - - // And stays: turns keep resolving their content and remeasuring after the - // jump, so a jump that only wins the first frame reads as landing and then - // sliding away. - await page.waitForTimeout(1_200); - const settled = await landing(); - expect(settled?.offset).toBeGreaterThan(-24); - expect(settled?.offset).toBeLessThan(24); - expect(settled?.tickIsCurrent).toBe(true); -}); - -test('manual transcript scrolling keeps exactly the visible prompt current', async ({ - promptRailWindow: page, -}) => { - await page.setViewportSize({ width: 1_000, height: 700 }); - await scrollTranscriptTo(page, 'bottom'); - await expectPromptRailMatchesReadingPosition(page); - await expect(page.locator('.maka-prompt-rail-tick[aria-current="true"]')).toHaveCount(1); - await expect(page.locator('.maka-prompt-rail-tick').last()).toHaveAttribute( - 'aria-current', - 'true', - ); - - await page.evaluate(() => { - const rail = document.querySelector('.maka-prompt-rail'); - if (!rail) throw new Error('the prompt rail is missing'); - const counts: number[] = []; - const record = () => counts.push( - rail.querySelectorAll('.maka-prompt-rail-tick[aria-current="true"]').length, - ); - const observer = new MutationObserver(record); - observer.observe(rail, { - attributes: true, - subtree: true, - attributeFilter: ['aria-current'], - }); - record(); - Object.assign(window, { - __makaPromptRailCurrentCounts: counts, - __makaPromptRailCurrentObserver: observer, - }); - }); - - await scrollTranscriptThroughHistory(page); - await scrollTranscriptTo(page, 'top'); - await expectPromptRailMatchesReadingPosition(page); - await expect(page.locator('.maka-prompt-rail-tick[aria-current="true"]')).toHaveCount(1); - await expect(page.locator('.maka-prompt-rail-tick').first()).toHaveAttribute( - 'aria-current', - 'true', - ); - - const currentCounts = await page.evaluate(() => { - const state = window as Window & { - __makaPromptRailCurrentCounts?: number[]; - __makaPromptRailCurrentObserver?: MutationObserver; - }; - state.__makaPromptRailCurrentObserver?.disconnect(); - return state.__makaPromptRailCurrentCounts ?? []; - }); - expect(currentCounts.length).toBeGreaterThan(1); - expect(currentCounts.every((count) => count === 1), currentCounts.join(',')).toBe(true); -}); - -test('streaming deltas do not reconstruct the prompt rail observer', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - const sendAndSettle = async (prompt: string, expectedTurns: number): Promise => { - await composer.fill(prompt); - await composer.press('Enter'); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(expectedTurns, { - timeout: 20_000, - }); - }; - await sendAndSettle('First prompt rail observer seed', 1); - await sendAndSettle('Second prompt rail observer seed', 2); - - await page.evaluate(() => { - const NativeIntersectionObserver = window.IntersectionObserver; - const state: { - constructions: number; - initialConstructions: number | null; - } = { constructions: 0, initialConstructions: null }; - window.IntersectionObserver = class extends NativeIntersectionObserver { - constructor( - callback: IntersectionObserverCallback, - options?: IntersectionObserverInit, - ) { - super(callback, options); - if ( - options?.root === document.querySelector('[data-chat-scroll-container="true"]') - && options.rootMargin === '0px 0px -66% 0px' - ) { - state.constructions += 1; - state.initialConstructions ??= state.constructions; - } - } - }; - Object.assign(window, { __makaPromptRailObserverProbe: state }); - }); - - const streamingPrompt = Array.from( - { length: 40 }, - (_, index) => `Observer stability line ${index + 1}`, - ).join('\n'); - await composer.fill(streamingPrompt); - await composer.press('Enter'); - - await expect.poll(() => page.evaluate(() => ( - window as Window & { - __makaPromptRailObserverProbe?: { constructions: number }; - } - ).__makaPromptRailObserverProbe?.constructions ?? 0), { - message: 'the third Turn creates the prompt rail observer', - }).toBeGreaterThan(0); - - // The fake backend emits nine characters per delta, so reaching the last - // line proves many same-Turn text updates landed after observer creation. - await expect(page.getByRole('log').getByText( - /Fake backend received:[\s\S]*Observer stability line 40/, - )).toBeVisible({ - timeout: 20_000, - }); - - const settled = await page.evaluate(() => ({ ...( - window as Window & { - __makaPromptRailObserverProbe: { - constructions: number; - initialConstructions: number | null; - }; - } - ).__makaPromptRailObserverProbe })); - expect(settled.constructions).toBe(settled.initialConstructions); -}); - -test('active transcript Turns keep stable DOM identities while scrolling', async ({ - promptRailWindow: page, -}) => { - const sourceCount = Number( - await page.locator('.maka-chat-message-list').getAttribute('data-turn-source-count'), - ); - expect(sourceCount).toBe(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); - expect(await page.locator('[data-turn-id]').count()).toBe(sourceCount); - await page.evaluate(() => { - for (const turn of document.querySelectorAll('[data-turn-id]')) { - turn.dataset.stableMountProbe = turn.dataset.turnId; - } - }); - - await scrollTranscriptTo(page, 'bottom'); - await scrollTranscriptAwayFromTail(page); - - expect(await page.locator('[data-turn-id]').count()).toBe(sourceCount); - expect(await page.locator('[data-turn-id][data-stable-mount-probe]').count()).toBe(sourceCount); -}); - -test('scrolling away preserves a turn-owned focus and selection', async ({ - promptRailWindow: page, -}) => { - await scrollTranscriptTo(page, 'bottom'); - await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); - await page.evaluate(() => { - const turn = document.querySelector('[data-turn-id="turn-prompt-rail-120"]'); - if (!turn) throw new Error('the tail Turn is missing'); - const turnOwnedAction = document.createElement('button'); - turnOwnedAction.dataset.turnOwnedAction = 'true'; - turnOwnedAction.textContent = 'Turn-owned action'; - turn.append(turnOwnedAction); - turnOwnedAction.focus(); - const range = document.createRange(); - range.selectNodeContents(turnOwnedAction); - const selection = document.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }); - await scrollTranscriptAwayFromTail(page); - - await expect.poll(async () => page.evaluate(() => { - const active = document.activeElement; - return { - retained: document.querySelector('[data-turn-id="turn-prompt-rail-120"]') !== null, - focusRetained: active instanceof HTMLElement - && active.dataset.turnOwnedAction === 'true', - selectionRetained: document.getSelection()?.isCollapsed === false, - }; - })).toEqual({ - retained: true, - focusRetained: true, - selectionRetained: true, - }); -}); - -test('offscreen active Turns remain findable and accessible', async ({ - promptRailWindow: page, -}) => { - const firstTurnId = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); - const turnNumber = Number(firstTurnId?.split('-').at(-1)); - expect(turnNumber).toBeGreaterThan(0); - const needle = `第 ${turnNumber} 个问题`; - await scrollTranscriptTo(page, 'bottom'); - - const found = await page.evaluate((text) => { - document.getSelection()?.removeAllRanges(); - return (window as Window & { find(text: string): boolean }).find(text); - }, needle); - expect(found).toBe(true); - expect(await page.evaluate(() => document.getSelection()?.toString() ?? '')).toContain(needle); - - const cdp = await page.context().newCDPSession(page); - const tree = await cdp.send('Accessibility.getFullAXTree'); - expect(tree.nodes.some((node) => node.name?.value?.includes(needle))).toBe(true); -}); - -test('switching sessions reconstructs only the Host active range', async ({ - promptRailWindow: page, -}) => { - await ensureSidebarExpanded(page); - const rows = page.locator('.maka-session-row'); - const selected = rows.locator('button.astryx-side-nav-item.selected'); - const originalId = await selected - .evaluate((button) => button.closest('.maka-session-row')?.getAttribute('data-session-id')); - if (!originalId) throw new Error('the prompt-rail Session is not selected'); - const otherId = await rows.evaluateAll( - (rows, selected) => rows - .map((row) => row.getAttribute('data-session-id')) - .find((sessionId) => sessionId !== selected) ?? null, - originalId, - ); - if (!otherId) throw new Error('the fixture has no second Session'); - await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button`) - .first() - .click(); - await expect(page.locator( - `.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button.selected`, - )) - .toHaveCount(1); - - await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(originalId)}] button`) - .first() - .click(); - await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); - expect(await page.locator('[data-turn-id]').count()) - .toBe(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); -}); - -test('a tick is what the pointer lands on, not the scrollbar', async ({ - promptRailWindow: page, -}) => { - // `elementFromPoint`, not `hover()`: dispatched events cannot see occlusion, - // and macOS's overlay scrollbar occludes without taking layout space. - const hit = await page.evaluate(() => { - const tick = document.querySelector('.maka-prompt-rail-tick'); - if (!tick) throw new Error('the prompt rail has no ticks'); - const box = tick.getBoundingClientRect(); - const found = document.elementFromPoint( - Math.round(box.left + box.width / 2), - Math.round(box.top + box.height / 2), - ); - return { insideRail: found?.closest('.maka-prompt-rail') !== null }; - }); - - expect(hit.insideRail).toBe(true); -}); diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index f72efb1f81..d12961ef00 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -20,7 +20,6 @@ import { FAKE_HOLD_OPEN_PROMPT, FAKE_HOLD_OPEN_REWRITE_PROMPT, - FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT, } from '@maka/runtime/test-only/fake-backend'; import type { Locator } from '@playwright/test'; import { @@ -175,93 +174,6 @@ test('remounting a live surface leaves accumulated output settled', async ({ .toBe(true); }); -test('keeps a completed reply after an interrupted turn and conversation remount', async ({ - window: page, -}) => { - await page.emulateMedia({ reducedMotion: 'no-preference' }); - expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('temporary conversation'); - await awaitSendReady(page); - await composer.press('Enter'); - await expect(page.getByRole('log')).toContainText( - 'Fake backend received: temporary conversation', - { timeout: 20_000 }, - ); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await expect(page.locator('[data-agents-page]')).toHaveAttribute( - 'data-sidebar-state', - 'expanded', - ); - const temporarySessionId = await sidebar - .locator('[data-session-id]:has([aria-current="page"])') - .getAttribute('data-session-id'); - expect(temporarySessionId).toBeTruthy(); - await composer.fill('draft before starting the interrupted conversation'); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); - await expect(composer).toHaveText(''); - - await composer.fill(FAKE_HOLD_OPEN_PROMPT); - await awaitSendReady(page); - await composer.press('Enter'); - await expect(page.locator('.maka-bubble-streaming')).toContainText( - 'Fake backend waiting', - { timeout: 20_000 }, - ); - const originalSessionId = await sidebar - .locator('[data-session-id]:has([aria-current="page"])') - .getAttribute('data-session-id'); - expect(originalSessionId).toBeTruthy(); - expect(originalSessionId).not.toBe(temporarySessionId); - await page.getByRole('button', { name: '停止' }).click(); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - await expect.poll( - () => page.evaluate(async (sessionId) => ( - (await window.maka.sessions.list()).find((session) => session.id === sessionId) - ?.runningTurnIds?.length ?? 0 - ), originalSessionId!), - { timeout: 20_000 }, - ).toBe(0); - await composer.fill(FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT); - await awaitSendReady(page); - await composer.press('Enter'); - await expect(page.locator('.maka-user-message', { - hasText: FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT, - })).toBeVisible(); - await expect(page.getByRole('button', { name: '停止' })).toBeVisible({ - timeout: 20_000, - }); - const steering = 'use the detailed response'; - const completedReply = 'Large response complete.'; - await steerActiveTurn(composer, steering); - await expect(page.getByRole('log')).toContainText(completedReply); - await expect(page.getByRole('button', { name: '停止' })).toHaveCount(0, { - timeout: 20_000, - }); - await expect(page.locator('.maka-bubble-streaming')).toHaveCount(0, { - timeout: 20_000, - }); - - const temporarySessionRow = sessionRow(sidebar, temporarySessionId!); - await temporarySessionRow.click(); - await expect(temporarySessionRow.locator('[aria-current="page"]')).toHaveCount(1, { - timeout: 20_000, - }); - await expect(page.getByRole('log')).toContainText('Fake backend received: temporary conversation'); - const originalSessionRow = sessionRow(sidebar, originalSessionId!); - await originalSessionRow.click(); - await expect(originalSessionRow.locator('[aria-current="page"]')).toHaveCount(1, { - timeout: 20_000, - }); - await expect(page.getByRole('log')).toContainText(completedReply); -}); - test('returning to a live conversation settles output accumulated while away', async ({ window: page, }) => { diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts deleted file mode 100644 index 2a7fa86772..0000000000 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ /dev/null @@ -1,774 +0,0 @@ -/* - * 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 { - awaitSendReady, - expect, - test, - COMPOSER_INPUT, - ensureSidebarExpanded, -} 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((names) => { - const button = [...document.querySelectorAll('button')].find( - (candidate) => names.includes(candidate.getAttribute('aria-label') ?? '') - || names.includes(candidate.textContent?.trim() ?? ''), - ); - if (!button) throw new Error(`the "${names.join('" / "')}" affordance is missing`); - const style = getComputedStyle(button); - return style.pointerEvents !== 'none' && Number(style.opacity) > 0.5; - }, ['滚动主对话到底部', 'Scroll main conversation to bottom']); -} - -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); -} - -function turnOffsetFromScroller(page: Page, turnId: string): Promise { - return page.evaluate(([selector, id]) => { - const root = document.querySelector(selector); - const turn = document.querySelector(`[data-turn-id="${CSS.escape(id as string)}"]`); - if (!root || !turn) return Number.POSITIVE_INFINITY; - return Math.round(turn.getBoundingClientRect().top - root.getBoundingClientRect().top); - }, [SCROLLER, turnId] as const); -} - -function waitForStableTurnAtScrollerStart(page: Page, turnId: string): Promise { - return page.evaluate(([selector, id]) => new Promise((resolve, reject) => { - let previousTop = Number.NaN; - let previousHeight = Number.NaN; - let stableFrames = 0; - const timeout = window.setTimeout( - () => reject(new Error(`Turn ${id} did not settle at the scroller start`)), - 10_000, - ); - const measure = (): void => { - const root = document.querySelector(selector); - const turn = root?.querySelector(`[data-turn-id="${CSS.escape(id)}"]`); - if (root && turn) { - const offset = turn.getBoundingClientRect().top - root.getBoundingClientRect().top; - stableFrames = Math.abs(offset) <= 4 - && root.scrollTop === previousTop - && root.scrollHeight === previousHeight - ? stableFrames + 1 - : 0; - previousTop = root.scrollTop; - previousHeight = root.scrollHeight; - if (stableFrames >= 6) { - window.clearTimeout(timeout); - resolve(); - return; - } - } - window.requestAnimationFrame(measure); - }; - measure(); - }), [SCROLLER, 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); - // Switching Session or model restarts asynchronous send admission. - await awaitSendReady(page); - 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); -}); - -test('switching Sessions restores a Turn anchor while a tail Session follows background growth', async ({ - promptRailWindow: page, -}) => { - test.slow(); - await page.setViewportSize({ width: 900, height: 700 }); - await ensureSidebarExpanded(page); - const rows = page.locator('.maka-session-row'); - const selected = rows.locator('button.astryx-side-nav-item.selected'); - const readingSessionId = await selected.evaluate( - (button) => button.closest('.maka-session-row')?.getAttribute('data-session-id'), - ); - if (!readingSessionId) throw new Error('the prompt-rail Session is not selected'); - const tailSessionId = await rows.evaluateAll( - (items, active) => items - .map((row) => row.getAttribute('data-session-id')) - .find((sessionId) => sessionId !== active) ?? null, - readingSessionId, - ); - if (!tailSessionId) throw new Error('the fixture has no tail-intent Session'); - const rowButton = (sessionId: string) => page.locator( - `.maka-session-row[data-session-id=${JSON.stringify(sessionId)}] button`, - ).first(); - - // Move the active bounded range away from the latest window, then establish - // a reading position through the real scroll event path. Reopening the - // Session now has to use the saved sequence before the Turn can exist. - await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); - const readingTurnId = 'turn-prompt-rail-1'; - await expect(page.locator(`[data-turn-id="${readingTurnId}"]`)).toHaveCount(1, { - timeout: 20_000, - }); - await expect.poll( - async () => Math.abs(await turnOffsetFromScroller(page, readingTurnId)), - { message: 'the unloaded prompt reaches the scroller start' }, - ).toBeLessThanOrEqual(24); - // The prompt rail holds its target through late content measurement. Six - // unchanged painted frames exceed its own quiet-frame handoff, so switching - // after this point cannot carry that release into the next Session. Observe - // transcript geometry directly: the Astryx dock can be briefly unmounted on - // a loaded Xvfb worker even though the reading position is already stable. - await waitForStableTurnAtScrollerStart(page, readingTurnId); - expect(await distanceToTail(page)).toBeGreaterThan(100); - - await rowButton(tailSessionId).click(); - await expect(rowButton(tailSessionId)).toHaveClass(/selected/); - await waitForPaintedFrames(page, 6); - expect(await distanceToTail(page)).toBeLessThanOrEqual(4); - // The fixture Session predates connection identities. Choosing any current - // model upgrades it onto the E2E Runtime Host before this test starts a Turn. - const modelSwitcher = page.locator('.maka-model-switcher-trigger'); - await modelSwitcher.click(); - await page.getByRole('menuitemradio', { name: 'glm-4.5', exact: true }).click(); - await expect(modelSwitcher).toContainText('glm-4.5'); - - await page.evaluate((sessionId) => { - const state = { complete: false, unsubscribe: () => undefined }; - state.unsubscribe = window.maka.sessions.subscribeEvents(sessionId, (event) => { - if (event.type !== 'complete') return; - state.complete = true; - state.unsubscribe(); - }); - (window as typeof window & { __makaBackgroundTailProbe?: typeof state }) - .__makaBackgroundTailProbe = state; - }, tailSessionId); - await sendPrompt(page, LONG_PROMPT); - await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible({ - timeout: 20_000, - }); - - // The transcript collapses before each async replacement. This round trip - // therefore exercises the production ordering that made a saved scrollTop - // become zero, not merely a pre-filled test tree. - await rowButton(readingSessionId).click(); - await expect.poll( - async () => Math.abs(await turnOffsetFromScroller(page, readingTurnId)), - { timeout: 20_000, message: 'the saved reading Turn returns to the scroller start' }, - ).toBeLessThanOrEqual(4); - expect(await scrollButtonOffered(page)).toBe(true); - - await expect.poll(() => page.evaluate(() => ( - window as typeof window & { - __makaBackgroundTailProbe?: { complete: boolean }; - } - ).__makaBackgroundTailProbe?.complete === true), { timeout: 30_000 }).toBe(true); - await rowButton(tailSessionId).click(); - await expect(rowButton(tailSessionId)).toHaveClass(/selected/); - await waitForPaintedFrames(page, 6); - const tail = await scrollMetrics(page); - expect(tail.distance, JSON.stringify(tail)).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 }); - // The turn's arrival and the tail-follow write are two steps, so one sample - // races the follow on a loaded runner. Poll until the reader has provably - // been carried back to the tail. - await expect.poll(async () => (await scrollMetrics(page)).distance, { - message: 'the transcript follows the landed answer to the tail', - }).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); - // The fixture is ready when the transcript exists, before its initial tail - // positioning necessarily completes. Poll one geometry sample so the pin has - // provably settled inside the load band and at the tail before the nested - // scroller exercises it. - await expect.poll(async () => { - const metrics = await scrollMetrics(page); - return { - insideLoadBand: metrics.scrollTop <= Math.max(640, metrics.clientHeight * 2), - settledAtTail: metrics.distance <= 4, - metrics, - }; - }, { - message: 'the initial transcript tail positioning settles', - }).toMatchObject({ - insideLoadBand: true, - settledAtTail: true, - }); - - 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 fixture is ready when the transcript exists, before its initial tail - // positioning necessarily completes. Writing zero while it is still at zero - // is a no-op, so no reader scroll event asks for earlier history. Require one - // geometry sample to prove both that the initial pin moved and where it - // landed before exercising the real move to the top. - await expect.poll(async () => { - const metrics = await scrollMetrics(page); - return { - positionedAwayFromStart: metrics.scrollTop > 0, - settledAtTail: metrics.distance <= 4, - metrics, - }; - }, { - message: 'the initial transcript tail positioning settles', - }).toMatchObject({ - positionedAwayFromStart: true, - settledAtTail: true, - }); - - // 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; - root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); - }, 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); - - // Same as above: the fixture being ready does not mean the initial tail - // positioning has completed. Poll until the pin has provably settled at the - // tail and inside the load band this test's history claim rests on. - await expect.poll(async () => { - const settled = await scrollMetrics(page); - return { - insideLoadBand: settled.scrollTop <= Math.max(640, settled.clientHeight * 2), - settledAtTail: settled.distance <= 4, - settled, - }; - }, { - message: 'the initial transcript tail positioning settles', - }).toMatchObject({ - insideLoadBand: true, - settledAtTail: true, - }); - - // 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); -}); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index 1ff6f54af6..fb26506f70 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -133,8 +133,8 @@ export function promptRailSession(now: number): SessionHeader { /** * A plain multi-prompt conversation: no tools, no thinking, no usage rows. - * `prompt-rail.spec.ts` measures the rail against this, so every turn is just - * a prompt and a reply long enough to push the transcript past the scrollport. + * The transcript perf suite measures against this, so every turn is just a + * prompt and a reply long enough to push the transcript past the scrollport. */ export function promptRailMessages(now: number): StoredMessage[] { const messages: StoredMessage[] = []; diff --git a/apps/desktop/src/renderer/scroll-motion-policy.ts b/apps/desktop/src/renderer/scroll-motion-policy.ts index 52a50e0c4c..74cf336533 100644 --- a/apps/desktop/src/renderer/scroll-motion-policy.ts +++ b/apps/desktop/src/renderer/scroll-motion-policy.ts @@ -53,10 +53,9 @@ export interface ScrollMotionPolicyInputs { * * Collapsing motion for every capture is right for a screenshot and wrong * for a test about scrolling: a scroll that finishes in one frame cannot - * collide with anything, so the fixture suite had no way to exercise the - * production smooth path at all (`prompt-rail.spec.ts` needs it — Astryx's - * auto-follow lock only contends with a scroll still in flight). This lets - * one fixture opt back in without loosening the default for the rest. + * collide with anything, and Astryx's auto-follow lock only contends with a + * scroll still in flight. This lets one fixture opt back in without + * loosening the default for the rest. * * Deliberately below the reduced-motion triggers: a fixture may ask for * motion the capture would otherwise skip, but nothing may override a diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index aaf66433a9..9901780cb0 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -19,9 +19,9 @@ /** * The state machine only. Whether the reader ends up looking at the right - * pixels is `apps/desktop/e2e/transcript-scroll.spec.ts`, in a real Chromium - * with a real scroller — a harness that fakes layout can only report the - * ordering the harness itself chose. + * pixels needs a real layout engine and currently has no test at all — a + * harness that fakes layout can only report the ordering the harness itself + * chose, so do not add that claim here. * * What is worth asserting here is the one property the whole design rests on: * a scroll event that this authority did not cause is the reader, exactly, with