Files
deepseek-harness/apps/web/tests/composer-draft-scroll.e2e.ts
T
Yichen Jiang e177571236 Merge remote-tracking branch 'origin/master' into worktree/web-textarea-refactor-991614
# Conflicts:
#	.agents/notes/archived/manifest.json
#	apps/web/tests/reference-composer.e2e.ts
#	pnpm-lock.yaml
2026-08-25 19:35:55 +08:00

339 lines
16 KiB
TypeScript

// 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<Page['locator']> {
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<void> {
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<ComposerMetrics> {
return page.evaluate(({ first, last }) => {
const input = document.querySelector<HTMLElement>('[data-composer-input][contenteditable="true"]')
if (input === null) throw new Error('no live composer surface in the DOM')
const scroll = input.closest<HTMLElement>('[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<typeof watchConsole>
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([])
})
})