composer-draft-scroll.e2e.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. // Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
  2. // GLYPHS, not just its caret.
  3. //
  4. // The composer paints its text in two stacked layers (see
  5. // packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
  6. // `<textarea>` carries the value, the selection and the caret but renders its
  7. // own glyphs `color: transparent`, and every visible character is painted by the
  8. // `[data-input-backdrop]` div underneath it, which also carries the claim-token
  9. // highlight, the chips and the ghost hint. The backdrop is `position: absolute;
  10. // inset: 0; overflow: hidden` — it is CLIPPED, not scrolled, and nothing in the
  11. // browser links its scroll offset to the textarea's.
  12. //
  13. // So past the cap the textarea scrolled and the words did not: the caret walked
  14. // off the bottom of a block of text frozen at line 1, and no gesture — wheel,
  15. // drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
  16. // backdrop on every textarea `scroll` and after every committed draft.
  17. //
  18. // Only a real engine can show this. Scrolling is layout: jsdom reports
  19. // `scrollHeight === clientHeight` for every element and never scrolls one, so
  20. // the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx has
  21. // to stub both offsets and can only prove the mirroring code path runs. What is
  22. // asserted here instead is the user-visible fact that path exists for — after
  23. // scrolling to the end of a long draft, the LAST line is the one on screen —
  24. // measured with a DOM Range over the backdrop's own text.
  25. //
  26. // Zero model calls: a fresh workspace's blank session already carries a live
  27. // composer, and the scenario only types into it. A stray stream would fail loud
  28. // with NO_ADAPTER.
  29. import { fileURLToPath } from 'node:url'
  30. import { join } from 'node:path'
  31. import type { Browser, Page } from 'playwright'
  32. import { chromium } from 'playwright'
  33. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  34. import {
  35. assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole,
  36. webSnapshotMode, type WebScaffold,
  37. } from './scaffold.ts'
  38. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  39. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
  40. /**
  41. * Committed golden of the composer's two-layer scroll geometry. The change
  42. * alters no DOM and no accessible name, so the aria goldens the other scenarios
  43. * commit are byte-identical with and without it; this records the relations
  44. * instead, which makes a shift in the cap or in the layer coupling a reviewable
  45. * diff rather than an assertion someone has to reconstruct.
  46. */
  47. const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
  48. const MODE = webSnapshotMode()
  49. /** Marks the first and last line so a Range can find them in the backdrop's text. */
  50. const FIRST_MARKER = 'FIRST-LINE-MARKER'
  51. const LAST_MARKER = 'LAST-LINE-MARKER'
  52. /** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
  53. const DRAFT_LINES = 40
  54. const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
  55. if (index === 0) return FIRST_MARKER
  56. if (index === DRAFT_LINES - 1) return LAST_MARKER
  57. return `draft line ${String(index + 1).padStart(2, '0')}`
  58. }).join('\n')
  59. /**
  60. * A draft ending in a newline: the shape whose layer extents diverge without
  61. * the backdrop's trailing-line sentinel. A textarea reserves a line box for the
  62. * caret after a final newline; `white-space: pre-wrap` collapses a text node's
  63. * trailing newline and generates none, so the backdrop would come out exactly
  64. * one line shorter and the mirrored offset would clamp a line above the caret.
  65. */
  66. const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
  67. /** The composer's two text layers as the browser lays them out. */
  68. interface ComposerMetrics {
  69. /** True when the draft is taller than the capped box — the situation under test. */
  70. overflows: boolean
  71. /** Visible height of the textarea's content box: the cap in pixels. */
  72. clientHeight: number
  73. /** Whole lines that fit in the visible box, at the composer's own line-height. */
  74. visibleLines: number
  75. /** The textarea's scroll offset, which the caret and the selection follow. */
  76. inputScrollTop: number
  77. /** The backdrop's scroll offset, which every visible glyph follows. */
  78. backdropScrollTop: number
  79. /** True when the two layers agree — the coupling this scenario exists for. */
  80. layersAgree: boolean
  81. /**
  82. * Top of the LAST draft line relative to the visible box's top, in pixels: at
  83. * most `clientHeight` when that line is on screen. This is the reported
  84. * symptom as a number — with the layers uncoupled the backdrop stays at offset
  85. * 0, so the last line sits a full draft-height below the box.
  86. */
  87. lastLineOffset: number
  88. /** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
  89. firstLineOffset: number
  90. /** Furthest the textarea can scroll. */
  91. inputMax: number
  92. /** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
  93. backdropMax: number
  94. }
  95. /**
  96. * Measure both composer layers in the page.
  97. * @param page - the page under test.
  98. * @returns the two layers' offsets and where the draft's first and last lines sit.
  99. */
  100. function measureComposer(page: Page): Promise<ComposerMetrics> {
  101. return page.evaluate(({ first, last }) => {
  102. const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
  103. if (input === null) throw new Error('no live composer textarea in the DOM')
  104. const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
  105. if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
  106. const box = input.getBoundingClientRect()
  107. // The draft carries no chips or claim token, so the backdrop holds exactly
  108. // one text node — the plain-text arm of the decoration walk.
  109. const text = backdrop.firstChild
  110. if (!(text instanceof Text)) throw new Error('backdrop is not a single plain text node')
  111. const offsetOf = (marker: string): number => {
  112. const at = text.data.indexOf(marker)
  113. if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
  114. const range = document.createRange()
  115. range.setStart(text, at)
  116. range.setEnd(text, at + marker.length)
  117. return range.getBoundingClientRect().top - box.top
  118. }
  119. const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
  120. // Each layer's own maximum, probed by asking for an impossible offset and
  121. // reading back what it clamped to, then restored. Reading scrollHeight -
  122. // clientHeight instead would compute the maximum rather than observe it.
  123. const restore = input.scrollTop
  124. const restoreBackdrop = backdrop.scrollTop
  125. input.scrollTop = 1e7
  126. backdrop.scrollTop = 1e7
  127. const inputMax = input.scrollTop
  128. const backdropMax = backdrop.scrollTop
  129. input.scrollTop = restore
  130. backdrop.scrollTop = restoreBackdrop
  131. return {
  132. inputMax,
  133. backdropMax,
  134. overflows: input.scrollHeight > input.clientHeight,
  135. clientHeight: input.clientHeight,
  136. visibleLines: Math.floor(input.clientHeight / lineHeight),
  137. inputScrollTop: input.scrollTop,
  138. backdropScrollTop: backdrop.scrollTop,
  139. layersAgree: input.scrollTop === backdrop.scrollTop,
  140. lastLineOffset: offsetOf(last),
  141. firstLineOffset: offsetOf(first),
  142. }
  143. }, { first: FIRST_MARKER, last: LAST_MARKER })
  144. }
  145. /**
  146. * Render the golden body.
  147. *
  148. * Absolute glyph coordinates are deliberately absent: they depend on font
  149. * metrics and would make the fixture fail on a machine that measures text
  150. * differently — a golden that needs re-recording per platform documents the
  151. * platform, not the change. What is recorded is the cap, the layer agreement,
  152. * and which lines are on screen, each a comparison that survives any layout
  153. * keeping the coupling.
  154. * @param top - metrics with the draft scrolled to its start.
  155. * @param bottom - metrics with the draft scrolled to its end.
  156. * @returns the golden body, without a trailing newline.
  157. */
  158. function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string {
  159. return [
  160. '# Composer draft scrolling (14-line cap, two text layers)',
  161. '',
  162. '## At the start of the draft',
  163. '',
  164. `- draft overflows the capped box: ${String(top.overflows)}`,
  165. `- visible lines: ${String(top.visibleLines)}`,
  166. `- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
  167. `- textarea scroll offset: ${String(top.inputScrollTop)}px`,
  168. `- glyph layer tracks it: ${String(top.layersAgree)}`,
  169. `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
  170. `- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
  171. '',
  172. '## Scrolled to the end of the draft',
  173. '',
  174. `- textarea moved: ${String(bottom.inputScrollTop > 0)}`,
  175. `- glyph layer tracks it: ${String(bottom.layersAgree)}`,
  176. `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
  177. `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
  178. '',
  179. '## Draft ending in a newline, scrolled to the end',
  180. '',
  181. `- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`,
  182. `- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`,
  183. `- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`,
  184. ].join('\n').trimEnd()
  185. }
  186. describe('web e2e: composer draft scrolling', () => {
  187. let scaffold: WebScaffold
  188. let browser: Browser
  189. let page: Page
  190. let tripwire: ReturnType<typeof watchConsole>
  191. beforeAll(async () => {
  192. scaffold = await launchWebScaffold({})
  193. browser = await chromium.launch()
  194. page = await newEnglishPage(browser)
  195. tripwire = watchConsole(page)
  196. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  197. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  198. await connectFreshWorkspace(page, 'composer-draft-scroll')
  199. await page.locator('textarea:enabled').first().fill(DRAFT)
  200. }, 180_000)
  201. afterAll(async () => {
  202. await browser?.close()
  203. await scaffold?.close()
  204. })
  205. it('caps the draft box and keeps both text layers at the start', async () => {
  206. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
  207. // Vacuity guard: without an overflowing draft there is nothing to scroll and
  208. // every assertion below holds trivially.
  209. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  210. // Typing the draft left the caret — and the box — at its end, so reach the
  211. // start by the same gesture a user would, and leave it there for the wheel
  212. // case below.
  213. await page.locator('textarea:enabled').first().hover()
  214. await page.mouse.wheel(0, -2000)
  215. await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
  216. const metrics = await measureComposer(page)
  217. // The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
  218. // 14 x 24px lines). The count, not the pixels: it is the figma constant and
  219. // survives a device-pixel-ratio change.
  220. expect(metrics.visibleLines).toBe(14)
  221. // Resting state: the draft's head is what a 40-line draft shows, and its
  222. // tail is far below the box. Both layers sit at the origin, which is why the
  223. // uncoupled build looks correct until something scrolls.
  224. expect(metrics.inputScrollTop).toBe(0)
  225. expect(metrics.layersAgree).toBe(true)
  226. expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
  227. expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
  228. expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
  229. expect(tripwire.pageErrors).toEqual([])
  230. }, 60_000)
  231. it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
  232. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
  233. const input = page.locator('textarea:enabled').first()
  234. await input.hover()
  235. // One delta past the whole draft: the textarea clamps at its own end, and
  236. // the wheel-chaining handler leaves it native because the box is not yet at
  237. // its edge when the gesture starts (the chaining itself is owned by the
  238. // unit spec).
  239. await page.mouse.wheel(0, 2000)
  240. await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
  241. .toBeGreaterThan(0)
  242. const metrics = await measureComposer(page)
  243. // The coupling, stated directly.
  244. expect(metrics.layersAgree).toBe(true)
  245. // The reported symptom, stated as what the user sees: the end of the draft
  246. // is on screen and its beginning is not. On the uncoupled build the glyph
  247. // layer stays at offset 0, so `lastLineOffset` is still a full draft below
  248. // the box and `firstLineOffset` is still 0 — the text never moved.
  249. expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
  250. expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
  251. expect(metrics.firstLineOffset).toBeLessThan(0)
  252. expect(tripwire.pageErrors).toEqual([])
  253. }, 60_000)
  254. it('typing at the end of a scrolled draft keeps the layers together', async () => {
  255. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
  256. // The other way the box moves. Typing at the caret — parked at the draft's
  257. // end by the wheel gesture — scrolls it into view, which is a `scroll` like
  258. // any other; this pins that an edit is not a separate case needing its own
  259. // mirror, which is why one listener is the whole implementation.
  260. const input = page.locator('textarea:enabled').first()
  261. await input.press('End')
  262. await input.pressSequentially(' tail')
  263. const metrics = await measureComposer(page)
  264. expect(metrics.layersAgree).toBe(true)
  265. expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
  266. expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
  267. expect(tripwire.pageErrors).toEqual([])
  268. }, 60_000)
  269. it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
  270. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
  271. // The layers reserve a final line box on different terms, so this shape is
  272. // the one that separates equal extents from a mirror that clamps early.
  273. const input = page.locator('textarea:enabled').first()
  274. await input.fill(DRAFT_TRAILING_NEWLINE)
  275. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  276. const extents = await measureComposer(page)
  277. // The invariant the sentinel exists for. Without it the textarea measured
  278. // 652 against the backdrop's 628 — one 24px line apart.
  279. expect(extents.backdropMax).toBe(extents.inputMax)
  280. await input.hover()
  281. await page.mouse.wheel(0, 4000)
  282. await expect.poll(async () => {
  283. const m = await measureComposer(page)
  284. return m.inputScrollTop === m.inputMax
  285. }, { timeout: 10_000 }).toBe(true)
  286. const bottom = await measureComposer(page)
  287. // At the very bottom the glyphs are level with the caret, not a line behind.
  288. expect(bottom.layersAgree).toBe(true)
  289. expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
  290. expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
  291. expect(tripwire.pageErrors).toEqual([])
  292. }, 60_000)
  293. it('matches the committed composer scroll geometry golden', async () => {
  294. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
  295. const input = page.locator('textarea:enabled').first()
  296. // Restore the pristine draft (the edit case appended to it) and return to
  297. // its start, both through ordinary gestures.
  298. await input.fill(DRAFT)
  299. await input.hover()
  300. await page.mouse.wheel(0, -2000)
  301. await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
  302. const top = await measureComposer(page)
  303. await input.hover()
  304. await page.mouse.wheel(0, 2000)
  305. await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
  306. .toBeGreaterThan(0)
  307. const bottom = await measureComposer(page)
  308. await input.fill(DRAFT_TRAILING_NEWLINE)
  309. await input.hover()
  310. await page.mouse.wheel(0, 4000)
  311. await expect.poll(async () => {
  312. const m = await measureComposer(page)
  313. return m.inputScrollTop === m.inputMax
  314. }, { timeout: 10_000 }).toBe(true)
  315. const trailingNewline = await measureComposer(page)
  316. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE)
  317. expect(tripwire.pageErrors).toEqual([])
  318. }, 60_000)
  319. it('commits exactly the fixtures it reads', async () => {
  320. // Zero model calls, so the scenario records no session fixture: the geometry
  321. // golden is the whole inventory.
  322. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  323. })
  324. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  325. expect(tripwire.warnings).toEqual([])
  326. expect(tripwire.pageErrors).toEqual([])
  327. })
  328. })