|
| 1 | +/** @vitest-environment jsdom */ |
| 2 | +import { act } from 'react' |
| 3 | +import Prism from 'prismjs' |
| 4 | +import { createRoot } from 'react-dom/client' |
| 5 | +import 'prismjs/components/prism-typescript' |
| 6 | +import { describe, expect, it } from 'vitest' |
| 7 | +import { |
| 8 | + HighlightedLines, |
| 9 | + splitHighlightedLines, |
| 10 | +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines' |
| 11 | + |
| 12 | +/** Compare per-character token ancestry, ignoring unstyled line wrappers. */ |
| 13 | +function styledCharacters(element: Element): Array<[string, string]> { |
| 14 | + const result: Array<[string, string]> = [] |
| 15 | + function visit(node: Node, classes: string[]) { |
| 16 | + if (node.nodeType === Node.TEXT_NODE) { |
| 17 | + for (const char of node.textContent ?? '') { |
| 18 | + if (char !== '\n') result.push([char, classes.join('/')]) |
| 19 | + } |
| 20 | + } else if (node instanceof Element) { |
| 21 | + const nextClasses = node.className ? [...classes, node.className] : classes |
| 22 | + for (const child of node.childNodes) visit(child, nextClasses) |
| 23 | + } |
| 24 | + } |
| 25 | + visit(element, []) |
| 26 | + return result |
| 27 | +} |
| 28 | + |
| 29 | +describe('highlighted code lines', () => { |
| 30 | + it('preserves full-fence Prism text and token styles across multiline constructs', () => { |
| 31 | + const cases = [ |
| 32 | + `const value = \`first\nsecond \${1 + 2}\nthird\`\n`, |
| 33 | + '/* a comment\nwith <script>alert(1)</script>\n*/\nconst x = "safe"', |
| 34 | + '<div title="a & b">\n <!-- multi\n line -->\n</div>', |
| 35 | + 'const x = "<img src=x onerror=alert(1)>"\n\nconst y = 1', |
| 36 | + ] |
| 37 | + for (const code of cases) { |
| 38 | + for (let end = 1; end <= code.length; end++) { |
| 39 | + const prefix = code.slice(0, end) |
| 40 | + for (const language of ['typescript', 'markup']) { |
| 41 | + const html = Prism.highlight(prefix, Prism.languages[language], language) |
| 42 | + const before = document.createElement('pre') |
| 43 | + before.innerHTML = html |
| 44 | + const after = document.createElement('pre') |
| 45 | + for (const line of splitHighlightedLines(html)) { |
| 46 | + const span = document.createElement('span') |
| 47 | + span.innerHTML = line |
| 48 | + after.appendChild(span) |
| 49 | + } |
| 50 | + expect(after.textContent).toBe(before.textContent) |
| 51 | + expect(styledCharacters(after)).toEqual(styledCharacters(before)) |
| 52 | + expect(after.querySelector('script, img')).toBeNull() |
| 53 | + } |
| 54 | + } |
| 55 | + } |
| 56 | + }) |
| 57 | + |
| 58 | + it('retains unchanged token nodes and updates earlier lines when grammar changes', async () => { |
| 59 | + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true |
| 60 | + const container = document.createElement('pre') |
| 61 | + const root = createRoot(container) |
| 62 | + const render = (code: string) => |
| 63 | + act(async () => { |
| 64 | + root.render( |
| 65 | + <HighlightedLines |
| 66 | + html={Prism.highlight(code, Prism.languages.typescript, 'typescript')} |
| 67 | + /> |
| 68 | + ) |
| 69 | + }) |
| 70 | + try { |
| 71 | + await render('const first = 1\nconst second = 2') |
| 72 | + const firstToken = container.querySelector('.token.keyword') |
| 73 | + await render('const first = 1\nconst second = 234\nconst third = 3') |
| 74 | + expect(container.querySelector('.token.keyword')).toBe(firstToken) |
| 75 | + await render('/* first line\nsecond line') |
| 76 | + await render('/* first line\nsecond line */\nconst end = 3') |
| 77 | + expect(container.querySelector('.token.comment')?.textContent).toBe('/* first line') |
| 78 | + expect(container.textContent).toBe('/* first line\nsecond line */\nconst end = 3') |
| 79 | + } finally { |
| 80 | + await act(async () => root.unmount()) |
| 81 | + } |
| 82 | + }) |
| 83 | +}) |
0 commit comments