web-block.spec.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. // @vitest-environment jsdom
  2. // WebBlock: both kinds of the web card. The search card's answer, its citation
  3. // list with the title-or-hostname label fallback and optional snippet/date, the
  4. // full source list under one <ol>, and the truncated indicator; the fetch
  5. // card's linked URL, status, and truncation. Safe-link
  6. // attributes on both kinds: an http(s) URL becomes an external anchor
  7. // (target/rel), any other URL renders as plain text with no href.
  8. import { afterEach, describe, expect, it } from 'vitest'
  9. import { cleanup, render } from '@testing-library/react'
  10. import { WebBlock } from '../src/index.ts'
  11. import type { WebSourceView } from '../src/index.ts'
  12. afterEach(cleanup)
  13. /** `count` sources with sequential hostnames, so each row reads distinctly. */
  14. function sources(count: number): WebSourceView[] {
  15. return Array.from({ length: count }, (_value, index) => ({
  16. url: `https://site-${index}.example.com/page`,
  17. title: `Source ${index}`,
  18. }))
  19. }
  20. describe('WebBlock search card', () => {
  21. it('renders the answer above the citation list', () => {
  22. const view = render(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
  23. expect(view.getByText('Answer')).toBeTruthy()
  24. expect(view.getByText('Source 0')).toBeTruthy()
  25. expect(view.getByText('Source 1')).toBeTruthy()
  26. })
  27. it('omits the answer block when there is no answer', () => {
  28. const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
  29. expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
  30. const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
  31. expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull()
  32. })
  33. it('shows the empty-state note when a search returns no answer and no sources', () => {
  34. const view = render(<WebBlock kind="search" sources={[]} truncated={false} />)
  35. expect(view.getByText('未找到结果')).toBeTruthy()
  36. // The empty note replaces the source list, not an empty <ol>.
  37. expect(view.container.querySelector('ol')).toBeNull()
  38. })
  39. it('shows the source list, not the empty note, when a source is present', () => {
  40. const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
  41. expect(view.container.querySelector('ol')).toBeTruthy()
  42. expect(view.queryByText('未找到结果')).toBeNull()
  43. })
  44. it('shows the source list when an empty source list still carries an answer', () => {
  45. const view = render(<WebBlock kind="search" answer="Just an answer" sources={[]} truncated={false} />)
  46. expect(view.getByText('Just an answer')).toBeTruthy()
  47. expect(view.queryByText('未找到结果')).toBeNull()
  48. })
  49. it('labels a source by its title, and by hostname when the title is absent', () => {
  50. const view = render(<WebBlock kind="search" truncated={false} sources={[
  51. { url: 'https://example.com/a', title: 'Titled' },
  52. { url: 'https://plain.example.org/b' },
  53. { url: 'https://empty.example.net/c', title: '' },
  54. ]} />)
  55. expect(view.getByText('Titled')).toBeTruthy()
  56. // No title / empty title: the hostname labels the link.
  57. expect(view.getByText('plain.example.org')).toBeTruthy()
  58. expect(view.getByText('empty.example.net')).toBeTruthy()
  59. })
  60. it('labels a source by the raw url when it parses to an empty hostname', () => {
  61. // file:/data:/javascript: URLs parse but have no hostname; the label must
  62. // fall back to the raw URL so it is never blank (and the link stays plain
  63. // text since the protocol is not http(s)).
  64. const view = render(<WebBlock kind="search" truncated={false} sources={[
  65. { url: 'file:///etc/passwd' },
  66. ]} />)
  67. expect(view.getByText('file:///etc/passwd')).toBeTruthy()
  68. })
  69. it('renders a source as a safe external anchor for an http(s) url', () => {
  70. const view = render(<WebBlock kind="search" truncated={false} sources={[
  71. { url: 'https://example.com/a', title: 'Titled' },
  72. ]} />)
  73. const anchor = view.getByText('Titled') as HTMLAnchorElement
  74. expect(anchor.tagName).toBe('A')
  75. expect(anchor.getAttribute('href')).toBe('https://example.com/a')
  76. expect(anchor.getAttribute('target')).toBe('_blank')
  77. expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
  78. })
  79. it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
  80. const view = render(<WebBlock kind="search" truncated={false} sources={[
  81. { url: 'javascript:alert(1)', title: 'Dangerous' },
  82. { url: 'not a url' },
  83. ]} />)
  84. const unsafe = view.getByText('Dangerous')
  85. expect(unsafe.tagName).toBe('SPAN')
  86. expect(unsafe.getAttribute('href')).toBeNull()
  87. // An unparseable url is not a link and cannot yield a hostname, so its raw
  88. // text is the label.
  89. const raw = view.getByText('not a url')
  90. expect(raw.tagName).toBe('SPAN')
  91. })
  92. it('shows a source snippet and publication date when present, and omits them when absent or empty', () => {
  93. const view = render(<WebBlock kind="search" truncated={false} sources={[
  94. { url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
  95. { url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
  96. { url: 'https://c.example.com', title: 'C' },
  97. ]} />)
  98. expect(view.getByText('excerpt')).toBeTruthy()
  99. expect(view.getByText('2026-07-01')).toBeTruthy()
  100. // The empty-string and absent arms both draw nothing beyond the link.
  101. expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1)
  102. expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1)
  103. })
  104. it('shows the truncated indicator only when the list was capped by the tool', () => {
  105. const on = render(<WebBlock kind="search" sources={sources(1)} truncated />)
  106. expect(on.getByText('来源列表已截断')).toBeTruthy()
  107. cleanup()
  108. const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
  109. expect(off.queryByText('来源列表已截断')).toBeNull()
  110. })
  111. it('renders every source in one <ol> with no expand control', () => {
  112. // The card shows the whole list the tool returned, with no head/tail
  113. // collapse and no expand button. jsdom does not resolve the CSS Modules
  114. // layout, so the scroll geometry the `.sources` max-height produces is
  115. // pinned by the assembled browser case in apps/web/tests/web-search-round.e2e.ts,
  116. // not here.
  117. const view = render(<WebBlock kind="search" sources={sources(30)} truncated={false} />)
  118. expect(view.container.querySelectorAll('li[class^="_source_"]')).toHaveLength(30)
  119. expect(view.container.querySelector('[aria-expanded]')).toBeNull()
  120. expect(view.container.querySelector('button')).toBeNull()
  121. // Every direct child of the <ol> is a source <li> (no marker-less expander).
  122. const ol = view.container.querySelector('ol')!
  123. expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
  124. })
  125. it('numbers every source by its 1-based citation index via <li value>', () => {
  126. const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} />)
  127. const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
  128. expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '3', '4'])
  129. })
  130. })
  131. describe('WebBlock fetch card', () => {
  132. it('renders the fetched url as a safe external anchor and its HTTP status', () => {
  133. const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
  134. const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
  135. expect(anchor.tagName).toBe('A')
  136. expect(anchor.getAttribute('href')).toBe('https://example.com/page')
  137. expect(anchor.getAttribute('target')).toBe('_blank')
  138. expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
  139. expect(view.getByText('HTTP 200')).toBeTruthy()
  140. })
  141. it('renders a non-http fetch url as plain text with no href', () => {
  142. const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
  143. const label = view.getByText('file:///etc/passwd')
  144. expect(label.tagName).toBe('SPAN')
  145. expect(label.getAttribute('href')).toBeNull()
  146. })
  147. it('shows the truncated indicator only when the content was cut', () => {
  148. const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
  149. expect(on.getByText('内容已截断')).toBeTruthy()
  150. cleanup()
  151. const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
  152. expect(off.queryByText('内容已截断')).toBeNull()
  153. })
  154. it('carries a non-200 status verbatim', () => {
  155. const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
  156. expect(view.getByText('HTTP 404')).toBeTruthy()
  157. })
  158. })