// Browser geometry for the composer's caret and visible text layers. A // same-task gap probe detects deferred scroll synchronization that DOM-only // tests cannot observe. import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-draft-scroll', import.meta.url)) /** Scroll geometry is absent from ARIA snapshots, so this scenario records it directly. */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() /** Marks the first and last line so a Range can find them in the backdrop's text. */ const FIRST_MARKER = 'FIRST-LINE-MARKER' const LAST_MARKER = 'LAST-LINE-MARKER' /** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */ const DRAFT_LINES = 40 const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => { if (index === 0) return FIRST_MARKER if (index === DRAFT_LINES - 1) return LAST_MARKER return `draft line ${String(index + 1).padStart(2, '0')}` }).join('\n') /** * A draft ending in a newline, where the two layers reserve their * final line box on different terms. A textarea keeps one for the caret after a * final newline; `white-space: pre-wrap` collapses a text node's trailing * newline and generates none. The hidden auto-grow mirror carries the newline * and so decides the height for both, which is why the backdrop needs no * padding of its own — but only a draft with a trailing newline can show it. */ const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n` /** The composer's text layers as the browser lays them out. */ interface ComposerMetrics { overflows: boolean clientHeight: number visibleLines: number scrollTop: number scrollMax: number inputScrollable: number /** * Distance between where the caret sits for a draft line and where the * backdrop paints that line, in pixels. A fixed value (the difference between * a line box's top and its glyph box's) is alignment; a value that CHANGES * with the scroll offset is the defect — the words trailing the caret. */ caretGlyphGap: number /** * How much the caret-to-glyph gap moves when the offset changes before a * scroll listener can run. */ gapShiftOnScroll: number lastLineOffset: number firstLineOffset: number inputWrapWidth: number backdropWrapWidth: number mirrorWrapWidth: number } /** * Measure the composer's layers in the page, in the caret's coordinate frame. * @param page - the page under test. * @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit. */ function measureComposer(page: Page): Promise { return page.evaluate(({ first, last }) => { const input = document.querySelector('textarea:enabled') if (input === null) throw new Error('no live composer textarea in the DOM') const scroll = input.closest('[data-input-scroll]') if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport') const backdrop = input.parentElement?.querySelector('[data-input-backdrop]') if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea') // The hidden auto-grow mirror: the textarea's next sibling, and the layer // that decides the box's height, so its wrap width matters as much as the // two that carry glyphs. const mirror = input.nextElementSibling if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea') // The draft carries no chips or claim token, so the decoration walk emits it // as a single text node, which is what the Range below needs. const text = backdrop.firstChild if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node') const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight) /** Where the backdrop paints the line holding `marker`, in viewport coordinates. */ const glyphTop = (marker: string): number => { const at = text.data.indexOf(marker) if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`) const range = document.createRange() range.setStart(text, at) range.setEnd(text, at + marker.length) return range.getBoundingClientRect().top } const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop) // Where the CARET sits on the draft's first line: the textarea lays its own // (transparent) glyphs out from its border box, shifted by any offset it // holds itself. Reading the caret's frame this way rather than the // scrollport's is what makes the gap the user-visible quantity — it stays // honest if the textarea ever starts scrolling on its own again. const gap = (): number => Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first)) // The same-task probe: move the offset and re-read the gap before the task // ends, which is before any scroll event could have run a listener. const before = gap() const restore = scroll.scrollTop scroll.scrollTop = restore === 0 ? 120 : 0 const gapShiftOnScroll = Math.abs(gap() - before) scroll.scrollTop = restore const box = scroll.getBoundingClientRect() return { inputWrapWidth: input.clientWidth, backdropWrapWidth: backdrop.clientWidth, mirrorWrapWidth: mirror.clientWidth, overflows: scroll.scrollHeight > scroll.clientHeight, clientHeight: scroll.clientHeight, visibleLines: Math.floor(scroll.clientHeight / lineHeight), scrollTop: scroll.scrollTop, scrollMax: scroll.scrollHeight - scroll.clientHeight, inputScrollable: input.scrollHeight - input.clientHeight, caretGlyphGap: before, gapShiftOnScroll, lastLineOffset: glyphTop(last) - box.top, firstLineOffset: glyphTop(first) - box.top, } }, { first: FIRST_MARKER, last: LAST_MARKER }) } /** * Render platform-neutral comparisons instead of font-dependent glyph * coordinates. * @param top - metrics with the draft scrolled to its start. * @param bottom - metrics with the draft scrolled to its end. * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end. * @param pasted - metrics right after a long block was pasted at the draft's end. * @returns the golden body, without a trailing newline. */ function renderGeometry( top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics, ): string { return [ '# Composer draft scrolling (14-line cap, two text layers, one scrollport)', '', '## At the start of the draft', '', `- draft overflows the capped box: ${String(top.overflows)}`, `- visible lines: ${String(top.visibleLines)}`, `- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`, `- all three layers wrap at one width: ${String( top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth, )}`, `- scroll offset: ${String(top.scrollTop)}px`, `- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`, `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`, `- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`, '', '## Scrolled to the end of the draft', '', `- offset moved: ${String(bottom.scrollTop > 0)}`, `- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`, `- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`, `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`, `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`, '', '## Draft ending in a newline, scrolled to the end', '', `- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`, `- the draft's own last line is on screen: ${String( trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight, )}`, '', '## Right after pasting a long block at the end', '', `- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`, `- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`, `- the pasted block's last line is on screen: ${String( pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight, )}`, ].join('\n').trimEnd() } describe('web e2e: composer draft scrolling', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { scaffold = await launchWebScaffold({}) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll') await page.locator('textarea:enabled').first().fill(DRAFT) }, 180_000) afterAll(async () => { await browser?.close() await scaffold?.close() }) it('caps the draft box and keeps both text layers at the start', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top')) // Vacuity guard: without an overflowing draft there is nothing to scroll and // every assertion below holds trivially. await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) // Typing the draft left the caret — and the box — at its end, so reach the // start by the same gesture a user would, and leave it there for the wheel // case below. await page.locator('textarea:enabled').first().hover() await page.mouse.wheel(0, -2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0) const metrics = await measureComposer(page) // The cap is the composer seat's `--dsh-composer-text-max-height` (336px = // 14 x 24px lines). The count, not the pixels: it is the figma constant and // survives a device-pixel-ratio change. expect(metrics.visibleLines).toBe(14) // One scrolling box: the textarea is as tall as the draft, so there is no // second offset for the caret to hold while the glyphs hold another. expect(metrics.inputScrollable).toBe(0) expect(metrics.scrollTop).toBe(0) expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0) expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight) expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('lays out all three text layers at one wrap width', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width')) // A layer that breaks lines somewhere else puts the words under the wrong // caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive // draft. All three share a containing block — the scrollport — so a // scrollbar that consumes layout space costs them the same width; with // only the textarea scrolling, WebKit reserves gutter space for it alone // (768 against 776) while chromium and firefox do not. const metrics = await measureComposer(page) expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth) // The mirror decides the box height, so it belongs in the same equality — // were it alone to wrap wider, the box would be measured too short and // clip content before the 14-line cap, with every other assertion green. expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('the glyphs cannot lag the caret: one task moves both', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag')) // The reported symptom, isolated. A scroll offset changes and the caret's // distance to its own glyphs is re-read before the task ends — before any // `scroll` listener could have run. With the layers on one scrollport the // browser moves both, so the distance is unchanged; with the glyph layer // catching up in a listener it is off by the whole delta until a later // frame, which is a caret flying away from its text mid-gesture. const metrics = await measureComposer(page) expect(metrics.gapShiftOnScroll).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('a wheel gesture over a long draft moves the words, not only the caret', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel')) const input = page.locator('textarea:enabled').first() await input.hover() const resting = (await measureComposer(page)).caretGlyphGap // One delta past the whole draft: the box clamps at its own end. await page.mouse.wheel(0, 2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }) .toBeGreaterThan(0) const metrics = await measureComposer(page) // The caret is still on its own glyphs after the gesture. expect(metrics.caretGlyphGap).toBe(resting) // The reported symptom, stated as what the user sees: the end of the draft // is on screen and its beginning is not. expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0) expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight) expect(metrics.firstLineOffset).toBeLessThan(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('typing at the end of a scrolled draft brings the caret back into view', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit')) // The other way the box moves, and the one that depends on the browser: the // textarea holds no scroll offset of its own, so revealing the caret after // an edit is a scroll-into-view that has to walk up to the scrollport. // Scroll away from the caret first, so the edit has somewhere to bring it // back from. const input = page.locator('textarea:enabled').first() await input.press('End') await input.hover() await page.mouse.wheel(0, -2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0) await input.pressSequentially(' tail') const metrics = await measureComposer(page) expect(metrics.scrollTop).toBeGreaterThan(0) expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0) expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('pasting a long block scrolls to the caret it leaves at the end', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste')) // The composer suppresses the native paste — the machine owns the draft and // the undo log — and restores the caret programmatically, which reveals // nothing on its own: in chromium and WebKit the view stays put while the // caret sits at the end of the pasted block, so the restore scrolls it // into view; this case pins it. const input = page.locator('textarea:enabled').first() await input.fill('one short line') await input.press('End') // A real `paste` event carrying real clipboard data, dispatched at the // textarea: the same event a Cmd-V delivers, and it runs the same handler. await input.evaluate((el, text) => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) // The engines disagree when the draft ends in a newline: the caret // lands on a line with nothing on it, where chromium reports no client // rects at all for the collapsed position. }, `\n${DRAFT}\n`) await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) // The restore lands one frame after the machine commits the draft, so the // box overflows before it moves; waiting on the offset is waiting for the // behavior itself, and its absence fails this poll. await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0) const metrics = await measureComposer(page) // The caret is at the end of what was pasted, so the draft's last line is // what has to be on screen. expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0) expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight) expect(metrics.gapShiftOnScroll).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('a draft ending in a newline scrolls to its true end, not a line above it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline')) // The layers reserve a final line box on different terms, so the trailing-newline case is // the one that separates a height every layer agrees on from a box measured // one line short of the caret's own last position. const input = page.locator('textarea:enabled').first() await input.fill(DRAFT_TRAILING_NEWLINE) await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) await input.hover() await page.mouse.wheel(0, 4000) await expect.poll(async () => { const m = await measureComposer(page) return m.scrollTop === m.scrollMax }, { timeout: 10_000 }).toBe(true) const bottom = await measureComposer(page) // At the very bottom the glyphs are level with the caret, and the draft's // own last line — the one before the empty final line — is on screen. expect(bottom.gapShiftOnScroll).toBe(0) expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0) expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('matches the committed composer scroll geometry golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden')) const input = page.locator('textarea:enabled').first() // Restore the pristine draft (the edit case appended to it) and return to // its start, both through ordinary gestures. await input.fill(DRAFT) await input.hover() await page.mouse.wheel(0, -2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0) const top = await measureComposer(page) await input.hover() await page.mouse.wheel(0, 2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }) .toBeGreaterThan(0) const bottom = await measureComposer(page) await input.fill(DRAFT_TRAILING_NEWLINE) await input.hover() await page.mouse.wheel(0, 4000) await expect.poll(async () => { const m = await measureComposer(page) return m.scrollTop === m.scrollMax }, { timeout: 10_000 }).toBe(true) const trailingNewline = await measureComposer(page) // The paste path, measured the way a user meets it: a short draft, the // caret at its end, one long block pasted in. await input.fill('one short line') await input.press('End') await input.evaluate((el, text) => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) // Keep a final glyph so the collapsed caret position has a client rect. }, `\n${DRAFT}`) await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0) const pasted = await measureComposer(page) await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('commits exactly the fixtures it reads', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) }) it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }) })