code-block.spec.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. // @vitest-environment jsdom
  2. // CodeBlock + the shiki singleton: registered grammars highlight into token
  3. // spans colored by --shiki-* custom properties; unknown/absent languages take
  4. // the identical-geometry plain arm; aliases resolve; the trailing newline is
  5. // display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
  6. // alongside the rest of the markdown family.
  7. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  8. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  9. import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
  10. import { highlightToHtml } from '../src/markdown/highlight.ts'
  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. })
  29. describe('CodeBlock', () => {
  30. it('renders the highlighted tree for TypeScript', () => {
  31. const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
  32. const pre = view.container.querySelector('pre.shiki')
  33. expect(pre).not.toBeNull()
  34. expect(pre!.textContent).toBe('const a = 1')
  35. expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
  36. })
  37. it('renders the plain arm for an unknown language with the text verbatim', () => {
  38. const view = render(<CodeBlock code={'IDENTIFICATION DIVISION.'} lang="cobol" />)
  39. expect(view.container.querySelector('pre.shiki')).toBeNull()
  40. expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
  41. })
  42. it('renders the plain arm when no language is given', () => {
  43. const view = render(<CodeBlock code="plain text" />)
  44. expect(view.container.querySelector('pre.shiki')).toBeNull()
  45. expect(view.getByText('plain text')).toBeTruthy()
  46. })
  47. it('shows the language banner and copies the pre textContent', async () => {
  48. vi.useFakeTimers()
  49. const writeText = vi.fn().mockResolvedValue(undefined)
  50. Object.defineProperty(navigator, 'clipboard', {
  51. configurable: true,
  52. value: { writeText },
  53. })
  54. render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
  55. expect(screen.getByText('ts')).toBeTruthy()
  56. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  57. expect(writeText).toHaveBeenCalledWith('const a = 1')
  58. // Flush the clipboard promise under fake timers before asserting the label.
  59. await act(async () => {
  60. await Promise.resolve()
  61. })
  62. expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
  63. // While the ok label is showing, further clicks are no-ops.
  64. fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
  65. expect(writeText).toHaveBeenCalledTimes(1)
  66. await vi.advanceTimersByTimeAsync(1000)
  67. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  68. })
  69. it('does not claim success when clipboard.writeText rejects', async () => {
  70. const writeText = vi.fn().mockRejectedValue(new Error('denied'))
  71. Object.defineProperty(navigator, 'clipboard', {
  72. configurable: true,
  73. value: { writeText },
  74. })
  75. render(<CodeBlock code="plain body" />)
  76. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  77. await act(async () => {
  78. await Promise.resolve()
  79. })
  80. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  81. expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
  82. })
  83. it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
  84. Object.defineProperty(navigator, 'clipboard', {
  85. configurable: true,
  86. value: undefined,
  87. })
  88. const exec = vi.fn().mockReturnValue(true)
  89. Object.defineProperty(document, 'execCommand', {
  90. configurable: true,
  91. value: exec,
  92. })
  93. render(<CodeBlock code="plain body" />)
  94. fireEvent.click(screen.getByRole('button', { name: '复制' }))
  95. expect(exec).toHaveBeenCalledWith('copy')
  96. expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
  97. })
  98. it('does not claim success when execCommand throws or is absent', async () => {
  99. Object.defineProperty(navigator, 'clipboard', {
  100. configurable: true,
  101. value: undefined,
  102. })
  103. Object.defineProperty(document, 'execCommand', {
  104. configurable: true,
  105. value: () => {
  106. throw new Error('denied')
  107. },
  108. })
  109. const denied = render(<CodeBlock code="plain body" />)
  110. fireEvent.click(denied.getByRole('button', { name: '复制' }))
  111. await Promise.resolve()
  112. expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
  113. denied.unmount()
  114. Object.defineProperty(document, 'execCommand', {
  115. configurable: true,
  116. value: undefined,
  117. })
  118. const absent = render(<CodeBlock code="plain body" />)
  119. fireEvent.click(absent.getByRole('button', { name: '复制' }))
  120. await Promise.resolve()
  121. expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
  122. expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
  123. })
  124. })