sidebar-scrollbar.e2e.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. // Web e2e scenario: the sidebar session list's scrollbar as the browser
  2. // actually lays it out — the observable half of the themed scrollbars
  3. // (packages/client/ui-theme/src/styles/scrollbar.css plus the
  4. // `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
  5. // ui-theme/ui-workspace unit specs read the CSS text; only a real engine
  6. // reports the reserved gutter width and the substituted `scrollbar-color`, so
  7. // those two facts live here.
  8. //
  9. // Zero model calls: the list only has to overflow, so the scenario seeds many
  10. // cold sessions from another spec's committed fixture (seeded-history's
  11. // seed.jsonl, reused read-only — this spec needs row count, not new recorded
  12. // content) and never launches a replay row. A stray stream would fail loud
  13. // with NO_ADAPTER.
  14. //
  15. // Headless-chromium caveats, load-bearing for what is asserted below.
  16. //
  17. // Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
  18. // content, consuming no layout width unless something reserves space. That is
  19. // the mode in which the reported symptom exists at all, so this environment
  20. // reproduces it rather than merely approximating it — without either
  21. // declaration the list's band is 0 and the bar covers 7px of the relative
  22. // time. (Under a classic space-consuming bar, `clientWidth` already excludes
  23. // the bar and nothing can be covered; a headed run under xvfb behaves that way
  24. // and cannot show the symptom.)
  25. //
  26. // The consequence for assertions: comparing the time element's right edge
  27. // against the list's CLIENT-area right edge holds in both states and proves
  28. // nothing, because with an overlay bar the client edge is the border edge. The
  29. // two signals that do separate the states are the reserved band width and
  30. // `timeCoveredBy`, which measures the overlap against the bar's own width.
  31. //
  32. // Both the `scrollbar-gutter: stable` reservation and the sheet's
  33. // `::-webkit-scrollbar` width are needed for that band, and neither suffices:
  34. // measured on the running app, deleting either one takes the band from 8 to 0
  35. // while the other stays in force. The gutter states that space be reserved; the
  36. // pseudo-element width is what makes chromium treat the bar as occupying layout
  37. // space in the first place.
  38. //
  39. // That conjunction is why `band` and `timeCoveredBy` are both asserted and
  40. // neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
  41. // 0, because the bar is then 8px wide and the row's right padding is also 8px,
  42. // so it abuts the timestamp without covering it; `band` catches that case.
  43. // Removing both is what produces the reported overlap, and `timeCoveredBy`
  44. // measures it at 7.
  45. //
  46. // The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
  47. // to `transparent` while the pointer is outside the column), so every
  48. // measurement below states which pointer position it was taken at: the
  49. // scenario parks the pointer over the sidebar before asserting a colour, and
  50. // the quiet state and its linger get their own test.
  51. //
  52. // Chromium also takes the `::-webkit-scrollbar*` path, not the standard
  53. // properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind
  54. // `@supports not selector(::-webkit-scrollbar)`, which is false here. The
  55. // resolved standard properties therefore read `auto`, and that reading is
  56. // asserted — a concrete value would mean the gate leaked and silenced the
  57. // pseudo-element rules. What the theme test measures instead is the pair the
  58. // pseudo-element rules read: the indirection variables as they resolve ON the
  59. // list, plus the `::-webkit-scrollbar-thumb:hover` declaration as it stands in
  60. // the cascade. The hover thumb colour is not observable any other way —
  61. // chromium folds the `:hover` rule into `getComputedStyle(el,
  62. // '::-webkit-scrollbar-thumb')`, so that query reports the hover colour at
  63. // rest and cannot pin either state (measured by deleting the hover rule live:
  64. // the same query flipped from the hover colour to the resting one).
  65. import { readFile } from 'node:fs/promises'
  66. import { fileURLToPath } from 'node:url'
  67. import { join } from 'node:path'
  68. import type { Browser, Page } from 'playwright'
  69. import { chromium } from 'playwright'
  70. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  71. import {
  72. assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
  73. webSnapshotMode, type WebScaffold,
  74. } from './scaffold.ts'
  75. import { newEnglishPage, saveFailureShot } from './support.ts'
  76. const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
  77. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url))
  78. /**
  79. * Committed golden of the resolved scrollbar style and geometry, in both
  80. * palettes. The aria goldens the other scenarios commit cannot carry this
  81. * change: it alters no DOM and no accessible name, so their normalized trees are
  82. * byte-identical with and without it. This one records the values instead, which
  83. * makes an unintended shift in thumb colour, band width, or rendering path a
  84. * reviewable diff rather than an assertion someone has to think about.
  85. */
  86. const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
  87. const MODE = webSnapshotMode()
  88. /** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */
  89. const SEED_COUNT = 24
  90. /** Geometry and resolved scrollbar style of one scroll container, measured in the page. */
  91. interface ListMetrics {
  92. /** Resolved `scrollbar-gutter`. */
  93. gutter: string
  94. /** Resolved `::-webkit-scrollbar` width: the pseudo-element path's own sizing. */
  95. width: string
  96. /** Resolved `::-webkit-scrollbar-track` background. */
  97. track: string
  98. /** Resolved `scrollbar-width`, expected `auto` because the gate excludes chromium. */
  99. standardWidth: string
  100. /** Resolved `scrollbar-color`, expected `auto` for the same reason. */
  101. standardColor: string
  102. /** `::-webkit-scrollbar-thumb:hover` background declarations found in the cascade, in sheet order. */
  103. hoverRules: string[]
  104. /** `--dsh-scrollbar-thumb` resolved on the list, serialized as a colour. */
  105. token: string
  106. /** `--dsh-scrollbar-thumb-hover` resolved on the list, serialized the same way. */
  107. hoverToken: string
  108. /** True when the list actually scrolls. */
  109. overflows: boolean
  110. /** Border-box width minus client width: the space the scrollbar takes out of the content area. */
  111. band: number
  112. /** Distance from the scrollbar's right edge to the sidebar edge. */
  113. scrollbarEdgeOffset: number
  114. /** Distance from the first row background's right edge to the sidebar edge. */
  115. rowEdgeInset: number
  116. /** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
  117. clientRight: number
  118. /** Border-box right edge in viewport coordinates. */
  119. borderRight: number
  120. /** Right edge of the first row's relative-time element, the content the unreserved bar covered. */
  121. timeRight: number
  122. /**
  123. * Pixels of the relative time the scrollbar paints over: how far its right
  124. * edge reaches into the band the bar occupies, `[borderRight - barWidth,
  125. * borderRight]`. This is the reported symptom as a number, and it is the one
  126. * geometric signal that separates the two states in this environment — see
  127. * the file header on why `clientWidth` comparisons cannot.
  128. */
  129. timeCoveredBy: number
  130. }
  131. /**
  132. * Measure the sidebar list in the page.
  133. * @param page - the page under test.
  134. * @returns the list's resolved scrollbar style and the geometry the
  135. * scrollbar-gutter/thin-scrollbar declarations shape.
  136. */
  137. function measureList(page: Page): Promise<ListMetrics> {
  138. return page.evaluate(() => {
  139. const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
  140. if (list === null) throw new Error('sidebar session list not in the DOM')
  141. const time = list.querySelector<HTMLElement>('[class*="time"]')
  142. if (time === null) throw new Error('no row relative-time element in the sidebar list')
  143. const row = list.querySelector<HTMLElement>('[role="treeitem"]')
  144. if (row === null) throw new Error('no row in the sidebar list')
  145. // Each indirection variable is resolved through its own throwaway probe
  146. // appended to the list: `var()` substitution then happens where the list
  147. // sits in the cascade, which is the claim, and `color` normalizes whatever
  148. // notation the palette sheet chose into one comparable serialization. A
  149. // REUSED probe would report only the last value read — `getComputedStyle`
  150. // returns a live declaration, so reassigning `style.color` retroactively
  151. // changes every earlier read.
  152. const resolve = (name: string): string => {
  153. const probe = document.createElement('span')
  154. probe.style.color = `var(${name})`
  155. list.append(probe)
  156. const value = getComputedStyle(probe).color
  157. probe.remove()
  158. return value
  159. }
  160. // The hover colour is read out of the cascade rather than computed:
  161. // chromium reports the `:hover` background for the resting pseudo-element
  162. // too (see the file header), so no computed query separates the states.
  163. // Cross-origin sheets throw on `cssRules`; none is expected, and skipping
  164. // them cannot mask the rule under test, which ships in the app's own CSS.
  165. const hoverRules = [...document.styleSheets]
  166. .flatMap((sheet) => {
  167. try {
  168. return [...sheet.cssRules]
  169. } catch {
  170. return []
  171. }
  172. })
  173. .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
  174. .filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
  175. .map(rule => rule.style.getPropertyValue('background'))
  176. const style = getComputedStyle(list)
  177. const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
  178. const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
  179. const listRect = list.getBoundingClientRect()
  180. const sidebarEdge = list.parentElement?.getBoundingClientRect().right
  181. if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
  182. return {
  183. gutter: style.scrollbarGutter,
  184. width: pseudoWidth,
  185. track: getComputedStyle(list, '::-webkit-scrollbar-track').backgroundColor,
  186. standardWidth: style.scrollbarWidth,
  187. standardColor: style.scrollbarColor,
  188. hoverRules,
  189. token: resolve('--dsh-scrollbar-thumb'),
  190. hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
  191. overflows: list.scrollHeight > list.clientHeight,
  192. band: listRect.width - list.clientWidth,
  193. scrollbarEdgeOffset: sidebarEdge - listRect.right,
  194. rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
  195. clientRight: listRect.left + list.clientWidth,
  196. borderRight: listRect.right,
  197. timeRight: time.getBoundingClientRect().right,
  198. // The bar is drawn in the rightmost `barWidth` of the border box, whether
  199. // or not that space was reserved. Its width comes from the sheet where the
  200. // sheet applies, and from the UA's own overlay bar otherwise — 15px is
  201. // what this chromium paints, measured with the rule absent. Taking the
  202. // UA width as the fallback is what keeps the assertion
  203. // honest: assuming 0 there would report no occlusion precisely in the
  204. // state that has it.
  205. timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
  206. }
  207. })
  208. }
  209. /**
  210. * Measure only overflow and row inset, which remain observable when every
  211. * session is hidden under a collapsed workspace group.
  212. * @param page - the page under test.
  213. * @returns the list overflow state and first row's trailing inset.
  214. */
  215. function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
  216. return page.evaluate(() => {
  217. const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
  218. if (list === null) throw new Error('sidebar session list not in the DOM')
  219. const row = list.querySelector<HTMLElement>('[role="treeitem"]')
  220. if (row === null) throw new Error('no row in the sidebar list')
  221. const sidebarEdge = list.parentElement?.getBoundingClientRect().right
  222. if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
  223. return {
  224. overflows: list.scrollHeight > list.clientHeight,
  225. rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
  226. }
  227. })
  228. }
  229. /** One palette's readings, taken at both pointer positions. */
  230. interface PaletteMetrics {
  231. /** Everything measured with the pointer over the list, which is when a thumb exists. */
  232. hovered: ListMetrics
  233. /** `--dsh-scrollbar-thumb` with the pointer parked outside the column. */
  234. quietThumb: string
  235. }
  236. /**
  237. * Read one palette at both pointer positions, ending with the pointer back
  238. * over the list so a caller measuring further leaves it revealed.
  239. * @param page - the page under test.
  240. * @returns the palette's quiet thumb and its hovered metrics.
  241. */
  242. async function measurePalette(page: Page): Promise<PaletteMetrics> {
  243. await pointAt(page, 'away')
  244. // Poll rather than sleep the linger out: the wait is the column's, and a
  245. // fixed sleep would either race it or pad every palette.
  246. await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
  247. const quietThumb = await resolveThumb(page)
  248. await pointAt(page, 'list')
  249. // Poll the reveal too: the reading below is a colour, and taking it in the
  250. // same tick as the pointer move would race React's flush and land a
  251. // transparent thumb in the golden.
  252. await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).not.toBe(NO_THUMB)
  253. return { hovered: await measureList(page), quietThumb }
  254. }
  255. /**
  256. * Render the golden body: the resolved scrollbar style of the list in each
  257. * palette, plus the geometric relations the scrollbar-gutter/thin-scrollbar
  258. * declarations establish.
  259. *
  260. * Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
  261. * `borderRight` depend on the sidebar's laid-out width and on font metrics, so
  262. * committing them would make the golden fail on a machine whose fonts measure
  263. * differently — a fixture that has to be re-recorded per platform documents the
  264. * platform, not the behavior. What is recorded instead is the band, the overlap,
  265. * and the two orderings, each of which is a difference or a comparison and so
  266. * survives any layout that keeps the reservation.
  267. * @param light - metrics measured under the light palette.
  268. * @param dark - metrics measured under the dark palette.
  269. * @returns the golden body, without a trailing newline.
  270. */
  271. function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
  272. const palette = (name: string, { hovered: metrics, quietThumb }: PaletteMetrics): string[] => [
  273. `## ${name}`,
  274. '',
  275. `- --dsh-scrollbar-thumb, pointer outside the sidebar: ${quietThumb}`,
  276. `- scrollbar-gutter: ${metrics.gutter}`,
  277. `- ::-webkit-scrollbar width: ${metrics.width}`,
  278. `- ::-webkit-scrollbar-track background: ${metrics.track}`,
  279. `- scrollbar-width: ${metrics.standardWidth}`,
  280. `- scrollbar-color: ${metrics.standardColor}`,
  281. `- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
  282. `- --dsh-scrollbar-thumb, pointer over the list: ${metrics.token}`,
  283. `- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
  284. `- list overflows: ${String(metrics.overflows)}`,
  285. `- reserved band: ${String(metrics.band)}px`,
  286. `- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
  287. `- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
  288. `- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
  289. `- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
  290. `- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
  291. '',
  292. ]
  293. return [
  294. '# Sidebar session list scrollbar',
  295. '',
  296. ...palette('Light palette', light),
  297. ...palette('Dark palette', dark),
  298. ].join('\n').trimEnd()
  299. }
  300. /**
  301. * Resolve `--dsh-scrollbar-thumb` as the list sees it, without the rest of the
  302. * geometry. Own probe element for the same reason {@link measureList} uses
  303. * one: `getComputedStyle` returns a live declaration.
  304. * @param page - the page under test.
  305. * @returns the resolved thumb colour, serialized as `rgb`/`rgba`.
  306. */
  307. function resolveThumb(page: Page): Promise<string> {
  308. return page.evaluate(() => {
  309. const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
  310. if (list === null) throw new Error('sidebar session list not in the DOM')
  311. const probe = document.createElement('span')
  312. probe.style.color = 'var(--dsh-scrollbar-thumb)'
  313. list.append(probe)
  314. const value = getComputedStyle(probe).color
  315. probe.remove()
  316. return value
  317. })
  318. }
  319. /** Fully transparent, which is how the quiet column spells "no thumb". */
  320. const NO_THUMB = 'rgba(0, 0, 0, 0)'
  321. /**
  322. * Park the pointer over the session list or outside the sidebar entirely. The
  323. * column reveals its scrollbars from real pointer movement, so a scenario that
  324. * never moves the mouse measures the quiet state whatever it intended to.
  325. * @param page - the page under test.
  326. * @param where - `list` to point at the session list, `away` for the far side
  327. * of the viewport (the conversation column).
  328. */
  329. async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
  330. const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox()
  331. if (box === null) throw new Error('sidebar session list has no layout box')
  332. const viewport = page.viewportSize()
  333. if (viewport === null) throw new Error('page has no viewport')
  334. const target = where === 'list'
  335. ? { x: box.x + box.width / 2, y: box.y + box.height / 2 }
  336. : { x: viewport.width - 5, y: box.y + box.height / 2 }
  337. await page.mouse.move(target.x, target.y)
  338. }
  339. /**
  340. * Reveal the seeded rows: every seeded session is unattached, so they all sit
  341. * in the collapsed Ungrouped bucket. Converges on expanded rather than
  342. * clicking once — startup auto-selection can expand the bucket first, and a
  343. * second click would collapse it again. Hand-rolled polling because
  344. * `expect.poll` is test-scoped and this runs in `beforeAll`.
  345. * @param page - the page under test.
  346. */
  347. async function expandSeededSessions(page: Page): Promise<void> {
  348. const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
  349. await bucket.waitFor({ timeout: 15_000 })
  350. const rows = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"]')
  351. const deadline = Date.now() + 30_000
  352. for (;;) {
  353. if (await bucket.getAttribute('aria-expanded') !== 'true') {
  354. await page.getByText('Ungrouped', { exact: true }).click()
  355. }
  356. if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
  357. if (Date.now() > deadline) {
  358. throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)
  359. }
  360. await page.waitForTimeout(200)
  361. }
  362. }
  363. describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => {
  364. let scaffold: WebScaffold
  365. let browser: Browser
  366. let page: Page
  367. let tripwire: ReturnType<typeof watchConsole>
  368. beforeAll(async () => {
  369. scaffold = await launchWebScaffold({})
  370. const fixture = await readFile(SEED, 'utf8')
  371. for (let index = 0; index < SEED_COUNT; index += 1) {
  372. await seedSession(scaffold, fixture, `sidebar-scrollbar-web-e2e-${String(index).padStart(2, '0')}`)
  373. }
  374. browser = await chromium.launch()
  375. // Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow
  376. // the list with room to spare.
  377. page = await newEnglishPage(browser, 800)
  378. tripwire = watchConsole(page)
  379. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  380. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  381. await expandSeededSessions(page)
  382. // Every assertion about a thumb colour needs a drawn thumb, and the column
  383. // only draws one under the pointer; the quiet state is asserted where it is
  384. // the subject rather than left as an ambient condition of the whole file.
  385. await pointAt(page, 'list')
  386. }, 180_000)
  387. afterAll(async () => {
  388. await browser?.close()
  389. await scaffold?.close()
  390. })
  391. it('reserves a scrollbar gutter on the overflowing session list', async () => {
  392. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-gutter'))
  393. // Vacuity guard: with a non-overflowing list `stable` still reserves, but
  394. // the scenario would no longer be reproducing the reported situation.
  395. await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true)
  396. const metrics = await measureList(page)
  397. expect(metrics.gutter).toBe('stable')
  398. // The control. `band > 0` is the whole observable effect of the
  399. // reservation: the scrollbar is taken out of the content area instead of
  400. // drawn over it. Removing the declaration makes it exactly 0. The value
  401. // itself is not pinned — it tracks `scrollbar-width` and the platform.
  402. expect(metrics.band).toBeGreaterThan(0)
  403. expect(metrics.scrollbarEdgeOffset).toBe(2)
  404. expect(metrics.rowEdgeInset).toBe(12)
  405. // The reported symptom, stated directly: no part of the row's relative time
  406. // lies under the bar. Without either declaration it measures 7 — the `h`
  407. // of `1h` is the covered part. Unlike the client-edge comparison below it
  408. // does not go vacuous under an overlay scrollbar, because it measures
  409. // against the bar's own width rather than against a content edge the
  410. // overlay bar does not move. It is not a replacement for the band
  411. // assertion above; see the file header for which regression each one
  412. // catches.
  413. expect(metrics.timeCoveredBy).toBe(0)
  414. // Corollaries of the reservation, kept because they pin where the band sits
  415. // rather than only that it exists: the time ends inside the content area,
  416. // and the content area ends before the border box. Each holds in both
  417. // states on its own (see the file header) and is meaningful only alongside
  418. // the two assertions above.
  419. expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight)
  420. expect(metrics.clientRight).toBeLessThan(metrics.borderRight)
  421. expect(tripwire.pageErrors).toEqual([])
  422. }, 60_000)
  423. it('draws no thumb until the pointer is over the column, and lingers on the way out', async () => {
  424. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-pointer'))
  425. const revealed = await resolveThumb(page)
  426. expect(revealed).not.toBe(NO_THUMB)
  427. await pointAt(page, 'away')
  428. // The linger, measured as a state rather than a duration: the thumb is
  429. // still drawn on the leave itself, and gone once the window has passed. A
  430. // tighter timing assertion would pin the wall clock of a CI machine.
  431. expect(await resolveThumb(page)).toBe(revealed)
  432. await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
  433. // The reservation is unconditional, so nothing moved while the bar was
  434. // hidden — this is what buys `transparent` over hiding the bar itself.
  435. const quiet = await measureList(page)
  436. expect(quiet.gutter).toBe('stable')
  437. expect(quiet.band).toBeGreaterThan(0)
  438. expect(quiet.timeCoveredBy).toBe(0)
  439. // Scrolling without a pointer — what a keyboard or a touch drag does —
  440. // leaves the column quiet. This is the one deliberate loss, and
  441. // it is pinned here rather than only described, so making a scroll
  442. // re-reveal the bar has to be a decision rather than a side effect.
  443. await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
  444. await page.waitForTimeout(500)
  445. expect(await resolveThumb(page)).toBe(NO_THUMB)
  446. await pointAt(page, 'list')
  447. await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(revealed)
  448. expect(tripwire.pageErrors).toEqual([])
  449. }, 60_000)
  450. it('keeps the row background inset when overflow disappears', async () => {
  451. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
  452. expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
  453. const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
  454. await bucket.click()
  455. try {
  456. await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
  457. expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
  458. } finally {
  459. await expandSeededSessions(page)
  460. }
  461. expect(tripwire.pageErrors).toEqual([])
  462. }, 60_000)
  463. it('renders the themed thumb through the WebKit path in both palettes', async () => {
  464. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
  465. const light = await measureList(page)
  466. // The gate's signature on this engine, and the reason it exists: chromium
  467. // implements `::-webkit-scrollbar`, so the standard properties stay at
  468. // their initial `auto`. A concrete value here would mean the gate leaked,
  469. // which is exactly what makes chromium discard the pseudo-element rules —
  470. // the hover token included.
  471. expect(light.standardWidth).toBe('auto')
  472. expect(light.standardColor).toBe('auto')
  473. // The pseudo-element path is the one in force: the sheet's own 8px sizing
  474. // and transparent track reached a container it never names.
  475. expect(light.width).toBe('8px')
  476. expect(light.track).toBe('rgba(0, 0, 0, 0)')
  477. // The resting and the hover rule each read the rebindable indirection, and
  478. // the two resolve to DIFFERENT colours on this list: the l1 pair arrived
  479. // here intact rather than collapsing to one value or falling back.
  480. expect(light.hoverRules).toEqual(['var(--dsh-scrollbar-thumb-hover)'])
  481. expect(light.token).toMatch(/^rgba?\(/)
  482. expect(light.hoverToken).not.toBe(light.token)
  483. // The dark palette declares different scrollbar tokens; driving the body
  484. // attribute pins the cascade the way lifecycle-chrome does (the Settings
  485. // gesture that sets it is owned there).
  486. await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
  487. const dark = await measureList(page)
  488. expect(dark.token).not.toBe(light.token)
  489. expect(dark.hoverToken).not.toBe(dark.token)
  490. expect(dark.hoverToken).not.toBe(light.hoverToken)
  491. await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
  492. const restored = await measureList(page)
  493. expect(restored.token).toBe(light.token)
  494. expect(restored.hoverToken).toBe(light.hoverToken)
  495. expect(tripwire.pageErrors).toEqual([])
  496. }, 60_000)
  497. it('matches the committed scrollbar geometry golden in both palettes', async () => {
  498. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
  499. const light = await measurePalette(page)
  500. await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
  501. const dark = await measurePalette(page)
  502. await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
  503. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
  504. expect(tripwire.pageErrors).toEqual([])
  505. }, 60_000)
  506. it('commits exactly the fixtures it reads', async () => {
  507. // The scenario borrows seeded-history's seed.jsonl rather than committing a
  508. // second copy, so this directory holds the golden alone.
  509. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  510. })
  511. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  512. expect(tripwire.warnings).toEqual([])
  513. expect(tripwire.pageErrors).toEqual([])
  514. })
  515. })