web-block.client.spec.tsx 10 KB

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