| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- // 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 { 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<ListMetrics> {
- return page.evaluate(() => {
- const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
- if (list === null) throw new Error('sidebar session list not in the DOM')
- const time = list.querySelector<HTMLElement>('[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<void> {
- 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<typeof watchConsole>
- 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 browser.newPage({ viewport: { width: 1680, height: 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([])
- })
- })
|