composer-draft-scroll.e2e.ts 25 KB

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