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>
241 lines
8.7 KiB
TypeScript
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)
|
|
})
|
|
})
|