web-search-round.e2e.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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 { WEB_SEARCH_MAX_RESULTS } from '@deepseek-ai/dsh-tool-web'
  15. import {
  16. assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  17. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  18. } from './scaffold.ts'
  19. import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
  20. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/web-search-round', import.meta.url))
  21. const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.v3.jsonl', import.meta.url))
  22. const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/web-search-round/ui.expected.md', import.meta.url))
  23. const MODE = webSnapshotMode()
  24. const QUERIES = ['DeepSeek Harness snapshot search', 'DeepSeek Harness multi-query search'] as const
  25. const PROMPT = `Use web_search once with queries ${JSON.stringify(QUERIES)}. Then reply exactly SEARCH_DONE and stop.`
  26. const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
  27. const SEARCH_CREDENTIAL = 'snapshot-search-key'
  28. /**
  29. * Provider results the double returns per query. The combined result exceeds
  30. * the shipped `searchMaxResults`, so the tool's round-robin cap and the card's
  31. * scroll container are both exercised. Each row carries a title, a snippet,
  32. * and a date, so 8 kept rows exceed the `.sources` 320px max-height.
  33. */
  34. const PROVIDER_RESULT_COUNT = 6
  35. /** One provider result's URL, by 1-based provider order. */
  36. function resultUrl(queryIndex: number, ordinal: number): string {
  37. return `https://docs.example.test/search/${queryIndex + 1}/${ordinal}`
  38. }
  39. /** One provider result's title, by 1-based provider order. */
  40. function resultTitle(queryIndex: number, ordinal: number): string {
  41. return `Snapshot Search ${queryIndex + 1} Result ${ordinal}`
  42. }
  43. /** One provider result's citation excerpt, by 1-based provider order. */
  44. function resultSnippet(queryIndex: number, ordinal: number): string {
  45. return `Snapshot search ${queryIndex + 1} excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
  46. }
  47. /** One provider result's `page_age`, by 1-based provider order (July 2026 days 01..12). */
  48. function resultPageAge(ordinal: number): string {
  49. return `2026-07-${String(ordinal).padStart(2, '0')}`
  50. }
  51. /** The 1-based provider ordinals, in provider order. */
  52. const RESULT_ORDINALS = Array.from({ length: PROVIDER_RESULT_COUNT }, (_value, index) => index + 1)
  53. /** Sources kept after round-robin merging reaches the shipped combined cap. */
  54. const KEPT_SOURCES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, queryIndex) => ({
  55. url: resultUrl(queryIndex, ordinal),
  56. title: resultTitle(queryIndex, ordinal),
  57. snippet: resultSnippet(queryIndex, ordinal),
  58. publishedAt: resultPageAge(ordinal),
  59. }))).slice(0, WEB_SEARCH_MAX_RESULTS)
  60. /** URLs omitted after the combined source cap is reached. */
  61. const DROPPED_SOURCE_URLS = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map(
  62. (_query, queryIndex) => resultUrl(queryIndex, ordinal),
  63. )).slice(WEB_SEARCH_MAX_RESULTS)
  64. interface CapturedSearchRequest {
  65. path: string
  66. apiKey: string | undefined
  67. body: unknown
  68. }
  69. /** Start the deterministic DeepSeek Messages double used by the real provider. */
  70. async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> {
  71. const server = createServer((request, response) => {
  72. let body = ''
  73. request.setEncoding('utf8')
  74. request.on('data', (chunk: string) => { body += chunk })
  75. request.on('end', () => {
  76. const parsedBody = JSON.parse(body) as unknown
  77. captured.push({
  78. path: request.url ?? '',
  79. apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
  80. body: parsedBody,
  81. })
  82. const serializedBody = JSON.stringify(parsedBody)
  83. const queryIndex = QUERIES.findIndex(query => serializedBody.includes(`Perform a web search for the query: ${query}`))
  84. if (queryIndex < 0) {
  85. response.writeHead(400, { 'content-type': 'application/json' })
  86. response.end(JSON.stringify({ error: 'unknown fixture query' }))
  87. return
  88. }
  89. response.writeHead(200, { 'content-type': 'application/json' })
  90. response.end(JSON.stringify({
  91. content: [
  92. {
  93. type: 'text',
  94. text: `Found ${PROVIDER_RESULT_COUNT} sources.`,
  95. citations: RESULT_ORDINALS.map(ordinal => ({
  96. type: 'web_search_result_location',
  97. url: resultUrl(queryIndex, ordinal),
  98. cited_text: resultSnippet(queryIndex, ordinal),
  99. })),
  100. },
  101. {
  102. type: 'web_search_tool_result',
  103. content: RESULT_ORDINALS.map(ordinal => ({
  104. type: 'web_search_result',
  105. url: resultUrl(queryIndex, ordinal),
  106. title: resultTitle(queryIndex, ordinal),
  107. page_age: resultPageAge(ordinal),
  108. })),
  109. },
  110. ],
  111. }))
  112. })
  113. })
  114. await new Promise<void>((resolve, reject) => {
  115. server.once('error', reject)
  116. server.listen(0, '127.0.0.1', () => {
  117. server.off('error', reject)
  118. resolve()
  119. })
  120. })
  121. const address = server.address() as AddressInfo
  122. return { server, baseURL: `http://127.0.0.1:${address.port}` }
  123. }
  124. describe('web e2e: shipped default web search', () => {
  125. let scaffold: WebScaffold
  126. let browser: Browser
  127. let page: Page
  128. let searchServer: Server | undefined
  129. let searchBaseURL: string
  130. let tripwire: ReturnType<typeof watchConsole>
  131. const searchRequests: CapturedSearchRequest[] = []
  132. const sessionEvents: SessionEvent[] = []
  133. beforeAll(async () => {
  134. const search = await startSearchServer(searchRequests)
  135. searchServer = search.server
  136. searchBaseURL = search.baseURL
  137. scaffold = await launchWebScaffold({
  138. compareReplaySession: true,
  139. deepSeekSearch: {
  140. baseURL: search.baseURL,
  141. apiKeyEnv: SEARCH_CREDENTIAL_REF,
  142. },
  143. ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
  144. })
  145. await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL)
  146. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  147. browser = await chromium.launch()
  148. page = await newEnglishPage(browser)
  149. tripwire = watchConsole(page)
  150. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  151. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  152. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  153. }, 120_000)
  154. afterAll(async () => {
  155. await browser?.close()
  156. await scaffold?.close()
  157. await new Promise<void>((resolve, reject) => {
  158. if (searchServer === undefined) {
  159. resolve()
  160. return
  161. }
  162. searchServer.close((error) => {
  163. if (error === undefined) resolve()
  164. else reject(error)
  165. })
  166. })
  167. })
  168. it('drives the recorded search to a settled turn (all modes)', async () => {
  169. onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive'))
  170. if (MODE !== 'record') {
  171. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  172. }
  173. const input = page.locator('[data-composer-input]').first()
  174. await input.waitFor({ timeout: 10_000 })
  175. const settled = scaffold.whenTurnSettled()
  176. await input.fill(PROMPT)
  177. await input.press('Enter')
  178. const sessionId = await settled
  179. if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
  180. }, 200_000)
  181. it.skipIf(MODE === 'record')('uses the real provider and persists the capped structured result', () => {
  182. expect(searchRequests).toHaveLength(QUERIES.length)
  183. for (const query of QUERIES) {
  184. const request = searchRequests.find(candidate => JSON.stringify(candidate.body).includes(query))
  185. if (request === undefined) throw new Error(`missing provider request for query: ${query}`)
  186. expect(request).toMatchObject({ path: '/messages', apiKey: SEARCH_CREDENTIAL })
  187. expect(request.body).toMatchObject({
  188. messages: [{
  189. role: 'user',
  190. content: [{ type: 'text', text: `Perform a web search for the query: ${query}` }],
  191. }],
  192. })
  193. const tools = (request.body as { tools?: unknown }).tools
  194. expect(tools).toHaveLength(1)
  195. expect((tools as unknown[])[0]).toMatchObject({ type: 'web_search_20250305', name: 'web_search' })
  196. }
  197. const auxiliaryRequests = sessionEvents.filter(
  198. (event): event is Extract<SessionEvent, { type: 'web/deepseek-search-llm-request' }> =>
  199. event.type === 'web/deepseek-search-llm-request',
  200. )
  201. expect(auxiliaryRequests).toHaveLength(QUERIES.length)
  202. for (const query of QUERIES) {
  203. const request = searchRequests.find(candidate => JSON.stringify(candidate.body).includes(query))
  204. const auxiliaryRequest = auxiliaryRequests.find(event => JSON.stringify(event.data.body).includes(query))
  205. if (request === undefined || auxiliaryRequest === undefined) {
  206. throw new Error(`missing paired provider request for query: ${query}`)
  207. }
  208. expect(auxiliaryRequest.data).toEqual({
  209. endpoint: `${searchBaseURL}/messages`,
  210. apiVersion: '2023-06-01',
  211. body: request.body,
  212. })
  213. }
  214. const searchCall = sessionEvents.find(
  215. (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
  216. event.type === 'tool/call' && event.data.name === 'web_search',
  217. )
  218. if (searchCall === undefined) throw new Error('the replayed turn did not call web_search')
  219. const searchResult = sessionEvents.find(
  220. (event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
  221. event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId,
  222. )
  223. if (searchResult === undefined) throw new Error('web_search produced no durable result')
  224. const content = searchResult.data.message.content[0]
  225. expect(content.isError).toBe(false)
  226. const rendered = content.content.filter(block => block.type === 'text').map(block => block.text).join('')
  227. // The tool interleaves sources from both seam results before applying the
  228. // combined cap, so each query remains represented in model-visible output.
  229. for (const source of KEPT_SOURCES) {
  230. expect(rendered).toContain(`[${source.title}](${source.url})`)
  231. }
  232. for (const url of DROPPED_SOURCE_URLS) {
  233. expect(rendered).not.toContain(url)
  234. }
  235. expect(rendered).toContain(
  236. `(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`,
  237. )
  238. expect(searchResult.data.meta).toMatchObject({
  239. sources: KEPT_SOURCES,
  240. truncated: true,
  241. })
  242. })
  243. it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => {
  244. onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
  245. await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
  246. .toBeGreaterThanOrEqual(1)
  247. const searchTool = page.locator('[data-tool="web_search"]')
  248. await expandOwningTurnProcess(page, searchTool)
  249. await searchTool.waitFor({ timeout: 10_000 })
  250. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  251. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  252. })
  253. it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => {
  254. onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll'))
  255. const row = page.locator('[data-tool="web_search"] [data-expandable]').first()
  256. await expandOwningTurnProcess(page, row)
  257. await row.click()
  258. await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
  259. const card = page.locator('[data-web="search"]')
  260. const sources = card.locator('ol')
  261. await sources.waitFor({ timeout: 10_000 })
  262. // The card draws exactly the sources the model saw after the combined cap.
  263. expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS)
  264. // The list is complete in the DOM, so the card carries no expand control.
  265. expect(await card.locator('button').count()).toBe(0)
  266. expect(await card.getByText('Source list truncated').isVisible()).toBe(true)
  267. const geometry = await sources.evaluate((element) => {
  268. const computed = getComputedStyle(element)
  269. return {
  270. maxHeight: computed.maxHeight,
  271. overflowY: computed.overflowY,
  272. scrollHeight: element.scrollHeight,
  273. clientHeight: element.clientHeight,
  274. }
  275. })
  276. expect(geometry.maxHeight).toBe('320px')
  277. expect(geometry.overflowY).toBe('auto')
  278. expect(geometry.scrollHeight).toBeGreaterThan(geometry.clientHeight)
  279. })
  280. it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => {
  281. onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room'))
  282. await expandOwningTurnProcess(page, page.locator('[data-tool="web_search"]'))
  283. // `overflow-y: auto` clips inline-start overflow with no way to scroll it
  284. // back, and markers are right-aligned to the content edge, so a marker wider
  285. // than `padding-left` silently loses its leading digits. `searchMaxResults`
  286. // is an unbounded positive integer, so measure the widest three-digit marker
  287. // in the list's own font and require the shipped padding to hold it.
  288. const marker = await page.locator('[data-web="search"] ol').evaluate((element) => {
  289. const probe = document.createElement('span')
  290. probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font:inherit'
  291. probe.textContent = '999. '
  292. element.append(probe)
  293. const widest = probe.getBoundingClientRect().width
  294. probe.remove()
  295. return { widest, paddingLeft: parseFloat(getComputedStyle(element).paddingLeft) }
  296. })
  297. expect(marker.paddingLeft).toBeGreaterThanOrEqual(marker.widest)
  298. })
  299. it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
  300. expect(tripwire.pageErrors).toEqual([])
  301. expect(tripwire.warnings).toEqual([])
  302. await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md'])
  303. })
  304. })