// Web e2e scenario: the input card holds one horizontal position across the // Chat and Trajectory tabs. // // The composer seat is the same node in both tabs, but it measures itself // against a different edge in each (see // packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css). // In Chat it is a sticky CHILD of the column's scroller, so it rides that // scroller's content box — the box a space-consuming scrollbar shortens. A view // that opts into a composer overlay (`data-conversation-composer-overlay`, which // Trajectory declares and which moves the column's own scrolling into the view) // gets an absolutely positioned seat instead, laid out against the padding box, // which the scrollbar never reduces. // // So the two tabs disagreed by exactly the bar's width for as long as the // transcript overflowed: the card jumped sideways on every tab switch, and // inside Chat alone at the moment a growing transcript started to scroll. The // column now reserves the gutter unconditionally (`scrollbar-gutter: stable`) // and states the overlay branch as a scroll container on the same axes, so both // edges are the same edge. // // Only a real engine can show this. The seat's geometry is layout: jsdom gives // every element a zero-sized box and reports no scrollbar at all, so a unit spec // can assert the declarations exist but not that the two states land in the same // place. What is asserted here is the user-visible fact — the card does not move // — measured as the distance between the two tabs' card rectangles. // // The browser is launched WITHOUT Playwright's default `--hide-scrollbars`, // which is load-bearing rather than incidental. Under that argument a scroll // container's bar consumes no layout width at all, so the two tabs agree before // this change as much as after it and every comparison below holds vacuously — // measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8 // and 0 with the argument dropped. Dropping it is also the faithful // configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width, // and a bar that occupies layout space is what the product actually draws. // // The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto` // on the scroller, `overflow: hidden` on the overlay branch — and measures the // same two tabs through it, which is what keeps the equal rectangles above from // being explained by a tab switch that never reached the layout. It is the // reported symptom as a number: the card moves 4px, half the 8px band, on each // edge. // // Zero model calls: a seeded cold session renders from its log, and switching // tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER. import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createChatScrollFixture } from './chat-scroll-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url)) /** * Committed golden of where the input card sits in each tab, at a wide viewport * (card at its width cap) and a narrow one (card shrinking with the column). * * Absolute coordinates are deliberately absent: they depend on the sidebar's * laid-out width and on font metrics, so committing them would produce a fixture * that has to be re-recorded per platform. What is recorded is the distance * between the two tabs' rectangles, which is zero when the reservation holds and * the bar's width when it does not — including under the control, so the golden * carries the difference the fix removes rather than only its absence. */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() /** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */ const FIXTURE = createChatScrollFixture({ markerPrefix: 'TAB_GEOMETRY', title: 'COMPOSER_TAB_GEOMETRY long session', turns: 24, }) const SEED_ID = 'composer-tab-geometry-web-e2e' /** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */ const WIDE_VIEWPORT = { width: 1680, height: 1000 } const NARROW_VIEWPORT = { width: 800, height: 1000 } /** * Resize to one measurement viewport after the responsive sidebar and center * column finish their track transition. * @param page - the page under test. * @param viewport - the viewport dimensions to apply. * @param sidebarCollapsed - the sidebar state expected at this width. */ async function setMeasuredViewport( page: Page, viewport: { width: number; height: number }, sidebarCollapsed: boolean, ): Promise { await page.setViewportSize(viewport) await page.locator('[data-sidebar-collapsed="true"]').waitFor({ state: sidebarCollapsed ? 'attached' : 'detached', timeout: 10_000, }) await page.locator('[data-conversation-scroll]').evaluate(async (host) => { const deadline = performance.now() + 5_000 let previous = host.getBoundingClientRect().width let stableFrames = 0 while (performance.now() < deadline) { await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) const current = host.getBoundingClientRect().width stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0 if (stableFrames >= 3) return previous = current } throw new Error('conversation width did not settle after the viewport changed') }) } /** * The pre-fix cascade, injected into the page: the reservation dropped and the * overlay branch back to a hidden box. `!important` beats the module rules * without a rebuild, and the id lets the control be lifted again in the same * session. */ const CONTROL_STYLE_ID = 'composer-tab-geometry-control' const CONTROL_CSS = ` [data-conversation-scroll] { scrollbar-gutter: auto !important; } [data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; } ` /** The column scroller and the input card as the browser lays them out, in one tab. */ interface TabMetrics { /** Resolved `scrollbar-gutter` on the column's scroller. */ gutter: string /** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */ overflowX: string /** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */ overflowY: string /** Border-box width minus client width: the space the scrollbar takes out of the content area. */ band: number /** True when the column's scroller actually scrolls — only Chat does. */ scrolls: boolean /** Left edge of the input card in viewport coordinates. */ cardLeft: number /** Right edge of the input card. */ cardRight: number /** Width of the input card, capped at the composer card max width. */ cardWidth: number } /** One tab's metrics beside the other's, plus the distances between them. */ interface TabComparison { chat: TabMetrics trajectory: TabMetrics /** Distance between the two tabs' card left edges: 0 when the card holds its position. */ leftShift: number /** Distance between the two tabs' card right edges. */ rightShift: number /** Difference between the two tabs' card widths. */ widthShift: number } /** * Measure the column scroller and the input card in the tab currently shown. * @param page - the page under test. * @returns the scroller's resolved overflow style and the card's rectangle. */ function measureTab(page: Page): Promise { return page.evaluate(() => { const host = document.querySelector('[data-conversation-scroll]') if (host === null) throw new Error('conversation column scroller not in the DOM') const card = host.querySelector('[data-composer-seat] [data-composer-card]') if (card === null) throw new Error('no input card inside the composer seat') const style = getComputedStyle(host) const hostRect = host.getBoundingClientRect() const cardRect = card.getBoundingClientRect() return { gutter: style.scrollbarGutter, overflowX: style.overflowX, overflowY: style.overflowY, band: hostRect.width - host.clientWidth, scrolls: host.scrollHeight > host.clientHeight, cardLeft: cardRect.left, cardRight: cardRect.right, cardWidth: cardRect.width, } }) } /** * Show one tab and wait for the view that owns it to be laid out. * @param page - the page under test. * @param tab - the tab to show. */ async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise { await page.getByRole('tab', { name: tab, exact: true }).click() if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 }) // Both measurements are taken after a paint, so a rectangle read mid-transition // cannot be reported as a shift the cascade did not cause. await page.evaluate(() => new Promise((settle) => { requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) }) })) } /** * Measure both tabs and the distances between them, leaving Chat shown. * @param page - the page under test. * @returns each tab's metrics and the card's displacement between them. */ async function compareTabs(page: Page): Promise { await showTab(page, 'Chat') const chat = await measureTab(page) await showTab(page, 'Trajectory') const trajectory = await measureTab(page) await showTab(page, 'Chat') return { chat, trajectory, leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft), rightShift: Math.abs(trajectory.cardRight - chat.cardRight), widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth), } } /** * Run the pre-fix cascade in the page for one measurement, then lift it. * @param page - the page under test. * @returns the comparison as the column laid out before this change. */ async function compareTabsWithoutReservation(page: Page): Promise { await page.evaluate(({ id, css }) => { const style = document.createElement('style') style.id = id style.textContent = css document.head.append(style) }, { id: CONTROL_STYLE_ID, css: CONTROL_CSS }) try { return await compareTabs(page) } finally { await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID) } } /** * Open the seeded session from the sidebar search. * * Cold summaries carry the temp workspace's basename, so the persisted first * message is the stable identity to search for, and the query itself drives the * lazy content-index reconciliation. Hand-rolled polling because `expect.poll` * is test-scoped and this runs in `beforeAll`. * @param page - the page under test. */ async function openSeededSession(page: Page): Promise { const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) await search.fill(FIXTURE.markers.user(1)) const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') const deadline = Date.now() + 60_000 for (;;) { if (await results.count() === 1) break if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results') await page.waitForTimeout(200) } await results.click() } /** * Render the golden body. * @param wide - comparison at the viewport where the card sits at its width cap. * @param narrow - comparison at the viewport where the card shrinks with the column. * @param control - comparison at the wide viewport with the reservation removed. * @returns the golden body, without a trailing newline. */ function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string { const section = (name: string, comparison: TabComparison): string[] => [ `## ${name}`, '', `- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`, `- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`, `- Chat reserved band: ${String(comparison.chat.band)}px`, `- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`, `- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`, `- Trajectory reserved band: ${String(comparison.trajectory.band)}px`, `- input card left edge moves between tabs: ${String(comparison.leftShift)}px`, `- input card right edge moves between tabs: ${String(comparison.rightShift)}px`, `- input card width changes between tabs: ${String(comparison.widthShift)}px`, '', ] return [ '# Input card position across the Chat and Trajectory tabs', '', ...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide), ...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow), ...section('Wide viewport, reservation removed in the page (control)', control), ].join('\n').trimEnd() } describe('web e2e: input card position across view tabs', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { scaffold = await launchWebScaffold({}) await seedSession(scaffold, FIXTURE.log, SEED_ID) // Scrollbars must take layout space here or the scenario proves nothing; // see the file header for the measurement behind dropping this argument. browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) page = await newEnglishPage(browser, WIDE_VIEWPORT.height) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await openSeededSession(page) await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last() .waitFor({ timeout: 30_000 }) }, 180_000) afterAll(async () => { await browser?.close() await scaffold?.close() }) it('reserves the same gutter in both tabs while the transcript scrolls', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band')) await setMeasuredViewport(page, WIDE_VIEWPORT, false) // Vacuity guard, in two parts. A transcript that does not overflow gives // Chat no scrollbar, and a hidden or overlaid bar gives it no width; either // would make the tabs agree without the reservation doing anything. await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true) const comparison = await compareTabs(page) expect(comparison.chat.band).toBeGreaterThan(0) // The reservation reaches both states, which is the whole change: the same // band, on a box that scrolls and on one that only holds a view. expect(comparison.chat.gutter).toBe('stable') expect(comparison.trajectory.gutter).toBe('stable') expect(comparison.trajectory.band).toBe(comparison.chat.band) // Declared as a scroll container on both axes rather than left to compute: // `overflow: hidden` would drop the reservation in WebKit, and a `visible` // horizontal axis computes to `auto` beside a scrolling one. expect(comparison.trajectory.overflowY).toBe('auto') expect(comparison.trajectory.overflowX).toBe('hidden') // Only Chat scrolls this box; the Trajectory view owns its own scrollers. expect(comparison.trajectory.scrolls).toBe(false) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('holds the input card in place when the tab changes', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide')) await setMeasuredViewport(page, WIDE_VIEWPORT, false) const comparison = await compareTabs(page) // The reported symptom as a number. At this viewport the card sits at its // width cap, so the pre-fix shift showed up as a centring difference — half // the band on each edge — rather than as a width change. expect(comparison.leftShift).toBe(0) expect(comparison.rightShift).toBe(0) expect(comparison.widthShift).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('holds the input card in place at a viewport where it shrinks with the column', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow')) await setMeasuredViewport(page, WIDE_VIEWPORT, false) const capped = await measureTab(page) await setMeasuredViewport(page, NARROW_VIEWPORT, true) const comparison = await compareTabs(page) // The other geometry, and a different failure: below the cap the card takes // the column's width, so an unreserved gutter changed its WIDTH by the whole // band instead of shifting it by half. Asserted against the capped // measurement rather than against the cap's pixel value, which belongs to // the stylesheet. expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth) expect(comparison.leftShift).toBe(0) expect(comparison.rightShift).toBe(0) expect(comparison.widthShift).toBe(0) await setMeasuredViewport(page, WIDE_VIEWPORT, false) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('moves the card again once the reservation is removed in the page', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control')) await setMeasuredViewport(page, WIDE_VIEWPORT, false) // The control: without it, equal rectangles could also mean the tab switch // never reached the layout. Under the pre-fix cascade the Chat scroller keeps // its bar and the Trajectory branch goes back to a hidden box with none, and // the card moves by half the band on each edge. const comparison = await compareTabsWithoutReservation(page) expect(comparison.chat.gutter).toBe('auto') expect(comparison.chat.band).toBeGreaterThan(0) expect(comparison.trajectory.band).toBe(0) expect(comparison.leftShift).toBe(comparison.chat.band / 2) expect(comparison.rightShift).toBe(comparison.chat.band / 2) // Restoring the sheet restores the fix, so the control cannot leak into the // remaining measurements. const restored = await compareTabs(page) expect(restored.leftShift).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('matches the committed tab geometry golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden')) await setMeasuredViewport(page, WIDE_VIEWPORT, false) const wide = await compareTabs(page) await setMeasuredViewport(page, NARROW_VIEWPORT, true) const narrow = await compareTabs(page) await setMeasuredViewport(page, WIDE_VIEWPORT, false) const control = await compareTabsWithoutReservation(page) await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('commits exactly the fixtures it reads', async () => { // The seeded session is generated in-process, so the geometry golden is the // whole inventory. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) }) it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }) })