sidebar-scrollbar.e2e.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. // Browser geometry for the sidebar scrollbar reservation and theme. Headless
  2. // Chromium uses overlay scrollbars, so the reserved band and `timeCoveredBy`
  3. // together distinguish reserved space from a bar painted over content. Its
  4. // computed pseudo-element style also folds in `:hover`, so the test reads that
  5. // declaration from the cascade.
  6. import { readFile } from 'node:fs/promises'
  7. import { fileURLToPath } from 'node:url'
  8. import { join } from 'node:path'
  9. import type { Browser, Page } from 'playwright'
  10. import { chromium } from 'playwright'
  11. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  12. import {
  13. assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
  14. webSnapshotMode, type WebScaffold,
  15. } from './scaffold.ts'
  16. import { newEnglishPage, saveFailureShot } from './support.ts'
  17. const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
  18. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/sidebar-scrollbar', import.meta.url))
  19. /** Geometry and resolved style are absent from ARIA snapshots, so this scenario records them directly. */
  20. const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
  21. const MODE = webSnapshotMode()
  22. /** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */
  23. const SEED_COUNT = 24
  24. /** Geometry and resolved scrollbar style of one scroll container, measured in the page. */
  25. interface ListMetrics {
  26. gutter: string
  27. width: string
  28. track: string
  29. standardWidth: string
  30. standardColor: string
  31. hoverRules: string[]
  32. token: string
  33. hoverToken: string
  34. overflows: boolean
  35. band: number
  36. scrollbarEdgeOffset: number
  37. rowEdgeInset: number
  38. clientRight: number
  39. borderRight: number
  40. timeRight: number
  41. /**
  42. * Pixels of relative time under the scrollbar, measured against the bar's
  43. * width because an overlay scrollbar does not move the client edge.
  44. */
  45. timeCoveredBy: number
  46. }
  47. /**
  48. * Measure the sidebar list in the page.
  49. * @param page - the page under test.
  50. * @returns the list's resolved scrollbar style and the geometry the
  51. * scrollbar-gutter/thin-scrollbar declarations shape.
  52. */
  53. function measureList(page: Page): Promise<ListMetrics> {
  54. return page.evaluate(() => {
  55. const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
  56. if (list === null) throw new Error('sidebar session list not in the DOM')
  57. const time = list.querySelector<HTMLElement>('[class*="time"]')
  58. if (time === null) throw new Error('no row relative-time element in the sidebar list')
  59. const row = list.querySelector<HTMLElement>('[role="treeitem"]')
  60. if (row === null) throw new Error('no row in the sidebar list')
  61. // Use one probe per variable because computed style declarations are live;
  62. // the color property also normalizes palette syntax.
  63. const resolve = (name: string): string => {
  64. const probe = document.createElement('span')
  65. probe.style.color = `var(${name})`
  66. list.append(probe)
  67. const value = getComputedStyle(probe).color
  68. probe.remove()
  69. return value
  70. }
  71. // Computed pseudo style folds in hover even at rest, so inspect the cascade.
  72. // Cross-origin sheets may throw and cannot contain the app-owned rule.
  73. const hoverRules = [...document.styleSheets]
  74. .flatMap((sheet) => {
  75. try {
  76. return [...sheet.cssRules]
  77. } catch {
  78. return []
  79. }
  80. })
  81. .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
  82. .filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
  83. .map(rule => rule.style.getPropertyValue('background'))
  84. const style = getComputedStyle(list)
  85. const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
  86. const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
  87. const listRect = list.getBoundingClientRect()
  88. const sidebarEdge = list.parentElement?.getBoundingClientRect().right
  89. if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
  90. return {
  91. gutter: style.scrollbarGutter,
  92. width: pseudoWidth,
  93. track: getComputedStyle(list, '::-webkit-scrollbar-track').backgroundColor,
  94. standardWidth: style.scrollbarWidth,
  95. standardColor: style.scrollbarColor,
  96. hoverRules,
  97. token: resolve('--dsh-scrollbar-thumb'),
  98. hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
  99. overflows: list.scrollHeight > list.clientHeight,
  100. band: listRect.width - list.clientWidth,
  101. scrollbarEdgeOffset: sidebarEdge - listRect.right,
  102. rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
  103. clientRight: listRect.left + list.clientWidth,
  104. borderRight: listRect.right,
  105. timeRight: time.getBoundingClientRect().right,
  106. // The bar is drawn in the rightmost `barWidth` of the border box, whether
  107. // or not that space was reserved. Its width comes from the sheet where the
  108. // sheet applies, and from the UA's own overlay bar otherwise — 15px is
  109. // what this chromium paints, measured with the rule absent. Taking the
  110. // UA width as the fallback is what keeps the assertion
  111. // honest: assuming 0 there would report no occlusion precisely in the
  112. // state that has it.
  113. timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
  114. }
  115. })
  116. }
  117. /**
  118. * Measure only overflow and row inset, which remain observable when every
  119. * session is hidden under a collapsed workspace group.
  120. * @param page - the page under test.
  121. * @returns the list overflow state and first row's trailing inset.
  122. */
  123. function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
  124. return page.evaluate(() => {
  125. const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
  126. if (list === null) throw new Error('sidebar session list not in the DOM')
  127. const row = list.querySelector<HTMLElement>('[role="treeitem"]')
  128. if (row === null) throw new Error('no row in the sidebar list')
  129. const sidebarEdge = list.parentElement?.getBoundingClientRect().right
  130. if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
  131. return {
  132. overflows: list.scrollHeight > list.clientHeight,
  133. rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
  134. }
  135. })
  136. }
  137. /** One palette's readings, taken at both pointer positions. */
  138. interface PaletteMetrics {
  139. hovered: ListMetrics
  140. quietThumb: string
  141. }
  142. /**
  143. * Read one palette at both pointer positions, ending with the pointer back
  144. * over the list so a caller measuring further leaves it revealed.
  145. * @param page - the page under test.
  146. * @returns the palette's quiet thumb and its hovered metrics.
  147. */
  148. async function measurePalette(page: Page): Promise<PaletteMetrics> {
  149. await pointAt(page, 'away')
  150. // Poll rather than sleep the linger out: the wait is the column's, and a
  151. // fixed sleep would either race it or pad every palette.
  152. await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
  153. const quietThumb = await resolveThumb(page)
  154. await pointAt(page, 'list')
  155. // Poll the reveal too: the reading below is a colour, and taking it in the
  156. // same tick as the pointer move would race React's flush and land a
  157. // transparent thumb in the golden.
  158. await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).not.toBe(NO_THUMB)
  159. return { hovered: await measureList(page), quietThumb }
  160. }
  161. /**
  162. * Render platform-neutral differences and comparisons instead of absolute
  163. * coordinates that depend on sidebar width and font metrics.
  164. * @param light - metrics measured under the light palette.
  165. * @param dark - metrics measured under the dark palette.
  166. * @returns the golden body, without a trailing newline.
  167. */
  168. function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
  169. const palette = (name: string, { hovered: metrics, quietThumb }: PaletteMetrics): string[] => [
  170. `## ${name}`,
  171. '',
  172. `- --dsh-scrollbar-thumb, pointer outside the sidebar: ${quietThumb}`,
  173. `- scrollbar-gutter: ${metrics.gutter}`,
  174. `- ::-webkit-scrollbar width: ${metrics.width}`,
  175. `- ::-webkit-scrollbar-track background: ${metrics.track}`,
  176. `- scrollbar-width: ${metrics.standardWidth}`,
  177. `- scrollbar-color: ${metrics.standardColor}`,
  178. `- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
  179. `- --dsh-scrollbar-thumb, pointer over the list: ${metrics.token}`,
  180. `- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
  181. `- list overflows: ${String(metrics.overflows)}`,
  182. `- reserved band: ${String(metrics.band)}px`,
  183. `- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
  184. `- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
  185. `- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
  186. `- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
  187. `- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
  188. '',
  189. ]
  190. return [
  191. '# Sidebar session list scrollbar',
  192. '',
  193. ...palette('Light palette', light),
  194. ...palette('Dark palette', dark),
  195. ].join('\n').trimEnd()
  196. }
  197. /**
  198. * Resolve `--dsh-scrollbar-thumb` as the list sees it, without the rest of the
  199. * geometry. Own probe element for the same reason {@link measureList} uses
  200. * one: `getComputedStyle` returns a live declaration.
  201. * @param page - the page under test.
  202. * @returns the resolved thumb colour, serialized as `rgb`/`rgba`.
  203. */
  204. function resolveThumb(page: Page): Promise<string> {
  205. return page.evaluate(() => {
  206. const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
  207. if (list === null) throw new Error('sidebar session list not in the DOM')
  208. const probe = document.createElement('span')
  209. probe.style.color = 'var(--dsh-scrollbar-thumb)'
  210. list.append(probe)
  211. const value = getComputedStyle(probe).color
  212. probe.remove()
  213. return value
  214. })
  215. }
  216. /** Fully transparent, which is how the quiet column spells "no thumb". */
  217. const NO_THUMB = 'rgba(0, 0, 0, 0)'
  218. /**
  219. * Park the pointer over the session list or outside the sidebar entirely. The
  220. * column reveals its scrollbars from real pointer movement, so a scenario that
  221. * never moves the mouse measures the quiet state whatever it intended to.
  222. * @param page - the page under test.
  223. * @param where - `list` to point at the session list, `away` for the far side
  224. * of the viewport (the conversation column).
  225. */
  226. async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
  227. const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox()
  228. if (box === null) throw new Error('sidebar session list has no layout box')
  229. const viewport = page.viewportSize()
  230. if (viewport === null) throw new Error('page has no viewport')
  231. const target = where === 'list'
  232. ? { x: box.x + box.width / 2, y: box.y + box.height / 2 }
  233. : { x: viewport.width - 5, y: box.y + box.height / 2 }
  234. await page.mouse.move(target.x, target.y)
  235. }
  236. /**
  237. * Reveal the seeded rows: every seeded session is unattached, so they all sit
  238. * in the collapsed Ungrouped bucket. Open the bucket, then use its transient
  239. * Show-more control because an open group intentionally renders only five
  240. * rows by default. Hand-rolled polling because
  241. * `expect.poll` is test-scoped and this runs in `beforeAll`.
  242. * @param page - the page under test.
  243. */
  244. async function expandSeededSessions(page: Page): Promise<void> {
  245. const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
  246. await bucket.waitFor({ timeout: 15_000 })
  247. const rows = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"]')
  248. const deadline = Date.now() + 30_000
  249. for (;;) {
  250. if (await bucket.getAttribute('aria-expanded') !== 'true') {
  251. await page.getByText('Ungrouped', { exact: true }).click()
  252. }
  253. const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ })
  254. if (await bucket.getAttribute('aria-expanded') === 'true'
  255. && await rows.count() <= SEED_COUNT / 2
  256. && await showMore.count() > 0) {
  257. await showMore.click()
  258. }
  259. if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
  260. if (Date.now() > deadline) {
  261. throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)
  262. }
  263. await page.waitForTimeout(200)
  264. }
  265. }
  266. describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => {
  267. let scaffold: WebScaffold
  268. let browser: Browser
  269. let page: Page
  270. let tripwire: ReturnType<typeof watchConsole>
  271. beforeAll(async () => {
  272. scaffold = await launchWebScaffold({})
  273. const fixture = await readFile(SEED, 'utf8')
  274. for (let index = 0; index < SEED_COUNT; index += 1) {
  275. await seedSession(scaffold, fixture, `sidebar-scrollbar-web-e2e-${String(index).padStart(2, '0')}`)
  276. }
  277. browser = await chromium.launch()
  278. // Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow
  279. // the list with room to spare.
  280. page = await newEnglishPage(browser, 800)
  281. tripwire = watchConsole(page)
  282. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  283. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  284. await expandSeededSessions(page)
  285. // Every assertion about a thumb colour needs a drawn thumb, and the column
  286. // only draws one under the pointer; the quiet state is asserted where it is
  287. // the subject rather than left as an ambient condition of the whole file.
  288. await pointAt(page, 'list')
  289. }, 180_000)
  290. afterAll(async () => {
  291. await browser?.close()
  292. await scaffold?.close()
  293. })
  294. it('reserves a scrollbar gutter on the overflowing session list', async () => {
  295. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-gutter'))
  296. // Vacuity guard: with a non-overflowing list `stable` still reserves, but
  297. // the scenario would no longer be reproducing the reported situation.
  298. await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true)
  299. const metrics = await measureList(page)
  300. expect(metrics.gutter).toBe('stable')
  301. // Pin presence, not width, because the width is platform-dependent.
  302. expect(metrics.band).toBeGreaterThan(0)
  303. expect(metrics.scrollbarEdgeOffset).toBe(2)
  304. expect(metrics.rowEdgeInset).toBe(12)
  305. // Measure against the bar because overlay scrollbars do not move the client edge.
  306. expect(metrics.timeCoveredBy).toBe(0)
  307. expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight)
  308. expect(metrics.clientRight).toBeLessThan(metrics.borderRight)
  309. expect(tripwire.pageErrors).toEqual([])
  310. }, 60_000)
  311. it('draws no thumb until the pointer is over the column, and lingers on the way out', async () => {
  312. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-pointer'))
  313. const revealed = await resolveThumb(page)
  314. expect(revealed).not.toBe(NO_THUMB)
  315. await pointAt(page, 'away')
  316. // The linger, measured as a state rather than a duration: the thumb is
  317. // still drawn on the leave itself, and gone once the window has passed. A
  318. // tighter timing assertion would pin the wall clock of a CI machine.
  319. expect(await resolveThumb(page)).toBe(revealed)
  320. await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
  321. // The reservation is unconditional, so nothing moved while the bar was
  322. // hidden — this is what buys `transparent` over hiding the bar itself.
  323. const quiet = await measureList(page)
  324. expect(quiet.gutter).toBe('stable')
  325. expect(quiet.band).toBeGreaterThan(0)
  326. expect(quiet.timeCoveredBy).toBe(0)
  327. // Scrolling without a pointer — what a keyboard or a touch drag does —
  328. // leaves the column quiet. This is the one deliberate loss, and
  329. // it is pinned here rather than only described, so making a scroll
  330. // re-reveal the bar has to be a decision rather than a side effect.
  331. await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
  332. await page.waitForTimeout(500)
  333. expect(await resolveThumb(page)).toBe(NO_THUMB)
  334. await pointAt(page, 'list')
  335. await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(revealed)
  336. expect(tripwire.pageErrors).toEqual([])
  337. }, 60_000)
  338. it('keeps the row background inset when overflow disappears', async () => {
  339. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
  340. expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
  341. const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
  342. await bucket.click()
  343. try {
  344. await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
  345. expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
  346. } finally {
  347. await expandSeededSessions(page)
  348. }
  349. expect(tripwire.pageErrors).toEqual([])
  350. }, 60_000)
  351. it('renders the themed thumb through the WebKit path in both palettes', async () => {
  352. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
  353. const light = await measureList(page)
  354. // The gate's signature on this engine, and the reason it exists: chromium
  355. // implements `::-webkit-scrollbar`, so the standard properties stay at
  356. // their initial `auto`. A concrete value here would mean the gate leaked,
  357. // which is exactly what makes chromium discard the pseudo-element rules —
  358. // the hover token included.
  359. expect(light.standardWidth).toBe('auto')
  360. expect(light.standardColor).toBe('auto')
  361. // The pseudo-element path is the one in force: the sheet's own 8px sizing
  362. // and transparent track reached a container it never names.
  363. expect(light.width).toBe('8px')
  364. expect(light.track).toBe('rgba(0, 0, 0, 0)')
  365. // The resting and the hover rule each read the rebindable indirection, and
  366. // the two resolve to DIFFERENT colours on this list: the l1 pair arrived
  367. // here intact rather than collapsing to one value or falling back.
  368. expect(light.hoverRules).toEqual(['var(--dsh-scrollbar-thumb-hover)'])
  369. expect(light.token).toMatch(/^rgba?\(/)
  370. expect(light.hoverToken).not.toBe(light.token)
  371. // The dark palette declares different scrollbar tokens; driving the body
  372. // attribute pins the cascade the way lifecycle-chrome does (the Settings
  373. // gesture that sets it is owned there).
  374. await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
  375. const dark = await measureList(page)
  376. expect(dark.token).not.toBe(light.token)
  377. expect(dark.hoverToken).not.toBe(dark.token)
  378. expect(dark.hoverToken).not.toBe(light.hoverToken)
  379. await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
  380. const restored = await measureList(page)
  381. expect(restored.token).toBe(light.token)
  382. expect(restored.hoverToken).toBe(light.hoverToken)
  383. expect(tripwire.pageErrors).toEqual([])
  384. }, 60_000)
  385. it('matches the committed scrollbar geometry golden in both palettes', async () => {
  386. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
  387. const light = await measurePalette(page)
  388. await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
  389. const dark = await measurePalette(page)
  390. await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
  391. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
  392. expect(tripwire.pageErrors).toEqual([])
  393. }, 60_000)
  394. it('commits exactly the fixtures it reads', async () => {
  395. // The scenario borrows seeded-history's session.v2.jsonl rather than committing a
  396. // second copy, so this directory holds the golden alone.
  397. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  398. })
  399. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  400. expect(tripwire.warnings).toEqual([])
  401. expect(tripwire.pageErrors).toEqual([])
  402. })
  403. })