// @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
    , 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() 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() expect(view.container.querySelector('[class^="_answer_"]')).toBeNull() const empty = render() 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() expect(view.getByText('未找到结果')).toBeTruthy() // The empty note replaces the source list, not an empty
      . expect(view.container.querySelector('ol')).toBeNull() }) it('shows the source list, not the empty note, when a source is present', () => { const view = render() 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() 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() 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() expect(view.getByText('file:///etc/passwd')).toBeTruthy() }) it('renders a source as a safe external anchor for an http(s) url', () => { const view = render() 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() 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() 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() expect(on.getByText('来源列表已截断')).toBeTruthy() cleanup() const off = render() expect(off.queryByText('来源列表已截断')).toBeNull() }) it('renders every source in one
        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() 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
          is a source
        1. (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
        2. ', () => { const view = render() 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() 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() 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() expect(on.getByText('内容已截断')).toBeTruthy() cleanup() const off = render() expect(off.queryByText('内容已截断')).toBeNull() }) it('carries a non-200 status verbatim', () => { const view = render() expect(view.getByText('HTTP 404')).toBeTruthy() }) })