web-search-round.e2e.ts 13 KB

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