composer-draft-scroll.e2e.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. // Web e2e scenario: a composer draft longer than the 14-line cap scrolls,
  2. // reveals the caret, and holds no second scroll offset.
  3. //
  4. // The composer is ONE contenteditable surface (see
  5. // packages/client/ui-conversation/src/client/skeleton/InputBar.module.css):
  6. // the Lexical editor's root carries the glyphs, the selection and the caret
  7. // together, grows with its content, and `[data-input-scroll]` — the
  8. // composer's single scrolling box — caps it at 14 lines. With one surface
  9. // there is no second text layer whose offset could drift from the caret's;
  10. // what remains to pin is the cap, the wheel gesture, and the caret reveals
  11. // (typing at a scrolled end, pasting a long block, a trailing-newline end).
  12. //
  13. // Only a real engine can show any of this. Scrolling is layout: jsdom reports
  14. // `scrollHeight === clientHeight` for every element and never scrolls one, so
  15. // the unit spec in packages/client/ui-conversation/tests/input-bar.client.spec.tsx can
  16. // only assert that the scrollport holds the surface.
  17. //
  18. // Zero model calls: a fresh workspace's blank session already carries a live
  19. // composer, and the scenario only types into it. A stray stream would fail loud
  20. // with NO_ADAPTER.
  21. import { fileURLToPath } from 'node:url'
  22. import { join } from 'node:path'
  23. import type { Browser, Page } from 'playwright'
  24. import { chromium } from 'playwright'
  25. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  26. import {
  27. assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole,
  28. webSnapshotMode, type WebScaffold,
  29. } from './scaffold.ts'
  30. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  31. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-draft-scroll', import.meta.url))
  32. /**
  33. * Committed golden of the composer's scroll geometry. The change alters no
  34. * accessible name, so the aria goldens the other scenarios commit are
  35. * byte-identical with and without it; this records the relations instead,
  36. * which makes a shift in the cap or in the reveal behavior a reviewable diff
  37. * rather than an assertion someone has to reconstruct.
  38. */
  39. const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
  40. const MODE = webSnapshotMode()
  41. /** Marks the first and last line so the measurement can find their line boxes. */
  42. const FIRST_MARKER = 'FIRST-LINE-MARKER'
  43. const LAST_MARKER = 'LAST-LINE-MARKER'
  44. /** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
  45. const DRAFT_LINES = 40
  46. const DRAFT_ROWS = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
  47. if (index === 0) return FIRST_MARKER
  48. if (index === DRAFT_LINES - 1) return LAST_MARKER
  49. return `draft line ${String(index + 1).padStart(2, '0')}`
  50. })
  51. /** The live composer surface. */
  52. function surface(page: Page): ReturnType<Page['locator']> {
  53. return page.locator('[data-composer-input][contenteditable="true"]').first()
  54. }
  55. /**
  56. * Replace the draft through real gestures: select-all, delete, then insert
  57. * the rows with soft line breaks (the composer's Enter submits).
  58. * @param page - the page under test.
  59. * @param rows - draft lines; a trailing empty row leaves a trailing newline.
  60. */
  61. async function typeDraft(page: Page, rows: readonly string[]): Promise<void> {
  62. const input = surface(page)
  63. await input.click()
  64. await page.keyboard.press('ControlOrMeta+KeyA')
  65. await page.keyboard.press('Delete')
  66. for (const [index, row] of rows.entries()) {
  67. if (index > 0) await page.keyboard.press('Shift+Enter')
  68. if (row !== '') await page.keyboard.insertText(row)
  69. }
  70. }
  71. /**
  72. * Replace the draft with one pasted block through the composer's own paste
  73. * path (the keymap's ClipboardEvent route).
  74. * @param page - the page under test.
  75. * @param rows - draft lines, joined with '\n' — one paste, not one keystroke each.
  76. */
  77. async function pasteDraft(page: Page, rows: readonly string[]): Promise<void> {
  78. await typeDraft(page, [])
  79. await surface(page).evaluate((el, text) => {
  80. const data = new DataTransfer()
  81. data.setData('text/plain', text)
  82. el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
  83. }, rows.join('\n'))
  84. }
  85. /** The composer's scrollport alone, for measurements taken after an edit removed the marked lines. */
  86. interface ScrollportMetrics {
  87. /** True when the draft is taller than the capped box. */
  88. overflows: boolean
  89. /** The composer's one scroll offset. */
  90. scrollTop: number
  91. /** Furthest that offset can go. */
  92. scrollMax: number
  93. }
  94. /**
  95. * Measure the composer's scrollport without addressing the draft's marked lines.
  96. * @param page - the page under test.
  97. * @returns the offset, its maximum, and whether the draft overflows.
  98. */
  99. function measureScrollport(page: Page): Promise<ScrollportMetrics> {
  100. return page.evaluate(() => {
  101. const input = document.querySelector<HTMLElement>('[data-composer-input][contenteditable="true"]')
  102. if (input === null) throw new Error('no live composer surface in the DOM')
  103. const scroll = input.closest<HTMLElement>('[data-input-scroll]')
  104. if (scroll === null) throw new Error('the composer surface is not inside a draft scrollport')
  105. return {
  106. overflows: scroll.scrollHeight > scroll.clientHeight,
  107. scrollTop: scroll.scrollTop,
  108. scrollMax: scroll.scrollHeight - scroll.clientHeight,
  109. }
  110. })
  111. }
  112. /** The composer's scroll surface as the browser lays it out. */
  113. interface ComposerMetrics {
  114. /** True when the draft is taller than the capped box — the situation under test. */
  115. overflows: boolean
  116. /** Visible height of the scrollport's content box: the cap in pixels. */
  117. clientHeight: number
  118. /** Whole lines that fit in the visible box, at the composer's own line-height. */
  119. visibleLines: number
  120. /** The composer's one scroll offset. */
  121. scrollTop: number
  122. /** Furthest that offset can go. */
  123. scrollMax: number
  124. /**
  125. * Scrollable overflow the editable surface holds on its own — 0, or a
  126. * second offset exists beside the scrollport's.
  127. */
  128. surfaceScrollable: number
  129. /** Top of the LAST draft line relative to the visible box's top: at most `clientHeight` when on screen. */
  130. lastLineOffset: number
  131. /** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
  132. firstLineOffset: number
  133. }
  134. /**
  135. * Measure the composer surface in the page.
  136. * @param page - the page under test.
  137. * @returns the offset, the cap, and where the draft's first and last lines sit.
  138. */
  139. function measureComposer(page: Page): Promise<ComposerMetrics> {
  140. return page.evaluate(({ first, last }) => {
  141. const input = document.querySelector<HTMLElement>('[data-composer-input][contenteditable="true"]')
  142. if (input === null) throw new Error('no live composer surface in the DOM')
  143. const scroll = input.closest<HTMLElement>('[data-input-scroll]')
  144. if (scroll === null) throw new Error('the composer surface is not inside a draft scrollport')
  145. const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
  146. /** Where the surface paints the line holding `marker`, in viewport coordinates. */
  147. const glyphTop = (marker: string): number => {
  148. const walker = document.createTreeWalker(input, NodeFilter.SHOW_TEXT)
  149. for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
  150. const text = node as Text
  151. const at = text.data.indexOf(marker)
  152. if (at < 0) continue
  153. const range = document.createRange()
  154. range.setStart(text, at)
  155. range.setEnd(text, at + marker.length)
  156. return range.getBoundingClientRect().top
  157. }
  158. throw new Error(`marker ${marker} missing from the composer text`)
  159. }
  160. const box = scroll.getBoundingClientRect()
  161. return {
  162. overflows: scroll.scrollHeight > scroll.clientHeight,
  163. clientHeight: scroll.clientHeight,
  164. visibleLines: Math.floor(scroll.clientHeight / lineHeight),
  165. scrollTop: scroll.scrollTop,
  166. scrollMax: scroll.scrollHeight - scroll.clientHeight,
  167. surfaceScrollable: input.scrollHeight - input.clientHeight,
  168. lastLineOffset: glyphTop(last) - box.top,
  169. firstLineOffset: glyphTop(first) - box.top,
  170. }
  171. }, { first: FIRST_MARKER, last: LAST_MARKER })
  172. }
  173. /**
  174. * Render the golden body.
  175. *
  176. * Absolute glyph coordinates are deliberately absent: they depend on font
  177. * metrics and would make the fixture fail on a machine that measures text
  178. * differently — a golden that needs re-recording per platform documents the
  179. * platform, not the behavior. What is recorded is the cap, the single-offset
  180. * invariant, and which lines are on screen, each a comparison that survives
  181. * any layout keeping the behavior.
  182. * @param top - metrics with the draft scrolled to its start.
  183. * @param bottom - metrics with the draft scrolled to its end.
  184. * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
  185. * @param pasted - metrics right after a long block was pasted at the draft's end.
  186. * @returns the golden body, without a trailing newline.
  187. */
  188. function renderGeometry(
  189. top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
  190. ): string {
  191. return [
  192. '# Composer draft scrolling (14-line cap, one editable surface, one scrollport)',
  193. '',
  194. '## At the start of the draft',
  195. '',
  196. `- draft overflows the capped box: ${String(top.overflows)}`,
  197. `- visible lines: ${String(top.visibleLines)}`,
  198. `- the surface holds no scroll offset of its own: ${String(top.surfaceScrollable === 0)}`,
  199. `- scroll offset: ${String(top.scrollTop)}px`,
  200. `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
  201. `- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
  202. '',
  203. '## Scrolled to the end of the draft',
  204. '',
  205. `- offset moved: ${String(bottom.scrollTop > 0)}`,
  206. `- the surface holds no scroll offset of its own: ${String(bottom.surfaceScrollable === 0)}`,
  207. `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
  208. `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
  209. '',
  210. '## Draft ending in a newline, scrolled to the end',
  211. '',
  212. `- the draft's own last line is on screen: ${String(
  213. trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
  214. )}`,
  215. '',
  216. '## Right after pasting a long block at the end',
  217. '',
  218. `- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
  219. `- the pasted block's last line is on screen: ${String(
  220. pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
  221. )}`,
  222. ].join('\n').trimEnd()
  223. }
  224. describe('web e2e: composer draft scrolling', () => {
  225. let scaffold: WebScaffold
  226. let browser: Browser
  227. let page: Page
  228. let tripwire: ReturnType<typeof watchConsole>
  229. beforeAll(async () => {
  230. scaffold = await launchWebScaffold({})
  231. browser = await chromium.launch()
  232. page = await newEnglishPage(browser)
  233. tripwire = watchConsole(page)
  234. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  235. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  236. await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
  237. await typeDraft(page, DRAFT_ROWS)
  238. }, 180_000)
  239. afterAll(async () => {
  240. await browser?.close()
  241. await scaffold?.close()
  242. })
  243. it('caps the draft box at 14 lines with a single scroll offset', async () => {
  244. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
  245. // Vacuity guard: without an overflowing draft there is nothing to scroll and
  246. // every assertion below holds trivially.
  247. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  248. // Typing the draft left the caret — and the box — at its end, so reach the
  249. // start by the same gesture a user would, and leave it there for the wheel
  250. // case below.
  251. await surface(page).hover()
  252. await page.mouse.wheel(0, -2000)
  253. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  254. const metrics = await measureComposer(page)
  255. // The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
  256. // 14 x 24px lines). The count, not the pixels: it is the figma constant and
  257. // survives a device-pixel-ratio change.
  258. expect(metrics.visibleLines).toBe(14)
  259. // One scrolling box: the surface grows with the draft, so it holds no
  260. // second offset beside the scrollport's.
  261. expect(metrics.surfaceScrollable).toBe(0)
  262. expect(metrics.scrollTop).toBe(0)
  263. expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
  264. expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
  265. expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
  266. expect(tripwire.pageErrors).toEqual([])
  267. }, 60_000)
  268. it('a wheel gesture over a long draft moves the draft', async () => {
  269. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
  270. await surface(page).hover()
  271. await page.mouse.wheel(0, 240)
  272. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
  273. .toBeGreaterThan(0)
  274. const scrolled = await measureComposer(page)
  275. expect(scrolled.firstLineOffset).toBeLessThan(0)
  276. expect(tripwire.pageErrors).toEqual([])
  277. }, 60_000)
  278. it('typing at the end of a scrolled draft brings the caret back into view', async () => {
  279. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-typing'))
  280. const input = surface(page)
  281. // Put the caret at the very end, scroll the view away from it, then type:
  282. // the editor's own caret reveal must bring the end back on screen.
  283. // Select-all + ArrowRight lands the caret at the document end on every
  284. // platform (Cmd/Ctrl+End is not a caret move in mac contenteditable).
  285. await input.click()
  286. await page.keyboard.press('ControlOrMeta+KeyA')
  287. await page.keyboard.press('ArrowRight')
  288. await input.hover()
  289. await page.mouse.wheel(0, -2000)
  290. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  291. await page.keyboard.insertText(' typed-at-end')
  292. await expect.poll(async () => {
  293. const m = await measureComposer(page)
  294. return m.lastLineOffset >= 0 && m.lastLineOffset < m.clientHeight
  295. }, { timeout: 10_000 }).toBe(true)
  296. expect(tripwire.pageErrors).toEqual([])
  297. }, 60_000)
  298. it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
  299. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
  300. // A trailing soft break reserves a final empty line box; the end of the
  301. // draft is below the last glyph line, and scrolling to the end must show it.
  302. await typeDraft(page, [...DRAFT_ROWS, ''])
  303. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  304. await surface(page).hover()
  305. await page.mouse.wheel(0, 4000)
  306. await expect.poll(async () => {
  307. const m = await measureComposer(page)
  308. return m.scrollTop === m.scrollMax
  309. }, { timeout: 10_000 }).toBe(true)
  310. const bottom = await measureComposer(page)
  311. // At the very bottom the draft's own last line — the one before the empty
  312. // final line — is on screen.
  313. expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
  314. expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
  315. expect(tripwire.pageErrors).toEqual([])
  316. }, 60_000)
  317. it('deleting a pasted draft\'s trailing lines keeps the box at the end', async () => {
  318. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste-delete'))
  319. // A pasted block is where a draft's structure and a typed one diverge: a
  320. // paste that keeps its newlines inside one text node leaves the browser
  321. // revealing the caret from a rect that spans the whole block, which jumps
  322. // the capped box back to the draft's head on the next edit at its end.
  323. await pasteDraft(page, [...DRAFT_ROWS, '', '', ''])
  324. await expect.poll(async () => (await measureScrollport(page)).overflows, { timeout: 10_000 }).toBe(true)
  325. await surface(page).hover()
  326. await page.mouse.wheel(0, 4000)
  327. await expect.poll(async () => {
  328. const m = await measureScrollport(page)
  329. return m.scrollTop === m.scrollMax
  330. }, { timeout: 10_000 }).toBe(true)
  331. // The caret at the draft's end, then the trailing lines removed one at a
  332. // time; the box must stay at the end, where the caret is. Select-all plus
  333. // ArrowRight lands the caret on the document end; the collapse has to be
  334. // observable before Backspace, or the whole selection is what gets deleted.
  335. const input = surface(page)
  336. await input.click()
  337. await page.keyboard.press('ControlOrMeta+KeyA')
  338. await page.keyboard.press('ArrowRight')
  339. await expect.poll(
  340. async () => page.evaluate(() => window.getSelection()?.isCollapsed ?? false),
  341. { timeout: 10_000 },
  342. ).toBe(true)
  343. await page.keyboard.press('Backspace')
  344. await expect.poll(async () => (await measureScrollport(page)).overflows, { timeout: 10_000 }).toBe(true)
  345. await expect.poll(async () => {
  346. const m = await measureScrollport(page)
  347. return m.scrollTop === m.scrollMax
  348. }, { timeout: 10_000 }).toBe(true)
  349. // The box is at its furthest offset rather than back at the draft's head.
  350. const afterBackspace = await measureScrollport(page)
  351. expect(afterBackspace.scrollMax).toBeGreaterThan(0)
  352. expect(afterBackspace.scrollTop).toBe(afterBackspace.scrollMax)
  353. // The same failure through the selection gesture: select the trailing
  354. // lines and delete them at once.
  355. await page.keyboard.press('Shift+ArrowUp')
  356. await page.keyboard.press('Shift+ArrowUp')
  357. await page.keyboard.press('Delete')
  358. await expect.poll(async () => {
  359. const m = await measureScrollport(page)
  360. return m.overflows && m.scrollTop === m.scrollMax
  361. }, { timeout: 10_000 }).toBe(true)
  362. expect(tripwire.pageErrors).toEqual([])
  363. }, 60_000)
  364. it('matches the committed composer scroll geometry golden', async () => {
  365. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
  366. // Restore the pristine draft (the edit cases appended to it) and return to
  367. // its start, both through ordinary gestures.
  368. await typeDraft(page, DRAFT_ROWS)
  369. await surface(page).hover()
  370. await page.mouse.wheel(0, -2000)
  371. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  372. const top = await measureComposer(page)
  373. await surface(page).hover()
  374. await page.mouse.wheel(0, 2000)
  375. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
  376. .toBeGreaterThan(0)
  377. const bottom = await measureComposer(page)
  378. await typeDraft(page, [...DRAFT_ROWS, ''])
  379. await surface(page).hover()
  380. await page.mouse.wheel(0, 4000)
  381. await expect.poll(async () => {
  382. const m = await measureComposer(page)
  383. return m.scrollTop === m.scrollMax
  384. }, { timeout: 10_000 }).toBe(true)
  385. const trailingNewline = await measureComposer(page)
  386. // The paste path, measured the way a user meets it: a short draft, the
  387. // caret at its end, one long block pasted in.
  388. await typeDraft(page, ['one short line'])
  389. await surface(page).evaluate((el, text) => {
  390. const data = new DataTransfer()
  391. data.setData('text/plain', text)
  392. el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
  393. }, `\n${DRAFT_ROWS.join('\n')}`)
  394. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  395. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
  396. const pasted = await measureComposer(page)
  397. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE)
  398. expect(tripwire.pageErrors).toEqual([])
  399. }, 60_000)
  400. it('commits exactly the fixtures it reads', async () => {
  401. // Zero model calls, so the scenario records no session fixture: the geometry
  402. // golden is the whole inventory.
  403. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  404. })
  405. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  406. expect(tripwire.warnings).toEqual([])
  407. expect(tripwire.pageErrors).toEqual([])
  408. })
  409. })