approval-composer.e2e.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. // Browser geometry for a pending approval whose model-supplied command would
  2. // push the actions outside the viewport without a capped text region.
  3. import { readFile } from 'node:fs/promises'
  4. import { fileURLToPath } from 'node:url'
  5. import { join } from 'node:path'
  6. import type { Browser, Page } from 'playwright'
  7. import { chromium } from 'playwright'
  8. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  9. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  10. // Empty type import: carries the approval package's session-event merge, so
  11. // the decided-outcome assertion below type-checks against the real union.
  12. import type {} from '@deepseek-ai/dsh-user-approval'
  13. import {
  14. assertFinalWorkspaceSnapshot, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  15. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  16. } from './scaffold.ts'
  17. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  18. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/approval-composer', import.meta.url))
  19. const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
  20. // The golden covers the stable waiting panel; direct assertions cover its answer.
  21. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  22. const MODE = webSnapshotMode()
  23. // Unrelated tokens keep the recorded model from compressing the payload into a
  24. // short shell loop that would not overflow the card.
  25. const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
  26. 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.`
  27. /** Draft used to measure the composer's own text cap: enough lines to pass it. */
  28. const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n')
  29. describe('web e2e: approval takeover keeps its actions reachable', () => {
  30. let scaffold: WebScaffold
  31. let browser: Browser
  32. let page: Page
  33. let tripwire: ReturnType<typeof watchConsole>
  34. const sessionEvents: SessionEvent[] = []
  35. beforeAll(async () => {
  36. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
  37. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  38. browser = await chromium.launch()
  39. page = await newEnglishPage(browser)
  40. tripwire = watchConsole(page)
  41. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  42. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  43. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  44. }, 120_000)
  45. afterAll(async () => {
  46. await browser?.close()
  47. await scaffold?.close()
  48. })
  49. it('caps the long command, answers through the panel, and runs the escalated command', async () => {
  50. onTestFailed(() => saveFailureShot(page, 'web-e2e-approval'))
  51. if (MODE !== 'record') {
  52. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  53. }
  54. const input = page.locator('[data-composer-input]').first()
  55. await input.waitFor({ timeout: 10_000 })
  56. // Derive the expected cap from the live composer instead of duplicating its pixel value.
  57. await input.fill(CAP_PROBE)
  58. const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
  59. expect(composerCap).toBeGreaterThan(0)
  60. await input.fill('')
  61. await page.locator('[aria-label^="Access mode"]').click()
  62. await page.getByRole('menuitem', { name: 'Read Only' }).click()
  63. await expect.poll(
  64. () => page.locator('[aria-label="Access mode, current: Read Only"]').count(),
  65. { timeout: 15_000 },
  66. ).toBe(1)
  67. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000)
  68. await input.fill(PROMPT)
  69. await input.press('Enter')
  70. const panel = page.locator('[data-approval-key]')
  71. await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
  72. const scroll = panel.locator('[data-approval-scroll]')
  73. await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0)
  74. if (MODE !== 'record') {
  75. const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
  76. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  77. const original = page.viewportSize() ?? { width: 1680, height: 1000 }
  78. for (const height of [1000, 700]) {
  79. await page.setViewportSize({ width: 900, height })
  80. const geometry = await panel.evaluate((root) => {
  81. const region = root.querySelector<HTMLElement>('[data-approval-scroll]')
  82. const card = region?.parentElement ?? null
  83. // Role/text, not the CSS-module class names: the built client hashes those.
  84. const buttons = [...root.querySelectorAll<HTMLElement>('button')]
  85. const rows = buttons.map(button => button.getBoundingClientRect())
  86. return {
  87. buttons: buttons.length,
  88. capped: region === null ? 0 : region.clientHeight,
  89. // A scrolling region proves the cap is genuinely engaged; without
  90. // it every assertion below would hold vacuously.
  91. scrolls: region === null ? false : region.scrollHeight > region.clientHeight,
  92. cardBottom: card === null ? Number.NaN : card.getBoundingClientRect().bottom,
  93. actionsTop: Math.min(...rows.map(rect => rect.top)),
  94. actionsBottom: Math.max(...rows.map(rect => rect.bottom)),
  95. viewport: window.innerHeight,
  96. }
  97. })
  98. expect(geometry.buttons).toBe(2)
  99. expect(geometry.scrolls).toBe(true)
  100. // The panel and composer share one cap; allow sub-pixel layout variance.
  101. expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1)
  102. expect(geometry.actionsTop).toBeGreaterThan(0)
  103. expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport)
  104. expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom)
  105. }
  106. await page.setViewportSize(original)
  107. }
  108. await panel.getByRole('button', { name: 'Allow once' }).click()
  109. const sessionId = await settled
  110. if (MODE === 'record') {
  111. await recordFixture(scaffold, sessionId, FIXTURE)
  112. await assertFinalWorkspaceSnapshot(SNAPSHOT_DIR, join(scaffold.workspaceCwd, 'workspace'))
  113. return
  114. }
  115. // Direct state and DOM assertions cover the answered outcome beyond the
  116. // pending panel's expected output.
  117. expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1)))
  118. .toContain('allowed-once')
  119. const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8')
  120. expect(written).toContain(TOKENS.slice(0, 64))
  121. await assertFinalWorkspaceSnapshot(SNAPSHOT_DIR, join(scaffold.workspaceCwd, 'workspace'))
  122. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1)
  123. expect(await page.locator('[data-approval-key]').count()).toBe(0)
  124. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  125. expect(tripwire.pageErrors).toEqual([])
  126. expect(tripwire.warnings).toEqual([])
  127. }, 300_000)
  128. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  129. await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md', 'workspace.expected'])
  130. })
  131. })