code-body.client.spec.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. // @vitest-environment jsdom
  2. /** Accumulated document rendering through the real streaming CodeBlock. */
  3. import { readFileSync } from 'node:fs'
  4. import { dirname, resolve } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { cleanup, render, waitFor } from '@testing-library/react'
  8. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  9. import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
  10. import { CodeBody } from '../src/client/code/CodeBody.tsx'
  11. import type { CodeBodyProps } from '../src/client/code/CodeBody.tsx'
  12. import type { DocumentContent } from '../src/client/document/contract.ts'
  13. import { en, zh } from '../src/client/code/locales.ts'
  14. import css from '../src/client/code/CodeBody.module.css'
  15. import primitiveCss from '../../ui-primitives/src/markdown/CodeBlock.module.css'
  16. const SESSION = 'code-preview' as SessionId
  17. let stylesheet: HTMLStyleElement | undefined
  18. afterEach(() => {
  19. try {
  20. cleanup()
  21. } finally {
  22. stylesheet?.remove()
  23. stylesheet = undefined
  24. }
  25. })
  26. function contents(pages: readonly string[], eof = false): DocumentContent {
  27. let offset = 1
  28. return {
  29. kind: 'text', text: pages.join('\n'), eof,
  30. pages: pages.map((text) => {
  31. const page = { offset, text, lines: text.split('\n').length }
  32. offset += page.lines
  33. return page
  34. }),
  35. }
  36. }
  37. /** Unused framework seats are omitted; this renderer reads only owner data and t. */
  38. function props(content: DocumentContent, overrides: Partial<CodeBodyProps> = {}): CodeBodyProps {
  39. return {
  40. resourceAddress: sessionFileAddress(SESSION, 'source.ts'), sessionId: SESSION,
  41. content, wrap: false, t: (key: keyof typeof en) => en[key], ...overrides,
  42. } as CodeBodyProps
  43. }
  44. function element(root: HTMLElement, selector: string): HTMLElement {
  45. const found = root.querySelector<HTMLElement>(selector)
  46. if (found === null) throw new Error(`missing ${selector}`)
  47. return found
  48. }
  49. function tokens(root: HTMLElement) {
  50. return [...root.querySelectorAll('.shiki .line')].map(line =>
  51. [...line.querySelectorAll<HTMLElement>('span[style]')].map(span => [span.textContent, span.style.color]))
  52. }
  53. /** Apply both source stylesheets using Vite's module names; jsdom has no CSS loader. */
  54. function installStyles(): void {
  55. const directory = dirname(fileURLToPath(import.meta.url))
  56. const own = readFileSync(resolve(directory, '../src/client/code/CodeBody.module.css'), 'utf8')
  57. .replaceAll('.renderer', `.${css.renderer}`).replaceAll('.code', `.${css.code}`)
  58. const shared = readFileSync(resolve(directory, '../../ui-primitives/src/markdown/CodeBlock.module.css'), 'utf8')
  59. .replaceAll('.block', `.${primitiveCss.block}`)
  60. .replaceAll('.content', `.${primitiveCss.content}`)
  61. stylesheet = document.createElement('style')
  62. // The shared defaults load last to prove the renderer's selectors override them.
  63. stylesheet.textContent = `${own}\n${shared}`
  64. document.head.append(stylesheet)
  65. }
  66. describe('CodeBody', () => {
  67. it('rejects a malformed file address without starting the highlighter', () => {
  68. expect(() => CodeBody(props(contents(['plain'], true), { resourceAddress: 'not-a-file-address' }))).toThrow('not a file address')
  69. })
  70. it.each([
  71. [sessionFileAddress(SESSION, 'src/space # question?.TS'), 'typescript'],
  72. [absoluteFileAddress('C:/project/module.MJS'), 'javascript'],
  73. ])('selects language from the decoded file address %s', (resourceAddress, language) => {
  74. const view = render(<CodeBody {...props(contents(['const answer = 42'], true), { resourceAddress })} />)
  75. expect(view.getByText(language, { exact: true })).toBeTruthy()
  76. expect(element(view.container, '.shiki').textContent).toBe('const answer = 42')
  77. expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
  78. expect(view.getByRole('button', { name: 'Copy' })).toBeTruthy()
  79. })
  80. it('carries multiline grammar state across pages and preserves completed lines through settlement', () => {
  81. const first = 'const before = 1;\n/* open comment'
  82. const next = 'still a comment\n*/\nconst after = 2;'
  83. const view = render(<CodeBody {...props(contents([first]))} />)
  84. const pre = element(view.container, '.shiki')
  85. const firstLine = element(pre, '.line')
  86. const copy = view.getByRole('button', { name: 'Copy' })
  87. view.rerender(<CodeBody {...props(contents([first, next]))} />)
  88. expect(view.container.querySelectorAll('.md-code-block')).toHaveLength(1)
  89. expect(element(view.container, '.shiki')).toBe(pre)
  90. expect(element(pre, '.line')).toBe(firstLine)
  91. expect(pre.querySelectorAll('.line')).toHaveLength(5)
  92. expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
  93. const continuation = pre.querySelectorAll('.line').item(2)
  94. expect(continuation.textContent).toBe('still a comment')
  95. expect(element(continuation as HTMLElement, 'span').style.color).toBe('var(--shiki-token-comment)')
  96. expect(pre.textContent).toBe(`${first}\n${next}`)
  97. view.rerender(<CodeBody {...props(contents([first, next], true))} />)
  98. expect(element(view.container, '.shiki')).toBe(pre)
  99. expect(element(pre, '.line')).toBe(firstLine)
  100. expect(view.getByRole('button', { name: 'Copy' })).toBe(copy)
  101. const settled = render(<CodeBody {...props(contents([first, next], true))} />)
  102. expect(tokens(view.container)).toEqual(tokens(settled.container))
  103. })
  104. it('highlights the complete source when the last page also reports eof', () => {
  105. const first = 'const text = `first'
  106. const last = 'second`;\nconst count = 2;'
  107. const view = render(<CodeBody {...props(contents([first]))} />)
  108. const copy = view.getByRole('button', { name: 'Copy' })
  109. view.rerender(<CodeBody {...props(contents([first, last], true))} />)
  110. expect(view.container.querySelectorAll('.md-code-block')).toHaveLength(1)
  111. expect(view.getByRole('button', { name: 'Copy' })).toBe(copy)
  112. const settled = render(<CodeBody {...props(contents([first, last], true))} />)
  113. expect(tokens(view.container)).toEqual(tokens(settled.container))
  114. expect(element(view.container, '.shiki').textContent).toBe(`${first}\n${last}`)
  115. })
  116. it('picks up a lazily loaded grammar without losing the source text', async () => {
  117. const code = 'def answer():\n return 42'
  118. const view = render(<CodeBody {...props(contents([code], true), { resourceAddress: sessionFileAddress(SESSION, 'answer.py') })} />)
  119. expect(element(view.container, 'pre').textContent).toBe(code)
  120. await waitFor(() => { expect(view.container.querySelector('.shiki')).not.toBeNull() })
  121. expect(element(view.container, '.shiki').textContent).toBe(code)
  122. expect(view.container.querySelectorAll('.shiki span[style]').length).toBeGreaterThan(1)
  123. })
  124. it.each([['html', '<h1>Source only</h1>'], ['md', '# Source only']])('renders %s as selectable source, not document markup', async (extension, code) => {
  125. const view = render(<CodeBody {...props(contents([code], true), { resourceAddress: sessionFileAddress(SESSION, `page.${extension}`) })} />)
  126. await waitFor(() => { expect(view.container.querySelector('.shiki')).not.toBeNull() })
  127. expect(element(view.container, '.shiki').textContent).toBe(code)
  128. expect(view.container.querySelector('h1')).toBeNull()
  129. })
  130. it('passes localized controls and ignores byte contents outside its loading mode', () => {
  131. const view = render(<CodeBody {...props(contents(['const a = 1'], true), { t: key => zh[key as keyof typeof zh] })} />)
  132. expect(view.getByRole('button', { name: '复制' })).toBeTruthy()
  133. view.rerender(<CodeBody {...props({ kind: 'bytes', data: new TextEncoder().encode('a') })} />)
  134. expect(view.container.querySelector('.md-code-block')).toBeNull()
  135. })
  136. it('keeps a stable inner scrollport while switching wrapping without remounting the highlighter', () => {
  137. installStyles()
  138. const code = `const identifier = "${'x'.repeat(300)}";`
  139. const content = contents([code])
  140. const view = render(<CodeBody {...props(content)} />)
  141. const pre = element(view.container, '.shiki')
  142. const block = element(view.container, '.md-code-block')
  143. const scrollport = element(view.container, '[data-code-block-content]')
  144. expect(getComputedStyle(block).marginTop).toBe('0px')
  145. expect(getComputedStyle(block).position).toBe('static')
  146. expect(getComputedStyle(element(view.container, '[data-code-preview]')).height).toBe('100%')
  147. expect(getComputedStyle(scrollport).display).toBe('block')
  148. expect(getComputedStyle(scrollport).overflow).toBe('auto')
  149. expect(getComputedStyle(pre).whiteSpace).toBe('pre')
  150. expect(getComputedStyle(pre).overflow).toBe('visible')
  151. expect(getComputedStyle(pre).wordBreak).toBe('normal')
  152. view.rerender(<CodeBody {...props(content, { wrap: true })} />)
  153. expect(element(view.container, '.shiki')).toBe(pre)
  154. expect(element(view.container, '[data-code-block-content]')).toBe(scrollport)
  155. expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap')
  156. expect(getComputedStyle(pre).overflowWrap).toBe('anywhere')
  157. expect(getComputedStyle(pre).overflow).toBe('visible')
  158. expect(pre.textContent).toBe(code)
  159. view.rerender(<CodeBody {...props(content)} />)
  160. expect(getComputedStyle(pre).whiteSpace).toBe('pre')
  161. expect(getComputedStyle(pre).overflowWrap).toBe('normal')
  162. })
  163. })