// Web e2e scenario: the sidebar session list's scrollbar as the browser // actually lays it out — the observable half of the themed-scrollbar change // (packages/client/ui-theme/src/styles/scrollbar.css plus the // `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The // ui-theme/ui-workspace unit specs read the CSS text; only a real engine // reports the reserved gutter width and the substituted `scrollbar-color`, so // those two facts live here. // // Zero model calls: the list only has to overflow, so the scenario seeds many // cold sessions from another spec's committed fixture (seeded-history's // seed.jsonl, reused read-only — this spec needs row count, not new recorded // content) and never launches a replay row. A stray stream would fail loud // with NO_ADAPTER. // // Headless-chromium caveats, load-bearing for what is asserted below. // // Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the // content, consuming no layout width unless something reserves space. That is // the mode in which the reported symptom exists at all, so this environment // reproduces it rather than merely approximating it — measured against clean // master, where the list's band is 0 and the bar covers 7px of the relative // time. (Under a classic space-consuming bar, `clientWidth` already excludes // the bar and nothing can be covered; a headed run under xvfb behaves that way // and cannot show the symptom.) // // The consequence for assertions: comparing the time element's right edge // against the list's CLIENT-area right edge holds in both states and proves // nothing, because with an overlay bar the client edge is the border edge. The // two signals that do separate the states are the reserved band width and // `timeCoveredBy`, which measures the overlap against the bar's own width. // // Both the `scrollbar-gutter: stable` reservation and the sheet's // `::-webkit-scrollbar` width are needed for that band, and neither suffices: // measured on the running app, deleting either one takes the band from 8 to 0 // while the other stays in force. The gutter states that space be reserved; the // pseudo-element width is what makes chromium treat the bar as occupying layout // space in the first place. // // That conjunction is why `band` and `timeCoveredBy` are both asserted and // neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at // 0, because the bar is then 8px wide and the row's right padding is also 8px, // so it abuts the timestamp without covering it; `band` catches that case. // Removing both — the actual master state — is what produces the reported // overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with // the other assertions in its test silenced. // // Chromium also takes the `::-webkit-scrollbar*` path, not the standard // properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind // `@supports not selector(::-webkit-scrollbar)`, which is false here. The // resolved standard properties therefore read `auto`, and that reading is // asserted — a concrete value would mean the gate leaked and silenced the // pseudo-element rules. What the theme test measures instead is the pair the // pseudo-element rules read: the indirection variables as they resolve ON the // list, plus the `::-webkit-scrollbar-thumb:hover` declaration as it stands in // the cascade. The hover thumb colour is not observable any other way — // chromium folds the `:hover` rule into `getComputedStyle(el, // '::-webkit-scrollbar-thumb')`, so that query reports the hover colour at // rest and cannot pin either state (measured by deleting the hover rule live: // the same query flipped from the hover colour to the resting one). import { readFile } from 'node:fs/promises' 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 { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url)) /** * Committed golden of the resolved scrollbar style and geometry, in both * palettes. The aria goldens the other scenarios commit cannot carry this * change: it alters no DOM and no accessible name, so their normalized trees are * byte-identical with and without it. This one records the values instead, which * makes an unintended shift in thumb colour, band width, or rendering path a * reviewable diff rather than an assertion someone has to think about. */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() /** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */ const SEED_COUNT = 24 /** Geometry and resolved scrollbar style of one scroll container, measured in the page. */ interface ListMetrics { /** Resolved `scrollbar-gutter`. */ gutter: string /** Resolved `::-webkit-scrollbar` width: the pseudo-element path's own sizing. */ width: string /** Resolved `::-webkit-scrollbar-track` background. */ track: string /** Resolved `scrollbar-width`, expected `auto` because the gate excludes chromium. */ standardWidth: string /** Resolved `scrollbar-color`, expected `auto` for the same reason. */ standardColor: string /** `::-webkit-scrollbar-thumb:hover` background declarations found in the cascade, in sheet order. */ hoverRules: string[] /** `--dsh-scrollbar-thumb` resolved on the list, serialized as a colour. */ token: string /** `--dsh-scrollbar-thumb-hover` resolved on the list, serialized the same way. */ hoverToken: string /** True when the list actually scrolls. */ overflows: boolean /** Border-box width minus client width: the space the scrollbar takes out of the content area. */ band: number /** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */ clientRight: number /** Border-box right edge in viewport coordinates. */ borderRight: number /** Right edge of the first row's relative-time element, the content the unreserved bar covered. */ timeRight: number /** * Pixels of the relative time the scrollbar paints over: how far its right * edge reaches into the band the bar occupies, `[borderRight - barWidth, * borderRight]`. This is the reported symptom as a number, and it is the one * geometric signal that separates the two states in this environment — see * the file header on why `clientWidth` comparisons cannot. */ timeCoveredBy: number } /** * Measure the sidebar list in the page. * @param page - the page under test. * @returns the list's resolved scrollbar style and the geometry the fix changes. */ function measureList(page: Page): Promise { return page.evaluate(() => { const list = document.querySelector('[role="tree"][aria-label="Sessions"]') if (list === null) throw new Error('sidebar session list not in the DOM') const time = list.querySelector('[class*="time"]') if (time === null) throw new Error('no row relative-time element in the sidebar list') // Each indirection variable is resolved through its own throwaway probe // appended to the list: `var()` substitution then happens where the list // sits in the cascade, which is the claim, and `color` normalizes whatever // notation the palette sheet chose into one comparable serialization. A // REUSED probe would report only the last value read — `getComputedStyle` // returns a live declaration, so reassigning `style.color` retroactively // changes every earlier read. const resolve = (name: string): string => { const probe = document.createElement('span') probe.style.color = `var(${name})` list.append(probe) const value = getComputedStyle(probe).color probe.remove() return value } // The hover colour is read out of the cascade rather than computed: // chromium reports the `:hover` background for the resting pseudo-element // too (see the file header), so no computed query separates the states. // Cross-origin sheets throw on `cssRules`; none is expected, and skipping // them cannot mask the rule under test, which ships in the app's own CSS. const hoverRules = [...document.styleSheets] .flatMap((sheet) => { try { return [...sheet.cssRules] } catch { return [] } }) .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) .filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover') .map(rule => rule.style.getPropertyValue('background')) const style = getComputedStyle(list) const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth) return { gutter: style.scrollbarGutter, width: pseudoWidth, track: getComputedStyle(list, '::-webkit-scrollbar-track').backgroundColor, standardWidth: style.scrollbarWidth, standardColor: style.scrollbarColor, hoverRules, token: resolve('--dsh-scrollbar-thumb'), hoverToken: resolve('--dsh-scrollbar-thumb-hover'), overflows: list.scrollHeight > list.clientHeight, band: list.getBoundingClientRect().width - list.clientWidth, clientRight: list.getBoundingClientRect().left + list.clientWidth, borderRight: list.getBoundingClientRect().right, timeRight: time.getBoundingClientRect().right, // The bar is drawn in the rightmost `barWidth` of the border box, whether // or not that space was reserved. Its width comes from the sheet where the // sheet applies, and from the UA's own overlay bar otherwise — 15px is // what this chromium paints, measured against master where the rule is // absent. Taking the UA width as the fallback is what keeps the assertion // honest: assuming 0 there would report no occlusion precisely in the // state that has it. timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)), } }) } /** * Render the golden body: the resolved scrollbar style of the list in each * palette, plus the geometric relations the fix establishes. * * Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and * `borderRight` depend on the sidebar's laid-out width and on font metrics, so * committing them would make the golden fail on a machine whose fonts measure * differently — a fixture that has to be re-recorded per platform documents the * platform, not the change. What is recorded instead is the band, the overlap, * and the two orderings, each of which is a difference or a comparison and so * survives any layout that keeps the reservation. * @param light - metrics measured under the light palette. * @param dark - metrics measured under the dark palette. * @returns the golden body, without a trailing newline. */ function renderGeometry(light: ListMetrics, dark: ListMetrics): string { const palette = (name: string, metrics: ListMetrics): string[] => [ `## ${name}`, '', `- scrollbar-gutter: ${metrics.gutter}`, `- ::-webkit-scrollbar width: ${metrics.width}`, `- ::-webkit-scrollbar-track background: ${metrics.track}`, `- scrollbar-width: ${metrics.standardWidth}`, `- scrollbar-color: ${metrics.standardColor}`, `- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`, `- --dsh-scrollbar-thumb: ${metrics.token}`, `- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`, `- list overflows: ${String(metrics.overflows)}`, `- reserved band: ${String(metrics.band)}px`, `- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`, `- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`, `- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`, '', ] return [ '# Sidebar session list scrollbar', '', ...palette('Light palette', light), ...palette('Dark palette', dark), ].join('\n').trimEnd() } /** * Reveal the seeded rows: every seeded session is unattached, so they all sit * in the collapsed Ungrouped bucket. Converges on expanded rather than * clicking once — startup auto-selection can expand the bucket first, and a * second click would collapse it again. Hand-rolled polling because * `expect.poll` is test-scoped and this runs in `beforeAll`. * @param page - the page under test. */ async function expandSeededSessions(page: Page): Promise { const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') await bucket.waitFor({ timeout: 15_000 }) const rows = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"]') const deadline = Date.now() + 30_000 for (;;) { if (await bucket.getAttribute('aria-expanded') !== 'true') { await page.getByText('Ungrouped', { exact: true }).click() } if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return if (Date.now() > deadline) { throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`) } await page.waitForTimeout(200) } } describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { scaffold = await launchWebScaffold({}) const fixture = await readFile(SEED, 'utf8') for (let index = 0; index < SEED_COUNT; index += 1) { await seedSession(scaffold, fixture, `sidebar-scrollbar-web-e2e-${String(index).padStart(2, '0')}`) } browser = await chromium.launch() // Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow // the list with room to spare. page = await newEnglishPage(browser, 800) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await expandSeededSessions(page) }, 180_000) afterAll(async () => { await browser?.close() await scaffold?.close() }) it('reserves a scrollbar gutter on the overflowing session list', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-gutter')) // Vacuity guard: with a non-overflowing list `stable` still reserves, but // the scenario would no longer be reproducing the reported situation. await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true) const metrics = await measureList(page) expect(metrics.gutter).toBe('stable') // The control. `band > 0` is the whole observable effect of the // reservation: the scrollbar is taken out of the content area instead of // drawn over it. Removing the declaration makes it exactly 0. The value // itself is not pinned — it tracks `scrollbar-width` and the platform. expect(metrics.band).toBeGreaterThan(0) // The reported symptom, stated directly: no part of the row's relative time // lies under the bar. Measures 7 on clean master — the `h` of `1h` is the // covered part. Unlike the client-edge comparison below it does not go // vacuous under an overlay scrollbar, because it measures against the bar's // own width rather than against a content edge the overlay bar does not // move. It is not a replacement for the band assertion above; see the file // header for which regression each one catches. expect(metrics.timeCoveredBy).toBe(0) // Corollaries of the reservation, kept because they pin where the band sits // rather than only that it exists: the time ends inside the content area, // and the content area ends before the border box. Each holds in both // states on its own (see the file header) and is meaningful only alongside // the two assertions above. expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight) expect(metrics.clientRight).toBeLessThan(metrics.borderRight) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('renders the themed thumb through the WebKit path in both palettes', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme')) const light = await measureList(page) // The gate's signature on this engine, and the reason it exists: chromium // implements `::-webkit-scrollbar`, so the standard properties stay at // their initial `auto`. A concrete value here would mean the gate leaked, // which is exactly what makes chromium discard the pseudo-element rules — // the hover token included. expect(light.standardWidth).toBe('auto') expect(light.standardColor).toBe('auto') // The pseudo-element path is the one in force: the sheet's own 8px sizing // and transparent track reached a container it never names. expect(light.width).toBe('8px') expect(light.track).toBe('rgba(0, 0, 0, 0)') // The resting and the hover rule each read the rebindable indirection, and // the two resolve to DIFFERENT colours on this list: the l1 pair arrived // here intact rather than collapsing to one value or falling back. expect(light.hoverRules).toEqual(['var(--dsh-scrollbar-thumb-hover)']) expect(light.token).toMatch(/^rgba?\(/) expect(light.hoverToken).not.toBe(light.token) // The dark palette declares different scrollbar tokens; driving the body // attribute pins the cascade the way lifecycle-chrome does (the Settings // gesture that sets it is owned there). await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const dark = await measureList(page) expect(dark.token).not.toBe(light.token) expect(dark.hoverToken).not.toBe(dark.token) expect(dark.hoverToken).not.toBe(light.hoverToken) await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) const restored = await measureList(page) expect(restored.token).toBe(light.token) expect(restored.hoverToken).toBe(light.hoverToken) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('matches the committed scrollbar geometry golden in both palettes', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden')) const light = await measureList(page) await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const dark = await measureList(page) await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('commits exactly the fixtures it reads', async () => { // The scenario borrows seeded-history's seed.jsonl rather than committing a // second copy, so this directory holds the golden alone. 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([]) }) })