Merge remote-tracking branch 'origin/master' into mergebot/pr981
This commit is contained in:
181
apps/web/tests/approval-composer.e2e.ts
Normal file
181
apps/web/tests/approval-composer.e2e.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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 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.
|
||||
await input.fill(CAP_PROBE)
|
||||
const composerCap = await input.evaluate(el => el.clientHeight)
|
||||
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: '允许一次' }).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'])
|
||||
})
|
||||
})
|
||||
64
apps/web/tests/snapshots/approval-composer/session.jsonl
Normal file
64
apps/web/tests/snapshots/approval-composer/session.jsonl
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
- text: 等待审批
|
||||
- group "审批详情": "escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt"
|
||||
- button "拒绝"
|
||||
- button "允许一次"
|
||||
@@ -26,6 +26,7 @@
|
||||
"tests/scaffold-hermetic.e2e.ts",
|
||||
"tests/live-interactions.e2e.ts",
|
||||
"tests/question-composer.e2e.ts",
|
||||
"tests/approval-composer.e2e.ts",
|
||||
"tests/plan-review.e2e.ts",
|
||||
"tests/steering.e2e.ts",
|
||||
"tests/navigation-panes.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user