composer-draft-scroll.e2e.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. // Browser geometry for the composer's caret and visible text layers. A
  2. // same-task gap probe detects deferred scroll synchronization that DOM-only
  3. // tests cannot observe.
  4. import { fileURLToPath } from 'node:url'
  5. import { join } from 'node:path'
  6. import type { Browser, Page } from 'playwright'
  7. import { chromium } from 'playwright'
  8. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  9. import {
  10. assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole,
  11. webSnapshotMode, type WebScaffold,
  12. } from './scaffold.ts'
  13. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  14. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-draft-scroll', import.meta.url))
  15. /** Scroll geometry is absent from ARIA snapshots, so this scenario records it directly. */
  16. const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
  17. const MODE = webSnapshotMode()
  18. /** Marks the first and last line so a Range can find them in the backdrop's text. */
  19. const FIRST_MARKER = 'FIRST-LINE-MARKER'
  20. const LAST_MARKER = 'LAST-LINE-MARKER'
  21. /** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
  22. const DRAFT_LINES = 40
  23. const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
  24. if (index === 0) return FIRST_MARKER
  25. if (index === DRAFT_LINES - 1) return LAST_MARKER
  26. return `draft line ${String(index + 1).padStart(2, '0')}`
  27. }).join('\n')
  28. /**
  29. * A draft ending in a newline, where the two layers reserve their
  30. * final line box on different terms. A textarea keeps one for the caret after a
  31. * final newline; `white-space: pre-wrap` collapses a text node's trailing
  32. * newline and generates none. The hidden auto-grow mirror carries the newline
  33. * and so decides the height for both, which is why the backdrop needs no
  34. * padding of its own — but only a draft with a trailing newline can show it.
  35. */
  36. const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
  37. /** The composer's text layers as the browser lays them out. */
  38. interface ComposerMetrics {
  39. overflows: boolean
  40. clientHeight: number
  41. visibleLines: number
  42. scrollTop: number
  43. scrollMax: number
  44. inputScrollable: number
  45. /**
  46. * Distance between where the caret sits for a draft line and where the
  47. * backdrop paints that line, in pixels. A fixed value (the difference between
  48. * a line box's top and its glyph box's) is alignment; a value that CHANGES
  49. * with the scroll offset is the defect — the words trailing the caret.
  50. */
  51. caretGlyphGap: number
  52. /**
  53. * How much the caret-to-glyph gap moves when the offset changes before a
  54. * scroll listener can run.
  55. */
  56. gapShiftOnScroll: number
  57. lastLineOffset: number
  58. firstLineOffset: number
  59. inputWrapWidth: number
  60. backdropWrapWidth: number
  61. mirrorWrapWidth: number
  62. }
  63. /**
  64. * Measure the composer's layers in the page, in the caret's coordinate frame.
  65. * @param page - the page under test.
  66. * @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit.
  67. */
  68. function measureComposer(page: Page): Promise<ComposerMetrics> {
  69. return page.evaluate(({ first, last }) => {
  70. const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
  71. if (input === null) throw new Error('no live composer textarea in the DOM')
  72. const scroll = input.closest<HTMLElement>('[data-input-scroll]')
  73. if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport')
  74. const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
  75. if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
  76. // The hidden auto-grow mirror: the textarea's next sibling, and the layer
  77. // that decides the box's height, so its wrap width matters as much as the
  78. // two that carry glyphs.
  79. const mirror = input.nextElementSibling
  80. if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
  81. // The draft carries no chips or claim token, so the decoration walk emits it
  82. // as a single text node, which is what the Range below needs.
  83. const text = backdrop.firstChild
  84. if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
  85. const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
  86. /** Where the backdrop paints the line holding `marker`, in viewport coordinates. */
  87. const glyphTop = (marker: string): number => {
  88. const at = text.data.indexOf(marker)
  89. if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
  90. const range = document.createRange()
  91. range.setStart(text, at)
  92. range.setEnd(text, at + marker.length)
  93. return range.getBoundingClientRect().top
  94. }
  95. const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop)
  96. // Where the CARET sits on the draft's first line: the textarea lays its own
  97. // (transparent) glyphs out from its border box, shifted by any offset it
  98. // holds itself. Reading the caret's frame this way rather than the
  99. // scrollport's is what makes the gap the user-visible quantity — it stays
  100. // honest if the textarea ever starts scrolling on its own again.
  101. const gap = (): number =>
  102. Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first))
  103. // The same-task probe: move the offset and re-read the gap before the task
  104. // ends, which is before any scroll event could have run a listener.
  105. const before = gap()
  106. const restore = scroll.scrollTop
  107. scroll.scrollTop = restore === 0 ? 120 : 0
  108. const gapShiftOnScroll = Math.abs(gap() - before)
  109. scroll.scrollTop = restore
  110. const box = scroll.getBoundingClientRect()
  111. return {
  112. inputWrapWidth: input.clientWidth,
  113. backdropWrapWidth: backdrop.clientWidth,
  114. mirrorWrapWidth: mirror.clientWidth,
  115. overflows: scroll.scrollHeight > scroll.clientHeight,
  116. clientHeight: scroll.clientHeight,
  117. visibleLines: Math.floor(scroll.clientHeight / lineHeight),
  118. scrollTop: scroll.scrollTop,
  119. scrollMax: scroll.scrollHeight - scroll.clientHeight,
  120. inputScrollable: input.scrollHeight - input.clientHeight,
  121. caretGlyphGap: before,
  122. gapShiftOnScroll,
  123. lastLineOffset: glyphTop(last) - box.top,
  124. firstLineOffset: glyphTop(first) - box.top,
  125. }
  126. }, { first: FIRST_MARKER, last: LAST_MARKER })
  127. }
  128. /**
  129. * Render platform-neutral comparisons instead of font-dependent glyph
  130. * coordinates.
  131. * @param top - metrics with the draft scrolled to its start.
  132. * @param bottom - metrics with the draft scrolled to its end.
  133. * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
  134. * @param pasted - metrics right after a long block was pasted at the draft's end.
  135. * @returns the golden body, without a trailing newline.
  136. */
  137. function renderGeometry(
  138. top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
  139. ): string {
  140. return [
  141. '# Composer draft scrolling (14-line cap, two text layers, one scrollport)',
  142. '',
  143. '## At the start of the draft',
  144. '',
  145. `- draft overflows the capped box: ${String(top.overflows)}`,
  146. `- visible lines: ${String(top.visibleLines)}`,
  147. `- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`,
  148. `- all three layers wrap at one width: ${String(
  149. top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
  150. )}`,
  151. `- scroll offset: ${String(top.scrollTop)}px`,
  152. `- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`,
  153. `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
  154. `- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
  155. '',
  156. '## Scrolled to the end of the draft',
  157. '',
  158. `- offset moved: ${String(bottom.scrollTop > 0)}`,
  159. `- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`,
  160. `- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`,
  161. `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
  162. `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
  163. '',
  164. '## Draft ending in a newline, scrolled to the end',
  165. '',
  166. `- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`,
  167. `- the draft's own last line is on screen: ${String(
  168. trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
  169. )}`,
  170. '',
  171. '## Right after pasting a long block at the end',
  172. '',
  173. `- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
  174. `- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`,
  175. `- the pasted block's last line is on screen: ${String(
  176. pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
  177. )}`,
  178. ].join('\n').trimEnd()
  179. }
  180. describe('web e2e: composer draft scrolling', () => {
  181. let scaffold: WebScaffold
  182. let browser: Browser
  183. let page: Page
  184. let tripwire: ReturnType<typeof watchConsole>
  185. beforeAll(async () => {
  186. scaffold = await launchWebScaffold({})
  187. browser = await chromium.launch()
  188. page = await newEnglishPage(browser)
  189. tripwire = watchConsole(page)
  190. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  191. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  192. await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
  193. await page.locator('textarea:enabled').first().fill(DRAFT)
  194. }, 180_000)
  195. afterAll(async () => {
  196. await browser?.close()
  197. await scaffold?.close()
  198. })
  199. it('caps the draft box and keeps both text layers at the start', async () => {
  200. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
  201. // Vacuity guard: without an overflowing draft there is nothing to scroll and
  202. // every assertion below holds trivially.
  203. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  204. // Typing the draft left the caret — and the box — at its end, so reach the
  205. // start by the same gesture a user would, and leave it there for the wheel
  206. // case below.
  207. await page.locator('textarea:enabled').first().hover()
  208. await page.mouse.wheel(0, -2000)
  209. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  210. const metrics = await measureComposer(page)
  211. // The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
  212. // 14 x 24px lines). The count, not the pixels: it is the figma constant and
  213. // survives a device-pixel-ratio change.
  214. expect(metrics.visibleLines).toBe(14)
  215. // One scrolling box: the textarea is as tall as the draft, so there is no
  216. // second offset for the caret to hold while the glyphs hold another.
  217. expect(metrics.inputScrollable).toBe(0)
  218. expect(metrics.scrollTop).toBe(0)
  219. expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
  220. expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
  221. expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
  222. expect(tripwire.pageErrors).toEqual([])
  223. }, 60_000)
  224. it('lays out all three text layers at one wrap width', async () => {
  225. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
  226. // A layer that breaks lines somewhere else puts the words under the wrong
  227. // caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
  228. // draft. All three share a containing block — the scrollport — so a
  229. // scrollbar that consumes layout space costs them the same width; with
  230. // only the textarea scrolling, WebKit reserves gutter space for it alone
  231. // (768 against 776) while chromium and firefox do not.
  232. const metrics = await measureComposer(page)
  233. expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
  234. // The mirror decides the box height, so it belongs in the same equality —
  235. // were it alone to wrap wider, the box would be measured too short and
  236. // clip content before the 14-line cap, with every other assertion green.
  237. expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth)
  238. expect(tripwire.pageErrors).toEqual([])
  239. }, 60_000)
  240. it('the glyphs cannot lag the caret: one task moves both', async () => {
  241. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag'))
  242. // The reported symptom, isolated. A scroll offset changes and the caret's
  243. // distance to its own glyphs is re-read before the task ends — before any
  244. // `scroll` listener could have run. With the layers on one scrollport the
  245. // browser moves both, so the distance is unchanged; with the glyph layer
  246. // catching up in a listener it is off by the whole delta until a later
  247. // frame, which is a caret flying away from its text mid-gesture.
  248. const metrics = await measureComposer(page)
  249. expect(metrics.gapShiftOnScroll).toBe(0)
  250. expect(tripwire.pageErrors).toEqual([])
  251. }, 60_000)
  252. it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
  253. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
  254. const input = page.locator('textarea:enabled').first()
  255. await input.hover()
  256. const resting = (await measureComposer(page)).caretGlyphGap
  257. // One delta past the whole draft: the box clamps at its own end.
  258. await page.mouse.wheel(0, 2000)
  259. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
  260. .toBeGreaterThan(0)
  261. const metrics = await measureComposer(page)
  262. // The caret is still on its own glyphs after the gesture.
  263. expect(metrics.caretGlyphGap).toBe(resting)
  264. // The reported symptom, stated as what the user sees: the end of the draft
  265. // is on screen and its beginning is not.
  266. expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
  267. expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
  268. expect(metrics.firstLineOffset).toBeLessThan(0)
  269. expect(tripwire.pageErrors).toEqual([])
  270. }, 60_000)
  271. it('typing at the end of a scrolled draft brings the caret back into view', async () => {
  272. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
  273. // The other way the box moves, and the one that depends on the browser: the
  274. // textarea holds no scroll offset of its own, so revealing the caret after
  275. // an edit is a scroll-into-view that has to walk up to the scrollport.
  276. // Scroll away from the caret first, so the edit has somewhere to bring it
  277. // back from.
  278. const input = page.locator('textarea:enabled').first()
  279. await input.press('End')
  280. await input.hover()
  281. await page.mouse.wheel(0, -2000)
  282. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  283. await input.pressSequentially(' tail')
  284. const metrics = await measureComposer(page)
  285. expect(metrics.scrollTop).toBeGreaterThan(0)
  286. expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
  287. expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
  288. expect(tripwire.pageErrors).toEqual([])
  289. }, 60_000)
  290. it('pasting a long block scrolls to the caret it leaves at the end', async () => {
  291. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
  292. // The composer suppresses the native paste — the machine owns the draft and
  293. // the undo log — and restores the caret programmatically, which reveals
  294. // nothing on its own: in chromium and WebKit the view stays put while the
  295. // caret sits at the end of the pasted block, so the restore scrolls it
  296. // into view; this case pins it.
  297. const input = page.locator('textarea:enabled').first()
  298. await input.fill('one short line')
  299. await input.press('End')
  300. // A real `paste` event carrying real clipboard data, dispatched at the
  301. // textarea: the same event a Cmd-V delivers, and it runs the same handler.
  302. await input.evaluate((el, text) => {
  303. const data = new DataTransfer()
  304. data.setData('text/plain', text)
  305. el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
  306. // The engines disagree when the draft ends in a newline: the caret
  307. // lands on a line with nothing on it, where chromium reports no client
  308. // rects at all for the collapsed position.
  309. }, `\n${DRAFT}\n`)
  310. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  311. // The restore lands one frame after the machine commits the draft, so the
  312. // box overflows before it moves; waiting on the offset is waiting for the
  313. // behavior itself, and its absence fails this poll.
  314. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
  315. const metrics = await measureComposer(page)
  316. // The caret is at the end of what was pasted, so the draft's last line is
  317. // what has to be on screen.
  318. expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
  319. expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
  320. expect(metrics.gapShiftOnScroll).toBe(0)
  321. expect(tripwire.pageErrors).toEqual([])
  322. }, 60_000)
  323. it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
  324. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
  325. // The layers reserve a final line box on different terms, so the trailing-newline case is
  326. // the one that separates a height every layer agrees on from a box measured
  327. // one line short of the caret's own last position.
  328. const input = page.locator('textarea:enabled').first()
  329. await input.fill(DRAFT_TRAILING_NEWLINE)
  330. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  331. await input.hover()
  332. await page.mouse.wheel(0, 4000)
  333. await expect.poll(async () => {
  334. const m = await measureComposer(page)
  335. return m.scrollTop === m.scrollMax
  336. }, { timeout: 10_000 }).toBe(true)
  337. const bottom = await measureComposer(page)
  338. // At the very bottom the glyphs are level with the caret, and the draft's
  339. // own last line — the one before the empty final line — is on screen.
  340. expect(bottom.gapShiftOnScroll).toBe(0)
  341. expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
  342. expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
  343. expect(tripwire.pageErrors).toEqual([])
  344. }, 60_000)
  345. it('matches the committed composer scroll geometry golden', async () => {
  346. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
  347. const input = page.locator('textarea:enabled').first()
  348. // Restore the pristine draft (the edit case appended to it) and return to
  349. // its start, both through ordinary gestures.
  350. await input.fill(DRAFT)
  351. await input.hover()
  352. await page.mouse.wheel(0, -2000)
  353. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  354. const top = await measureComposer(page)
  355. await input.hover()
  356. await page.mouse.wheel(0, 2000)
  357. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
  358. .toBeGreaterThan(0)
  359. const bottom = await measureComposer(page)
  360. await input.fill(DRAFT_TRAILING_NEWLINE)
  361. await input.hover()
  362. await page.mouse.wheel(0, 4000)
  363. await expect.poll(async () => {
  364. const m = await measureComposer(page)
  365. return m.scrollTop === m.scrollMax
  366. }, { timeout: 10_000 }).toBe(true)
  367. const trailingNewline = await measureComposer(page)
  368. // The paste path, measured the way a user meets it: a short draft, the
  369. // caret at its end, one long block pasted in.
  370. await input.fill('one short line')
  371. await input.press('End')
  372. await input.evaluate((el, text) => {
  373. const data = new DataTransfer()
  374. data.setData('text/plain', text)
  375. el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
  376. // Keep a final glyph so the collapsed caret position has a client rect.
  377. }, `\n${DRAFT}`)
  378. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  379. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
  380. const pasted = await measureComposer(page)
  381. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE)
  382. expect(tripwire.pageErrors).toEqual([])
  383. }, 60_000)
  384. it('commits exactly the fixtures it reads', async () => {
  385. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  386. })
  387. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  388. expect(tripwire.warnings).toEqual([])
  389. expect(tripwire.pageErrors).toEqual([])
  390. })
  391. })