conversation-column-overflow.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. // Web e2e scenario: the conversation column scrolls on one axis only, as the
  2. // browser actually lays it out. The reported symptom was a horizontal
  3. // scrollbar under the whole center column once the window (or the sidebar
  4. // drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the
  5. // column and becoming user-scrollable.
  6. //
  7. // The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
  8. // hero box (ConversationRoot.module.css) so the blur scales with the input
  9. // card. What changed is the scroll container: `[data-conversation-scroll]`
  10. // scrolls vertically, and a box that scrolls in one axis computes the other
  11. // axis's initial `visible` to `auto`, so the bleed came back as a bar. The
  12. // fix states `overflow-x: hidden` there.
  13. //
  14. // Only a real engine reports that pair — the bleed and the resulting scroll
  15. // range — so the scenario sweeps viewport widths that bracket the glow's
  16. // width and asserts both at each stop. Asserting no horizontal scroll alone
  17. // would go vacuous the moment the glow stopped bleeding for an unrelated
  18. // reason, which is why each stop also records whether it bleeds; the wide stop
  19. // is the control where it does not.
  20. //
  21. // Zero model calls: the hero is the boot state, so nothing is seeded and no
  22. // replay row mounts. A stray stream would fail loud with NO_ADAPTER.
  23. import { fileURLToPath } from 'node:url'
  24. import { join } from 'node:path'
  25. import type { Browser, Page } from 'playwright'
  26. import { chromium } from 'playwright'
  27. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  28. import {
  29. assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
  30. type WebScaffold,
  31. } from './scaffold.ts'
  32. import { newEnglishPage, saveFailureShot } from './support.ts'
  33. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-overflow', import.meta.url))
  34. /**
  35. * Committed golden of the one-axis relation at every stop. It records
  36. * relations and booleans, never absolute coordinates: the column width follows
  37. * the viewport and the sidebar, and a golden carrying pixels would document the
  38. * platform instead of the change.
  39. */
  40. const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
  41. const MODE = webSnapshotMode()
  42. /** Narrow sweep stop where the mutation control retains overflow across scrollbar implementations. */
  43. const CONTROL_VIEWPORT = 600
  44. /**
  45. * Viewport widths bracketing the glow: the narrow stops retain the reported
  46. * bleed while the widest stop proves the relation can also be false.
  47. */
  48. const WIDTHS = [1680, 1200, 1000, 800, CONTROL_VIEWPORT]
  49. /** Element id of the mutation control's injected sheet, so the test can take it back out. */
  50. const CONTROL_STYLE_ID = 'dsh-column-overflow-control'
  51. /** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */
  52. const WHEEL_DELTA = 300
  53. /** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */
  54. interface ColumnMetrics {
  55. /** Viewport width the stop was measured at. */
  56. width: number
  57. /** The column's content width. Not committed to the golden — it is what settles after a resize, and what the sweep waits on. */
  58. columnWidth: number
  59. /** Resolved `overflow-x` on the conversation scroll container. */
  60. overflowX: string
  61. /** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */
  62. glowBleeds: boolean
  63. /**
  64. * `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
  65. * `auto` both report the same value, because `hidden` clips the bleed rather
  66. * than reflowing it away. Recorded because it is the vacuity guard in
  67. * numbers — it must stay positive at the narrow stops, or the scenario has
  68. * stopped reproducing the situation the fix is for.
  69. */
  70. bleedRange: number
  71. /** True when the column still scrolls vertically — the axis the fix must not take away. */
  72. scrollsVertically: boolean
  73. }
  74. /**
  75. * Measure the conversation column at the page's current viewport.
  76. * @param page - the page under test.
  77. * @param width - the viewport width already applied, recorded with the reading.
  78. * @returns the stop's overflow relations.
  79. */
  80. function measureColumn(page: Page, width: number): Promise<ColumnMetrics> {
  81. return page.evaluate((viewportWidth) => {
  82. const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
  83. if (scroller === null) throw new Error('conversation scroll container not in the DOM')
  84. const glow = scroller.querySelector<SVGElement>('[class*="heroGlow"]')
  85. if (glow === null) throw new Error('hero glow not in the DOM — the boot state is not the hero')
  86. const box = scroller.getBoundingClientRect()
  87. const glowBox = glow.getBoundingClientRect()
  88. return {
  89. width: viewportWidth,
  90. columnWidth: scroller.clientWidth,
  91. overflowX: getComputedStyle(scroller).overflowX,
  92. // `clientWidth` is the content edge, which is what the scrollable
  93. // overflow region is measured against; either side counts as a bleed,
  94. // though only the right one can produce a bar in this writing mode.
  95. glowBleeds: glowBox.right > box.left + scroller.clientWidth + 0.5 || glowBox.left < box.left - 0.5,
  96. bleedRange: scroller.scrollWidth - scroller.clientWidth,
  97. scrollsVertically: getComputedStyle(scroller).overflowY === 'auto',
  98. }
  99. }, width)
  100. }
  101. /**
  102. * Scroll the column sideways the way a user would and report where it landed.
  103. *
  104. * This is the one signal that separates the two states, and it is why the
  105. * scenario needs a real engine: `overflow-x: hidden` leaves the box
  106. * programmatically scrollable and leaves `scrollWidth` untouched, so every
  107. * property reading agrees across the fix. Only refusing an actual input event
  108. * differs — measured at the 1200px stop, the shipped column stays at 0 while
  109. * the same page with `overflow-x: auto` forced on lands at its scroll boundary.
  110. * @param page - the page under test.
  111. * @returns `scrollLeft` after one horizontal wheel over the column.
  112. */
  113. async function wheelHorizontally(page: Page): Promise<number> {
  114. const origin = await page.evaluate(() => {
  115. const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
  116. if (scroller === null) throw new Error('conversation scroll container not in the DOM')
  117. // Start from the origin so the reading is this gesture's own effect.
  118. scroller.scrollLeft = 0
  119. const box = scroller.getBoundingClientRect()
  120. // Near the top of the column, clear of the centered hero card: the wheel
  121. // must reach the column, not a nested scroller the composer owns.
  122. return { x: box.left + box.width / 2, y: box.top + 60 }
  123. })
  124. await page.mouse.move(origin.x, origin.y)
  125. await page.mouse.wheel(WHEEL_DELTA, 0)
  126. // A fixed settle, then two frames. Polling for a settled value cannot be
  127. // used here — the value under test is 0, which a poll starting at 0 accepts
  128. // before the gesture has had any chance to move it — so the wait is
  129. // generous enough to cover a smooth-scroll animation on any engine the lane
  130. // runs on. The timing is identical on both sides of the mutation control
  131. // below, which is what makes a 0 reading evidence rather than a race won.
  132. await page.waitForTimeout(400)
  133. return page.evaluate(() => new Promise<number>((resolve) => {
  134. requestAnimationFrame(() => {
  135. requestAnimationFrame(() => {
  136. resolve(document.querySelector<HTMLElement>('[data-conversation-scroll]')?.scrollLeft ?? -1)
  137. })
  138. })
  139. }))
  140. }
  141. /**
  142. * Measure the positive horizontal scroll boundary without changing the
  143. * shipped overflow mode. This is distinct from `scrollWidth - clientWidth`
  144. * when a stable scrollbar gutter leaves part of the overflow on the negative
  145. * side of the scroll origin.
  146. * @param page - the page under test.
  147. * @returns the greatest positive `scrollLeft` reachable by the control gesture.
  148. */
  149. async function horizontalScrollLimit(page: Page): Promise<number> {
  150. return page.evaluate((delta) => {
  151. const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
  152. if (scroller === null) throw new Error('conversation scroll container not in the DOM')
  153. const previousScrollBehavior = scroller.style.scrollBehavior
  154. scroller.style.scrollBehavior = 'auto'
  155. scroller.scrollLeft = delta
  156. const limit = scroller.scrollLeft
  157. scroller.scrollLeft = 0
  158. scroller.style.scrollBehavior = previousScrollBehavior
  159. return limit
  160. }, WHEEL_DELTA)
  161. }
  162. /** A stop's readings plus where a horizontal wheel over it landed. */
  163. type ColumnStop = ColumnMetrics & {
  164. /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */
  165. scrollLeftAfterWheel: number
  166. }
  167. /**
  168. * Render the golden body: one line per stop, relations only.
  169. *
  170. * Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
  171. * which the fix pins to 0 by construction. The bleed is recorded as a boolean
  172. * rather than its width, so the golden survives any platform whose column
  173. * lands a pixel off — a fixture that has to be re-recorded per platform
  174. * documents the platform, not the change.
  175. * @param stops - the measured stops, in sweep order.
  176. * @returns the golden body, without a trailing newline.
  177. */
  178. function renderGeometry(stops: ColumnStop[]): string {
  179. return [
  180. '# Conversation column horizontal overflow',
  181. '',
  182. '| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |',
  183. '| --- | --- | --- | --- | --- |',
  184. ...stops.map(stop => `| ${String(stop.width)}px | ${stop.overflowX} | ${String(stop.glowBleeds)} `
  185. + `| ${String(stop.scrollLeftAfterWheel)}px | ${String(stop.scrollsVertically)} |`),
  186. ].join('\n')
  187. }
  188. describe('web e2e: the conversation column scrolls on one axis', () => {
  189. let scaffold: WebScaffold
  190. let browser: Browser
  191. let page: Page
  192. let tripwire: ReturnType<typeof watchConsole>
  193. beforeAll(async () => {
  194. scaffold = await launchWebScaffold({})
  195. browser = await chromium.launch()
  196. page = await newEnglishPage(browser, 900)
  197. tripwire = watchConsole(page)
  198. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  199. await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 })
  200. }, 180_000)
  201. afterAll(async () => {
  202. await browser?.close()
  203. await scaffold?.close()
  204. })
  205. /**
  206. * Resize to a viewport and read the column once its width stops moving.
  207. *
  208. * The glow rides the hero box, which rides the column, and the frame eases
  209. * its column tracks over `--ds-transition-duration-slow`: reading straight
  210. * after a resize can report the previous viewport's relation, or a width
  211. * caught mid-transition.
  212. * @param width - viewport width to settle at.
  213. * @returns the column's readings at that width.
  214. */
  215. const settleAt = async (width: number): Promise<ColumnMetrics> => {
  216. await page.setViewportSize({ width, height: 900 })
  217. let previous = -1
  218. await expect.poll(async () => {
  219. const current = (await measureColumn(page, width)).columnWidth
  220. const settled = current === previous
  221. previous = current
  222. return settled
  223. }, { timeout: 10_000 }).toBe(true)
  224. return measureColumn(page, width)
  225. }
  226. /**
  227. * Sweep the stops once per run and hand the SAME readings to every assertion
  228. * below, so the golden and the assertions describe one measurement instead of
  229. * two runs that could disagree. Memoized rather than re-run per test: the
  230. * gestures below move the viewport, and a second sweep would be a second
  231. * chance for a resize to settle differently.
  232. * @returns the stops in {@link WIDTHS} order.
  233. */
  234. let swept: Promise<ColumnStop[]> | undefined
  235. const sweep = (): Promise<ColumnStop[]> => {
  236. swept ??= (async () => {
  237. const stops: ColumnStop[] = []
  238. for (const width of WIDTHS) {
  239. stops.push({ ...await settleAt(width), scrollLeftAfterWheel: await wheelHorizontally(page) })
  240. }
  241. return stops
  242. })()
  243. return swept
  244. }
  245. it('never scrolls horizontally, at any width the glow bleeds past', async () => {
  246. onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow'))
  247. const stops = await sweep()
  248. // The vacuity guard, in two halves: the glow has to reach past the column
  249. // at the narrow stops, and that reach has to still register as scrollable
  250. // overflow. Without both, the claim below holds for free.
  251. expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([
  252. 1200, 1000, 800, CONTROL_VIEWPORT,
  253. ])
  254. for (const stop of stops.filter(stop => stop.glowBleeds)) {
  255. expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0)
  256. }
  257. for (const stop of stops) {
  258. expect(stop.overflowX, `viewport ${String(stop.width)}`).toBe('hidden')
  259. // The reported symptom, stated directly: a horizontal wheel over the
  260. // column moves nothing, at every stop.
  261. expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
  262. // The axis the column is a scroller for must survive the fix.
  263. expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
  264. }
  265. expect(tripwire.pageErrors).toEqual([])
  266. }, 120_000)
  267. it('reports the pre-fix state when the axis is opened back up', async () => {
  268. onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
  269. // The mutation control, run in the page rather than against a second
  270. // build: it restores exactly what the fix changed — the initial `visible`
  271. // that a one-axis scroller computes to `auto` — and shows the same gesture,
  272. // at the same timing, carrying the column to its positive scroll boundary.
  273. // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
  274. // Injected with an id rather than through `addStyleTag`, so the teardown
  275. // below can take the sheet out again by selector: it must not outlive this
  276. // test, or the golden ends up reading the control.
  277. await page.evaluate((id: string) => {
  278. const sheet = document.createElement('style')
  279. sheet.id = id
  280. sheet.textContent = '[data-conversation-scroll] { overflow-x: auto !important; }'
  281. document.head.append(sheet)
  282. }, CONTROL_STYLE_ID)
  283. try {
  284. // Resolve the mutated layout at the narrowest sweep stop. At wider stops,
  285. // a classic scrollbar can change the available box enough to remove the
  286. // overflow that the control is meant to expose.
  287. const before = await settleAt(CONTROL_VIEWPORT)
  288. expect(before.overflowX).toBe('auto')
  289. expect(before.bleedRange).toBeGreaterThan(0)
  290. const scrollLimit = await horizontalScrollLimit(page)
  291. // The control has a reachable horizontal range, and the gesture exceeds
  292. // it so the equality below proves that the wheel reached the far edge.
  293. expect(scrollLimit).toBeGreaterThan(0)
  294. expect(scrollLimit).toBeLessThan(WHEEL_DELTA)
  295. // Rounded: `scrollLeft` is fractional under a fractional layout while
  296. // the claim is that the column reached the positive boundary, not that
  297. // two engines agree on a sub-pixel.
  298. expect(Math.round(await wheelHorizontally(page))).toBe(Math.round(scrollLimit))
  299. } finally {
  300. await page.evaluate((id: string) => {
  301. document.getElementById(id)?.remove()
  302. }, CONTROL_STYLE_ID)
  303. }
  304. // The override is gone and the shipped state is back: the later goldens
  305. // read the product, not the control.
  306. expect((await settleAt(CONTROL_VIEWPORT)).overflowX).toBe('hidden')
  307. expect(tripwire.pageErrors).toEqual([])
  308. }, 120_000)
  309. it('matches the committed column-overflow golden', async () => {
  310. onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-golden'))
  311. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE)
  312. expect(tripwire.pageErrors).toEqual([])
  313. }, 120_000)
  314. it('commits exactly the fixtures it reads', async () => {
  315. // No model calls, so no replay log: the golden is the whole inventory.
  316. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  317. })
  318. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  319. expect(tripwire.warnings).toEqual([])
  320. expect(tripwire.pageErrors).toEqual([])
  321. })
  322. })