code-block.client.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. // @vitest-environment jsdom
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  4. import type { ComponentProps } from 'react'
  5. import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx'
  6. import { highlightToHtml, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
  7. import { markdownLabels } from './labels.client.ts'
  8. function CodeBlock(props: Omit<ComponentProps<typeof LocalizedCodeBlock>, 'copyLabel' | 'copiedLabel' | 'sourceLabel' | 'lineNumbersLabel'>) {
  9. return <LocalizedCodeBlock {...props} {...markdownLabels.code} />
  10. }
  11. const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
  12. const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand')
  13. afterEach(() => {
  14. cleanup()
  15. vi.useRealTimers()
  16. vi.restoreAllMocks()
  17. if (clipboardDescriptor === undefined) Reflect.deleteProperty(navigator, 'clipboard')
  18. else Object.defineProperty(navigator, 'clipboard', clipboardDescriptor)
  19. if (execCommandDescriptor === undefined) Reflect.deleteProperty(document, 'execCommand')
  20. else Object.defineProperty(document, 'execCommand', execCommandDescriptor)
  21. })
  22. beforeEach(() => {
  23. vi.useRealTimers()
  24. })
  25. describe('highlightToHtml', () => {
  26. it('highlights a registered grammar into css-variables token spans', () => {
  27. const html = highlightToHtml('const x: number = 1', 'typescript')
  28. expect(html).toContain('pre class="shiki css-variables"')
  29. expect(html).toContain('var(--shiki-')
  30. })
  31. it.each([['ts'], ['js'], ['bash'], ['sh'], ['jsonc']])('resolves the %s alias', (alias) => {
  32. expect(highlightToHtml('x', alias)).toContain('shiki')
  33. })
  34. it('returns undefined for unknown or absent languages', () => {
  35. expect(highlightToHtml('x', 'cobol')).toBeUndefined()
  36. expect(highlightToHtml('x', undefined)).toBeUndefined()
  37. })
  38. // Every read-tool language hint whose grammar loads lazily (the boot set —
  39. // ts/js/shell/sh/json — is covered above). Touching each one drives its own
  40. // dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
  41. const LAZY_ALIASES = [
  42. 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
  43. 'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
  44. 'xml', 'lua',
  45. ]
  46. it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
  47. const registered = Promise.withResolvers<undefined>()
  48. // Registration notifications, not a private polling deadline, establish readiness.
  49. const stop = subscribeGrammarLoaded(() => {
  50. if (LAZY_ALIASES.every(alias => highlightToHtml('x', alias) !== undefined)) registered.resolve(undefined)
  51. })
  52. try {
  53. for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias), alias).toBeUndefined()
  54. await registered.promise
  55. for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias), alias).toContain('shiki')
  56. } finally {
  57. stop()
  58. }
  59. })
  60. })
  61. describe('CodeBlock', () => {
  62. it('omits its header when the owner supplies toolbar controls', () => {
  63. const view = render(<CodeBlock code="plain text" showHeader={false} />)
  64. expect(view.container.querySelector('[data-code-block-header]')).toBeNull()
  65. expect(view.container.querySelector('[data-code-block-banner]')).toBeNull()
  66. expect(view.container.querySelector('pre')?.textContent).toBe('plain text')
  67. })
  68. it('reports the stable source-content wrapper to its owner', () => {
  69. const contentRef = vi.fn<(node: HTMLDivElement | null) => void>()
  70. const view = render(<CodeBlock code="plain text" contentRef={contentRef} />)
  71. const content = view.container.querySelector('[data-code-block-content]')
  72. expect(contentRef).toHaveBeenCalledWith(content)
  73. view.rerender(<CodeBlock code="updated text" contentRef={contentRef} />)
  74. expect(view.container.querySelector('[data-code-block-content]')).toBe(content)
  75. view.unmount()
  76. expect(contentRef).toHaveBeenLastCalledWith(null)
  77. })
  78. it('renders the highlighted tree for TypeScript', () => {
  79. const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
  80. const pre = view.container.querySelector('pre.shiki')
  81. expect(pre).not.toBeNull()
  82. expect(pre!.textContent).toBe('const a = 1')
  83. expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
  84. expect(view.container.querySelector('[data-line-numbers]')).toBeNull()
  85. })
  86. it.each(['ts', 'unregistered'])('numbers %s source lines without copying the gutter', async (lang) => {
  87. const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
  88. const writeText = vi.fn().mockResolvedValue(undefined)
  89. vi.useFakeTimers()
  90. try {
  91. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  92. const code = 'const first = 1\n\nconst last = 3'
  93. const view = render(<CodeBlock code={`${code}\n`} lang={lang} lineNumbers />)
  94. expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
  95. expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent))
  96. .toEqual(['const first = 1', '', 'const last = 3'])
  97. expect(view.container.querySelector('pre')!.textContent).toBe(code)
  98. await act(async () => { fireEvent.click(view.getByRole('button', { name: '复制' })) })
  99. expect(writeText).toHaveBeenCalledWith(code)
  100. await act(async () => { await vi.runOnlyPendingTimersAsync() })
  101. } finally {
  102. cleanup()
  103. vi.useRealTimers()
  104. if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard')
  105. else Object.defineProperty(navigator, 'clipboard', clipboard)
  106. }
  107. })
  108. it('keeps an empty numbered line and widens the gutter as streaming content grows', () => {
  109. const view = render(<CodeBlock code="" lineNumbers />)
  110. expect(view.container.querySelectorAll('code > .line')).toHaveLength(1)
  111. expect(view.container.querySelector('pre')!.textContent).toBe('')
  112. const gutter = () => view.container.querySelector<HTMLElement>('[style*="--dsl-code-block-line-number-width"]')!
  113. .style.getPropertyValue('--dsl-code-block-line-number-width')
  114. expect(gutter()).toBe('2ch')
  115. view.rerender(<CodeBlock code="const first = 1" lang="ts" streaming lineNumbers />)
  116. const code = ['const first = 1', ...Array.from({ length: 99 }, (_, index) => `const line${index} = 0`)].join('\n')
  117. view.rerender(<CodeBlock code={code} lang="ts" streaming lineNumbers />)
  118. expect(view.container.querySelectorAll('code > .line')).toHaveLength(100)
  119. const firstLine = view.container.querySelector('code > .line')
  120. expect(view.container.querySelector('pre')!.textContent).toBe(code)
  121. expect(gutter()).toBe('3ch')
  122. view.rerender(<CodeBlock code={code} lang="ts" lineNumbers />)
  123. expect(view.container.querySelector('code > .line')).toBe(firstLine)
  124. expect(gutter()).toBe('3ch')
  125. })
  126. it('renders the plain arm for an unknown language with the text verbatim', () => {
  127. const view = render(<CodeBlock code={'IDENTIFICATION DIVISION.'} lang="cobol" />)
  128. expect(view.container.querySelector('pre.shiki')).toBeNull()
  129. expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
  130. })
  131. it('renders the plain arm when no language is given', () => {
  132. const view = render(<CodeBlock code="plain text" />)
  133. expect(view.container.querySelector('pre.shiki')).toBeNull()
  134. expect(view.getByText('plain text')).toBeTruthy()
  135. })
  136. it('shows a noninteractive selected source label for a block without preview', () => {
  137. const view = render(<CodeBlock code="const a = 1" lang="ts" />)
  138. const banner = view.container.querySelector('[data-code-block-banner]')!
  139. expect(screen.getByText(markdownLabels.code.sourceLabel).tagName).toBe('SPAN')
  140. expect(screen.queryByRole('button', { name: markdownLabels.code.sourceLabel })).toBeNull()
  141. expect([...banner.querySelectorAll('button')].map(button => button.getAttribute('aria-label')))
  142. .toEqual([markdownLabels.code.lineNumbersLabel, markdownLabels.code.copyLabel])
  143. })
  144. it.each(['ts', undefined])('trims exactly one trailing newline from %s source and its clipboard text', async (lang) => {
  145. const writeText = vi.fn().mockResolvedValue(undefined)
  146. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  147. const view = render(<CodeBlock code={'const a = 1\n\n'} lang={lang} />)
  148. expect(view.container.querySelector('pre')?.textContent).toBe('const a = 1\n')
  149. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
  150. expect(writeText).toHaveBeenCalledWith('const a = 1\n')
  151. })
  152. it('shows the language banner and copies the displayed source text', async () => {
  153. vi.useFakeTimers()
  154. const writeText = vi.fn().mockResolvedValue(undefined)
  155. Object.defineProperty(navigator, 'clipboard', {
  156. configurable: true,
  157. value: { writeText },
  158. })
  159. const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
  160. const sourceElement = view.container.querySelector('pre')
  161. expect(screen.getByText('ts')).toBeTruthy()
  162. const copy = screen.getByRole('button', { name: '复制' })
  163. expect(copy.textContent).toBe('')
  164. const idleIcon = copy.querySelector('svg')?.outerHTML
  165. fireEvent.click(copy)
  166. expect(writeText).toHaveBeenCalledWith('const a = 1')
  167. // Flush the clipboard promise under fake timers before asserting the label.
  168. await act(async () => {
  169. await Promise.resolve()
  170. })
  171. expect(screen.getByRole('button', { name: '复制成功' })).toBe(copy)
  172. expect(copy.textContent).toBe('')
  173. expect(copy.querySelector('svg')?.outerHTML).not.toBe(idleIcon)
  174. expect(view.container.querySelector('pre')).toBe(sourceElement)
  175. // While the ok label is showing, further clicks are no-ops.
  176. fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
  177. expect(writeText).toHaveBeenCalledTimes(1)
  178. await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
  179. expect(screen.getByRole('button', { name: '复制' })).toBe(copy)
  180. expect(copy.querySelector('svg')?.outerHTML).toBe(idleIcon)
  181. })
  182. it('shows localized copy and confirmation tooltips for keyboard focus', async () => {
  183. const writeText = vi.fn().mockResolvedValue(undefined)
  184. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  185. render(<CodeBlock code="plain body" />)
  186. const copy = screen.getByRole('button', { name: '复制' })
  187. fireEvent.focus(copy)
  188. expect(screen.getByRole('tooltip').textContent).toBe('复制')
  189. await act(async () => { fireEvent.click(copy) })
  190. expect(screen.getByRole('tooltip').textContent).toBe('复制成功')
  191. })
  192. it('does not schedule confirmation after an outstanding clipboard write finishes on an unmounted block', async () => {
  193. vi.useFakeTimers()
  194. const pending = Promise.withResolvers<undefined>()
  195. const writeText = vi.fn(() => pending.promise)
  196. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  197. const view = render(<CodeBlock code="plain body" />)
  198. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  199. expect(writeText).toHaveBeenCalledWith('plain body')
  200. view.unmount()
  201. await act(async () => { pending.resolve(undefined) })
  202. expect(vi.getTimerCount()).toBe(0)
  203. expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
  204. })
  205. it('clears the confirmation timer when the block unmounts', async () => {
  206. vi.useFakeTimers()
  207. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } })
  208. const view = render(<CodeBlock code="plain body" />)
  209. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
  210. expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
  211. view.unmount()
  212. expect(vi.getTimerCount()).toBe(0)
  213. })
  214. it('does not claim success when clipboard.writeText rejects', async () => {
  215. const writeText = vi.fn().mockRejectedValue(new Error('denied'))
  216. Object.defineProperty(navigator, 'clipboard', {
  217. configurable: true,
  218. value: { writeText },
  219. })
  220. render(<CodeBlock code="plain body" />)
  221. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  222. await act(async () => {
  223. await Promise.resolve()
  224. })
  225. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  226. expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
  227. })
  228. it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
  229. Object.defineProperty(navigator, 'clipboard', {
  230. configurable: true,
  231. value: undefined,
  232. })
  233. const exec = vi.fn().mockReturnValue(true)
  234. Object.defineProperty(document, 'execCommand', {
  235. configurable: true,
  236. value: exec,
  237. })
  238. render(<CodeBlock code="plain body" />)
  239. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  240. expect(exec).toHaveBeenCalledWith('copy')
  241. expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
  242. })
  243. it('does not claim success when execCommand throws or is absent', async () => {
  244. Object.defineProperty(navigator, 'clipboard', {
  245. configurable: true,
  246. value: undefined,
  247. })
  248. Object.defineProperty(document, 'execCommand', {
  249. configurable: true,
  250. value: () => {
  251. throw new Error('denied')
  252. },
  253. })
  254. const denied = render(<CodeBlock code="plain body" />)
  255. fireEvent.click(denied.getByRole('button', { name: '复制' }))
  256. await Promise.resolve()
  257. expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
  258. denied.unmount()
  259. Object.defineProperty(document, 'execCommand', {
  260. configurable: true,
  261. value: undefined,
  262. })
  263. const absent = render(<CodeBlock code="plain body" />)
  264. fireEvent.click(absent.getByRole('button', { name: '复制' }))
  265. await Promise.resolve()
  266. expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
  267. expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
  268. })
  269. })