present.e2e.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. /** Recorded source-file delivery, edits, reload, deletion, and Session ZIP behavior. */
  2. import { readFile, unlink, mkdir, mkdtemp, writeFile, rm, realpath } from 'node:fs/promises'
  3. import { join, delimiter } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { chromium, type Browser, type Page } from 'playwright'
  6. import { unzipSync, strFromU8 } from 'fflate'
  7. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
  8. import { tmpdir, release } from 'node:os'
  9. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  10. import type {} from '@deepseek-ai/dsh-tool-present/types'
  11. import {
  12. acknowledgeReloadConnectionLoss, assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria,
  13. compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture,
  14. watchConsole, webSnapshotMode, type WebScaffold,
  15. } from './scaffold.ts'
  16. import { connectFreshWorkspace, newEnglishPage } from './support.ts'
  17. const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url))
  18. const FIXTURE = join(DIR, 'session.v3.jsonl')
  19. const MODE = webSnapshotMode()
  20. const PROMPT = 'Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. '
  21. + 'Use bash to run exactly `printf "DELIVERED_REPORT\\n" > report.txt; printf "DELIVERED_NOTE\\n" > 说明.txt`. '
  22. + 'Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string "AFTER_PRESENT" (not an Error object) from that same run_code program. '
  23. + 'Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph.'
  24. // The recorded Bash scenario and executable opener fixture require a POSIX host outside WSL.
  25. describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes('microsoft'))('web e2e: explicit file delivery', () => {
  26. let scaffold: WebScaffold
  27. let browser: Browser
  28. let page: Page
  29. let tripwire: ReturnType<typeof watchConsole>
  30. let sessionId: SessionId
  31. let cwd: string
  32. let disposeApproval: (() => void) | undefined
  33. const events: SessionEvent[] = []
  34. let nativeRoot: string | undefined
  35. let openLog: string
  36. const opened = async (): Promise<Array<{ path: string; content: string | null; action: 'open' | 'reveal' }>> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string | null; action: 'open' | 'reveal' })
  37. const downloads: string[] = []
  38. beforeAll(async () => {
  39. nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-present-native-'))
  40. openLog = join(nativeRoot, 'opened.jsonl')
  41. await writeFile(openLog, '')
  42. // Exercise the built Host through its actual OS command, replacing only the desktop application.
  43. const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
  44. await writeFile(join(nativeRoot, command), `#!${process.execPath}
  45. const fs = require('node:fs');
  46. const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2];
  47. const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open';
  48. fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, content: action === 'open' ? fs.readFileSync(path, 'utf8') : null }) + '\\n');
  49. `, { mode: 0o700 })
  50. vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
  51. await mkdir(DIR, { recursive: true })
  52. scaffold = await launchWebScaffold({
  53. extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)),
  54. agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true,
  55. ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
  56. })
  57. disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
  58. scaffold.ctx.on('session/event', (_session, event) => { events.push(event) })
  59. browser = await chromium.launch()
  60. page = await newEnglishPage(browser)
  61. tripwire = watchConsole(page)
  62. page.on('download', (download) => { downloads.push(download.suggestedFilename()) })
  63. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  64. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  65. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  66. }, 120_000)
  67. afterAll(async () => {
  68. try {
  69. await browser?.close()
  70. } finally {
  71. disposeApproval?.()
  72. try {
  73. await scaffold?.close()
  74. } finally {
  75. vi.unstubAllEnvs()
  76. if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true })
  77. }
  78. }
  79. })
  80. it('declares nested deliveries even when the enclosing program subsequently fails', async () => {
  81. if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  82. const settled = scaffold.whenTurnSettled()
  83. const input = page.locator('[data-composer-input]').first()
  84. await input.fill(PROMPT)
  85. await input.press('Enter')
  86. sessionId = await settled
  87. const workspace = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
  88. if (workspace === undefined) throw new Error('present Session has no workspace')
  89. cwd = workspace
  90. if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
  91. await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 })
  92. await assertFinalWorkspaceSnapshot(DIR, cwd)
  93. expect(events.filter(event => event.type === 'deliverables/presented').flatMap(event => event.data.files.map(file => file.path)))
  94. .toEqual(['report.txt', '说明.txt'])
  95. for (const event of events) {
  96. if (event.type === 'deliverables/presented') {
  97. expect(event.data.files).toEqual([
  98. { path: 'report.txt', description: 'delivered report' },
  99. { path: '说明.txt', description: 'delivered note' },
  100. ])
  101. }
  102. }
  103. expect(events.some(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true)
  104. expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError)).toBe(true)
  105. }, 200_000)
  106. it('opens current source files after edits and reload, and reports deletion without downloading', async () => {
  107. await writeFile(join(cwd, 'report.txt'), 'EDITED_REPORT\n')
  108. await writeFile(join(cwd, '说明.txt'), 'EDITED_NOTE\n')
  109. for (const reload of [false, true]) {
  110. if (reload) {
  111. const warningStart = tripwire.warnings.length
  112. await page.reload({ waitUntil: 'load' })
  113. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  114. await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 })
  115. }
  116. const row = page.locator('[data-presented-files-row]')
  117. await row.waitFor()
  118. expect(await row.getByRole('button').count()).toBe(4)
  119. expect(await row.getByTitle(join(cwd, 'report.txt'), { exact: true }).innerText()).toBe('report.txt')
  120. const beforeReveal = (await opened()).length
  121. await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click()
  122. const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST')
  123. await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click()
  124. expect((await revealResponse).status()).toBe(204)
  125. await expect.poll(opened).toHaveLength(beforeReveal + 1)
  126. expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) })
  127. for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) {
  128. const count = (await opened()).length
  129. const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
  130. await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click()
  131. expect((await response).status()).toBe(204)
  132. await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
  133. expect(await opened()).toHaveLength(count + 1)
  134. expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes })
  135. }
  136. }
  137. const count = (await opened()).length
  138. const openedResponse = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
  139. await page.locator('code').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click()
  140. await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
  141. expect((await openedResponse).status()).toBe(204)
  142. expect(await opened()).toHaveLength(count + 1)
  143. expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' })
  144. expect(downloads).toEqual([])
  145. const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
  146. expect(response.status()).toBe(200)
  147. const entries = unzipSync(await response.body())
  148. expect(Object.keys(entries)).toHaveLength(1)
  149. const exported = strFromU8(Object.values(entries)[0]!)
  150. expect(exported).toContain('deliverables/presented')
  151. const declarations = exported.trim().split('\n').map(line => JSON.parse(line) as SessionEvent)
  152. .filter(event => event.type === 'deliverables/presented')
  153. expect(declarations).toHaveLength(1)
  154. expect(declarations[0]!.data.files).toEqual([
  155. { path: 'report.txt', description: 'delivered report' },
  156. { path: '说明.txt', description: 'delivered note' },
  157. ])
  158. expect(exported).not.toContain('EDITED_REPORT')
  159. if (MODE !== 'record') {
  160. const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  161. await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
  162. await page.locator('[data-turn-process]').click()
  163. const failed = page.locator('[data-tool="present"][data-state="error"]')
  164. const delivered = page.locator('[data-tool="present"][data-state="ok"]')
  165. expect(await failed.count()).toBe(1)
  166. expect(await delivered.count()).toBe(1)
  167. expect(await failed.innerText()).toContain('Delivery failed')
  168. expect(await delivered.innerText()).toContain('Delivered')
  169. await page.locator('[data-turn-process]').click()
  170. await page.setViewportSize({ width: 480, height: 900 })
  171. const row = page.locator('[data-presented-files-row]')
  172. await row.scrollIntoViewIfNeeded()
  173. for (const card of await row.getByRole('button').all()) {
  174. const bounds = await card.boundingBox()
  175. expect(bounds).not.toBeNull()
  176. expect(bounds!.x).toBeGreaterThanOrEqual(0)
  177. expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(480)
  178. }
  179. }
  180. const beforeDelete = (await opened()).length
  181. await unlink(join(cwd, 'report.txt'))
  182. const missing = page.waitForResponse(response => response.url().includes('/api/present.open?'))
  183. await page.locator('[data-presented-files-row]').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click()
  184. expect((await missing).status()).toBe(404)
  185. await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor()
  186. expect(await opened()).toHaveLength(beforeDelete)
  187. expect(downloads).toEqual([])
  188. expect(tripwire.pageErrors).toEqual([])
  189. expect(tripwire.warnings).toEqual([])
  190. })
  191. })