// Web e2e scenario for the shipped default search composition. A real browser // drives `web_search`; the model stream is replayed while the real DeepSeek // provider calls a deterministic local Anthropic-compatible endpoint through // the real credentials service. import { readFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { WEB_SEARCH_MAX_RESULTS } from '@deepseek-ai/dsh-tool-web' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() const QUERY = 'DeepSeek Harness snapshot search' const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.` const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY') const SEARCH_CREDENTIAL = 'snapshot-search-key' /** * Provider results the double returns, exceeding the shipped `searchMaxResults` * so the seam's cap and the card's scroll container are both exercised. Each row * carries a title, a snippet, and a date, so 8 kept rows exceed the `.sources` * 320px max-height. */ const PROVIDER_RESULT_COUNT = 12 /** One provider result's URL, by 1-based provider order. */ function resultUrl(ordinal: number): string { return `https://docs.example.test/search/${ordinal}` } /** One provider result's title, by 1-based provider order. */ function resultTitle(ordinal: number): string { return `Snapshot Search Result ${ordinal}` } /** One provider result's citation excerpt, by 1-based provider order. */ function resultSnippet(ordinal: number): string { return `Snapshot search excerpt ${ordinal}: the harness replays this source list from a local endpoint.` } /** One provider result's `page_age`, by 1-based provider order (July 2026 days 01..12). */ function resultPageAge(ordinal: number): string { return `2026-07-${String(ordinal).padStart(2, '0')}` } /** The 1-based provider ordinals, in provider order. */ const RESULT_ORDINALS = Array.from({ length: PROVIDER_RESULT_COUNT }, (_value, index) => index + 1) interface CapturedSearchRequest { path: string apiKey: string | undefined body: unknown } /** Start the deterministic DeepSeek Messages double used by the real provider. */ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> { const server = createServer((request, response) => { let body = '' request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { captured.push({ path: request.url ?? '', apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined, body: JSON.parse(body) as unknown, }) response.writeHead(200, { 'content-type': 'application/json' }) response.end(JSON.stringify({ content: [ { type: 'text', text: `Found ${PROVIDER_RESULT_COUNT} sources.`, citations: RESULT_ORDINALS.map(ordinal => ({ type: 'web_search_result_location', url: resultUrl(ordinal), cited_text: resultSnippet(ordinal), })), }, { type: 'web_search_tool_result', content: RESULT_ORDINALS.map(ordinal => ({ type: 'web_search_result', url: resultUrl(ordinal), title: resultTitle(ordinal), page_age: resultPageAge(ordinal), })), }, ], })) }) }) await new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '127.0.0.1', () => { server.off('error', reject) resolve() }) }) const address = server.address() as AddressInfo return { server, baseURL: `http://127.0.0.1:${address.port}` } } describe('web e2e: shipped default web search', () => { let scaffold: WebScaffold let browser: Browser let page: Page let searchServer: Server | undefined let searchBaseURL: string let tripwire: ReturnType const searchRequests: CapturedSearchRequest[] = [] const sessionEvents: SessionEvent[] = [] beforeAll(async () => { const search = await startSearchServer(searchRequests) searchServer = search.server searchBaseURL = search.baseURL scaffold = await launchWebScaffold({ deepSeekSearch: { baseURL: search.baseURL, apiKeyEnv: SEARCH_CREDENTIAL_REF, }, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { await browser?.close() await scaffold?.close() await new Promise((resolve, reject) => { if (searchServer === undefined) { resolve() return } searchServer.close((error) => { if (error === undefined) resolve() else reject(error) }) }) }) it('drives the recorded search to a settled turn (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive')) if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) await input.press('Enter') const sessionId = await settled if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) }, 200_000) it.skipIf(MODE === 'record')('uses the real provider and persists the capped structured result', () => { expect(searchRequests).toHaveLength(1) expect(searchRequests[0]).toMatchObject({ path: '/messages', apiKey: SEARCH_CREDENTIAL, body: { messages: [{ role: 'user', content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }], }], tools: [{ type: 'web_search_20250305', name: 'web_search' }], }, }) const auxiliaryRequest = sessionEvents.find( (event): event is Extract => event.type === 'web/deepseek-search-llm-request', ) expect(auxiliaryRequest?.data).toEqual({ endpoint: `${searchBaseURL}/messages`, apiVersion: '2023-06-01', body: searchRequests[0]?.body, }) const searchCall = sessionEvents.find( (event): event is Extract => event.type === 'tool/call' && event.data.name === 'web_search', ) if (searchCall === undefined) throw new Error('the replayed turn did not call web_search') const searchResult = sessionEvents.find( (event): event is Extract => event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId, ) if (searchResult === undefined) throw new Error('web_search produced no durable result') const content = searchResult.data.message.content[0] expect(content.isError).toBe(false) const rendered = content.content.filter(block => block.type === 'text').map(block => block.text).join('') // The seam caps the provider's list at the shipped searchMaxResults before // the tool renders it, so the kept prefix is model-visible and the dropped // suffix is not. for (const ordinal of RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS)) { expect(rendered).toContain(`[${resultTitle(ordinal)}](${resultUrl(ordinal)})`) } for (const ordinal of RESULT_ORDINALS.slice(WEB_SEARCH_MAX_RESULTS)) { expect(rendered).not.toContain(resultUrl(ordinal)) } expect(rendered).toContain( `(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`, ) expect(searchResult.data.meta).toMatchObject({ sources: RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS).map(ordinal => ({ url: resultUrl(ordinal), title: resultTitle(ordinal), snippet: resultSnippet(ordinal), publishedAt: resultPageAge(ordinal), })), truncated: true, }) }) it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria')) await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 }) .toBeGreaterThanOrEqual(1) await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll')) const row = page.locator('[data-tool="web_search"] [data-expandable]').first() await row.click() await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') const card = page.locator('[data-web="search"]') const sources = card.locator('ol') await sources.waitFor({ timeout: 10_000 }) // The card draws exactly the sources the model saw: the seam's cap, not the // provider's list length. expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS) // The list is complete in the DOM, so the card carries no expand control. expect(await card.locator('button').count()).toBe(0) expect(await card.getByText('来源列表已截断').isVisible()).toBe(true) const geometry = await sources.evaluate((element) => { const computed = getComputedStyle(element) return { maxHeight: computed.maxHeight, overflowY: computed.overflowY, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, } }) expect(geometry.maxHeight).toBe('320px') expect(geometry.overflowY).toBe('auto') expect(geometry.scrollHeight).toBeGreaterThan(geometry.clientHeight) }) it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room')) // `overflow-y: auto` clips inline-start overflow with no way to scroll it // back, and markers are right-aligned to the content edge, so a marker wider // than `padding-left` silently loses its leading digits. `searchMaxResults` // is an unbounded positive integer, so measure the widest three-digit marker // in the list's own font and require the shipped padding to hold it. const marker = await page.locator('[data-web="search"] ol').evaluate((element) => { const probe = document.createElement('span') probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font:inherit' probe.textContent = '999. ' element.append(probe) const widest = probe.getBoundingClientRect().width probe.remove() return { widest, paddingLeft: parseFloat(getComputedStyle(element).paddingLeft) } }) expect(marker.paddingLeft).toBeGreaterThanOrEqual(marker.widest) }) it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) }) })