1
0

code-body.client.spec.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  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. stylesheet = document.createElement('style')
  61. // The shared defaults load last to prove the renderer's selectors override them.
  62. stylesheet.textContent = `${own}\n${shared}`
  63. document.head.append(stylesheet)
  64. }
  65. describe('CodeBody', () => {
  66. it('rejects a malformed file address without starting the highlighter', () => {
  67. expect(() => CodeBody(props(contents(['plain'], true), { resourceAddress: 'not-a-file-address' }))).toThrow('not a file address')
  68. })
  69. it.each([
  70. [sessionFileAddress(SESSION, 'src/space # question?.TS'), 'typescript'],
  71. [absoluteFileAddress('C:/project/module.MJS'), 'javascript'],
  72. ])('selects language from the decoded file address %s', (resourceAddress, language) => {
  73. const view = render(<CodeBody {...props(contents(['const answer = 42'], true), { resourceAddress })} />)
  74. expect(view.getByText(language, { exact: true })).toBeTruthy()
  75. expect(element(view.container, '.shiki').textContent).toBe('const answer = 42')
  76. expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
  77. expect(view.getByRole('button', { name: 'Copy' })).toBeTruthy()
  78. })
  79. it('carries multiline grammar state across pages and preserves completed lines through settlement', () => {
  80. const first = 'const before = 1;\n/* open comment'
  81. const next = 'still a comment\n*/\nconst after = 2;'
  82. const view = render(<CodeBody {...props(contents([first]))} />)
  83. const pre = element(view.container, '.shiki')
  84. const firstLine = element(pre, '.line')
  85. const copy = view.getByRole('button', { name: 'Copy' })
  86. view.rerender(<CodeBody {...props(contents([first, next]))} />)
  87. expect(view.container.querySelectorAll('.md-code-block')).toHaveLength(1)
  88. expect(element(view.container, '.shiki')).toBe(pre)
  89. expect(element(pre, '.line')).toBe(firstLine)
  90. expect(pre.querySelectorAll('.line')).toHaveLength(5)
  91. expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
  92. const continuation = pre.querySelectorAll('.line').item(2)
  93. expect(continuation.textContent).toBe('still a comment')
  94. expect(element(continuation as HTMLElement, 'span').style.color).toBe('var(--shiki-token-comment)')
  95. expect(pre.textContent).toBe(`${first}\n${next}`)
  96. view.rerender(<CodeBody {...props(contents([first, next], true))} />)
  97. expect(element(view.container, '.shiki')).toBe(pre)
  98. expect(element(pre, '.line')).toBe(firstLine)
  99. expect(view.getByRole('button', { name: 'Copy' })).toBe(copy)
  100. const settled = render(<CodeBody {...props(contents([first, next], true))} />)
  101. expect(tokens(view.container)).toEqual(tokens(settled.container))
  102. })
  103. it('highlights the complete source when the last page also reports eof', () => {
  104. const first = 'const text = `first'
  105. const last = 'second`;\nconst count = 2;'
  106. const view = render(<CodeBody {...props(contents([first]))} />)
  107. const copy = view.getByRole('button', { name: 'Copy' })
  108. view.rerender(<CodeBody {...props(contents([first, last], true))} />)
  109. expect(view.container.querySelectorAll('.md-code-block')).toHaveLength(1)
  110. expect(view.getByRole('button', { name: 'Copy' })).toBe(copy)
  111. const settled = render(<CodeBody {...props(contents([first, last], true))} />)
  112. expect(tokens(view.container)).toEqual(tokens(settled.container))
  113. expect(element(view.container, '.shiki').textContent).toBe(`${first}\n${last}`)
  114. })
  115. it('picks up a lazily loaded grammar without losing the source text', async () => {
  116. const code = 'def answer():\n return 42'
  117. const view = render(<CodeBody {...props(contents([code], true), { resourceAddress: sessionFileAddress(SESSION, 'answer.py') })} />)
  118. expect(element(view.container, 'pre').textContent).toBe(code)
  119. await waitFor(() => { expect(view.container.querySelector('.shiki')).not.toBeNull() })
  120. expect(element(view.container, '.shiki').textContent).toBe(code)
  121. expect(view.container.querySelectorAll('.shiki span[style]').length).toBeGreaterThan(1)
  122. })
  123. it.each([['html', '<h1>Source only</h1>'], ['md', '# Source only']])('renders %s as selectable source, not document markup', async (extension, code) => {
  124. const view = render(<CodeBody {...props(contents([code], true), { resourceAddress: sessionFileAddress(SESSION, `page.${extension}`) })} />)
  125. await waitFor(() => { expect(view.container.querySelector('.shiki')).not.toBeNull() })
  126. expect(element(view.container, '.shiki').textContent).toBe(code)
  127. expect(view.container.querySelector('h1')).toBeNull()
  128. })
  129. it('passes localized controls and ignores byte contents outside its loading mode', () => {
  130. const view = render(<CodeBody {...props(contents(['const a = 1'], true), { t: key => zh[key as keyof typeof zh] })} />)
  131. expect(view.getByRole('button', { name: '复制' })).toBeTruthy()
  132. view.rerender(<CodeBody {...props({ kind: 'bytes', data: new TextEncoder().encode('a') })} />)
  133. expect(view.container.querySelector('.md-code-block')).toBeNull()
  134. })
  135. it('switches wrapping without truncating text, remounting the highlighter, or taking over scrolling', () => {
  136. installStyles()
  137. const code = `const identifier = "${'x'.repeat(300)}";`
  138. const content = contents([code])
  139. const view = render(<CodeBody {...props(content)} />)
  140. const pre = element(view.container, '.shiki')
  141. const block = element(view.container, '.md-code-block')
  142. expect(getComputedStyle(block).marginTop).toBe('0px')
  143. expect(getComputedStyle(block).position).toBe('static')
  144. expect(getComputedStyle(element(view.container, '[data-code-preview]')).minHeight).toBe('100%')
  145. expect(getComputedStyle(pre).whiteSpace).toBe('pre')
  146. expect(getComputedStyle(pre).overflow).toBe('visible')
  147. expect(getComputedStyle(pre).wordBreak).toBe('normal')
  148. view.rerender(<CodeBody {...props(content, { wrap: true })} />)
  149. expect(element(view.container, '.shiki')).toBe(pre)
  150. expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap')
  151. expect(getComputedStyle(pre).overflowWrap).toBe('anywhere')
  152. expect(getComputedStyle(pre).overflow).toBe('visible')
  153. expect(pre.textContent).toBe(code)
  154. view.rerender(<CodeBody {...props(content)} />)
  155. expect(getComputedStyle(pre).whiteSpace).toBe('pre')
  156. expect(getComputedStyle(pre).overflowWrap).toBe('normal')
  157. })
  158. })