web-search-round.e2e.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. // Web e2e scenario for the shipped default search composition. A real browser
  2. // drives `web_search`; the model stream is replayed while the real DeepSeek
  3. // provider calls a deterministic local Anthropic-compatible endpoint through
  4. // the real credentials service.
  5. import { readFile } from 'node:fs/promises'
  6. import { createServer, type Server } from 'node:http'
  7. import type { AddressInfo } from 'node:net'
  8. import { fileURLToPath } from 'node:url'
  9. import type { Browser, Page } from 'playwright'
  10. import { chromium } from 'playwright'
  11. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  12. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  13. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  14. import {
  15. assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  16. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  17. } from './scaffold.ts'
  18. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  19. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', import.meta.url))
  20. const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url))
  21. const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url))
  22. const MODE = webSnapshotMode()
  23. const QUERY = 'DeepSeek Harness snapshot search'
  24. const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
  25. const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
  26. const SEARCH_CREDENTIAL = 'snapshot-search-key'
  27. const RESULT_URL = 'https://docs.example.test/search'
  28. interface CapturedSearchRequest {
  29. path: string
  30. apiKey: string | undefined
  31. body: unknown
  32. }
  33. /** Start the deterministic DeepSeek Messages double used by the real provider. */
  34. async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> {
  35. const server = createServer((request, response) => {
  36. let body = ''
  37. request.setEncoding('utf8')
  38. request.on('data', (chunk: string) => { body += chunk })
  39. request.on('end', () => {
  40. captured.push({
  41. path: request.url ?? '',
  42. apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
  43. body: JSON.parse(body) as unknown,
  44. })
  45. response.writeHead(200, { 'content-type': 'application/json' })
  46. response.end(JSON.stringify({
  47. content: [
  48. {
  49. type: 'text',
  50. text: 'Found one source.',
  51. citations: [{
  52. type: 'web_search_result_location',
  53. url: RESULT_URL,
  54. cited_text: 'Snapshot search excerpt.',
  55. }],
  56. },
  57. {
  58. type: 'web_search_tool_result',
  59. content: [{
  60. type: 'web_search_result',
  61. url: RESULT_URL,
  62. title: 'Snapshot Search Result',
  63. page_age: '2026-07-31',
  64. }],
  65. },
  66. ],
  67. }))
  68. })
  69. })
  70. await new Promise<void>((resolve, reject) => {
  71. server.once('error', reject)
  72. server.listen(0, '127.0.0.1', () => {
  73. server.off('error', reject)
  74. resolve()
  75. })
  76. })
  77. const address = server.address() as AddressInfo
  78. return { server, baseURL: `http://127.0.0.1:${address.port}` }
  79. }
  80. describe('web e2e: shipped default web search', () => {
  81. let scaffold: WebScaffold
  82. let browser: Browser
  83. let page: Page
  84. let searchServer: Server | undefined
  85. let searchBaseURL: string
  86. let tripwire: ReturnType<typeof watchConsole>
  87. const searchRequests: CapturedSearchRequest[] = []
  88. const sessionEvents: SessionEvent[] = []
  89. beforeAll(async () => {
  90. const search = await startSearchServer(searchRequests)
  91. searchServer = search.server
  92. searchBaseURL = search.baseURL
  93. scaffold = await launchWebScaffold({
  94. deepSeekSearch: {
  95. baseURL: search.baseURL,
  96. apiKeyEnv: SEARCH_CREDENTIAL_REF,
  97. },
  98. ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
  99. })
  100. await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL)
  101. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  102. browser = await chromium.launch()
  103. page = await newEnglishPage(browser)
  104. tripwire = watchConsole(page)
  105. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  106. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  107. await connectFreshWorkspace(page)
  108. }, 120_000)
  109. afterAll(async () => {
  110. await browser?.close()
  111. await scaffold?.close()
  112. await new Promise<void>((resolve, reject) => {
  113. if (searchServer === undefined) {
  114. resolve()
  115. return
  116. }
  117. searchServer.close((error) => {
  118. if (error === undefined) resolve()
  119. else reject(error)
  120. })
  121. })
  122. })
  123. it('drives the recorded search to a settled turn (all modes)', async () => {
  124. onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive'))
  125. if (MODE !== 'record') {
  126. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  127. }
  128. const input = page.locator('textarea').first()
  129. await input.waitFor({ timeout: 10_000 })
  130. const settled = scaffold.whenTurnSettled()
  131. await input.fill(PROMPT)
  132. await input.press('Enter')
  133. const sessionId = await settled
  134. if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
  135. }, 200_000)
  136. it.skipIf(MODE === 'record')('uses the real provider and persists the structured result', () => {
  137. expect(searchRequests).toHaveLength(1)
  138. expect(searchRequests[0]).toMatchObject({
  139. path: '/messages',
  140. apiKey: SEARCH_CREDENTIAL,
  141. body: {
  142. messages: [{
  143. role: 'user',
  144. content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }],
  145. }],
  146. tools: [{ type: 'web_search_20250305', name: 'web_search' }],
  147. },
  148. })
  149. const auxiliaryRequest = sessionEvents.find(
  150. (event): event is Extract<SessionEvent, { type: 'web/deepseek-search-llm-request' }> =>
  151. event.type === 'web/deepseek-search-llm-request',
  152. )
  153. expect(auxiliaryRequest?.data).toEqual({
  154. endpoint: `${searchBaseURL}/messages`,
  155. apiVersion: '2023-06-01',
  156. body: searchRequests[0]?.body,
  157. })
  158. const searchCall = sessionEvents.find(
  159. (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
  160. event.type === 'tool/call' && event.data.name === 'web_search',
  161. )
  162. if (searchCall === undefined) throw new Error('the replayed turn did not call web_search')
  163. const searchResult = sessionEvents.find(
  164. (event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
  165. event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId,
  166. )
  167. if (searchResult === undefined) throw new Error('web_search produced no durable result')
  168. const content = searchResult.data.message.content[0]
  169. expect(content.isError).toBe(false)
  170. expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
  171. .toContain(`[Snapshot Search Result](${RESULT_URL})`)
  172. expect(searchResult.data.meta).toMatchObject({
  173. sources: [{
  174. url: RESULT_URL,
  175. title: 'Snapshot Search Result',
  176. snippet: 'Snapshot search excerpt.',
  177. publishedAt: '2026-07-31',
  178. }],
  179. truncated: false,
  180. })
  181. })
  182. it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => {
  183. onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
  184. await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
  185. .toBeGreaterThanOrEqual(1)
  186. await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 })
  187. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  188. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  189. })
  190. it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
  191. expect(tripwire.pageErrors).toEqual([])
  192. expect(tripwire.warnings).toEqual([])
  193. await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
  194. })
  195. })