code-block.client.spec.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  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 { CodeBlock } from '../src/markdown/CodeBlock.tsx'
  5. import { highlightToHtml } from '../src/markdown/highlight.ts'
  6. afterEach(cleanup)
  7. beforeEach(() => {
  8. vi.useRealTimers()
  9. })
  10. describe('highlightToHtml', () => {
  11. it('highlights a registered grammar into css-variables token spans', () => {
  12. const html = highlightToHtml('const x: number = 1', 'typescript')
  13. expect(html).toContain('pre class="shiki css-variables"')
  14. expect(html).toContain('var(--shiki-')
  15. })
  16. it.each([['ts'], ['js'], ['bash'], ['sh'], ['jsonc']])('resolves the %s alias', (alias) => {
  17. expect(highlightToHtml('x', alias)).toContain('shiki')
  18. })
  19. it('returns undefined for unknown or absent languages', () => {
  20. expect(highlightToHtml('x', 'cobol')).toBeUndefined()
  21. expect(highlightToHtml('x', undefined)).toBeUndefined()
  22. })
  23. // Every read-tool language hint whose grammar loads lazily (the boot set —
  24. // ts/js/shell/sh/json — is covered above). Touching each one drives its own
  25. // dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
  26. const LAZY_ALIASES = [
  27. 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
  28. 'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
  29. 'xml', 'lua',
  30. ]
  31. it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
  32. // First touch returns the plain fallback (undefined) and starts the import.
  33. for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toBeUndefined()
  34. // Once every grammar has registered, the same call highlights.
  35. await vi.waitFor(() => {
  36. for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
  37. }, { timeout: 5_000 })
  38. })
  39. })
  40. describe('CodeBlock', () => {
  41. it('renders the highlighted tree for TypeScript', () => {
  42. const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
  43. const pre = view.container.querySelector('pre.shiki')
  44. expect(pre).not.toBeNull()
  45. expect(pre!.textContent).toBe('const a = 1')
  46. expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
  47. })
  48. it('renders the plain arm for an unknown language with the text verbatim', () => {
  49. const view = render(<CodeBlock code={'IDENTIFICATION DIVISION.'} lang="cobol" />)
  50. expect(view.container.querySelector('pre.shiki')).toBeNull()
  51. expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
  52. })
  53. it('renders the plain arm when no language is given', () => {
  54. const view = render(<CodeBlock code="plain text" />)
  55. expect(view.container.querySelector('pre.shiki')).toBeNull()
  56. expect(view.getByText('plain text')).toBeTruthy()
  57. })
  58. it('shows the language banner and copies the pre textContent', async () => {
  59. vi.useFakeTimers()
  60. const writeText = vi.fn().mockResolvedValue(undefined)
  61. Object.defineProperty(navigator, 'clipboard', {
  62. configurable: true,
  63. value: { writeText },
  64. })
  65. render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
  66. expect(screen.getByText('ts')).toBeTruthy()
  67. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  68. expect(writeText).toHaveBeenCalledWith('const a = 1')
  69. // Flush the clipboard promise under fake timers before asserting the label.
  70. await act(async () => {
  71. await Promise.resolve()
  72. })
  73. expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
  74. // While the ok label is showing, further clicks are no-ops.
  75. fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
  76. expect(writeText).toHaveBeenCalledTimes(1)
  77. await vi.advanceTimersByTimeAsync(1000)
  78. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  79. })
  80. it('does not claim success when clipboard.writeText rejects', async () => {
  81. const writeText = vi.fn().mockRejectedValue(new Error('denied'))
  82. Object.defineProperty(navigator, 'clipboard', {
  83. configurable: true,
  84. value: { writeText },
  85. })
  86. render(<CodeBlock code="plain body" />)
  87. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  88. await act(async () => {
  89. await Promise.resolve()
  90. })
  91. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  92. expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
  93. })
  94. it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
  95. Object.defineProperty(navigator, 'clipboard', {
  96. configurable: true,
  97. value: undefined,
  98. })
  99. const exec = vi.fn().mockReturnValue(true)
  100. Object.defineProperty(document, 'execCommand', {
  101. configurable: true,
  102. value: exec,
  103. })
  104. render(<CodeBlock code="plain body" />)
  105. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  106. expect(exec).toHaveBeenCalledWith('copy')
  107. expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
  108. })
  109. it('does not claim success when execCommand throws or is absent', async () => {
  110. Object.defineProperty(navigator, 'clipboard', {
  111. configurable: true,
  112. value: undefined,
  113. })
  114. Object.defineProperty(document, 'execCommand', {
  115. configurable: true,
  116. value: () => {
  117. throw new Error('denied')
  118. },
  119. })
  120. const denied = render(<CodeBlock code="plain body" />)
  121. fireEvent.click(denied.getByRole('button', { name: '复制' }))
  122. await Promise.resolve()
  123. expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
  124. denied.unmount()
  125. Object.defineProperty(document, 'execCommand', {
  126. configurable: true,
  127. value: undefined,
  128. })
  129. const absent = render(<CodeBlock code="plain body" />)
  130. fireEvent.click(absent.getByRole('button', { name: '复制' }))
  131. await Promise.resolve()
  132. expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
  133. expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
  134. })
  135. })