// Web e2e scenario: a composer draft longer than the 14-line cap scrolls, // reveals the caret, and holds no second scroll offset. // // The composer is ONE contenteditable surface (see // packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): // the Lexical editor's root carries the glyphs, the selection and the caret // together, grows with its content, and `[data-input-scroll]` — the // composer's single scrolling box — caps it at 14 lines. With one surface // there is no second text layer whose offset could drift from the caret's; // what remains to pin is the cap, the wheel gesture, and the caret reveals // (typing at a scrolled end, pasting a long block, a trailing-newline end). // // Only a real engine can show any of this. Scrolling is layout: jsdom reports // `scrollHeight === clientHeight` for every element and never scrolls one, so // the unit spec in packages/client/ui-conversation/tests/input-bar.client.spec.tsx can // only assert that the scrollport holds the surface. // // Zero model calls: a fresh workspace's blank session already carries a live // composer, and the scenario only types into it. A stray stream would fail loud // with NO_ADAPTER. 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)) /** * Committed golden of the composer's scroll geometry. The change alters no * accessible name, so the aria goldens the other scenarios commit are * byte-identical with and without it; this records the relations instead, * which makes a shift in the cap or in the reveal behavior a reviewable diff * rather than an assertion someone has to reconstruct. */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() /** Marks the first and last line so the measurement can find their line boxes. */ 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_ROWS = 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')}` }) /** The live composer surface. */ function surface(page: Page): ReturnType { return page.locator('[data-composer-input][contenteditable="true"]').first() } /** * Replace the draft through real gestures: select-all, delete, then insert * the rows with soft line breaks (the composer's Enter submits). * @param page - the page under test. * @param rows - draft lines; a trailing empty row leaves a trailing newline. */ async function typeDraft(page: Page, rows: readonly string[]): Promise { const input = surface(page) await input.click() await page.keyboard.press('ControlOrMeta+KeyA') await page.keyboard.press('Delete') for (const [index, row] of rows.entries()) { if (index > 0) await page.keyboard.press('Shift+Enter') if (row !== '') await page.keyboard.insertText(row) } } /** The composer's scroll surface as the browser lays it out. */ interface ComposerMetrics { /** True when the draft is taller than the capped box — the situation under test. */ overflows: boolean /** Visible height of the scrollport's content box: the cap in pixels. */ clientHeight: number /** Whole lines that fit in the visible box, at the composer's own line-height. */ visibleLines: number /** The composer's one scroll offset. */ scrollTop: number /** Furthest that offset can go. */ scrollMax: number /** * Scrollable overflow the editable surface holds on its own — 0, or a * second offset exists beside the scrollport's. */ surfaceScrollable: number /** Top of the LAST draft line relative to the visible box's top: at most `clientHeight` when on screen. */ lastLineOffset: number /** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */ firstLineOffset: number } /** * Measure the composer surface in the page. * @param page - the page under test. * @returns the offset, the cap, and where the draft's first and last lines sit. */ function measureComposer(page: Page): Promise { return page.evaluate(({ first, last }) => { const input = document.querySelector('[data-composer-input][contenteditable="true"]') if (input === null) throw new Error('no live composer surface in the DOM') const scroll = input.closest('[data-input-scroll]') if (scroll === null) throw new Error('the composer surface is not inside a draft scrollport') const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight) /** Where the surface paints the line holding `marker`, in viewport coordinates. */ const glyphTop = (marker: string): number => { const walker = document.createTreeWalker(input, NodeFilter.SHOW_TEXT) for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { const text = node as Text const at = text.data.indexOf(marker) if (at < 0) continue const range = document.createRange() range.setStart(text, at) range.setEnd(text, at + marker.length) return range.getBoundingClientRect().top } throw new Error(`marker ${marker} missing from the composer text`) } const box = scroll.getBoundingClientRect() return { overflows: scroll.scrollHeight > scroll.clientHeight, clientHeight: scroll.clientHeight, visibleLines: Math.floor(scroll.clientHeight / lineHeight), scrollTop: scroll.scrollTop, scrollMax: scroll.scrollHeight - scroll.clientHeight, surfaceScrollable: input.scrollHeight - input.clientHeight, lastLineOffset: glyphTop(last) - box.top, firstLineOffset: glyphTop(first) - box.top, } }, { first: FIRST_MARKER, last: LAST_MARKER }) } /** * Render the golden body. * * Absolute glyph coordinates are deliberately absent: they depend on font * metrics and would make the fixture fail on a machine that measures text * differently — a golden that needs re-recording per platform documents the * platform, not the behavior. What is recorded is the cap, the single-offset * invariant, and which lines are on screen, each a comparison that survives * any layout keeping the behavior. * @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, one editable surface, one scrollport)', '', '## At the start of the draft', '', `- draft overflows the capped box: ${String(top.overflows)}`, `- visible lines: ${String(top.visibleLines)}`, `- the surface holds no scroll offset of its own: ${String(top.surfaceScrollable === 0)}`, `- scroll offset: ${String(top.scrollTop)}px`, `- 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)}`, `- the surface holds no scroll offset of its own: ${String(bottom.surfaceScrollable === 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', '', `- 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)}`, `- 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.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll') await typeDraft(page, DRAFT_ROWS) }, 180_000) afterAll(async () => { await browser?.close() await scaffold?.close() }) it('caps the draft box at 14 lines with a single scroll offset', 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 surface(page).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 surface grows with the draft, so it holds no // second offset beside the scrollport's. expect(metrics.surfaceScrollable).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('a wheel gesture over a long draft moves the draft', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel')) await surface(page).hover() await page.mouse.wheel(0, 240) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }) .toBeGreaterThan(0) const scrolled = await measureComposer(page) expect(scrolled.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-typing')) const input = surface(page) // Put the caret at the very end, scroll the view away from it, then type: // the editor's own caret reveal must bring the end back on screen. // Select-all + ArrowRight lands the caret at the document end on every // platform (Cmd/Ctrl+End is not a caret move in mac contenteditable). await input.click() await page.keyboard.press('ControlOrMeta+KeyA') await page.keyboard.press('ArrowRight') await input.hover() await page.mouse.wheel(0, -2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0) await page.keyboard.insertText(' typed-at-end') await expect.poll(async () => { const m = await measureComposer(page) return m.lastLineOffset >= 0 && m.lastLineOffset < m.clientHeight }, { timeout: 10_000 }).toBe(true) 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')) // A trailing soft break reserves a final empty line box; the end of the // draft is below the last glyph line, and scrolling to the end must show it. await typeDraft(page, [...DRAFT_ROWS, '']) await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) await surface(page).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 draft's own last line — the one before the empty // final line — is on screen. 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')) // Restore the pristine draft (the edit cases appended to it) and return to // its start, both through ordinary gestures. await typeDraft(page, DRAFT_ROWS) await surface(page).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 surface(page).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 typeDraft(page, [...DRAFT_ROWS, '']) await surface(page).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 typeDraft(page, ['one short line']) await surface(page).evaluate((el, text) => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) }, `\n${DRAFT_ROWS.join('\n')}`) 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 () => { // Zero model calls, so the scenario records no session fixture: the geometry // golden is the whole inventory. 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([]) }) })