mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The composer paints its draft in two layers — the textarea owns the value, the selection and the caret, the backdrop paints every visible glyph — and they had one scroll offset each, kept equal by a `scroll` listener. That holds at rest and not in motion: a wheel gesture scrolls the textarea on the compositor, the listener runs afterwards, and for those frames the caret sits at the new offset with the words at the old one. Measured on a harness of the same geometry, a 200px offset change separates caret from glyphs by ~200px (chromium 203, firefox 202, WebKit 203) until a later frame — the caret flying out of its own text when a user swipes a long draft quickly. Both layers now ride one scrollport: `[data-input-scroll]` carries the 14-line cap, the auto-grow stack inside it is as tall as the whole draft, and the textarea holds no scrollable overflow of its own. The browser applies one offset to both layers in the same frame, so the coupling is structural rather than maintained. The backdrop's trailing-line sentinel and the cross-engine wrap-width premise go with the mirror: the layers now share a containing block, which closes the WebKit 768-against-776 gap by construction.
184 lines
9.8 KiB
TypeScript
184 lines
9.8 KiB
TypeScript
// Web e2e scenario: the composer-takeover approval panel under a long
|
|
// command. The shipped composition confines bash through the sandbox policy
|
|
// and routes its escalation through the approval seam, so a read-only session
|
|
// asked to write a file produces a REAL pending approval — the panel renders
|
|
// in the browser, the test measures its geometry, answers through it, and the
|
|
// escalated command then runs. Replay is deterministic: the denial, the
|
|
// escalation retry and its command text arrive from replayed chunks, and the
|
|
// answer click is the test's own gesture (the same sanctioned reaction to
|
|
// model content as the question composer: the turn cannot complete without it).
|
|
//
|
|
// Geometry is the point of the scenario. The command is unbounded model text,
|
|
// and before the cap a long one grew the card until the refuse/allow buttons
|
|
// left the viewport — an approval the user could see and not answer.
|
|
import { readFile } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { join } from 'node:path'
|
|
import type { Browser, Page } from 'playwright'
|
|
import { chromium } from 'playwright'
|
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
// Empty type import: carries the approval package's session-event merge, so
|
|
// the decided-outcome assertion below type-checks against the real union.
|
|
import type {} from '@deepseek-ai/dsh-user-approval'
|
|
import {
|
|
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
|
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
|
} from './scaffold.ts'
|
|
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
|
|
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url))
|
|
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
|
// The scenario's one golden: the waiting panel. Everything the answered state
|
|
// proves is asserted directly — see the world-state block at the end.
|
|
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
|
const MODE = webSnapshotMode()
|
|
|
|
// Irreducible payload: the command has to be long enough to pass the card's
|
|
// height cap, which is the only shape that reproduces an action row pushed off
|
|
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
|
|
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
|
|
// short command proves nothing here. The formula keeps the source small; the
|
|
// model receives the expanded literal it has to put in the command.
|
|
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
|
|
const PROMPT = `Write a file named notes.txt in the workspace containing exactly this text on one line: ${TOKENS}. Use one bash command with the literal text inline. Then reply with the single word DONE and stop.`
|
|
|
|
/** Draft used to measure the composer's own text cap: enough lines to pass it. */
|
|
const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n')
|
|
|
|
describe('web e2e: approval takeover keeps its actions reachable', () => {
|
|
let scaffold: WebScaffold
|
|
let browser: Browser
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
const sessionEvents: SessionEvent[] = []
|
|
|
|
beforeAll(async () => {
|
|
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
|
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
|
browser = await chromium.launch()
|
|
page = await newEnglishPage(browser)
|
|
tripwire = watchConsole(page)
|
|
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
await connectFreshWorkspace(page)
|
|
}, 120_000)
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
await scaffold?.close()
|
|
})
|
|
|
|
it('caps the long command, answers through the panel, and runs the escalated command', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-approval'))
|
|
if (MODE !== 'record') {
|
|
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
|
}
|
|
const input = page.locator('textarea').first()
|
|
await input.waitFor({ timeout: 10_000 })
|
|
|
|
// 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.closest('[data-input-scroll]')?.clientHeight ?? 0)
|
|
expect(composerCap).toBeGreaterThan(0)
|
|
await input.fill('')
|
|
|
|
// Read-only: the mode whose denial the model escalates from. Switched
|
|
// through the shipped access-mode chip, not a test-only seam.
|
|
await page.locator('[aria-label^="Access mode"]').click()
|
|
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
|
await expect.poll(
|
|
() => page.locator('[aria-label="Access mode, current: Read Only"]').count(),
|
|
{ timeout: 15_000 },
|
|
).toBe(1)
|
|
|
|
const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000)
|
|
await input.fill(PROMPT)
|
|
await input.press('Enter')
|
|
|
|
// The panel takes over the input area while the tool blocks. Its presence
|
|
// is a STABLE waiting state (it stays until answered), so waitFor is
|
|
// race-free.
|
|
const panel = page.locator('[data-approval-key]')
|
|
await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
|
|
const scroll = panel.locator('[data-approval-scroll]')
|
|
await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0)
|
|
|
|
if (MODE !== 'record') {
|
|
// This golden owns the stable waiting surface; the answered golden below
|
|
// owns the resulting transcript.
|
|
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
|
|
|
// The regression this scenario exists for: an uncapped card grew with
|
|
// the command until the action row left the viewport. Measured at the
|
|
// lane baseline and at a short viewport, on the live panel.
|
|
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
|
for (const height of [1000, 700]) {
|
|
await page.setViewportSize({ width: 900, height })
|
|
const geometry = await panel.evaluate((root) => {
|
|
const region = root.querySelector<HTMLElement>('[data-approval-scroll]')
|
|
const card = region?.parentElement ?? null
|
|
// Role/text, not the CSS-module class names: the built client hashes those.
|
|
const buttons = [...root.querySelectorAll<HTMLElement>('button')]
|
|
const rows = buttons.map(button => button.getBoundingClientRect())
|
|
return {
|
|
buttons: buttons.length,
|
|
capped: region === null ? 0 : region.clientHeight,
|
|
// A scrolling region proves the cap is genuinely engaged; without
|
|
// it every assertion below would hold vacuously.
|
|
scrolls: region === null ? false : region.scrollHeight > region.clientHeight,
|
|
cardBottom: card === null ? Number.NaN : card.getBoundingClientRect().bottom,
|
|
actionsTop: Math.min(...rows.map(rect => rect.top)),
|
|
actionsBottom: Math.max(...rows.map(rect => rect.bottom)),
|
|
viewport: window.innerHeight,
|
|
}
|
|
})
|
|
expect(geometry.buttons).toBe(2)
|
|
expect(geometry.scrolls).toBe(true)
|
|
// One cap for the seat: the panel's text region stops where the
|
|
// composer draft does (sub-pixel tolerance for the shared padding).
|
|
expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1)
|
|
// Both buttons stay inside the card AND inside the viewport — the
|
|
// answerable state the cap exists to guarantee.
|
|
expect(geometry.actionsTop).toBeGreaterThan(0)
|
|
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport)
|
|
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom)
|
|
}
|
|
await page.setViewportSize(original)
|
|
}
|
|
|
|
await panel.getByRole('button', { name: 'Allow once' }).click()
|
|
|
|
const sessionId = await settled
|
|
if (MODE === 'record') {
|
|
await recordFixture(scaffold, sessionId, FIXTURE)
|
|
return
|
|
}
|
|
// World state: the granted escalation is what let the command run, and the
|
|
// panel leaves with the regular composer restored. Asserted on the world
|
|
// and the DOM rather than through a transcript golden — the denied first
|
|
// attempt renders the OS's own refusal ("Operation not permitted" on
|
|
// macOS, "Read-only file system" on Linux), so the answered transcript is
|
|
// not a platform-neutral golden surface.
|
|
expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1)))
|
|
.toContain('allowed-once')
|
|
const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8')
|
|
expect(written).toContain(TOKENS.slice(0, 64))
|
|
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1)
|
|
expect(await page.locator('[data-approval-key]').count()).toBe(0)
|
|
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
expect(tripwire.warnings).toEqual([])
|
|
}, 300_000)
|
|
|
|
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
|
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
|
})
|
|
})
|