Files
Codewalkers/apps/web/src/components/review/use-syntax-highlight.test.ts
Lukas May 0608900a53 feat: move syntax highlighting off main thread via Web Worker pool
Adds a 2-worker pool in use-syntax-highlight.ts so shiki tokenisation
runs off the main thread. Callers continue to receive null while the
worker is in flight and a LineTokenMap once it resolves — no caller
changes needed.

Fallback: if Worker construction is blocked (e.g. CSP), the hook falls
back to the existing createHighlighter singleton but processes 200 lines
at a time, yielding between chunks via scheduler.yield()/setTimeout(0)
to avoid long tasks.

Also adds worker.format:'es' to vite.config.ts (required when the app
uses code-splitting) and covers all paths with Vitest tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 19:39:31 +01:00

241 lines
8.7 KiB
TypeScript

// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { vi, describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
// ── Worker mock infrastructure ─────────────────────────────────────────────
//
// We stub Worker BEFORE importing use-syntax-highlight so that initWorkers()
// (called from useEffect on first render) picks up our mock.
// Module-level state (workers, pending, workersInitialized) is shared across
// all tests in this file — we control behaviour through the mock instances.
type WorkerHandler = (event: { data: unknown }) => void
class MockWorker {
static instances: MockWorker[] = []
messageHandler: WorkerHandler | null = null
postMessage = vi.fn()
constructor() {
MockWorker.instances.push(this)
}
addEventListener(type: string, handler: WorkerHandler) {
if (type === 'message') this.messageHandler = handler
}
/** Simulate a message arriving from the worker thread */
simulateResponse(data: unknown) {
this.messageHandler?.({ data })
}
}
// Stub Worker before the hook module is loaded.
// initWorkers() is lazy (called inside useEffect), so the stub is in place
// by the time any test renders a hook.
beforeAll(() => {
vi.stubGlobal('Worker', MockWorker)
})
afterAll(() => {
vi.unstubAllGlobals()
})
beforeEach(() => {
// Reset call history between tests; keep instances (pool is created once)
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
})
// Import the hook AFTER the beforeAll stub is registered (hoisted evaluation
// of the module will not call initWorkers() — that happens in useEffect).
import { useHighlightedFile } from './use-syntax-highlight'
// ── Helpers ────────────────────────────────────────────────────────────────
const MOCK_TOKEN_A = { content: 'const', color: '#569cd6', offset: 0 }
const MOCK_TOKEN_B = { content: 'x', color: '#9cdcfe', offset: 0 }
function makeLine(
content: string,
newLineNumber: number,
type: 'added' | 'context' | 'removed' = 'added',
) {
return { content, newLineNumber, type } as const
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('useHighlightedFile — worker path', () => {
// ── Test 1: Correct message format ───────────────────────────────────────
it('posts a message to a worker with filePath, language, code, and lineNumbers', async () => {
const lines = [
makeLine('const x = 1', 1, 'added'),
makeLine('const y = 2', 2, 'context'),
]
renderHook(() => useHighlightedFile('src/index.ts', lines))
// Wait for initWorkers() to fire and postMessage to be called
await waitFor(() => {
const totalCalls = MockWorker.instances.reduce(
(n, w) => n + w.postMessage.mock.calls.length,
0,
)
expect(totalCalls).toBeGreaterThan(0)
})
// Find which worker received the message
const calledWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
expect(calledWorker).toBeDefined()
expect(calledWorker!.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
filePath: 'src/index.ts',
language: 'typescript',
code: 'const x = 1\nconst y = 2',
lineNumbers: [1, 2],
}),
)
})
// ── Test 2: Response builds token map ─────────────────────────────────────
it('returns null initially and a LineTokenMap after worker responds', async () => {
const lines = [makeLine('const x = 1', 10, 'added')]
const { result } = renderHook(() => useHighlightedFile('component.ts', lines))
// Immediately null while worker is pending
expect(result.current).toBeNull()
// Capture the request id from whichever worker received it
let requestId = ''
let respondingWorker: MockWorker | undefined
await waitFor(() => {
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
expect(respondingWorker).toBeDefined()
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
expect(requestId).not.toBe('')
})
// Simulate the worker responding
act(() => {
respondingWorker!.simulateResponse({
id: requestId,
tokens: [{ lineNumber: 10, tokens: [MOCK_TOKEN_A] }],
})
})
await waitFor(() => {
expect(result.current).not.toBeNull()
expect(result.current?.get(10)).toEqual([MOCK_TOKEN_A])
})
})
// ── Test 3: Worker error response → null ──────────────────────────────────
it('returns null when worker responds with an error field', async () => {
const lines = [makeLine('code here', 1, 'added')]
const { result } = renderHook(() => useHighlightedFile('bad.ts', lines))
let requestId = ''
let respondingWorker: MockWorker | undefined
await waitFor(() => {
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
expect(respondingWorker).toBeDefined()
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
})
act(() => {
respondingWorker!.simulateResponse({
id: requestId,
tokens: [],
error: 'Worker crashed',
})
})
// Error → stays null (plain text fallback in the UI)
await new Promise<void>((r) => setTimeout(r, 20))
expect(result.current).toBeNull()
})
// ── Test 4: Unmount before response — no state update ────────────────────
it('silently discards a late worker response after unmount', async () => {
const lines = [makeLine('const z = 3', 5, 'added')]
const { result, unmount } = renderHook(() => useHighlightedFile('late.ts', lines))
let requestId = ''
let respondingWorker: MockWorker | undefined
await waitFor(() => {
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
expect(respondingWorker).toBeDefined()
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
})
// Unmount before the response arrives
unmount()
// Simulate the late response — should be silently dropped
act(() => {
respondingWorker!.simulateResponse({
id: requestId,
tokens: [{ lineNumber: 5, tokens: [MOCK_TOKEN_B] }],
})
})
// result.current is frozen at last rendered value (null) — no update fired
expect(result.current).toBeNull()
})
// ── Test 5: Round-robin — two simultaneous requests go to different workers
it('distributes two simultaneous requests across both pool workers', async () => {
// Ensure the pool has been initialised (first test may have done this)
// and reset call counts for clean measurement.
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
const lines1 = [makeLine('alpha', 1, 'added')]
const lines2 = [makeLine('beta', 1, 'added')]
// Render two hook instances at the same time
renderHook(() => useHighlightedFile('file1.ts', lines1))
renderHook(() => useHighlightedFile('file2.ts', lines2))
await waitFor(() => {
const total = MockWorker.instances.reduce((n, w) => n + w.postMessage.mock.calls.length, 0)
expect(total).toBe(2)
})
// Both pool workers should each have received exactly one request
// (round-robin: even requestCount → workers[0], odd → workers[1])
const counts = MockWorker.instances.map((w) => w.postMessage.mock.calls.length)
// Pool has 2 workers; each should have received 1 of the 2 requests
expect(counts[0]).toBe(1)
expect(counts[1]).toBe(1)
})
// ── Test 6: Unknown language → no request ────────────────────────────────
it('returns null immediately for files with no detectable language', async () => {
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
const lines = [makeLine('raw data', 1, 'added')]
const { result } = renderHook(() => useHighlightedFile('data.xyz', lines))
await new Promise<void>((r) => setTimeout(r, 50))
expect(result.current).toBeNull()
const total = MockWorker.instances.reduce((n, w) => n + w.postMessage.mock.calls.length, 0)
expect(total).toBe(0)
})
})