Merge remote-tracking branch 'origin/master' into worktree/custom-deepseek-models

This commit is contained in:
Yichen Jiang
2026-08-04 14:01:28 +08:00
135 changed files with 3255 additions and 1430 deletions

View File

@@ -0,0 +1,53 @@
// Trusted non-loopback Web access must not wedge on the loopback-only
// settings API while the mandatory product notice owns the viewport.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('#root', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('advances process-locally and presents the notice again after reload', async () => {
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
await expect.poll(
() => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
{ timeout: 15_000 },
).toBe(false)
const reloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})

View File

@@ -89,7 +89,7 @@ const REPLAY_PROVIDERS = [{
export interface WebScaffold {
/** The active snapshot mode this scaffold booted under. */
mode: WebSnapshotMode
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
/** Browser-facing origin for the bound test server. */
baseUrl: string
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
ctx: Context
@@ -166,6 +166,12 @@ export interface LaunchOptions {
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
/**
* Browse through a trusted non-loopback hostname that the browser resolves
* to loopback (for example `*.localhost`). The test server stays bound to
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
*/
remoteAuthority?: string
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -185,6 +191,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste
export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
requireDist()
const mode = webSnapshotMode()
const browserHost = options.remoteAuthority ?? '127.0.0.1'
if (mode === 'record') {
// Both owning vitest configs (web unconditionally, snapshot in record
// mode) load the repo-root .env before this file runs.
@@ -261,7 +268,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
{
id: 'webserver',
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
},
...options.remoteAuthority === undefined
? []
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
{ id: 'settings', config: { dshHome: harnessHome } },
{ id: 'credentials', config: { dshHome: harnessHome } },
// The shipped directory-picker row is the -auto chooser, which resolves
@@ -352,7 +365,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
return {
harnessHome,
mode,
baseUrl: `http://127.0.0.1:${port}`,
baseUrl: `http://${browserHost}:${port}`,
ctx,
workspaceCwd,
persistenceRoot,

View File

@@ -47,10 +47,10 @@ const EXPECTED_TOOLS = [
]
/**
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
* `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED
* ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair
* is always present on every host — asserted as fixed members, not a host
* dependency.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
@@ -65,7 +65,9 @@ it('assembles the shipped Web catalog with the confined access default', async (
scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name)))
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and

View File

@@ -11,6 +11,7 @@ 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,
@@ -25,7 +26,37 @@ 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'
const RESULT_URL = 'https://docs.example.test/search'
/**
* 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
@@ -50,21 +81,21 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
content: [
{
type: 'text',
text: 'Found one source.',
citations: [{
text: `Found ${PROVIDER_RESULT_COUNT} sources.`,
citations: RESULT_ORDINALS.map(ordinal => ({
type: 'web_search_result_location',
url: RESULT_URL,
cited_text: 'Snapshot search excerpt.',
}],
url: resultUrl(ordinal),
cited_text: resultSnippet(ordinal),
})),
},
{
type: 'web_search_tool_result',
content: [{
content: RESULT_ORDINALS.map(ordinal => ({
type: 'web_search_result',
url: RESULT_URL,
title: 'Snapshot Search Result',
page_age: '2026-07-31',
}],
url: resultUrl(ordinal),
title: resultTitle(ordinal),
page_age: resultPageAge(ordinal),
})),
},
],
}))
@@ -141,7 +172,7 @@ describe('web e2e: shipped default web search', () => {
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
}, 200_000)
it.skipIf(MODE === 'record')('uses the real provider and persists the structured result', () => {
it.skipIf(MODE === 'record')('uses the real provider and persists the capped structured result', () => {
expect(searchRequests).toHaveLength(1)
expect(searchRequests[0]).toMatchObject({
path: '/messages',
@@ -177,16 +208,27 @@ describe('web e2e: shipped default web search', () => {
if (searchResult === undefined) throw new Error('web_search produced no durable result')
const content = searchResult.data.message.content[0]
expect(content.isError).toBe(false)
expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toContain(`[Snapshot Search Result](${RESULT_URL})`)
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: [{
url: RESULT_URL,
title: 'Snapshot Search Result',
snippet: 'Snapshot search excerpt.',
publishedAt: '2026-07-31',
}],
truncated: false,
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,
})
})
@@ -199,6 +241,55 @@ describe('web e2e: shipped default web search', () => {
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([])

View File

@@ -36,6 +36,7 @@
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/remote-welcome.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/hmr-live.e2e.ts",