test(web): pin the source list's scroll geometry in the assembled browser

jsdom resolves no CSS Modules layout and reports scrollHeight === clientHeight
for every element, so the unit test cannot witness the scroll the .sources
max-height produces. Drive it in the real browser instead.

The web-search-round search double now returns 12 provider results with a
title, snippet, and date each. That pins the seam's cap end to end for the
first time in a real composition — searchMaxResults keeps 8, the model-visible
text carries the kept titles and none of the dropped URLs, meta.truncated is
true — and 8 such rows overflow the 320px container. A case after the aria
golden expands the row and asserts 8 <li>, no button, the truncation
indicator, and computed max-height/overflow-y with scrollHeight > clientHeight.

The recorded stream and the aria golden are unchanged: replay is a positional
cursor over the fixture's chunks and the search double is a separate local
endpoint, while the card is collapsed at capture time.
This commit is contained in:
Chinesezjc
2026-08-03 16:46:05 +08:00
parent 19f2c45bb0
commit a0b4b65ab8
4 changed files with 98 additions and 23 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
2026-08-03-web-search-source-scroll.md: 8ec9aee890015ecc1360b447a8bcee5585365e86
2026-08-03-web-search-source-scroll.zh.md: a9cf64d421e972ec80f1746489bac2eb15d0dc50
2026-08-03-web-search-source-scroll.md: fe98aca07fae9b6b3e612ae432f14cf5db462461
2026-08-03-web-search-source-scroll.zh.md: 38573647d59d659c8cdec53a42913848d268c6be

View File

@@ -36,6 +36,8 @@ Every source the tool returned is always in the DOM, so no source the view carri
`packages/client/ui-primitives/tests/web-block.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-conversation/tests/web-card.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. The `packages/web/tool-web` tests are unchanged — the model side did not move.
jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 12 provider results, each with a title, a citation snippet, and a date. That first pins the seam's cap end to end in a real composition — the shipped `searchMaxResults` keeps 8, the model-visible render text carries the 8 kept titles and none of the 4 dropped URLs plus `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over `scrollHeight` 574 against `clientHeight` 320. Neither the recorded stream nor the aria golden moved: replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`, while the card is collapsed at capture time so its `<ol>` is out of the DOM and the summary row carries no source count.
## Related
- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the capped-once list.

View File

@@ -36,6 +36,8 @@ Status: implemented
`packages/client/ui-primitives/tests/web-block.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一个 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-conversation/tests/web-card.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 的测试不变——模型侧未曾移动。
jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 返回 12 条 provider 结果,每条带标题、引用摘录与日期。这首先在真实组合里端到端钉住 seam 的裁剪——出厂 `searchMaxResults` 保留 8 条,面向模型的 render 文本含这 8 条标题、不含被丢弃的 4 条 URL并含 `(Showing the first 8 sources. Refine the query for more.)``meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言8 个 `<li>`、卡片内任何位置都没有 `<button>``来源列表已截断` 指示可见,以及计算样式 `max-height: 320px``overflow-y: auto``scrollHeight` 为 574、`clientHeight` 为 320。录制的模型流与 aria golden 都未变动replay 是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是 provider 经 `fetch` 抵达的另一个本地端点;捕获时卡片处于折叠状态,其 `<ol>` 不在 DOM 中,摘要行也不携带来源数量。
## Related
- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;那份裁剪过一次的列表的来源。

View File

@@ -26,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
@@ -51,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),
})),
},
],
}))
@@ -142,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',
@@ -178,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,
})
})
@@ -200,6 +241,36 @@ 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')('stayed clean and kept the exact fixture inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])