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