From 04f692b638793ea137927e109e325fed12aeb7e9 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Sat, 29 Aug 2026 02:37:50 -0700 Subject: [PATCH] test(desktop): settle transcript scroll extremes A single scrollTop assignment can leave the scroller 2982px above the bottom, so the virtual window ends seven turns short and the tail turn never mounts. Re-scroll until a painted frame agrees it is at the end. Generated-by: Claude Code --- apps/desktop/e2e/prompt-rail.spec.ts | 49 ++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 44d7e64173..589b0ac832 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -85,7 +85,8 @@ function probeRail(page: Page): Promise { return page.evaluate(RAIL_PROBE) as Promise; } -async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promise { +/** One jump, so a caller can still observe whatever the app does after it. */ +async function scrollTranscriptOnce(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'); @@ -95,6 +96,24 @@ async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promi await waitForPaintedFrames(page); } +/** A single jump can stop thousands of px short of a virtualized extreme. */ +async function settleTranscriptAtExtreme(page: Page, position: 'top' | 'bottom'): Promise { + // Polls the remaining distance rather than a boolean, so giving up says how + // far off it was. + await expect.poll(async () => { + await scrollTranscriptOnce(page, position); + return page.evaluate((where) => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('the chat scroll container is missing'); + return Math.round( + where === 'top' + ? scroller.scrollTop + : scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop, + ); + }, position); + }).toBeLessThanOrEqual(1); +} + async function waitForPaintedFrames(page: Page, count = 2): Promise { await page.evaluate((frames) => new Promise((resolve) => { const tick = (left: number) => { @@ -118,7 +137,7 @@ function notifyTranscriptScrolled(page: Page): Promise { async function loadPromptRailBeyondVirtualWindow(page: Page): Promise { const transcript = page.locator('.maka-chat-message-list'); - await scrollTranscriptTo(page, 'top'); + await scrollTranscriptOnce(page, 'top'); await transcript.hover(); await page.mouse.wheel(0, -100); await expect.poll(async () => Number(await transcript.getAttribute('data-turn-source-count'))) @@ -154,7 +173,7 @@ test('the rail stays inside the scrollport at both scroll extremes', async ({ expect(scroll.height).toBeGreaterThan(scroll.client); for (const position of ['top', 'bottom'] as const) { - await scrollTranscriptTo(page, position); + await settleTranscriptAtExtreme(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). @@ -291,8 +310,26 @@ test('evicting a turn-owned sibling interaction hands focus back to the transcri const scroller = page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]'); await scroller.waitFor(); await loadPromptRailBeyondVirtualWindow(page); - await scrollTranscriptTo(page, 'bottom'); - await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + await settleTranscriptAtExtreme(page, 'bottom'); + // A bare count says only that a turn is missing. Report the scroll and window + // state as one string; a subset matcher would print just the key it matched. + await expect.poll(async () => page.evaluate(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('the chat scroll container is missing'); + const mounted = [...document.querySelectorAll('[data-virtual-turn-id]')] + .map((turn) => turn.dataset.virtualTurnId ?? ''); + const gap = Math.round(scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop); + const source = document.querySelector('.maka-chat-message-list') + ?.getAttribute('data-turn-source-count'); + return [ + `tail=${mounted.includes('turn-prompt-rail-120')}`, + `gap=${gap}`, + `client=${scroller.clientHeight}`, + `mounted=${mounted.length}`, + `last=${mounted.at(-1) ?? 'none'}`, + `source=${source ?? 'none'}`, + ].join(' '); + })).toMatch(/^tail=true /u); const retainedTurnId = await page.evaluate(() => { const turns = document.querySelectorAll('[data-virtual-turn-id]'); const turn = turns.item(turns.length - 1); @@ -314,7 +351,7 @@ test('evicting a turn-owned sibling interaction hands focus back to the transcri // the one-shot jump so the assertion still catches any later restore that // would pin the viewport back on the retained Turn (#3121). await waitForPaintedFrames(page); - await scrollTranscriptTo(page, 'top'); + await scrollTranscriptOnce(page, 'top'); await notifyTranscriptScrolled(page); await expect.poll(async () => page.evaluate((turnId) => { const root = document.querySelector('[data-chat-scroll-container="true"]');