// @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() 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 and no expand control under the cap', () => { const view = render() 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() // 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('numbers a collapsed tail by each source original position, not its visible slot', () => { // maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read // as citations 9 and 10 (via
  1. ), not renumbered 3 and 4. const view = render() const items = [...view.container.querySelectorAll('li[class^="_source_"]')] expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10']) }) it('keeps the expander out of the ordered-list numbering', () => { // The expander is a marker-less
  2. , so it is valid inside
      and does not // consume a citation number between the head and tail sources. const view = render() const ol = view.container.querySelector('ol')! // Every direct child is an
    1. (no bare