Merge remote-tracking branch 'origin/master' into worktree/custom-deepseek-models

# Conflicts:
#	packages/client/ui-models/src/client/ModelsSection.module.css
#	packages/client/ui-models/src/client/ModelsSection.tsx
This commit is contained in:
Yichen Jiang
2026-08-05 11:46:23 +08:00
1011 changed files with 18374 additions and 31554 deletions

View File

@@ -77,12 +77,14 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
// The composer's own text cap, measured on the live textarea before the
// takeover replaces it. The panel's scroll region must stop at the same
// height (the designer's requirement: one cap for the composer seat), and
// measuring it here keeps the assertion free of the px value itself.
// The composer's own text cap, measured on the live draft scrollport before
// the takeover replaces it — the box that carries the cap, while the
// textarea inside it is as tall as the whole draft. The panel's scroll
// region must stop at the same height (the designer's requirement: one cap
// for the composer seat), and measuring it here keeps the assertion free of
// the px value itself.
await input.fill(CAP_PROBE)
const composerCap = await input.evaluate(el => el.clientHeight)
const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
expect(composerCap).toBeGreaterThan(0)
await input.fill('')

View File

@@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
const row = page.locator('[data-sample="bash"]').first()
const call = row.locator('xpath=..')
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1)
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
@@ -64,7 +64,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
await call.getByText('OUT', { exact: true }).waitFor()
await call.getByText('Wait until cancellation', { exact: false }).waitFor()
await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor()
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2)
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
// The borrowed fixture's UTC date is still the previous day in PDT;

View File

@@ -1,13 +1,13 @@
// @vitest-environment jsdom
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
// ModuleLoader path (loadBundle) and proves the boot graph
// assembles — staged activation across the immediately tier and the inject
// layers, per-plugin CSS injection, and a rendered journey reaching chat
// content from the keyless FixtureApiClient transport.
//
// Component behavior remains owned by per-package suites (SlotTestRuntime
// benches over src). This smoke additionally pins the resident approval
// benches over src). This smoke additionally pins the resident interaction
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
import { readFileSync } from 'node:fs'
@@ -91,11 +91,11 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
loadBundle: async (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
if (code === undefined) throw new Error(`missing built bundle ${url}`)
;(0, eval)(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }
@@ -105,14 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The resident approval fixture proves the assembled workspace plugin
// distinguishes a blocked running session from an ordinarily busy one.
// The resident fixture has both a question and an approval; composer routing
// exposes the question first, and the assembled workspace plugin mirrors that
// actionable wait instead of the underlying running state.
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]')
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
within(waitingRow).getByText('Waiting for approval')
within(waitingRow).getByText('Waiting for answer')
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(waitingTitle)

View File

@@ -0,0 +1,328 @@
// Web e2e contract for a conversation grown through the real composer rather
// than pre-seeded history. Twelve deterministic replay turns exercise repeated
// send/settle/render cycles, including two real bash executions and one long,
// multi-chunk final turn. Assertions stay semantic: no host timing, heap, or
// mounted-row cardinality is treated as a correctness contract.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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 { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const TURN_COUNT = 12
const TOOL_TURNS = [4, 9] as const
const STREAM_PACE_MS = 10
interface TurnSpec {
readonly index: number
readonly prompt: string
readonly userMarker: string
readonly firstMarker: string
readonly doneMarker: string
readonly deltas: readonly string[]
readonly callId?: ReturnType<typeof CallId>
readonly toolResultMarker?: string
}
function suffix(index: number): string {
return String(index).padStart(3, '0')
}
function longFinalPrompt(userMarker: string): string {
return [
`${userMarker} Reconcile this accumulated conversation without losing earlier turn ownership.`,
...Array.from(
{ length: 36 },
(_, index) => `Context ${String(index + 1).padStart(2, '0')}: preserve token-${String(index)} and verify ${'payload '.repeat(12).trimEnd()}.`,
),
'Return one continuous response and finish with the requested completion marker.',
].join('\n')
}
function turnSpec(index: number): TurnSpec {
const id = suffix(index)
const userMarker = `CONTINUOUS_CHAT_USER_${id}`
const firstMarker = `CONTINUOUS_CHAT_FIRST_${id}`
const doneMarker = `CONTINUOUS_CHAT_DONE_${id}`
const deltaCount = index === TURN_COUNT ? 36 : 8
const deltas = Array.from({ length: deltaCount }, (_, chunkIndex) => {
if (chunkIndex === 0) return `${firstMarker} `
if (chunkIndex === deltaCount - 1) return `${doneMarker}.`
return `turn-${id}-chunk-${String(chunkIndex).padStart(2, '0')} keeps semantic ownership stable. `
})
if (!TOOL_TURNS.includes(index as (typeof TOOL_TURNS)[number])) {
return {
index,
prompt: index === TURN_COUNT
? longFinalPrompt(userMarker)
: `${userMarker} Continue this same conversation through turn ${String(index)}.`,
userMarker,
firstMarker,
doneMarker,
deltas,
}
}
return {
index,
prompt: `${userMarker} Run the requested deterministic tool for turn ${String(index)}, then continue.`,
userMarker,
firstMarker,
doneMarker,
deltas,
callId: CallId(`continuous-chat-tool-${id}`),
toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`,
}
}
function textStream(spec: TurnSpec): StreamChunk[] {
const response = spec.deltas.join('')
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...spec.deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{
type: 'usage',
usage: {
inputTokens: Math.ceil(spec.prompt.length / 4),
outputTokens: Math.ceil(response.length / 4),
},
},
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function toolStream(spec: TurnSpec): StreamChunk[] {
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
throw new Error(`turn ${String(spec.index)} has no tool identity`)
}
const args = JSON.stringify({
command: `printf '${spec.toolResultMarker}\\n'`,
description: spec.toolResultMarker,
})
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{
type: 'tool-call-delta',
index: 0,
id: spec.callId,
name: 'bash',
argumentsDelta: args,
},
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: spec.callId, name: 'bash', arguments: args },
},
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 24 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
function replayScript(specs: readonly TurnSpec[]): ReplayOverrideDoc {
return specs.flatMap((spec): ReplayEntry[] => {
const final: ReplayEntry = { kind: 'chunks', chunks: textStream(spec) }
return spec.callId === undefined
? [final]
: [{ kind: 'chunks', chunks: toolStream(spec) }, final]
})
}
function userText(event: Extract<SessionEvent, { type: 'user/message' }>): string {
return event.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
return event.data.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>): string {
return event.data.message.content[0].content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
describe('web e2e: continuous conversation grown through the composer', () => {
let browser: Browser
let page: Page
let replayDir: string
let scaffold: WebScaffold
let tripwire: ReturnType<typeof watchConsole>
const consoleWarnings: string[] = []
const sessionEvents: SessionEvent[] = []
const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1))
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(replayScript(specs)))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
replayContextWindow: 10_000_000,
paceMs: STREAM_PACE_MS,
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
sessionEvents.push(event)
})
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
page.on('console', (message) => {
if (message.type() === 'warning') consoleWarnings.push(message.text())
})
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'continuous Chat e2e cleanup failed')
})
it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
const composer = page.locator('textarea:enabled').last()
await composer.waitFor({ timeout: 15_000 })
let sessionId: SessionId | undefined
for (const spec of specs) {
const eventStart = sessionEvents.length
expect(await composer.inputValue()).toBe('')
expect(await composer.isEnabled()).toBe(true)
await composer.fill(spec.prompt)
expect(await composer.inputValue()).toBe(spec.prompt)
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
const echoedUser = sessionEvents.slice(eventStart).find(
(event): event is SessionEvent<'user/message'> => (
event.type === 'user/message'
&& event.data.source.kind === 'user'
&& userText(event).includes(spec.userMarker)
),
)
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await userRow.textContent()).toContain(spec.userMarker)
await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
const settledSessionId = await settled
if (sessionId === undefined) {
sessionId = settledSessionId
} else {
expect(settledSessionId).toBe(sessionId)
}
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('')
await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true)
const turnEvents = sessionEvents.slice(eventStart)
const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => (
event.type === 'turn/start'
))
const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => (
event.type === 'user/message' && event.data.source.kind === 'user'
))
const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => (
event.type === 'assistant/message'
))
const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker))
const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end'
))
const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
expect(turnStarts).toHaveLength(1)
expect(turnStarts[0]?.data.turn).toBe(spec.index)
expect(users).toHaveLength(1)
expect(users[0]?.seq).toBe(echoedUser.seq)
expect(userText(users[0]!)).toBe(spec.prompt)
expect(finalAssistants).toHaveLength(1)
expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2)
expect(turnEnds).toHaveLength(1)
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
expect(calls).toHaveLength(0)
expect(results).toHaveLength(0)
continue
}
expect(calls).toHaveLength(1)
expect(results).toHaveLength(1)
expect(calls[0]?.data).toMatchObject({
turn: spec.index,
callId: spec.callId,
name: 'bash',
})
expect(results[0]?.data.turn).toBe(spec.index)
expect(results[0]?.data.message.source.callId).toBe(spec.callId)
expect(results[0]?.data.message.content[0].isError).toBe(false)
expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
const disclosure = toolRow.locator('[data-sample="bash"]')
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
// The collapsed summary deliberately repeats the result marker; the
// last exact match is the expanded terminal output owned by this call.
await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 })
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
}
if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
event.type === 'turn/end' && event.data.reason.kind === 'completed'
))).toHaveLength(TURN_COUNT)
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
expect(sessionEvents.filter(event => (
event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
)).length).toBeGreaterThan(30)
expect(consoleWarnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 180_000)
})

View File

@@ -0,0 +1,271 @@
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
// input only navigates to the semantic target; assertions pin content identity
// and interaction routing rather than scroll geometry or mounted row counts.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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 type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const SESSION_ID = 'chat-long-interactions-e2e'
const FIXTURE_TURNS = 88
const TOOL_TURN = FIXTURE_TURNS
const BRANCH_TURN = 80
const TARGET_CALL_1 = 'chat-scroll-088-1'
const TARGET_CALL_2 = 'chat-scroll-088-2'
const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.'
const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST'
const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE'
const FIXTURE = createChatScrollFixture({
markerPrefix: 'INTERACTION',
title: 'CHAT_INTERACTION long semantic identity session',
turns: FIXTURE_TURNS,
})
function continuationChunks(): StreamChunk[] {
const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.`
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` },
{ type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` },
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
return { kind: 'chunks', chunks }
}
function carries(event: SessionEvent, marker: string): boolean {
return JSON.stringify(event).includes(marker)
}
function textContent(content: readonly unknown[]): string {
return content.flatMap((block) => {
if (typeof block !== 'object' || block === null) return []
const candidate = block as { type?: unknown; text?: unknown }
return candidate.type === 'text' && typeof candidate.text === 'string'
? [candidate.text]
: []
}).join('')
}
async function nextPaint(page: Page): Promise<void> {
await page.evaluate(async () => {
await document.fonts.ready
await new Promise<void>(resolve => requestAnimationFrame(() => {
requestAnimationFrame(() => { resolve() })
}))
})
}
async function openSeed(page: Page): Promise<void> {
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await results.first().waitFor({ timeout: 60_000 })
const resultCount = await results.count()
if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
await results.click()
await results.click()
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
.last().waitFor({ timeout: 30_000 })
await nextPaint(page)
}
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
const scrollport = page.locator('[data-conversation-scroll]')
const box = await scrollport.boundingBox()
if (box === null) throw new Error('conversation scrollport has no layout box')
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
for (let attempt = 0; attempt < 20; attempt += 1) {
if (await page.locator(selector).count() > 0) return
await page.mouse.wheel(0, deltaY)
await nextPaint(page)
}
throw new Error(`semantic Chat target did not mount: ${selector}`)
}
function requiredEvent<T extends SessionEvent['type']>(
events: readonly SessionEvent[],
type: T,
marker: string,
): Extract<SessionEvent, { type: T }> {
const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
candidate.type === type && carries(candidate, marker)
))
if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
return event
}
describe('web e2e: long Chat interaction contract', () => {
let browser: Browser
let page: Page
let replayDir: string
let scaffold: WebScaffold
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())]
await writeFile(replayOverride, JSON.stringify(replay))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
replayContextWindow: 10_000_000,
paceMs: 18,
})
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeed(page)
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed')
})
it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
if (source === undefined) throw new Error('seeded long-history agent is not attached')
const toolUserMarker = FIXTURE.markers.user(TOOL_TURN)
const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker)
const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker)
const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker)
const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker)
const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end' && event.data.turn === BRANCH_TURN
))
if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
const expectedUserText = textContent(branchUserEvent.data.content)
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1)
expect(await call1.count()).toBe(1)
expect(await call2.count()).toBe(1)
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
expect(await call1.textContent()).toContain(toolMarker1)
expect(await call2.textContent()).toContain(toolMarker2)
const expectedOrder = [
`node:${String(toolUserEvent.seq)}`,
`call:${TARGET_CALL_1}`,
`call:${TARGET_CALL_2}`,
`node:${String(toolAssistantEvent.seq)}`,
]
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
.filter((key): key is string => key !== undefined && keys.includes(key))
), expectedOrder)
expect(actualOrder).toEqual(expectedOrder)
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
))))
expect(groupKeys[0]).not.toBeNull()
expect(groupKeys[1]).toBe(groupKeys[0])
const summary1 = call1.locator('[data-sample="bash"]')
const summary2 = call2.locator('[data-sample="bash"]')
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
expect(await summary2.getAttribute('aria-expanded')).toBe('false')
await summary2.focus()
await summary2.press('Enter')
await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
expect(await userRow.textContent()).toContain(branchUserMarker)
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await userRow.hover()
await userRow.getByRole('button', { name: 'Copy', exact: true }).click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
.toBe(expectedUserText)
await assistantRow.hover()
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
{ timeout: 15_000 },
).toBeDefined()
const child = scaffold.ctx.agents.list()
.find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
if (child === undefined) throw new Error('message branch did not create a child session')
expect(child.session.header.seedLength).toBe(boundary.seq + 1)
expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true)
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
.getByRole('button').last()
await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 })
.toBe(`${FIXTURE.title} (1)`)
await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
const settled = scaffold.whenTurnSettled(60_000)
const composer = page.locator('textarea:enabled').last()
await composer.fill(CONTINUE_PROMPT)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await settled).toBe(child.session.id)
await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
expect(await composer.inputValue()).toBe('')
expect(await composer.isEnabled()).toBe(true)
expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
expect(child.session.events.filter(event => carries(event, CONTINUE_PROMPT))).toHaveLength(1)
const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end'
))
expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 180_000)
})

View File

@@ -0,0 +1,686 @@
// Browser geometry contracts for a long Chat transcript. These scenarios are
// deliberately virtualizer-neutral: they assert semantic-row position,
// bottom ownership, interaction state, and the real outer scroll host rather
// than DOM cardinality or implementation-specific spacer markup.
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
const REPLAY_CONTEXT_WINDOW = 10_000_000
const STREAM_PACE_MS = 24
const GEOMETRY_TOLERANCE = 2
const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.'
const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST'
const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE'
const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.'
const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call')
const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT'
const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
const HISTORY_FIXTURE = createChatScrollFixture({
markerPrefix: 'HISTORY',
title: 'CHAT_SCROLL_HISTORY long paging session',
})
const TOOL_FIXTURE = createChatScrollFixture({
markerPrefix: 'TOOL',
title: 'CHAT_SCROLL_TOOL live tool session',
})
const RESTORE_FIXTURE_A = createChatScrollFixture({
markerPrefix: 'RESTORE_A',
title: 'CHAT_SCROLL_RESTORE_A long session',
})
const RESTORE_FIXTURE_B = createChatScrollFixture({
markerPrefix: 'RESTORE_B',
title: 'CHAT_SCROLL_RESTORE_B comparison session',
turns: 32,
})
interface ScrollGeometry {
readonly distanceFromBottom: number
readonly scrollTop: number
}
interface FlowAnchor {
readonly key: string
readonly top: number
}
interface ScrollWorld {
readonly events: SessionEvent[]
readonly page: Page
readonly replayDir?: string
readonly scaffold: WebScaffold
readonly tripwire: ReturnType<typeof watchConsole>
}
interface ScrollWorldOptions {
readonly failureShot: string
readonly replay?: ReplayOverrideDoc
readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
}
function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
const deltas = Array.from({ length: deltaCount }, (_, index) => {
if (index === 0) return `${first} `
if (index === deltaCount - 1) return `${done}.`
return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
})
const response = deltas.join('')
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{
type: 'usage',
usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
},
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function toolStream(): StreamChunk[] {
const command = [
`: > ${TOOL_READY_FILE}`,
`while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
'line=1',
`while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
].join('; ')
const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{
type: 'tool-call-delta',
index: 0,
id: LIVE_TOOL_CALL_ID,
name: 'bash',
argumentsDelta: args,
},
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
},
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
return { kind: 'chunks', chunks }
}
async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
let replayDir: string | undefined
let scaffold: WebScaffold | undefined
let page: Page | undefined
try {
if (options.replay !== undefined) {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(options.replay))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
paceMs: STREAM_PACE_MS,
replayContextWindow: REPLAY_CONTEXT_WINDOW,
})
} else {
scaffold = await launchWebScaffold({})
}
for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
const events: SessionEvent[] = []
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
page = await newEnglishPage(browser, 900)
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Session-list bootstrap can replace the controlled search state. Wait
// for the seeded baseline before openSeed starts the lazy content query.
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
return {
events,
page,
scaffold,
tripwire,
...(replayDir === undefined ? {} : { replayDir }),
}
} catch (error) {
const failures: unknown[] = [error]
if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
}
if (failures.length === 1) throw error
throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
}
}
async function closeScrollWorld(world: ScrollWorld): Promise<void> {
const failures: unknown[] = []
// newEnglishPage/browser.newPage owns an isolated context. Close the whole
// context so its SSE connection and cache cannot leak into the next world
// in this file's shared Chromium process.
await world.page.context().close().catch((error: unknown) => failures.push(error))
await world.scaffold.close().catch((error: unknown) => failures.push(error))
if (world.replayDir !== undefined) {
await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed')
}
async function withScrollWorld(
options: ScrollWorldOptions,
run: (world: ScrollWorld) => Promise<void>,
): Promise<void> {
const world = await launchScrollWorld(options)
let runFailure: unknown
try {
await run(world)
} catch (error) {
runFailure = error
try {
await saveFailureShot(world.page, options.failureShot)
} catch {
// Best-effort evidence must never prevent cleanup of the owned world.
}
}
let cleanupFailure: unknown
try {
await closeScrollWorld(world)
} catch (error) {
cleanupFailure = error
}
if (runFailure !== undefined && cleanupFailure !== undefined) {
throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
}
if (runFailure !== undefined) throw runFailure
if (cleanupFailure !== undefined) throw cleanupFailure
}
async function nextPaint(page: Page): Promise<void> {
await page.evaluate(async () => {
await document.fonts.ready
await new Promise<void>(resolve => requestAnimationFrame(() => {
requestAnimationFrame(() => { resolve() })
}))
})
}
function scrollGeometry(page: Page): Promise<ScrollGeometry> {
return page.locator('[data-conversation-scroll]').evaluate(host => ({
distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
scrollTop: host.scrollTop,
}))
}
async function conversationTurns(page: Page): Promise<number> {
const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
await stats.waitFor({ timeout: 15_000 })
const value = await stats.textContent()
const match = value?.match(/^(\d+) turns · \d+ steps$/)
if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
return Number(match[1])
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Cold summaries initially show the temporary workspace basename, so the
// persisted first-message marker is the stable user-facing identity. The
// query itself triggers lazy content-index reconciliation; no transient
// empty-state paint is used as a barrier.
await search.fill(fixture.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
await results.click()
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
if (tailMarker !== undefined) {
await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
}
await nextPaint(page)
}
async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
const box = await page.locator('[data-conversation-scroll]').boundingBox()
if (box === null) throw new Error('conversation scrollport has no layout box')
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
await page.mouse.wheel(0, deltaY)
await nextPaint(page)
}
async function wheelToHistoryStart(page: Page): Promise<void> {
for (let attempt = 0; attempt < 12; attempt += 1) {
if ((await scrollGeometry(page)).scrollTop <= 1) break
await wheelTranscript(page, -2_400)
}
await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
}
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
for (let attempt = 0; attempt < 16; attempt += 1) {
if (await page.locator(selector).count() > 0) return
await wheelTranscript(page, deltaY)
}
throw new Error(`selector did not mount during transcript wheel: ${selector}`)
}
async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
const target = page.locator(selector)
for (let attempt = 0; attempt < 32; attempt += 1) {
if (await target.count() > 0 && await target.evaluate((row) => {
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
if (host === null) return false
const viewport = host.getBoundingClientRect()
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
const rect = row.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < visibleBottom
})) return
await wheelTranscript(page, deltaY)
}
throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
}
function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
return page.locator('[data-conversation-scroll]').evaluate((host) => {
const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
const viewport = host.getBoundingClientRect()
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
const visible = rows.filter((candidate) => {
const rect = candidate.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < visibleBottom
})
const row = visible[0]
if (row?.dataset.chatAnchorKey === undefined) {
throw new Error(`no visible settled Chat row: ${JSON.stringify({
composerTop: visibleBottom,
host: { bottom: viewport.bottom, top: viewport.top },
rows: rows.slice(0, 4).map(candidate => ({
callId: candidate.dataset.chatCallId,
key: candidate.dataset.chatAnchorKey,
rect: {
bottom: candidate.getBoundingClientRect().bottom,
top: candidate.getBoundingClientRect().top,
},
})),
totalRows: rows.length,
})}`)
}
return {
key: row.dataset.chatAnchorKey,
top: row.getBoundingClientRect().top - viewport.top,
}
})
}
function flowTop(page: Page, key: string): Promise<number> {
return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
const host = row.closest('[data-conversation-scroll]')
if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
return row.getBoundingClientRect().top - host.getBoundingClientRect().top
}, key)
}
async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
timeout: 10_000,
message: `flow row ${anchor.key} moved relative to the transcript viewport`,
}).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
}
async function expectBottom(page: Page): Promise<void> {
await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
timeout: 10_000,
}).toBeLessThanOrEqual(1)
}
async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
const row = node.closest('[data-chat-flow-key], [data-streaming]')
const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
throw new Error('latest marker or composer geometry is unavailable')
}
return {
composerTop: composer.getBoundingClientRect().top,
rowBottom: row.getBoundingClientRect().bottom,
}
})
expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
}
async function loadEarlierWithAnchor(page: Page): Promise<void> {
await wheelToHistoryStart(page)
const older = page.getByRole('button', { name: 'Load earlier', exact: true })
await older.waitFor({ timeout: 10_000 })
const anchor = await visibleFlowAnchor(page)
const before = await conversationTurns(page)
await older.click()
await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
await nextPaint(page)
await expectSameFlowTop(page, anchor)
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
function eventCarries(event: SessionEvent, marker: string): boolean {
return JSON.stringify(event).includes(marker)
}
function assertClean(world: ScrollWorld): void {
expect(world.tripwire.pageErrors).toEqual([])
expect(world.tripwire.warnings).toEqual([])
}
let browser: Browser
describe('web e2e: long Chat scroll contract', () => {
beforeAll(async () => {
browser = await chromium.launch()
})
afterAll(async () => {
await browser?.close()
})
it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-history-stream',
replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
}, async (world) => {
await openSeed(
world.page,
HISTORY_FIXTURE,
HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
)
await expectBottom(world.page)
let releaseHistory = (): void => {}
let held = false
let releaseGate: (() => void) | undefined
const gate = new Promise<void>((resolve) => { releaseGate = resolve })
releaseHistory = () => { releaseGate?.() }
await world.page.route('**/api/session.history', async (route) => {
const request = route.request().postDataJSON() as {
method?: string
payload?: { beforeSeq?: number }
}
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
held = true
await gate
}
await route.continue()
})
const settled = world.scaffold.whenTurnSettled(60_000)
try {
const composer = world.page.locator('textarea:enabled').last()
await composer.fill(LIVE_TEXT_PROMPT)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
await wheelToHistoryStart(world.page)
const beforeTurns = await conversationTurns(world.page)
await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
await wheelTranscript(world.page, 420)
const readerAnchor = await visibleFlowAnchor(world.page)
const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 10_000 },
).toBeGreaterThan(chunksAfterAnchor + 5)
releaseHistory()
await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
await nextPaint(world.page)
await expectSameFlowTop(world.page, readerAnchor)
} finally {
releaseHistory()
}
await settled
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await world.page.unroute('**/api/session.history')
let additionalPages = 0
while (additionalPages < 8) {
await wheelToHistoryStart(world.page)
if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
await loadEarlierWithAnchor(world.page)
additionalPages += 1
}
expect(additionalPages).toBeGreaterThan(0)
expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-live-tool',
replay: [
replayEntry(toolStream()),
replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
],
seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }],
}, async (world) => {
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
const settled = world.scaffold.whenTurnSettled(60_000)
let released = false
try {
const composer = world.page.locator('textarea:enabled').last()
await composer.fill(LIVE_TOOL_PROMPT)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
await liveRow.waitFor({ timeout: 15_000 })
expect(await liveRow.getAttribute('data-state')).toBe('running')
await expectBottom(world.page)
await wheelTranscript(world.page, -1_200)
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
const awayAnchor = await visibleFlowAnchor(world.page)
const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
await writeFile(releasePath, 'release\n')
released = true
await expect.poll(
() => world.events.some(event => event.type === 'tool/result'),
{ timeout: 15_000 },
).toBe(true)
await expect.poll(
() => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
{ timeout: 15_000 },
).toBe(true)
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksBeforeRelease + 5)
await expectSameFlowTop(world.page, awayAnchor)
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
await expectBottom(world.page)
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksAtRepin + 5)
await expectBottom(world.page)
} finally {
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
}
await settled
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expectBottom(world.page)
await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
const liveRow = world.page.locator(liveRowSelector)
await wheelUntilVisible(world.page, liveRowSelector, -300)
const toolAnchor = await liveRow.evaluate((row) => {
const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
if (flow?.dataset.chatAnchorKey === undefined || host === null) {
throw new Error('live tool row has no settled flow identity')
}
return {
key: flow.dataset.chatAnchorKey,
top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
}
})
await liveRow.click()
await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
await expectSameFlowTop(world.page, toolAnchor)
await wheelToHistoryStart(world.page)
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
await expectBottom(world.page)
await wheelUntilMounted(world.page, liveRowSelector, -1_100)
const restoredRow = world.page.locator(liveRowSelector)
await restoredRow.waitFor({ timeout: 10_000 })
expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-restore-composer',
seeds: [
{ fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
{ fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
],
}, async (world) => {
await openSeed(
world.page,
RESTORE_FIXTURE_A,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await loadEarlierWithAnchor(world.page)
await loadEarlierWithAnchor(world.page)
await wheelToHistoryStart(world.page)
await wheelTranscript(world.page, 1_300)
const sessionAnchor = await visibleFlowAnchor(world.page)
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.setViewportSize({ width: 700, height: 900 })
// The narrow breakpoint auto-collapses the sidebar. Re-open it because
// this scenario switches sessions while pinning the narrow Chat scroll owner.
await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor)
await openSeed(
world.page,
RESTORE_FIXTURE_B,
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
)
await openSeed(
world.page,
RESTORE_FIXTURE_A,
)
await expectSameFlowTop(world.page, sessionAnchor)
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
await backToBottom.evaluate((button) => {
if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
button.click()
const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
.find(tab => tab.textContent?.trim() === 'Trajectory')
if (!(trajectory instanceof HTMLElement)) {
throw new Error('Trajectory tab is unavailable during pinned remount')
}
trajectory.click()
})
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await expectBottom(world.page)
await openSeed(
world.page,
RESTORE_FIXTURE_B,
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
)
await openSeed(
world.page,
RESTORE_FIXTURE_A,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await expectBottom(world.page)
const composer = world.page.locator('textarea:enabled').last()
const longDraft = Array.from(
{ length: 18 },
(_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
).join('\n')
await composer.fill(longDraft)
await nextPaint(world.page)
await expectBottom(world.page)
await expectMarkerAboveComposer(
world.page,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await composer.fill('short draft')
await nextPaint(world.page)
await wheelTranscript(world.page, -900)
const resizeAnchor = await visibleFlowAnchor(world.page)
await composer.fill(longDraft)
await nextPaint(world.page)
await expectSameFlowTop(world.page, resizeAnchor)
await composer.fill('short draft')
await nextPaint(world.page)
await expectSameFlowTop(world.page, resizeAnchor)
const beforeChain = await scrollGeometry(world.page)
await composer.hover()
await world.page.mouse.wheel(0, -320)
await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
.toBeLessThan(beforeChain.scrollTop)
assertClean(world)
})
}, 180_000)
})

View File

@@ -0,0 +1,235 @@
// Synthetic long-chat history for browser behavior contracts. The fixture is
// generated through Session so pagination exercises the same event shapes as
// persisted conversations, while unique markers let tests identify semantic
// rows without depending on CSS-module names or the eventual virtualizer DOM.
import {
CallId,
createAssistantMessage,
createToolResultMessage,
createUserMessage,
} from '@deepseek-ai/dsh-llm'
import {
SESSION_FORMAT_VERSION,
Session,
SessionId,
} from '@deepseek-ai/dsh-session'
// Carries the session/title event declaration into this fixture builder.
import type {} from '@deepseek-ai/dsh-session-title'
/** Options for one deterministic long-chat fixture. */
export interface ChatScrollFixtureOptions {
/** Marker namespace, used when two sessions share one browser world. */
readonly markerPrefix: string
/** Searchable title projected into the sidebar. */
readonly title: string
/** Number of closed turns to generate. */
readonly turns?: number
}
/** Semantic marker helpers returned with a generated fixture. */
interface ChatScrollMarkers {
/** Marker painted in the human message for a turn. */
user(turn: number): string
/** Marker painted in the final assistant message for a turn. */
assistant(turn: number): string
/** Marker painted in one seeded bash call and result. */
tool(turn: number, index: number): string
}
/** Generated JSONL plus the stable facts browser scenarios assert. */
export interface ChatScrollFixture {
readonly log: string
readonly markers: ChatScrollMarkers
readonly title: string
readonly turns: number
}
const DEFAULT_TURNS = 88
const TOOL_INTERVAL = 8
const CODE_INTERVAL = 11
function text(value: string): { type: 'text'; text: string }[] {
return [{ type: 'text', text: value }]
}
function suffix(turn: number): string {
return String(turn).padStart(3, '0')
}
function markerHelpers(prefix: string): ChatScrollMarkers {
return {
user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
}
}
function appendRequestHeader(session: Session, turn: number, step: number): void {
session.append('request/header', {
header: {
config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
},
reason: turn === 1 && step === 1 ? 'initial' : 'change',
})
}
function appendAssistant(session: Session, turn: number, step: number, body: string): void {
session.append('assistant/message', {
turn,
step,
message: createAssistantMessage({
content: text(body),
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
usage: {
inputTokens: 2_000 + turn * 7,
outputTokens: 180 + step * 20,
},
}, { surfaceOp: 'append' })
}
function codeBlock(turn: number): string {
if (turn % CODE_INTERVAL !== 0) return ''
const lines = Array.from(
{ length: 30 },
(_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
)
return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
}
function appendToolStep(
session: Session,
markers: ChatScrollMarkers,
turn: number,
): void {
const calls = [1, 2].map((index) => {
const marker = markers.tool(turn, index)
const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
const args = JSON.stringify({
command: `printf '${marker}\\n'`,
description: marker,
})
return { args, callId, marker }
})
session.append('assistant/message', {
turn,
step: 1,
message: createAssistantMessage({
content: [
{ type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
...calls.map(call => ({
type: 'tool-call' as const,
id: call.callId,
name: 'bash',
arguments: call.args,
})),
],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
}, { surfaceOp: 'append' })
for (const call of calls) {
const source = session.append('tool/call', {
turn,
step: 1,
callId: call.callId,
name: 'bash',
arguments: call.args,
})
session.append('tool/result', {
turn,
step: 1,
message: createToolResultMessage({
callId: call.callId,
content: text(Array.from(
{ length: 12 },
(_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
).join('\n')),
isError: false,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
}
function fixtureLog(session: Session): string {
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: Date.now() - 60_000,
cwd: '{{cwd}}',
delegationDepth: 0,
}),
...session.events.map(event => JSON.stringify(event)),
'',
].join('\n')
}
/**
* Build a multi-page conversation with prose, fenced code, and paired bash
* calls/results. Every turn is closed, so cold resume cannot repair or mutate
* the seed before the browser observes it.
* @param options - Fixture identity and optional turn count.
* @returns Canonical JSONL and semantic marker helpers.
*/
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
const turns = options.turns ?? DEFAULT_TURNS
const markers = markerHelpers(options.markerPrefix)
const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
for (let turn = 1; turn <= turns; turn += 1) {
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const user = session.append('user/message', createUserMessage({
content: text(
`${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
+ 'Keep the visible message stable while history, tools, and new output change around it.',
),
source: { kind: 'user' },
}), { surfaceOp: 'append' })
if (turn === 1) {
session.append('session/title', {
title: options.title,
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
}
session.append('step/start', { turn, step: 1 })
appendRequestHeader(session, turn, 1)
if (turn % TOOL_INTERVAL === 0) {
appendToolStep(session, markers, turn)
session.append('step/end', { turn, step: 1 })
session.append('step/start', { turn, step: 2 })
appendRequestHeader(session, turn, 2)
appendAssistant(
session,
turn,
2,
`${markers.assistant(turn)} Both tool results are accounted for. `
+ `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
)
session.append('step/end', { turn, step: 2 })
} else {
appendAssistant(
session,
turn,
1,
`${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
+ `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
+ 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
+ `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
)
session.append('step/end', { turn, step: 1 })
}
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return { log: fixtureLog(session), markers, title: options.title, turns }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,31 @@
// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
// GLYPHS, not just its caret.
// GLYPHS AND ITS CARET AS ONE.
//
// The composer paints its text in two stacked layers (see
// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
// `<textarea>` carries the value, the selection and the caret but renders its
// own glyphs `color: transparent`, and every visible character is painted by the
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
// highlight, the chips and the ghost hint. The backdrop is `position: absolute;
// inset: 0; overflow: hidden` — it is CLIPPED, not scrolled, and nothing in the
// browser links its scroll offset to the textarea's.
// highlight, the chips and the ghost hint.
//
// So past the cap the textarea scrolled and the words did not: the caret walked
// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
// backdrop on every textarea `scroll`, which is the one event every way of
// moving the box ends in.
// Two layers can only stay together by moving together. They now do: both sit
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
// tall as the whole draft — so one offset, applied by the browser, moves the
// caret and the words in the same frame. Scrolling the textarea and assigning
// its offset to the backdrop looks equivalent and is not: a wheel gesture is
// composited off the main thread, so the assignment lands frames late and the
// caret visibly flies ahead of the text it belongs to.
//
// Mirroring an offset is only correct while both layers can reach it, so the
// geometry underneath is asserted here alongside the visible outcome: the
// backdrop's trailing-line sentinel (a textarea reserves a line box for the
// caret after a final newline; `pre-wrap` collapses one), and one wrap width
// across all three layers (only the textarea scrolls, so only it can lose
// width to a scrollbar that consumes layout space). Either breaks the extent
// equality, and an unreachable offset clamps the glyphs below the caret.
// That failure is what the same-task measurement below pins. Every metric here
// is read through the caret's own coordinate frame — where the textarea puts
// line n — against where the backdrop paints line n, because that difference is
// the defect a user sees, and it is the one number a mirror between two boxes
// cannot hold at zero.
//
// Only a real engine can show this. Scrolling is layout: jsdom reports
// 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.spec.tsx has
// to stub both offsets and can only prove the mirroring code path runs. What is
// asserted here instead is the user-visible fact that path exists for — after
// scrolling to the end of a long draft, the LAST line is the one on screen —
// measured with a DOM Range over the backdrop's own text.
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx can
// only assert that one scrollport contains both layers.
//
// 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
@@ -49,10 +44,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
/**
* Committed golden of the composer's two-layer scroll geometry. The change
* alters no DOM and 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 layer coupling a reviewable
* diff rather than an assertion someone has to reconstruct.
* 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 layer coupling a reviewable diff rather
* than an assertion someone has to reconstruct.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -69,41 +64,54 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
}).join('\n')
/**
* A draft ending in a newline: the shape whose layer extents diverge without
* the backdrop's trailing-line sentinel. A textarea reserves a line box for the
* caret after a final newline; `white-space: pre-wrap` collapses a text node's
* trailing newline and generates none, so the backdrop would come out exactly
* one line shorter and the mirrored offset would clamp a line above the caret.
* A draft ending in a newline: the shape 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 of this shape can show it.
*/
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
/** The composer's two text layers as the browser lays them out. */
/** The composer's text layers as the browser lays them out. */
interface ComposerMetrics {
/** True when the draft is taller than the capped box — the situation under test. */
overflows: boolean
/** Visible height of the textarea's content box: the cap in pixels. */
/** 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 textarea's scroll offset, which the caret and the selection follow. */
inputScrollTop: number
/** The backdrop's scroll offset, which every visible glyph follows. */
backdropScrollTop: number
/** True when the two layers agree — the coupling this scenario exists for. */
layersAgree: boolean
/** The composer's one scroll offset, which the caret and the glyphs both follow. */
scrollTop: number
/** Furthest that offset can go. */
scrollMax: number
/**
* Scrollable overflow the textarea holds on its own — 0, or a second offset
* exists that nothing keeps equal to this one.
*/
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 that gap moves when the offset changes inside a single task: 0
* here, because one box carries both layers. Assigning one box's offset to
* another cannot be 0 — a scroll event is dispatched after the task that
* moved the box, so between the two there is a frame with the caret at the
* new offset and the glyphs at the old one.
*/
gapShiftOnScroll: number
/**
* Top of the LAST draft line relative to the visible box's top, in pixels: at
* most `clientHeight` when that line is on screen. This is the reported
* symptom as a number — with the layers uncoupled the backdrop stays at offset
* 0, so the last line sits a full draft-height below the box.
* most `clientHeight` when that line is 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
/** Furthest the textarea can scroll. */
inputMax: number
/** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
backdropMax: number
/** Content width the textarea wraps at. */
inputWrapWidth: number
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
@@ -113,14 +121,16 @@ interface ComposerMetrics {
}
/**
* Measure both composer layers in the page.
* Measure the composer's layers in the page, in the caret's coordinate frame.
* @param page - the page under test.
* @returns the two layers' offsets and where the draft's first and last lines sit.
* @returns the offset, the caret-to-glyph gap, 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<HTMLTextAreaElement>('textarea:enabled')
if (input === null) throw new Error('no live composer textarea in the DOM')
const scroll = input.closest<HTMLElement>('[data-input-scroll]')
if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport')
const backdrop = input.parentElement?.querySelector<HTMLElement>('[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
@@ -128,47 +138,50 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
// two that carry glyphs.
const mirror = input.nextElementSibling
if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
const box = input.getBoundingClientRect()
// The draft carries no chips or claim token, so the decoration walk emits it
// as one text node — the backdrop's first, ahead of the trailing-line
// sentinel React renders as a second one. Both markers live in that first
// node, which is what the Range below needs.
// 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 offsetOf = (marker: string): number => {
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 - box.top
return range.getBoundingClientRect().top
}
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
// Each layer's own maximum, probed by asking for an impossible offset and
// reading back what it clamped to, then restored. Reading scrollHeight -
// clientHeight instead would compute the maximum rather than observe it.
const restore = input.scrollTop
const restoreBackdrop = backdrop.scrollTop
input.scrollTop = 1e7
backdrop.scrollTop = 1e7
const inputMax = input.scrollTop
const backdropMax = backdrop.scrollTop
input.scrollTop = restore
backdrop.scrollTop = restoreBackdrop
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 {
inputMax,
backdropMax,
inputWrapWidth: input.clientWidth,
backdropWrapWidth: backdrop.clientWidth,
mirrorWrapWidth: mirror.clientWidth,
overflows: input.scrollHeight > input.clientHeight,
clientHeight: input.clientHeight,
visibleLines: Math.floor(input.clientHeight / lineHeight),
inputScrollTop: input.scrollTop,
backdropScrollTop: backdrop.scrollTop,
layersAgree: input.scrollTop === backdrop.scrollTop,
lastLineOffset: offsetOf(last),
firstLineOffset: offsetOf(first),
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 })
}
@@ -179,43 +192,56 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
* 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 change. What is recorded is the cap, the layer agreement,
* and which lines are on screen, each a comparison that survives any layout
* keeping the coupling.
* platform, not the change. What is recorded is the cap, the caret-to-glyph
* relation, and which lines are on screen, each a comparison that survives any
* layout keeping the coupling.
* @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): string {
function renderGeometry(
top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
): string {
return [
'# Composer draft scrolling (14-line cap, two text layers)',
'# 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)}`,
`- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
`- 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,
)}`,
`- textarea scroll offset: ${String(top.inputScrollTop)}px`,
`- glyph layer tracks it: ${String(top.layersAgree)}`,
`- 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',
'',
`- textarea moved: ${String(bottom.inputScrollTop > 0)}`,
`- glyph layer tracks it: ${String(bottom.layersAgree)}`,
`- 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',
'',
`- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`,
`- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`,
`- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`,
`- 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()
}
@@ -251,17 +277,16 @@ describe('web e2e: composer draft scrolling', () => {
// case below.
await page.locator('textarea:enabled').first().hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
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)
// Resting state: the draft's head is what a 40-line draft shows, and its
// tail is far below the box. Both layers sit at the origin, which is why the
// uncoupled build looks correct until something scrolls.
expect(metrics.inputScrollTop).toBe(0)
expect(metrics.layersAgree).toBe(true)
// 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)
@@ -270,19 +295,12 @@ describe('web e2e: composer draft scrolling', () => {
it('lays out all three text layers at one wrap width', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
// The premise under the mirror, asserted rather than assumed. Only .input
// scrolls, so only .input can lose content width to a scrollbar that
// consumes layout space; a narrower .input wraps a long draft onto more
// lines, ends up taller, and its larger maximum makes the mirrored offset
// clamp below the caret. Measured on a standalone harness, an 8px width
// difference is worth 2 to 5 lines on a wrap-sensitive draft.
//
// This holds on the lane's engine and is what a regression would break —
// it is NOT vacuous: measured on the same app, WebKit reports 768 against
// 776 here, which is the divergence the Agent Note records as a
// pre-existing, engine-specific limitation. The mirror is unaffected there
// today because the extents still agree; this assertion is what would
// notice if the lane's engine ever moved into the same state.
// 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. The three now share a containing block — the scrollport — so a
// scrollbar that consumes layout space costs them the same width; before,
// only the textarea scrolled, and WebKit reserved gutter space for it alone
// (768 against 776) while chromium and firefox did 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 —
@@ -292,66 +310,112 @@ describe('web e2e: composer draft scrolling', () => {
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 moved 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()
// One delta past the whole draft: the textarea clamps at its own end, and
// the wheel-chaining handler leaves it native because the box is not yet at
// its edge when the gesture starts (the chaining itself is owned by the
// unit spec).
const resting = (await measureComposer(page)).caretGlyphGap
// One delta past the whole draft: the box clamps at its own end, and the
// wheel-chaining handler leaves it native because the box is not yet at its
// edge when the gesture starts (the chaining itself is owned by the unit spec).
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
const metrics = await measureComposer(page)
// The coupling, stated directly.
expect(metrics.layersAgree).toBe(true)
// 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. On the uncoupled build the glyph
// layer stays at offset 0, so `lastLineOffset` is still a full draft below
// the box and `firstLineOffset` is still 0 — the text never moved.
// 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 keeps the layers together', async () => {
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. Typing at the caret — parked at the draft's
// end by the wheel gesture — scrolls it into view, which is a `scroll` like
// any other; this pins that an edit is not a separate case needing its own
// mirror, which is why one listener is the whole implementation.
// The other way the box moves, and the one that depends on the browser: the
// textarea no longer scrolls, 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.layersAgree).toBe(true)
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: measured in chromium and WebKit, the view stayed
// where it was while the caret sat at the end of the pasted block. The
// restore now scrolls it into view, and this is the case that proves 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 }))
// Ending in a newline is the shape the engines disagree on: 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 this shape is
// the one that separates equal extents from a mirror that clamps early.
// 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)
const extents = await measureComposer(page)
// The invariant the sentinel exists for. Without it the textarea measured
// 652 against the backdrop's 628 — one 24px line apart.
expect(extents.backdropMax).toBe(extents.inputMax)
await input.hover()
await page.mouse.wheel(0, 4000)
await expect.poll(async () => {
const m = await measureComposer(page)
return m.inputScrollTop === m.inputMax
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, not a line behind.
expect(bottom.layersAgree).toBe(true)
// 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([])
@@ -365,11 +429,11 @@ describe('web e2e: composer draft scrolling', () => {
await input.fill(DRAFT)
await input.hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
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)).inputScrollTop, { timeout: 10_000 })
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
const bottom = await measureComposer(page)
await input.fill(DRAFT_TRAILING_NEWLINE)
@@ -377,10 +441,25 @@ describe('web e2e: composer draft scrolling', () => {
await page.mouse.wheel(0, 4000)
await expect.poll(async () => {
const m = await measureComposer(page)
return m.inputScrollTop === m.inputMax
return m.scrollTop === m.scrollMax
}, { timeout: 10_000 }).toBe(true)
const trailingNewline = await measureComposer(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE)
// 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 }))
// The ordinary shape — not ending in a newline — so the collapsed branch
// of the reveal keeps a real engine under it; the case above owns the
// after-newline branch.
}, `\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)

View File

@@ -33,6 +33,7 @@ const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
const MODE = webSnapshotMode()
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
const REPLAY_PACE_MS = 100
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
let scaffold: WebScaffold
@@ -42,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)

View File

@@ -9,9 +9,9 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import type { Browser, Page, Response } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
@@ -34,12 +34,46 @@ const SEED_ID = 'navigation-panes-web-e2e'
const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
async function baselineResponse(
page: Page,
method: 'session.list' | 'workspace.list',
): Promise<Response> {
return page.waitForResponse(response => (
response.request().method() === 'POST'
&& new URL(response.url()).pathname === `/api/${method}`
), { timeout: 30_000 })
}
async function assertBaselineSucceeded(response: Response, method: string): Promise<void> {
expect(response.ok(), `${method} baseline HTTP response`).toBe(true)
const body = await response.json() as { result?: { ok?: unknown } }
expect(body.result?.ok, `${method} baseline RPC result`).toBe(true)
}
async function ensureSeedOpen(page: Page): Promise<void> {
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
if (await chat.count() === 0) {
await search.fill('WATERFALL')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
await result.click()
await chat.waitFor({ timeout: 15_000 })
}
await chat.click()
await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 })
if (await search.inputValue() !== '') {
await search.fill('')
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
}
}
describe('web e2e: navigation & panes over a rich seeded session', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let slotErrors: string[]
let tripwire: ReturnType<typeof watchConsole> = { warnings: [], pageErrors: [] }
let slotErrors: string[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({})
@@ -57,6 +91,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await seedSession(scaffold, raw, SEED_ID)
}
browser = await chromium.launch()
}, 120_000)
beforeEach(async () => {
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
slotErrors = []
@@ -65,7 +102,19 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
slotErrors.push(message.text())
}
})
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
// Initial navigation and list ownership settle only after both independent
// RPC baselines succeed; arm before navigation so neither response is missed.
const sessionBaseline = baselineResponse(page, 'session.list')
const workspaceBaseline = baselineResponse(page, 'workspace.list')
const [, sessionResponse, workspaceResponse] = await Promise.all([
page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
sessionBaseline,
workspaceBaseline,
])
await Promise.all([
assertBaselineSucceeded(sessionResponse, 'session.list'),
assertBaselineSucceeded(workspaceResponse, 'workspace.list'),
])
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
@@ -73,9 +122,32 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterEach(async () => {
const failures: unknown[] = []
try {
expect({
pageErrors: tripwire.pageErrors,
slotErrors,
warnings: tripwire.warnings,
}).toEqual({
pageErrors: [],
slotErrors: [],
warnings: [],
})
} catch (error) {
failures.push(error)
}
await page?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed')
})
afterAll(async () => {
await browser?.close()
await scaffold?.close()
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'navigation e2e cleanup failed')
})
it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
@@ -102,6 +174,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// The API baselines can settle before React commits their projection. The
// seeded count is the final user-visible barrier before editing search.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
@@ -136,6 +211,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
await ensureSeedOpen(page)
await page.getByRole('tab', { name: 'Trajectory' }).click()
await page.waitForTimeout(100)
const overlayLayout = await page.getByRole('table').evaluate((table) => {
@@ -200,7 +276,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
await ensureSeedOpen(page)
await page.getByRole('tab', { name: 'Trajectory' }).click()
const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
await plot.waitFor({ timeout: 15_000 })
const before = await page.locator('tr[data-kind]').count()
const box = await plot.boundingBox()
if (box === null) throw new Error('trajectory timeline plot has no layout box')
@@ -217,7 +296,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
await page.getByRole('tab', { name: 'Chat' }).click()
await ensureSeedOpen(page)
const bashRow = page.locator('[data-sample="bash"]').first()
await bashRow.waitFor({ timeout: 15_000 })
const frame = page.locator('[style*="grid-template-columns"]').first()
@@ -239,9 +318,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
await page.getByRole('tab', { name: 'Chat' }).click()
await ensureSeedOpen(page)
// The card is expand-gated behind the whole-row toggle (the unified
// tool-row interaction): open it if a previous case left it collapsed.
// tool-row interaction): open it if this fresh view leaves it collapsed.
// Expanded, the recorded command's own output sits in the message flow,
// derived from the logged call/result presentations alone.
const bashRow = page.locator('[data-sample="bash"]').first()
@@ -322,10 +401,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
}, 60_000)
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(slotErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md',

View File

@@ -25,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// The waiting golden owns the decision card; the approved golden owns the
// transcript the approval leaves behind — the state the card cannot see.
const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
const MODE = webSnapshotMode()
@@ -82,9 +83,15 @@ describe('web e2e: plan review takeover round trip', () => {
expect(await page.locator('[data-question-key]').count()).toBe(0)
await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
await card.getByRole('button', { name: 'Approve' }).click()
@@ -100,6 +107,7 @@ describe('web e2e: plan review takeover round trip', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Card gone; regular input restored.
expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
@@ -108,6 +116,8 @@ describe('web e2e: plan review takeover round trip', () => {
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md',
])
})
})

View File

@@ -23,6 +23,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
// Final golden: the answered transcript — the question resolved into its tool
// round trip and the final reply, the state the composer goldens cannot see.
@@ -76,11 +77,17 @@ describe('web e2e: resident question composer round trip', () => {
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
if (MODE !== 'record') {
// This golden owns the stable question surface; the answered-state
// golden below owns the resulting transcript.
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
// Squeezed card: the option rows are the capped card's scroll content, so
@@ -155,6 +162,7 @@ describe('web e2e: resident question composer round trip', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Composer gone; regular input restored.
expect(await page.locator('[data-question-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
// Golden of the answered transcript: the ask_user_question round trip
// rendered as history (question tool row + DONE), composer takeover gone.
@@ -168,6 +176,7 @@ describe('web e2e: resident question composer round trip', () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
'ui.expected.md',
'sidebar.expected.md',
'composed.expected.md',
'answered.expected.md',
])

View File

@@ -2,8 +2,8 @@
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
// Boots the REAL web composition — the shipped base plus web overlay through
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// replay (default, keyless: normally disables the llm-deepseek row and
// inserts dsh-llm-replay in providers mode), record (real adapter + key,
// harvests fixtures from live session memory), refresh (keyless replay that
@@ -85,6 +85,14 @@ const REPLAY_PROVIDERS = [{
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
}]
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
if (contextWindow === undefined) return REPLAY_PROVIDERS
return REPLAY_PROVIDERS.map(provider => ({
...provider,
models: provider.models.map(model => ({ ...model, contextWindow })),
}))
}
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
export interface WebScaffold {
/** The active snapshot mode this scaffold booted under. */
@@ -134,6 +142,8 @@ export interface LaunchOptions {
replayOverride?: string
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
paceMs?: number
/** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
replayContextWindow?: number
/**
* Tool presentation mode patched onto the shipped `tools` row (`code`
* collapses the wire to run_code + the SDK prompt section). Omit for the
@@ -344,7 +354,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (mode !== 'record' && options.replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: options.replayFixture,
providers: REPLAY_PROVIDERS,
providers: replayProviders(options.replayContextWindow),
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),

View File

@@ -128,11 +128,11 @@ describe('assembled search card', () => {
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
loadBundle: async (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
if (code === undefined) throw new Error(`missing built bundle ${url}`)
;(0, eval)(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }

View File

@@ -44,6 +44,12 @@
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
// the other assertions in its test silenced.
//
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
// to `transparent` while the pointer is outside the column), so every
// measurement below states which pointer position it was taken at: the
// scenario parks the pointer over the sidebar before asserting a colour, and
// the quiet state and its linger get their own test.
//
// Chromium also takes the `::-webkit-scrollbar*` path, not the standard
// properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind
// `@supports not selector(::-webkit-scrollbar)`, which is false here. The
@@ -106,6 +112,10 @@ interface ListMetrics {
overflows: boolean
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** Distance from the scrollbar's right edge to the sidebar edge. */
scrollbarEdgeOffset: number
/** Distance from the first row background's right edge to the sidebar edge. */
rowEdgeInset: number
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
clientRight: number
/** Border-box right edge in viewport coordinates. */
@@ -133,6 +143,8 @@ function measureList(page: Page): Promise<ListMetrics> {
if (list === null) throw new Error('sidebar session list not in the DOM')
const time = list.querySelector<HTMLElement>('[class*="time"]')
if (time === null) throw new Error('no row relative-time element in the sidebar list')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
// Each indirection variable is resolved through its own throwaway probe
// appended to the list: `var()` substitution then happens where the list
// sits in the cascade, which is the claim, and `color` normalizes whatever
@@ -167,6 +179,9 @@ function measureList(page: Page): Promise<ListMetrics> {
const style = getComputedStyle(list)
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
const listRect = list.getBoundingClientRect()
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
gutter: style.scrollbarGutter,
width: pseudoWidth,
@@ -177,9 +192,11 @@ function measureList(page: Page): Promise<ListMetrics> {
token: resolve('--dsh-scrollbar-thumb'),
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight,
band: list.getBoundingClientRect().width - list.clientWidth,
clientRight: list.getBoundingClientRect().left + list.clientWidth,
borderRight: list.getBoundingClientRect().right,
band: listRect.width - list.clientWidth,
scrollbarEdgeOffset: sidebarEdge - listRect.right,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
clientRight: listRect.left + list.clientWidth,
borderRight: listRect.right,
timeRight: time.getBoundingClientRect().right,
// The bar is drawn in the rightmost `barWidth` of the border box, whether
// or not that space was reserved. Its width comes from the sheet where the
@@ -188,11 +205,60 @@ function measureList(page: Page): Promise<ListMetrics> {
// absent. Taking the UA width as the fallback is what keeps the assertion
// honest: assuming 0 there would report no occlusion precisely in the
// state that has it.
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
}
})
}
/**
* Measure only overflow and row inset, which remain observable when every
* session is hidden under a collapsed workspace group.
* @param page - the page under test.
* @returns the list overflow state and first row's trailing inset.
*/
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
overflows: list.scrollHeight > list.clientHeight,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
}
})
}
/** One palette's readings, taken at both pointer positions. */
interface PaletteMetrics {
/** Everything measured with the pointer over the list, which is when a thumb exists. */
hovered: ListMetrics
/** `--dsh-scrollbar-thumb` with the pointer parked outside the column. */
quietThumb: string
}
/**
* Read one palette at both pointer positions, ending with the pointer back
* over the list so a caller measuring further leaves it revealed.
* @param page - the page under test.
* @returns the palette's quiet thumb and its hovered metrics.
*/
async function measurePalette(page: Page): Promise<PaletteMetrics> {
await pointAt(page, 'away')
// Poll rather than sleep the linger out: the wait is the column's, and a
// fixed sleep would either race it or pad every palette.
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
const quietThumb = await resolveThumb(page)
await pointAt(page, 'list')
// Poll the reveal too: the reading below is a colour, and taking it in the
// same tick as the pointer move would race React's flush and land a
// transparent thumb in the golden.
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).not.toBe(NO_THUMB)
return { hovered: await measureList(page), quietThumb }
}
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the fix establishes.
@@ -208,20 +274,23 @@ function measureList(page: Page): Promise<ListMetrics> {
* @param dark - metrics measured under the dark palette.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
const palette = (name: string, metrics: ListMetrics): string[] => [
function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
const palette = (name: string, { hovered: metrics, quietThumb }: PaletteMetrics): string[] => [
`## ${name}`,
'',
`- --dsh-scrollbar-thumb, pointer outside the sidebar: ${quietThumb}`,
`- scrollbar-gutter: ${metrics.gutter}`,
`- ::-webkit-scrollbar width: ${metrics.width}`,
`- ::-webkit-scrollbar-track background: ${metrics.track}`,
`- scrollbar-width: ${metrics.standardWidth}`,
`- scrollbar-color: ${metrics.standardColor}`,
`- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
`- --dsh-scrollbar-thumb: ${metrics.token}`,
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
`- --dsh-scrollbar-thumb, pointer over the list: ${metrics.token}`,
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
@@ -235,6 +304,48 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
].join('\n').trimEnd()
}
/**
* Resolve `--dsh-scrollbar-thumb` as the list sees it, without the rest of the
* geometry. Own probe element for the same reason {@link measureList} uses
* one: `getComputedStyle` returns a live declaration.
* @param page - the page under test.
* @returns the resolved thumb colour, serialized as `rgb`/`rgba`.
*/
function resolveThumb(page: Page): Promise<string> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const probe = document.createElement('span')
probe.style.color = 'var(--dsh-scrollbar-thumb)'
list.append(probe)
const value = getComputedStyle(probe).color
probe.remove()
return value
})
}
/** Fully transparent, which is how the quiet column spells "no thumb". */
const NO_THUMB = 'rgba(0, 0, 0, 0)'
/**
* Park the pointer over the session list or outside the sidebar entirely. The
* column reveals its scrollbars from real pointer movement, so a scenario that
* never moves the mouse measures the quiet state whatever it intended to.
* @param page - the page under test.
* @param where - `list` to point at the session list, `away` for the far side
* of the viewport (the conversation column).
*/
async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox()
if (box === null) throw new Error('sidebar session list has no layout box')
const viewport = page.viewportSize()
if (viewport === null) throw new Error('page has no viewport')
const target = where === 'list'
? { x: box.x + box.width / 2, y: box.y + box.height / 2 }
: { x: viewport.width - 5, y: box.y + box.height / 2 }
await page.mouse.move(target.x, target.y)
}
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Converges on expanded rather than
@@ -280,6 +391,10 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expandSeededSessions(page)
// Every assertion about a thumb colour needs a drawn thumb, and the column
// only draws one under the pointer; the quiet state is asserted where it is
// the subject rather than left as an ambient condition of the whole file.
await pointAt(page, 'list')
}, 180_000)
afterAll(async () => {
@@ -299,6 +414,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
// drawn over it. Removing the declaration makes it exactly 0. The value
// itself is not pinned — it tracks `scrollbar-width` and the platform.
expect(metrics.band).toBeGreaterThan(0)
expect(metrics.scrollbarEdgeOffset).toBe(2)
expect(metrics.rowEdgeInset).toBe(12)
// The reported symptom, stated directly: no part of the row's relative time
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
// covered part. Unlike the client-edge comparison below it does not go
@@ -317,6 +434,48 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('draws no thumb until the pointer is over the column, and lingers on the way out', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-pointer'))
const revealed = await resolveThumb(page)
expect(revealed).not.toBe(NO_THUMB)
await pointAt(page, 'away')
// The linger, measured as a state rather than a duration: the thumb is
// still drawn on the leave itself, and gone once the window has passed. A
// tighter timing assertion would pin the wall clock of a CI machine.
expect(await resolveThumb(page)).toBe(revealed)
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
// The reservation is unconditional, so nothing moved while the bar was
// hidden — this is what buys `transparent` over hiding the bar itself.
const quiet = await measureList(page)
expect(quiet.gutter).toBe('stable')
expect(quiet.band).toBeGreaterThan(0)
expect(quiet.timeCoveredBy).toBe(0)
// Scrolling without a pointer — what a keyboard or a touch drag does —
// leaves the column quiet. This is the change's one deliberate loss, and
// it is pinned here rather than only described, so making a scroll
// re-reveal the bar has to be a decision rather than a side effect.
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
await page.waitForTimeout(500)
expect(await resolveThumb(page)).toBe(NO_THUMB)
await pointAt(page, 'list')
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(revealed)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the row background inset when overflow disappears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.click()
try {
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
} finally {
await expandSeededSessions(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders the themed thumb through the WebKit path in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
const light = await measureList(page)
@@ -354,9 +513,9 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
it('matches the committed scrollbar geometry golden in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
const light = await measureList(page)
const light = await measurePalette(page)
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await measureList(page)
const dark = await measurePalette(page)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
expect(tripwire.pageErrors).toEqual([])

View File

@@ -14,10 +14,10 @@
- img
- img
- text: Context injection
- 'button "Failed Bash Error: command aborted" [expanded]':
- 'button "Failed Bash Error: tool call aborted" [expanded]':
- img
- text: "Failed Bash Error: command aborted"
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted"
- text: "Failed Bash Error: tool call aborted"
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted"
- button "Inspect"
- 'button "Failed Bash Error: tool call aborted before dispatch"':
- img

View File

@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -1,25 +1,31 @@
# Composer draft scrolling (14-line cap, two text layers)
# Composer draft scrolling (14-line cap, two text layers, one scrollport)
## At the start of the draft
- draft overflows the capped box: true
- visible lines: 14
- both layers share one scroll extent: true
- the textarea holds no scroll offset of its own: true
- all three layers wrap at one width: true
- textarea scroll offset: 0px
- glyph layer tracks it: true
- scroll offset: 0px
- caret and glyphs stay level when the offset changes: true
- first draft line is on screen: true
- last draft line is on screen: false
## Scrolled to the end of the draft
- textarea moved: true
- glyph layer tracks it: true
- offset moved: true
- caret sits on its own glyphs: true
- caret and glyphs stay level when the offset changes: true
- first draft line has scrolled out above: true
- last draft line is on screen: true
## Draft ending in a newline, scrolled to the end
- both layers share one scroll extent: true
- glyph layer tracks the caret: true
- last draft line is on screen: true
- caret sits on its own glyphs: true
- the draft's own last line is on screen: true
## Right after pasting a long block at the end
- the composer scrolled to the caret it left: true
- caret and glyphs stay level when the offset changes: true
- the pasted block's last line is on screen: true

View File

@@ -51,7 +51,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -31,7 +31,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -28,6 +28,7 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
- tooltip "Commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash

View File

@@ -23,7 +23,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -20,7 +20,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -25,7 +25,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -19,7 +19,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -20,7 +20,7 @@
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn 7/25 {{clock}}
- text: Available only on the last message of a completed turn 7/25 {{clock}}Ran for {{duration}}
- button "Read a.txt":
- img
- img
@@ -46,7 +46,7 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- text: 7/25 {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -0,0 +1 @@
- 'treeitem "Plan awaiting review Plan a small change: add now" [selected]'

View File

@@ -31,7 +31,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -0,0 +1 @@
- treeitem "Waiting for answer Use the ask_user_question tool to now" [selected]

View File

@@ -30,6 +30,7 @@
- textbox "Edit queued message": Edited queue item
- button "Save queued message":
- img
- tooltip "Save queued message"
- button "Cancel editing":
- img
- textbox "Message the agent"

View File

@@ -23,7 +23,7 @@
- paragraph: partial
- status: Deep diving...
- region "To-dos":
- button "To-dos 1/2 tasks · 1 in progress"
- button "To-dos 1 completed · 1 in progress"
- img
- text: Ongoing Goal Keep the composer context panels aligned
- button "Pause goal":

View File

@@ -20,7 +20,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Edited queue item {{clock}}
- text: {{clock}}Ran for {{duration}} Edited queue item {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:

View File

@@ -21,6 +21,7 @@
- text: Edited queue item
- button "Edit queued message":
- img
- tooltip "Edit queued message"
- button "Remove queued message":
- img
- button "Steer queued message":

View File

@@ -33,7 +33,7 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- text: 7/25 {{clock}}Ran for {{duration}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary

View File

@@ -33,7 +33,7 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- text: 7/25 {{clock}}Ran for {{duration}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary

View File

@@ -2,32 +2,38 @@
## Light palette
- --dsh-scrollbar-thumb, pointer outside the sidebar: rgba(0, 0, 0, 0)
- scrollbar-gutter: stable
- ::-webkit-scrollbar width: 8px
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb: rgb(229, 229, 229)
- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
- --dsh-scrollbar-thumb, pointer over the list: rgb(229, 229, 229)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(212, 212, 212)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true
## Dark palette
- --dsh-scrollbar-thumb, pointer outside the sidebar: rgba(0, 0, 0, 0)
- scrollbar-gutter: stable
- ::-webkit-scrollbar width: 8px
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb: rgb(60, 60, 61)
- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
- --dsh-scrollbar-thumb, pointer over the list: rgb(84, 85, 87)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(101, 103, 107)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true

View File

@@ -37,7 +37,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Now give the same explanation to a human reader. {{clock}}
- text: {{clock}}Ran for {{duration}} Now give the same explanation to a human reader. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
@@ -43,7 +43,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -23,7 +23,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img