The READMEs of ui-primitives and ui-conversation still documented the removed maxSources prop, DEFAULT_WEB_MAX_SOURCES, CHAT_WEB_MAX_SOURCES, and the head/tail collapse; the 2026-07-30 frontend note described the same as current behavior. Restate all of them as the fixed-height scroll container and cross-link the two notes both ways. Bound the model-visible/card-visible correspondence: spill-policy's tools/post-execute replaces an oversized result's content while leaving presentationMeta whole, so the card's contract is the view it receives, not the model's context. Narrow the unit test's name and header comment to what jsdom establishes (one <ol>, no expand control); jsdom does not resolve CSS Modules layout, so the scroll geometry belongs to an assembled browser case.
179 lines
8.8 KiB
TypeScript
179 lines
8.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
// WebBlock: both kinds of the web card. The search card's answer, its citation
|
|
// list with the title-or-hostname label fallback and optional snippet/date, the
|
|
// full source list under one <ol>, and the truncated indicator; the fetch
|
|
// card's linked URL, status, and truncation. Safe-link
|
|
// attributes on both kinds: an http(s) URL becomes an external anchor
|
|
// (target/rel), any other URL renders as plain text with no href.
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { cleanup, render } from '@testing-library/react'
|
|
import { WebBlock } from '../src/index.ts'
|
|
import type { WebSourceView } from '../src/index.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
/** `count` sources with sequential hostnames, so each row reads distinctly. */
|
|
function sources(count: number): WebSourceView[] {
|
|
return Array.from({ length: count }, (_value, index) => ({
|
|
url: `https://site-${index}.example.com/page`,
|
|
title: `Source ${index}`,
|
|
}))
|
|
}
|
|
|
|
describe('WebBlock search card', () => {
|
|
it('renders the answer above the citation list', () => {
|
|
const view = render(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
|
|
expect(view.getByText('Answer')).toBeTruthy()
|
|
expect(view.getByText('Source 0')).toBeTruthy()
|
|
expect(view.getByText('Source 1')).toBeTruthy()
|
|
})
|
|
|
|
it('omits the answer block when there is no answer', () => {
|
|
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
|
|
expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
|
|
const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
|
|
expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull()
|
|
})
|
|
|
|
it('shows the empty-state note when a search returns no answer and no sources', () => {
|
|
const view = render(<WebBlock kind="search" sources={[]} truncated={false} />)
|
|
expect(view.getByText('未找到结果')).toBeTruthy()
|
|
// The empty note replaces the source list, not an empty <ol>.
|
|
expect(view.container.querySelector('ol')).toBeNull()
|
|
})
|
|
|
|
it('shows the source list, not the empty note, when a source is present', () => {
|
|
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
|
|
expect(view.container.querySelector('ol')).toBeTruthy()
|
|
expect(view.queryByText('未找到结果')).toBeNull()
|
|
})
|
|
|
|
it('shows the source list when an empty source list still carries an answer', () => {
|
|
const view = render(<WebBlock kind="search" answer="Just an answer" sources={[]} truncated={false} />)
|
|
expect(view.getByText('Just an answer')).toBeTruthy()
|
|
expect(view.queryByText('未找到结果')).toBeNull()
|
|
})
|
|
|
|
it('labels a source by its title, and by hostname when the title is absent', () => {
|
|
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
|
{ url: 'https://example.com/a', title: 'Titled' },
|
|
{ url: 'https://plain.example.org/b' },
|
|
{ url: 'https://empty.example.net/c', title: '' },
|
|
]} />)
|
|
expect(view.getByText('Titled')).toBeTruthy()
|
|
// No title / empty title: the hostname labels the link.
|
|
expect(view.getByText('plain.example.org')).toBeTruthy()
|
|
expect(view.getByText('empty.example.net')).toBeTruthy()
|
|
})
|
|
|
|
it('labels a source by the raw url when it parses to an empty hostname', () => {
|
|
// file:/data:/javascript: URLs parse but have no hostname; the label must
|
|
// fall back to the raw URL so it is never blank (and the link stays plain
|
|
// text since the protocol is not http(s)).
|
|
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
|
{ url: 'file:///etc/passwd' },
|
|
]} />)
|
|
expect(view.getByText('file:///etc/passwd')).toBeTruthy()
|
|
})
|
|
|
|
it('renders a source as a safe external anchor for an http(s) url', () => {
|
|
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
|
{ url: 'https://example.com/a', title: 'Titled' },
|
|
]} />)
|
|
const anchor = view.getByText('Titled') as HTMLAnchorElement
|
|
expect(anchor.tagName).toBe('A')
|
|
expect(anchor.getAttribute('href')).toBe('https://example.com/a')
|
|
expect(anchor.getAttribute('target')).toBe('_blank')
|
|
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
|
|
})
|
|
|
|
it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
|
|
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
|
{ url: 'javascript:alert(1)', title: 'Dangerous' },
|
|
{ url: 'not a url' },
|
|
]} />)
|
|
const unsafe = view.getByText('Dangerous')
|
|
expect(unsafe.tagName).toBe('SPAN')
|
|
expect(unsafe.getAttribute('href')).toBeNull()
|
|
// An unparseable url is not a link and cannot yield a hostname, so its raw
|
|
// text is the label.
|
|
const raw = view.getByText('not a url')
|
|
expect(raw.tagName).toBe('SPAN')
|
|
})
|
|
|
|
it('shows a source snippet and publication date when present, and omits them when absent or empty', () => {
|
|
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
|
{ url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
|
{ url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
|
|
{ url: 'https://c.example.com', title: 'C' },
|
|
]} />)
|
|
expect(view.getByText('excerpt')).toBeTruthy()
|
|
expect(view.getByText('2026-07-01')).toBeTruthy()
|
|
// The empty-string and absent arms both draw nothing beyond the link.
|
|
expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1)
|
|
expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1)
|
|
})
|
|
|
|
it('shows the truncated indicator only when the list was capped by the tool', () => {
|
|
const on = render(<WebBlock kind="search" sources={sources(1)} truncated />)
|
|
expect(on.getByText('来源列表已截断')).toBeTruthy()
|
|
cleanup()
|
|
const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
|
|
expect(off.queryByText('来源列表已截断')).toBeNull()
|
|
})
|
|
|
|
it('renders every source in one <ol> with no expand control', () => {
|
|
// The card shows the whole list the tool returned, with no head/tail
|
|
// collapse and no expand button. jsdom does not resolve the CSS Modules
|
|
// layout, so the scroll geometry the `.sources` max-height produces is
|
|
// pinned by the assembled browser case in apps/web/tests/web-search-round.e2e.ts,
|
|
// not here.
|
|
const view = render(<WebBlock kind="search" sources={sources(30)} truncated={false} />)
|
|
expect(view.container.querySelectorAll('li[class^="_source_"]')).toHaveLength(30)
|
|
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
|
expect(view.container.querySelector('button')).toBeNull()
|
|
// Every direct child of the <ol> is a source <li> (no marker-less expander).
|
|
const ol = view.container.querySelector('ol')!
|
|
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
|
|
})
|
|
|
|
it('numbers every source by its 1-based citation index via <li value>', () => {
|
|
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} />)
|
|
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
|
|
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '3', '4'])
|
|
})
|
|
})
|
|
|
|
describe('WebBlock fetch card', () => {
|
|
it('renders the fetched url as a safe external anchor and its HTTP status', () => {
|
|
const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
|
|
const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
|
|
expect(anchor.tagName).toBe('A')
|
|
expect(anchor.getAttribute('href')).toBe('https://example.com/page')
|
|
expect(anchor.getAttribute('target')).toBe('_blank')
|
|
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
|
|
expect(view.getByText('HTTP 200')).toBeTruthy()
|
|
})
|
|
|
|
it('renders a non-http fetch url as plain text with no href', () => {
|
|
const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
|
|
const label = view.getByText('file:///etc/passwd')
|
|
expect(label.tagName).toBe('SPAN')
|
|
expect(label.getAttribute('href')).toBeNull()
|
|
})
|
|
|
|
it('shows the truncated indicator only when the content was cut', () => {
|
|
const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
|
|
expect(on.getByText('内容已截断')).toBeTruthy()
|
|
cleanup()
|
|
const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
|
|
expect(off.queryByText('内容已截断')).toBeNull()
|
|
})
|
|
|
|
it('carries a non-200 status verbatim', () => {
|
|
const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
|
|
expect(view.getByText('HTTP 404')).toBeTruthy()
|
|
})
|
|
})
|