approval-composer.e2e.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. // Web e2e scenario: the composer-takeover approval panel under a long
  2. // command. The shipped composition confines bash through the sandbox policy
  3. // and routes its escalation through the approval seam, so a read-only session
  4. // asked to write a file produces a REAL pending approval — the panel renders
  5. // in the browser, the test measures its geometry, answers through it, and the
  6. // escalated command then runs. Replay is deterministic: the denial, the
  7. // escalation retry and its command text arrive from replayed chunks, and the
  8. // answer click is the test's own gesture (the same sanctioned reaction to
  9. // model content as the question composer: the turn cannot complete without it).
  10. //
  11. // Geometry is the point of the scenario. The command is unbounded model text,
  12. // and before the cap a long one grew the card until the refuse/allow buttons
  13. // left the viewport — an approval the user could see and not answer.
  14. import { readFile } from 'node:fs/promises'
  15. import { fileURLToPath } from 'node:url'
  16. import { join } from 'node:path'
  17. import type { Browser, Page } from 'playwright'
  18. import { chromium } from 'playwright'
  19. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  20. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  21. // Empty type import: carries the approval package's session-event merge, so
  22. // the decided-outcome assertion below type-checks against the real union.
  23. import type {} from '@deepseek-ai/dsh-user-approval'
  24. import {
  25. assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  26. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  27. } from './scaffold.ts'
  28. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  29. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url))
  30. const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
  31. // The scenario's one golden: the waiting panel. Everything the answered state
  32. // proves is asserted directly — see the world-state block at the end.
  33. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  34. const MODE = webSnapshotMode()
  35. // Irreducible payload: the command has to be long enough to pass the card's
  36. // height cap, which is the only shape that reproduces an action row pushed off
  37. // screen. Unrelated tokens, not a repeated word — a repeated word is what the
  38. // model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
  39. // short command proves nothing here. The formula keeps the source small; the
  40. // model receives the expanded literal it has to put in the command.
  41. const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
  42. 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.`
  43. /** Draft used to measure the composer's own text cap: enough lines to pass it. */
  44. const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n')
  45. describe('web e2e: approval takeover keeps its actions reachable', () => {
  46. let scaffold: WebScaffold
  47. let browser: Browser
  48. let page: Page
  49. let tripwire: ReturnType<typeof watchConsole>
  50. const sessionEvents: SessionEvent[] = []
  51. beforeAll(async () => {
  52. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
  53. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  54. browser = await chromium.launch()
  55. page = await newEnglishPage(browser)
  56. tripwire = watchConsole(page)
  57. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  58. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  59. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  60. }, 120_000)
  61. afterAll(async () => {
  62. await browser?.close()
  63. await scaffold?.close()
  64. })
  65. it('caps the long command, answers through the panel, and runs the escalated command', async () => {
  66. onTestFailed(() => saveFailureShot(page, 'web-e2e-approval'))
  67. if (MODE !== 'record') {
  68. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  69. }
  70. const input = page.locator('textarea').first()
  71. await input.waitFor({ timeout: 10_000 })
  72. // The composer's own text cap, measured on the live draft scrollport before
  73. // the takeover replaces it — the box that carries the cap, while the
  74. // textarea inside it is as tall as the whole draft. The panel's scroll
  75. // region must stop at the same height (the designer's requirement: one cap
  76. // for the composer seat), and measuring it here keeps the assertion free of
  77. // the px value itself.
  78. await input.fill(CAP_PROBE)
  79. const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
  80. expect(composerCap).toBeGreaterThan(0)
  81. await input.fill('')
  82. // Read-only: the mode whose denial the model escalates from. Switched
  83. // through the shipped access-mode chip, not a test-only seam.
  84. await page.locator('[aria-label^="Access mode"]').click()
  85. await page.getByRole('menuitem', { name: 'Read Only' }).click()
  86. await expect.poll(
  87. () => page.locator('[aria-label="Access mode, current: Read Only"]').count(),
  88. { timeout: 15_000 },
  89. ).toBe(1)
  90. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000)
  91. await input.fill(PROMPT)
  92. await input.press('Enter')
  93. // The panel takes over the input area while the tool blocks. Its presence
  94. // is a STABLE waiting state (it stays until answered), so waitFor is
  95. // race-free.
  96. const panel = page.locator('[data-approval-key]')
  97. await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
  98. const scroll = panel.locator('[data-approval-scroll]')
  99. await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0)
  100. if (MODE !== 'record') {
  101. // This golden owns the stable waiting surface; the answered golden below
  102. // owns the resulting transcript.
  103. const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
  104. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  105. // The regression this scenario exists for: an uncapped card grew with
  106. // the command until the action row left the viewport. Measured at the
  107. // lane baseline and at a short viewport, on the live panel.
  108. const original = page.viewportSize() ?? { width: 1680, height: 1000 }
  109. for (const height of [1000, 700]) {
  110. await page.setViewportSize({ width: 900, height })
  111. const geometry = await panel.evaluate((root) => {
  112. const region = root.querySelector<HTMLElement>('[data-approval-scroll]')
  113. const card = region?.parentElement ?? null
  114. // Role/text, not the CSS-module class names: the built client hashes those.
  115. const buttons = [...root.querySelectorAll<HTMLElement>('button')]
  116. const rows = buttons.map(button => button.getBoundingClientRect())
  117. return {
  118. buttons: buttons.length,
  119. capped: region === null ? 0 : region.clientHeight,
  120. // A scrolling region proves the cap is genuinely engaged; without
  121. // it every assertion below would hold vacuously.
  122. scrolls: region === null ? false : region.scrollHeight > region.clientHeight,
  123. cardBottom: card === null ? Number.NaN : card.getBoundingClientRect().bottom,
  124. actionsTop: Math.min(...rows.map(rect => rect.top)),
  125. actionsBottom: Math.max(...rows.map(rect => rect.bottom)),
  126. viewport: window.innerHeight,
  127. }
  128. })
  129. expect(geometry.buttons).toBe(2)
  130. expect(geometry.scrolls).toBe(true)
  131. // One cap for the seat: the panel's text region stops where the
  132. // composer draft does (sub-pixel tolerance for the shared padding).
  133. expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1)
  134. // Both buttons stay inside the card AND inside the viewport — the
  135. // answerable state the cap exists to guarantee.
  136. expect(geometry.actionsTop).toBeGreaterThan(0)
  137. expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport)
  138. expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom)
  139. }
  140. await page.setViewportSize(original)
  141. }
  142. await panel.getByRole('button', { name: 'Allow once' }).click()
  143. const sessionId = await settled
  144. if (MODE === 'record') {
  145. await recordFixture(scaffold, sessionId, FIXTURE)
  146. return
  147. }
  148. // World state: the granted escalation is what let the command run, and the
  149. // panel leaves with the regular composer restored. Asserted on the world
  150. // and the DOM rather than through a transcript golden — the denied first
  151. // attempt renders the OS's own refusal ("Operation not permitted" on
  152. // macOS, "Read-only file system" on Linux), so the answered transcript is
  153. // not a platform-neutral golden surface.
  154. expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1)))
  155. .toContain('allowed-once')
  156. const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8')
  157. expect(written).toContain(TOKENS.slice(0, 64))
  158. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1)
  159. expect(await page.locator('[data-approval-key]').count()).toBe(0)
  160. await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  161. expect(tripwire.pageErrors).toEqual([])
  162. expect(tripwire.warnings).toEqual([])
  163. }, 300_000)
  164. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  165. await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
  166. })
  167. })