composer-draft-scroll.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  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. /** The composer's scroll surface as the browser lays it out. */
  72. interface ComposerMetrics {
  73. /** True when the draft is taller than the capped box — the situation under test. */
  74. overflows: boolean
  75. /** Visible height of the scrollport's content box: the cap in pixels. */
  76. clientHeight: number
  77. /** Whole lines that fit in the visible box, at the composer's own line-height. */
  78. visibleLines: number
  79. /** The composer's one scroll offset. */
  80. scrollTop: number
  81. /** Furthest that offset can go. */
  82. scrollMax: number
  83. /**
  84. * Scrollable overflow the editable surface holds on its own — 0, or a
  85. * second offset exists beside the scrollport's.
  86. */
  87. surfaceScrollable: number
  88. /** Top of the LAST draft line relative to the visible box's top: at most `clientHeight` when on screen. */
  89. lastLineOffset: number
  90. /** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
  91. firstLineOffset: number
  92. }
  93. /**
  94. * Measure the composer surface in the page.
  95. * @param page - the page under test.
  96. * @returns the offset, the cap, and where the draft's first and last lines sit.
  97. */
  98. function measureComposer(page: Page): Promise<ComposerMetrics> {
  99. return page.evaluate(({ first, last }) => {
  100. const input = document.querySelector<HTMLElement>('[data-composer-input][contenteditable="true"]')
  101. if (input === null) throw new Error('no live composer surface in the DOM')
  102. const scroll = input.closest<HTMLElement>('[data-input-scroll]')
  103. if (scroll === null) throw new Error('the composer surface is not inside a draft scrollport')
  104. const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
  105. /** Where the surface paints the line holding `marker`, in viewport coordinates. */
  106. const glyphTop = (marker: string): number => {
  107. const walker = document.createTreeWalker(input, NodeFilter.SHOW_TEXT)
  108. for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
  109. const text = node as Text
  110. const at = text.data.indexOf(marker)
  111. if (at < 0) continue
  112. const range = document.createRange()
  113. range.setStart(text, at)
  114. range.setEnd(text, at + marker.length)
  115. return range.getBoundingClientRect().top
  116. }
  117. throw new Error(`marker ${marker} missing from the composer text`)
  118. }
  119. const box = scroll.getBoundingClientRect()
  120. return {
  121. overflows: scroll.scrollHeight > scroll.clientHeight,
  122. clientHeight: scroll.clientHeight,
  123. visibleLines: Math.floor(scroll.clientHeight / lineHeight),
  124. scrollTop: scroll.scrollTop,
  125. scrollMax: scroll.scrollHeight - scroll.clientHeight,
  126. surfaceScrollable: input.scrollHeight - input.clientHeight,
  127. lastLineOffset: glyphTop(last) - box.top,
  128. firstLineOffset: glyphTop(first) - box.top,
  129. }
  130. }, { first: FIRST_MARKER, last: LAST_MARKER })
  131. }
  132. /**
  133. * Render the golden body.
  134. *
  135. * Absolute glyph coordinates are deliberately absent: they depend on font
  136. * metrics and would make the fixture fail on a machine that measures text
  137. * differently — a golden that needs re-recording per platform documents the
  138. * platform, not the behavior. What is recorded is the cap, the single-offset
  139. * invariant, and which lines are on screen, each a comparison that survives
  140. * any layout keeping the behavior.
  141. * @param top - metrics with the draft scrolled to its start.
  142. * @param bottom - metrics with the draft scrolled to its end.
  143. * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
  144. * @param pasted - metrics right after a long block was pasted at the draft's end.
  145. * @returns the golden body, without a trailing newline.
  146. */
  147. function renderGeometry(
  148. top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
  149. ): string {
  150. return [
  151. '# Composer draft scrolling (14-line cap, one editable surface, one scrollport)',
  152. '',
  153. '## At the start of the draft',
  154. '',
  155. `- draft overflows the capped box: ${String(top.overflows)}`,
  156. `- visible lines: ${String(top.visibleLines)}`,
  157. `- the surface holds no scroll offset of its own: ${String(top.surfaceScrollable === 0)}`,
  158. `- scroll offset: ${String(top.scrollTop)}px`,
  159. `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
  160. `- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
  161. '',
  162. '## Scrolled to the end of the draft',
  163. '',
  164. `- offset moved: ${String(bottom.scrollTop > 0)}`,
  165. `- the surface holds no scroll offset of its own: ${String(bottom.surfaceScrollable === 0)}`,
  166. `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
  167. `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
  168. '',
  169. '## Draft ending in a newline, scrolled to the end',
  170. '',
  171. `- the draft's own last line is on screen: ${String(
  172. trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
  173. )}`,
  174. '',
  175. '## Right after pasting a long block at the end',
  176. '',
  177. `- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
  178. `- the pasted block's last line is on screen: ${String(
  179. pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
  180. )}`,
  181. ].join('\n').trimEnd()
  182. }
  183. describe('web e2e: composer draft scrolling', () => {
  184. let scaffold: WebScaffold
  185. let browser: Browser
  186. let page: Page
  187. let tripwire: ReturnType<typeof watchConsole>
  188. beforeAll(async () => {
  189. scaffold = await launchWebScaffold({})
  190. browser = await chromium.launch()
  191. page = await newEnglishPage(browser)
  192. tripwire = watchConsole(page)
  193. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  194. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  195. await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
  196. await typeDraft(page, DRAFT_ROWS)
  197. }, 180_000)
  198. afterAll(async () => {
  199. await browser?.close()
  200. await scaffold?.close()
  201. })
  202. it('caps the draft box at 14 lines with a single scroll offset', async () => {
  203. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
  204. // Vacuity guard: without an overflowing draft there is nothing to scroll and
  205. // every assertion below holds trivially.
  206. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  207. // Typing the draft left the caret — and the box — at its end, so reach the
  208. // start by the same gesture a user would, and leave it there for the wheel
  209. // case below.
  210. await surface(page).hover()
  211. await page.mouse.wheel(0, -2000)
  212. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  213. const metrics = await measureComposer(page)
  214. // The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
  215. // 14 x 24px lines). The count, not the pixels: it is the figma constant and
  216. // survives a device-pixel-ratio change.
  217. expect(metrics.visibleLines).toBe(14)
  218. // One scrolling box: the surface grows with the draft, so it holds no
  219. // second offset beside the scrollport's.
  220. expect(metrics.surfaceScrollable).toBe(0)
  221. expect(metrics.scrollTop).toBe(0)
  222. expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
  223. expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
  224. expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
  225. expect(tripwire.pageErrors).toEqual([])
  226. }, 60_000)
  227. it('a wheel gesture over a long draft moves the draft', async () => {
  228. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
  229. await surface(page).hover()
  230. await page.mouse.wheel(0, 240)
  231. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
  232. .toBeGreaterThan(0)
  233. const scrolled = await measureComposer(page)
  234. expect(scrolled.firstLineOffset).toBeLessThan(0)
  235. expect(tripwire.pageErrors).toEqual([])
  236. }, 60_000)
  237. it('typing at the end of a scrolled draft brings the caret back into view', async () => {
  238. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-typing'))
  239. const input = surface(page)
  240. // Put the caret at the very end, scroll the view away from it, then type:
  241. // the editor's own caret reveal must bring the end back on screen.
  242. // Select-all + ArrowRight lands the caret at the document end on every
  243. // platform (Cmd/Ctrl+End is not a caret move in mac contenteditable).
  244. await input.click()
  245. await page.keyboard.press('ControlOrMeta+KeyA')
  246. await page.keyboard.press('ArrowRight')
  247. await input.hover()
  248. await page.mouse.wheel(0, -2000)
  249. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  250. await page.keyboard.insertText(' typed-at-end')
  251. await expect.poll(async () => {
  252. const m = await measureComposer(page)
  253. return m.lastLineOffset >= 0 && m.lastLineOffset < m.clientHeight
  254. }, { timeout: 10_000 }).toBe(true)
  255. expect(tripwire.pageErrors).toEqual([])
  256. }, 60_000)
  257. it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
  258. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
  259. // A trailing soft break reserves a final empty line box; the end of the
  260. // draft is below the last glyph line, and scrolling to the end must show it.
  261. await typeDraft(page, [...DRAFT_ROWS, ''])
  262. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  263. await surface(page).hover()
  264. await page.mouse.wheel(0, 4000)
  265. await expect.poll(async () => {
  266. const m = await measureComposer(page)
  267. return m.scrollTop === m.scrollMax
  268. }, { timeout: 10_000 }).toBe(true)
  269. const bottom = await measureComposer(page)
  270. // At the very bottom the draft's own last line — the one before the empty
  271. // final line — is on screen.
  272. expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
  273. expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
  274. expect(tripwire.pageErrors).toEqual([])
  275. }, 60_000)
  276. it('matches the committed composer scroll geometry golden', async () => {
  277. onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
  278. // Restore the pristine draft (the edit cases appended to it) and return to
  279. // its start, both through ordinary gestures.
  280. await typeDraft(page, DRAFT_ROWS)
  281. await surface(page).hover()
  282. await page.mouse.wheel(0, -2000)
  283. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
  284. const top = await measureComposer(page)
  285. await surface(page).hover()
  286. await page.mouse.wheel(0, 2000)
  287. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
  288. .toBeGreaterThan(0)
  289. const bottom = await measureComposer(page)
  290. await typeDraft(page, [...DRAFT_ROWS, ''])
  291. await surface(page).hover()
  292. await page.mouse.wheel(0, 4000)
  293. await expect.poll(async () => {
  294. const m = await measureComposer(page)
  295. return m.scrollTop === m.scrollMax
  296. }, { timeout: 10_000 }).toBe(true)
  297. const trailingNewline = await measureComposer(page)
  298. // The paste path, measured the way a user meets it: a short draft, the
  299. // caret at its end, one long block pasted in.
  300. await typeDraft(page, ['one short line'])
  301. await surface(page).evaluate((el, text) => {
  302. const data = new DataTransfer()
  303. data.setData('text/plain', text)
  304. el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
  305. }, `\n${DRAFT_ROWS.join('\n')}`)
  306. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
  307. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
  308. const pasted = await measureComposer(page)
  309. await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE)
  310. expect(tripwire.pageErrors).toEqual([])
  311. }, 60_000)
  312. it('commits exactly the fixtures it reads', async () => {
  313. // Zero model calls, so the scenario records no session fixture: the geometry
  314. // golden is the whole inventory.
  315. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
  316. })
  317. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
  318. expect(tripwire.warnings).toEqual([])
  319. expect(tripwire.pageErrors).toEqual([])
  320. })
  321. })