| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- // 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<void> {
- 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<void>((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<TabMetrics> {
- return page.evaluate(() => {
- const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
- if (host === null) throw new Error('conversation column scroller not in the DOM')
- const card = host.querySelector<HTMLElement>('[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<void> {
- 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<void>((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<TabComparison> {
- 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<TabComparison> {
- 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<void> {
- 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<typeof watchConsole>
- 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([])
- })
- })
|