116 lines
3.7 KiB
TypeScript
116 lines
3.7 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
|
import { MermaidDiagram } from './MermaidDiagram'
|
|
|
|
const mermaidMock = vi.hoisted(() => ({
|
|
initialize: vi.fn(),
|
|
render: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('mermaid', () => ({
|
|
default: mermaidMock,
|
|
}))
|
|
|
|
describe('MermaidDiagram', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mermaidMock.render.mockResolvedValue({
|
|
svg: '<svg aria-label="Rendered Mermaid"><g><text>A to B</text></g></svg>',
|
|
})
|
|
})
|
|
|
|
it('renders Mermaid SVG for valid source', async () => {
|
|
render(
|
|
<MermaidDiagram
|
|
diagram={'flowchart LR\nA --> B'}
|
|
source={'```mermaid\nflowchart LR\nA --> B\n```'}
|
|
/>,
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
|
|
})
|
|
expect(mermaidMock.render).toHaveBeenCalledWith(expect.stringMatching(/^tolaria-mermaid-/), 'flowchart LR\nA --> B')
|
|
expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'default' }))
|
|
})
|
|
|
|
it('opens the rendered SVG in a lightbox', async () => {
|
|
render(
|
|
<MermaidDiagram
|
|
diagram={'flowchart LR\nA --> B'}
|
|
source={'```mermaid\nflowchart LR\nA --> B\n```'}
|
|
/>,
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open Mermaid diagram' }))
|
|
expect(screen.getByTestId('mermaid-diagram-dialog-viewport').querySelector('svg')).not.toBeNull()
|
|
})
|
|
|
|
it('tags Mermaid SVG style elements with the runtime CSP nonce', async () => {
|
|
mermaidMock.render.mockResolvedValueOnce({
|
|
svg: '<svg aria-label="Rendered Mermaid"><style>.node{fill:#000}</style><g><text>A to B</text></g></svg>',
|
|
})
|
|
|
|
render(
|
|
<MermaidDiagram
|
|
diagram={'flowchart LR\nA --> B'}
|
|
source={'```mermaid\nflowchart LR\nA --> B\n```'}
|
|
/>,
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('style')).not.toBeNull()
|
|
})
|
|
|
|
const style = screen.getByTestId('mermaid-diagram-viewport').querySelector('style')
|
|
expect(style?.getAttribute('nonce')).toBe(RUNTIME_STYLE_NONCE)
|
|
})
|
|
|
|
it('keeps Mermaid foreignObject labels visible after sanitizing the SVG', async () => {
|
|
mermaidMock.render.mockResolvedValueOnce({
|
|
svg: [
|
|
'<svg aria-label="Rendered Mermaid">',
|
|
'<g class="node">',
|
|
'<foreignObject width="200" height="40">',
|
|
'<div xmlns="http://www.w3.org/1999/xhtml">',
|
|
'<span class="nodeLabel" onclick="alert(1)">Employee<br>clocks in</span>',
|
|
'</div>',
|
|
'</foreignObject>',
|
|
'</g>',
|
|
'</svg>',
|
|
].join(''),
|
|
})
|
|
|
|
render(
|
|
<MermaidDiagram
|
|
diagram={'flowchart LR\nA(["Employee clocks in"]) --> B'}
|
|
source={'```mermaid\nflowchart LR\nA(["Employee clocks in"]) --> B\n```'}
|
|
/>,
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('mermaid-diagram-viewport')).toHaveTextContent('Employeeclocks in')
|
|
})
|
|
expect(screen.getByText('Employeeclocks in')).not.toHaveAttribute('onclick')
|
|
})
|
|
|
|
it('falls back to the original source when Mermaid cannot render', async () => {
|
|
mermaidMock.render.mockRejectedValueOnce(new Error('parse error'))
|
|
|
|
render(
|
|
<MermaidDiagram
|
|
diagram={'flowchart LR\nA --'}
|
|
source={'```mermaid\nflowchart LR\nA --\n```'}
|
|
/>,
|
|
)
|
|
|
|
expect(await screen.findByText('Mermaid diagram unavailable')).toBeInTheDocument()
|
|
expect(screen.getByLabelText('Mermaid source')).toHaveTextContent('flowchart LR')
|
|
})
|
|
})
|