code-block.client.spec.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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'>) {
  9. return <LocalizedCodeBlock {...props} {...markdownLabels.code} />
  10. }
  11. afterEach(cleanup)
  12. beforeEach(() => {
  13. vi.useRealTimers()
  14. })
  15. describe('highlightToHtml', () => {
  16. it('highlights a registered grammar into css-variables token spans', () => {
  17. const html = highlightToHtml('const x: number = 1', 'typescript')
  18. expect(html).toContain('pre class="shiki css-variables"')
  19. expect(html).toContain('var(--shiki-')
  20. })
  21. it.each([['ts'], ['js'], ['bash'], ['sh'], ['jsonc']])('resolves the %s alias', (alias) => {
  22. expect(highlightToHtml('x', alias)).toContain('shiki')
  23. })
  24. it('returns undefined for unknown or absent languages', () => {
  25. expect(highlightToHtml('x', 'cobol')).toBeUndefined()
  26. expect(highlightToHtml('x', undefined)).toBeUndefined()
  27. })
  28. // Every read-tool language hint whose grammar loads lazily (the boot set —
  29. // ts/js/shell/sh/json — is covered above). Touching each one drives its own
  30. // dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
  31. const LAZY_ALIASES = [
  32. 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
  33. 'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
  34. 'xml', 'lua',
  35. ]
  36. it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
  37. const registered = Promise.withResolvers<undefined>()
  38. // Registration notifications, not a private polling deadline, establish readiness.
  39. const stop = subscribeGrammarLoaded(() => {
  40. if (LAZY_ALIASES.every(alias => highlightToHtml('x', alias) !== undefined)) registered.resolve(undefined)
  41. })
  42. try {
  43. for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias), alias).toBeUndefined()
  44. await registered.promise
  45. for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias), alias).toContain('shiki')
  46. } finally {
  47. stop()
  48. }
  49. })
  50. })
  51. describe('CodeBlock', () => {
  52. it('reports the stable source-content wrapper to its owner', () => {
  53. const contentRef = vi.fn<(node: HTMLDivElement | null) => void>()
  54. const view = render(<CodeBlock code="plain text" contentRef={contentRef} />)
  55. const content = view.container.querySelector('[data-code-block-content]')
  56. expect(contentRef).toHaveBeenCalledWith(content)
  57. view.rerender(<CodeBlock code="updated text" contentRef={contentRef} />)
  58. expect(view.container.querySelector('[data-code-block-content]')).toBe(content)
  59. view.unmount()
  60. expect(contentRef).toHaveBeenLastCalledWith(null)
  61. })
  62. it('renders the highlighted tree for TypeScript', () => {
  63. const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
  64. const pre = view.container.querySelector('pre.shiki')
  65. expect(pre).not.toBeNull()
  66. expect(pre!.textContent).toBe('const a = 1')
  67. expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
  68. expect(view.container.querySelector('[data-line-numbers]')).toBeNull()
  69. })
  70. it.each(['ts', 'unregistered'])('numbers %s source lines without copying the gutter', async (lang) => {
  71. const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
  72. const writeText = vi.fn().mockResolvedValue(undefined)
  73. vi.useFakeTimers()
  74. try {
  75. Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
  76. const code = 'const first = 1\n\nconst last = 3'
  77. const view = render(<CodeBlock code={`${code}\n`} lang={lang} lineNumbers />)
  78. expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
  79. expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent))
  80. .toEqual(['const first = 1', '', 'const last = 3'])
  81. expect(view.container.querySelector('pre')!.textContent).toBe(code)
  82. await act(async () => { fireEvent.click(view.getByRole('button', { name: '复制' })) })
  83. expect(writeText).toHaveBeenCalledWith(code)
  84. await act(async () => { await vi.runOnlyPendingTimersAsync() })
  85. } finally {
  86. cleanup()
  87. vi.useRealTimers()
  88. if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard')
  89. else Object.defineProperty(navigator, 'clipboard', clipboard)
  90. }
  91. })
  92. it('keeps an empty numbered line and widens the gutter as streaming content grows', () => {
  93. const view = render(<CodeBlock code="" lineNumbers />)
  94. expect(view.container.querySelectorAll('code > .line')).toHaveLength(1)
  95. expect(view.container.querySelector('pre')!.textContent).toBe('')
  96. const gutter = () => view.container.querySelector<HTMLElement>('[data-line-numbers]')!
  97. .style.getPropertyValue('--dsl-code-block-line-number-width')
  98. expect(gutter()).toBe('2ch')
  99. view.rerender(<CodeBlock code="const first = 1" lang="ts" streaming lineNumbers />)
  100. const code = ['const first = 1', ...Array.from({ length: 99 }, (_, index) => `const line${index} = 0`)].join('\n')
  101. view.rerender(<CodeBlock code={code} lang="ts" streaming lineNumbers />)
  102. expect(view.container.querySelectorAll('code > .line')).toHaveLength(100)
  103. const firstLine = view.container.querySelector('code > .line')
  104. expect(view.container.querySelector('pre')!.textContent).toBe(code)
  105. expect(gutter()).toBe('3ch')
  106. view.rerender(<CodeBlock code={code} lang="ts" lineNumbers />)
  107. expect(view.container.querySelector('code > .line')).toBe(firstLine)
  108. expect(gutter()).toBe('3ch')
  109. })
  110. it('renders the plain arm for an unknown language with the text verbatim', () => {
  111. const view = render(<CodeBlock code={'IDENTIFICATION DIVISION.'} lang="cobol" />)
  112. expect(view.container.querySelector('pre.shiki')).toBeNull()
  113. expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
  114. })
  115. it('renders the plain arm when no language is given', () => {
  116. const view = render(<CodeBlock code="plain text" />)
  117. expect(view.container.querySelector('pre.shiki')).toBeNull()
  118. expect(view.getByText('plain text')).toBeTruthy()
  119. })
  120. it('shows the language banner and copies the pre textContent', async () => {
  121. vi.useFakeTimers()
  122. const writeText = vi.fn().mockResolvedValue(undefined)
  123. Object.defineProperty(navigator, 'clipboard', {
  124. configurable: true,
  125. value: { writeText },
  126. })
  127. render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
  128. expect(screen.getByText('ts')).toBeTruthy()
  129. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  130. expect(writeText).toHaveBeenCalledWith('const a = 1')
  131. // Flush the clipboard promise under fake timers before asserting the label.
  132. await act(async () => {
  133. await Promise.resolve()
  134. })
  135. expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
  136. // While the ok label is showing, further clicks are no-ops.
  137. fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
  138. expect(writeText).toHaveBeenCalledTimes(1)
  139. await vi.advanceTimersByTimeAsync(1000)
  140. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  141. })
  142. it('does not claim success when clipboard.writeText rejects', async () => {
  143. const writeText = vi.fn().mockRejectedValue(new Error('denied'))
  144. Object.defineProperty(navigator, 'clipboard', {
  145. configurable: true,
  146. value: { writeText },
  147. })
  148. render(<CodeBlock code="plain body" />)
  149. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  150. await act(async () => {
  151. await Promise.resolve()
  152. })
  153. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  154. expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
  155. })
  156. it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
  157. Object.defineProperty(navigator, 'clipboard', {
  158. configurable: true,
  159. value: undefined,
  160. })
  161. const exec = vi.fn().mockReturnValue(true)
  162. Object.defineProperty(document, 'execCommand', {
  163. configurable: true,
  164. value: exec,
  165. })
  166. render(<CodeBlock code="plain body" />)
  167. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  168. expect(exec).toHaveBeenCalledWith('copy')
  169. expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
  170. })
  171. it('does not claim success when execCommand throws or is absent', async () => {
  172. Object.defineProperty(navigator, 'clipboard', {
  173. configurable: true,
  174. value: undefined,
  175. })
  176. Object.defineProperty(document, 'execCommand', {
  177. configurable: true,
  178. value: () => {
  179. throw new Error('denied')
  180. },
  181. })
  182. const denied = render(<CodeBlock code="plain body" />)
  183. fireEvent.click(denied.getByRole('button', { name: '复制' }))
  184. await Promise.resolve()
  185. expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
  186. denied.unmount()
  187. Object.defineProperty(document, 'execCommand', {
  188. configurable: true,
  189. value: undefined,
  190. })
  191. const absent = render(<CodeBlock code="plain body" />)
  192. fireEvent.click(absent.getByRole('button', { name: '复制' }))
  193. await Promise.resolve()
  194. expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
  195. expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
  196. })
  197. })