feat(web): render web_search/web_fetch output as a web card
Consume the card:'web' result view (structured sources + answer for search, the URL and HTTP status for fetch) the web backend PR added. WebBlock (ui-primitives) draws both kinds via the kind discriminant: a citation list of safe external links (the MarkdownText protocol allowlist, title-or-hostname label), a truncation indicator, a height cap matching TerminalBlock; a fetch summary for the other kind. web-card-model is the single resultView derivation; a keyed WebRow registers under web_search and web_fetch with the card resident under its summary. The generic fallback and the details panel are web-aware. Fixture gains web_search and web_fetch turns for the built-boot snapshot.
This commit is contained in:
124
packages/client/ui-primitives/src/WebBlock.module.css
Normal file
124
packages/client/ui-primitives/src/WebBlock.module.css
Normal file
@@ -0,0 +1,124 @@
|
||||
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface,
|
||||
16px vertical margin) so a web card, a terminal card, and a fenced code block
|
||||
read as one family. A source list is prose, not aligned output, so it wraps
|
||||
normally rather than scrolling horizontally like a terminal card's output. */
|
||||
|
||||
.block {
|
||||
--dsl-web-radius: 12px;
|
||||
|
||||
margin: 16px 0;
|
||||
padding: 12px 14px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-web-radius);
|
||||
}
|
||||
|
||||
/* The provider answer reads as body prose above the citation list; its own
|
||||
MarkdownText margins are trimmed so the list sits tight under it. */
|
||||
.answer {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.answer > :global(div) > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.answer > :global(div) > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* The citation list: ordered so each source reads as a numbered reference. */
|
||||
.sources {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sourceLink {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sourceLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.snippet {
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.published {
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.truncated {
|
||||
margin-top: 8px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The fetch card is a compact summary: the URL over a status/truncation row. */
|
||||
.fetch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fetchUrl {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fetchUrl:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fetchMeta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The fetch card's truncation note sits inline beside the status, so it drops
|
||||
the search card's top margin. */
|
||||
.fetch .truncated {
|
||||
margin-top: 0;
|
||||
}
|
||||
209
packages/client/ui-primitives/src/WebBlock.tsx
Normal file
209
packages/client/ui-primitives/src/WebBlock.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
// WebBlock: the surface for a completed web retrieval. One component draws both
|
||||
// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an
|
||||
// optional provider answer above a citation list of sources (each a safe
|
||||
// external link labelled by its title, or its hostname when the provider gave
|
||||
// none, with the snippet and publication date below it), and a `fetch` shows a
|
||||
// compact retrieval summary (the linked final URL and its HTTP status). Both
|
||||
// mark a capped retrieval. Every link is a same-origin-safe external anchor:
|
||||
// only http(s) URLs become anchors (target/rel set), the same protocol allowlist
|
||||
// MarkdownText applies to untrusted assistant-authored links; an unparseable or
|
||||
// non-http URL renders as plain text. Geometry, radius, and fonts mirror
|
||||
// CodeBlock/TerminalBlock so a web card reads as one family with them; a long
|
||||
// source list caps at maxSources with a head/tail collapse using the same
|
||||
// arithmetic as TerminalBlock's output cap.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
import css from './WebBlock.module.css'
|
||||
|
||||
/**
|
||||
* Sources shown before the height cap collapses the middle of a citation list.
|
||||
* Matches TerminalBlock's default output budget so both cards cut a long body
|
||||
* at the same place; the chat row narrows it through the maxSources prop.
|
||||
*/
|
||||
export const DEFAULT_WEB_MAX_SOURCES = 16
|
||||
|
||||
/**
|
||||
* One citeable source drawn in a search card: the projection of the contract's
|
||||
* `WebSource`, with the optional fields kept optional so a provider that
|
||||
* returned only a URL still renders (its hostname becomes the label).
|
||||
*/
|
||||
export interface WebSourceView {
|
||||
/** The source URL; becomes a safe external link when it is http(s). */
|
||||
url: string
|
||||
/** The source title; when absent the URL's hostname labels the link. */
|
||||
title?: string | undefined
|
||||
/** A short excerpt or summary shown under the link. */
|
||||
snippet?: string | undefined
|
||||
/** Publication/crawl timestamp, a provider-supplied string shown under the link. */
|
||||
publishedAt?: string | undefined
|
||||
}
|
||||
|
||||
/** A `web_search` card: an optional answer over a capped citation list. */
|
||||
export interface WebSearchBlockProps {
|
||||
kind: 'search'
|
||||
/** The provider-generated answer, rendered as markdown above the sources. */
|
||||
answer?: string | undefined
|
||||
/** The cited sources, in provider order. */
|
||||
sources: WebSourceView[]
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
truncated: boolean
|
||||
/** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */
|
||||
maxSources?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** A `web_fetch` card: the retrieval summary for one fetched URL. */
|
||||
export interface WebFetchBlockProps {
|
||||
kind: 'fetch'
|
||||
/** The final URL after allowed redirects; becomes a safe external link when http(s). */
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/** True when the provider or the output cap cut the fetched content. */
|
||||
truncated: boolean
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** A completed web retrieval card, discriminated by `kind`. */
|
||||
export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
|
||||
|
||||
/**
|
||||
* The URL to link to, or undefined when the URL must render as plain text. The
|
||||
* allowlist is MarkdownText's own for untrusted links: only http(s) becomes a
|
||||
* navigable external anchor, so a `javascript:`/`data:`/`file:` URL or an
|
||||
* unparseable string never reaches the DOM as an href.
|
||||
* @param url - the source or fetch URL, from tool result content.
|
||||
* @returns the href to use, or undefined for plain text.
|
||||
*/
|
||||
function safeHref(url: string): string | undefined {
|
||||
try {
|
||||
const { protocol } = new URL(url)
|
||||
return protocol === 'http:' || protocol === 'https:' ? url : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The link's visible label: the title when the provider gave one, otherwise the
|
||||
* URL's hostname, falling back to the raw URL when it does not parse.
|
||||
* @param url - the source URL.
|
||||
* @param title - the provider title, if any.
|
||||
* @returns the label text.
|
||||
*/
|
||||
function linkLabel(url: string, title: string | undefined): string {
|
||||
if (title !== undefined && title !== '') return title
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single URL rendered as a safe external anchor, or as plain text when the
|
||||
* URL is not an http(s) link.
|
||||
* @param props.url - the URL to render.
|
||||
* @param props.label - the visible label.
|
||||
* @param props.className - class for the anchor or the plain span.
|
||||
* @returns the anchor or span element.
|
||||
*/
|
||||
function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) {
|
||||
const href = safeHref(url)
|
||||
if (href === undefined) return <span className={className}>{label}</span>
|
||||
return (
|
||||
<a className={className} href={href} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One source row in a search card: the safe link plus its snippet and date.
|
||||
* @param props.source - the source to render.
|
||||
* @returns the source list item.
|
||||
*/
|
||||
function SourceItem({ source }: { source: WebSourceView }) {
|
||||
return (
|
||||
<li className={css.source}>
|
||||
<SafeLink url={source.url} label={linkLabel(source.url, source.title)} className={css.sourceLink} />
|
||||
{source.snippet !== undefined && source.snippet !== '' && (
|
||||
<div className={css.snippet}>{source.snippet}</div>
|
||||
)}
|
||||
{source.publishedAt !== undefined && source.publishedAt !== '' && (
|
||||
<div className={css.published}>{source.publishedAt}</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The search card body: the answer over the capped source list.
|
||||
* @param props - see {@link WebSearchBlockProps}.
|
||||
* @returns the search card element.
|
||||
*/
|
||||
function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
const hidden = sources.length - maxSources
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock's output cap, so a long body's head
|
||||
// and tail slices agree between the two cards.
|
||||
const headCount = Math.ceil(maxSources / 2)
|
||||
const tailCount = maxSources - headCount
|
||||
const head = capped ? sources.slice(0, headCount) : sources
|
||||
const tail = capped ? sources.slice(sources.length - tailCount) : []
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-web="search">
|
||||
{answer !== undefined && answer !== '' && (
|
||||
<div className={css.answer}><MarkdownText text={answer} /></div>
|
||||
)}
|
||||
<ol className={css.sources}>
|
||||
{head.map((source, index) => <SourceItem key={index} source={source} />)}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 条来源`}
|
||||
</button>
|
||||
)}
|
||||
{tail.map((source, index) => <SourceItem key={sources.length - tailCount + index} source={source} />)}
|
||||
</ol>
|
||||
{truncated && <div className={css.truncated}>来源列表已截断</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fetch card body: the linked URL and its HTTP status.
|
||||
* @param props - see {@link WebFetchBlockProps}.
|
||||
* @returns the fetch card element.
|
||||
*/
|
||||
function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) {
|
||||
return (
|
||||
<div className={clsx(css.block, css.fetch, className)} data-web="fetch">
|
||||
<SafeLink url={url} label={url} className={css.fetchUrl} />
|
||||
<div className={css.fetchMeta}>
|
||||
<span className={css.status}>HTTP {statusCode}</span>
|
||||
{truncated && <span className={css.truncated}>内容已截断</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a completed web retrieval as a structured card.
|
||||
* @param props - see {@link WebBlockProps}; `kind` selects the search or fetch body.
|
||||
* @returns the web card element.
|
||||
*/
|
||||
export function WebBlock(props: WebBlockProps) {
|
||||
return props.kind === 'search' ? <WebSearchBlock {...props} /> : <WebFetchBlock {...props} />
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps } from './TerminalBlock.tsx'
|
||||
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
|
||||
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
|
||||
165
packages/client/ui-primitives/tests/web-block.spec.tsx
Normal file
165
packages/client/ui-primitives/tests/web-block.spec.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
// @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
|
||||
// source-list height cap and its expand control, 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, fireEvent, render } from '@testing-library/react'
|
||||
import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
|
||||
import type { WebSourceView } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** `count` sources with sequential hostnames, so the cap slices read 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('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('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 and no expand control under the cap', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
})
|
||||
|
||||
it('slices head and tail over the cap and expands on click', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
|
||||
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
|
||||
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
|
||||
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.textContent).toBe('… 其余 6 条来源')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
|
||||
const collapse = view.getByRole('button', { name: '收起来源' })
|
||||
expect(collapse.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(collapse.textContent).toBe('收起')
|
||||
|
||||
fireEvent.click(collapse)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('renders the head slice alone when the cap leaves no tail', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
|
||||
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
|
||||
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps at the documented default when maxSources is absent', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
|
||||
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user