import { describe, it, expect, vi, beforeEach } from 'vitest'; import { PythonMacroWorkerRuntime, type WorkerLike, type WorkerFactory } from '../../src/main/engine/PythonMacroWorkerRuntime'; function createAutoRespondFactory(): { factory: WorkerFactory; postMessages: unknown[]; } { const postMessages: unknown[] = []; const factory: WorkerFactory = () => { let messageHandler: ((msg: unknown) => void) | null = null; const worker: WorkerLike = { on(event: string, handler: (...args: unknown[]) => void) { if (event === 'message') { messageHandler = handler as (msg: unknown) => void; } }, postMessage(message: unknown) { postMessages.push(message); const msg = message as { type: string; requestId: string }; if (msg.type === 'renderMacro') { setTimeout(() => { messageHandler?.({ type: 'macroResult', requestId: msg.requestId, html: '

Python macro output

', data: { key: 'value' }, warnings: [], }); }, 10); } }, terminate() {}, removeAllListeners() { messageHandler = null; }, }; setTimeout(() => { messageHandler?.({ type: 'ready' }); }, 5); return worker; }; return { factory, postMessages }; } function createManualFactory(): { factory: WorkerFactory; postMessages: unknown[]; triggerReady: () => void; triggerResult: (requestId: string, html: string, data?: Record, warnings?: string[]) => void; triggerError: (requestId: string, error: string) => void; triggerFatalError: (error: string) => void; } { const postMessages: unknown[] = []; let messageHandler: ((msg: unknown) => void) | null = null; const triggerReady = () => { messageHandler?.({ type: 'ready' }); }; const triggerResult = (requestId: string, html: string, data?: Record, warnings?: string[]) => { messageHandler?.({ type: 'macroResult', requestId, html, data, warnings }); }; const triggerError = (requestId: string, error: string) => { messageHandler?.({ type: 'macroError', requestId, error }); }; const triggerFatalError = (error: string) => { messageHandler?.({ type: 'error', error }); }; const factory: WorkerFactory = () => { const worker: WorkerLike = { on(event: string, handler: (...args: unknown[]) => void) { if (event === 'message') { messageHandler = handler as (msg: unknown) => void; } }, postMessage(message: unknown) { postMessages.push(message); }, terminate() {}, removeAllListeners() { messageHandler = null; }, }; setTimeout(() => triggerReady(), 5); return worker; }; return { factory, postMessages, triggerReady, triggerResult, triggerError, triggerFatalError }; } describe('PythonMacroWorkerRuntime', () => { let runtime: PythonMacroWorkerRuntime; beforeEach(() => { vi.clearAllMocks(); }); it('should render a Python macro successfully', async () => { const { factory } = createAutoRespondFactory(); runtime = new PythonMacroWorkerRuntime(factory); const result = await runtime.renderMacro({ scriptContent: 'def render(ctx): return {"html": "

output

"}', entrypoint: 'render', contextJson: JSON.stringify({ env: { isPreview: false } }), timeoutMs: 3000, }); expect(result.html).toBe('

Python macro output

'); expect(result.data).toEqual({ key: 'value' }); }); it('should track macro execution counter', async () => { const { factory } = createAutoRespondFactory(); runtime = new PythonMacroWorkerRuntime(factory); expect(runtime.macroCount).toBe(0); await runtime.renderMacro({ scriptContent: 'def render(ctx): return {"html": ""}', entrypoint: 'render', contextJson: '{}', }); expect(runtime.macroCount).toBe(1); }); it('should reset counters', async () => { const { factory } = createAutoRespondFactory(); runtime = new PythonMacroWorkerRuntime(factory); await runtime.renderMacro({ scriptContent: 'def render(ctx): return {"html": ""}', entrypoint: 'render', contextJson: '{}', }); expect(runtime.macroCount).toBe(1); runtime.resetCounters(); expect(runtime.macroCount).toBe(0); expect(runtime.errorCount).toBe(0); expect(runtime.timeoutCount).toBe(0); }); it('should dispose without errors', async () => { const { factory } = createAutoRespondFactory(); runtime = new PythonMacroWorkerRuntime(factory); await runtime.renderMacro({ scriptContent: 'def render(ctx): return {"html": ""}', entrypoint: 'render', contextJson: '{}', }); expect(() => runtime.dispose()).not.toThrow(); }); it('should pass cacheKey to the worker', async () => { const { factory, postMessages } = createAutoRespondFactory(); runtime = new PythonMacroWorkerRuntime(factory); await runtime.renderMacro({ scriptContent: 'def render(ctx): return {"html": ""}', entrypoint: 'render', contextJson: '{}', cacheKey: 'script-1:v2', }); const lastMessage = postMessages[postMessages.length - 1] as Record; expect(lastMessage.type).toBe('renderMacro'); expect(lastMessage.cacheKey).toBe('script-1:v2'); }); it('should reject on timeout and increment timeoutCount', async () => { const { factory } = createManualFactory(); runtime = new PythonMacroWorkerRuntime(factory); const promise = runtime.renderMacro({ scriptContent: 'import time; time.sleep(999)', entrypoint: 'slow', contextJson: '{}', timeoutMs: 50, }); await expect(promise).rejects.toThrow('Python macro timed out after 50ms'); expect(runtime.timeoutCount).toBe(1); expect(runtime.macroCount).toBe(0); }); it('should increment errorCount when worker returns macroError', async () => { const { factory, triggerError } = createManualFactory(); runtime = new PythonMacroWorkerRuntime(factory); const promise = runtime.renderMacro({ scriptContent: 'def bad(): raise Exception("fail")', entrypoint: 'bad', contextJson: '{}', timeoutMs: 3000, }); await new Promise((r) => setTimeout(r, 20)); triggerError('py-macro-1', 'Script execution failed'); await expect(promise).rejects.toThrow('Script execution failed'); expect(runtime.errorCount).toBe(1); expect(runtime.macroCount).toBe(1); }); it('should reject on fatal worker error', async () => { const { factory, triggerFatalError } = createManualFactory(); runtime = new PythonMacroWorkerRuntime(factory); const promise = runtime.renderMacro({ scriptContent: '', entrypoint: 'x', contextJson: '{}', timeoutMs: 3000, }); await new Promise((r) => setTimeout(r, 20)); triggerFatalError('Pyodide failed to load'); await expect(promise).rejects.toThrow('Pyodide failed to load'); }); it('should serialize concurrent requests (queue)', async () => { const postMessages: unknown[] = []; let messageHandler: ((msg: unknown) => void) | null = null; const factory: WorkerFactory = () => { const worker: WorkerLike = { on(event: string, handler: (...args: unknown[]) => void) { if (event === 'message') { messageHandler = handler as (msg: unknown) => void; } }, postMessage(message: unknown) { postMessages.push(message); }, terminate() {}, removeAllListeners() { messageHandler = null; }, }; setTimeout(() => messageHandler?.({ type: 'ready' }), 0); return worker; }; runtime = new PythonMacroWorkerRuntime(factory); // Prime the worker const p0 = runtime.renderMacro({ scriptContent: 'init', entrypoint: 'render', contextJson: '{}', timeoutMs: 3000, }); await new Promise((r) => setTimeout(r, 10)); const initReq = postMessages[0] as { requestId: string }; messageHandler?.({ type: 'macroResult', requestId: initReq.requestId, html: 'ok' }); await p0; postMessages.length = 0; // Enqueue two concurrent requests — both dispatch to the async path. // The first will become active; the second sees activeRequest set and queues. const p1 = runtime.renderMacro({ scriptContent: 'first', entrypoint: 'render', contextJson: '{}', timeoutMs: 3000 }); // Let p1's dispatchNext settle so activeRequest is set before p2 enqueues await new Promise((r) => setTimeout(r, 10)); const p2 = runtime.renderMacro({ scriptContent: 'second', entrypoint: 'render', contextJson: '{}', timeoutMs: 3000 }); await new Promise((r) => setTimeout(r, 10)); // Only first should be dispatched expect(postMessages.length).toBe(1); const firstReq = postMessages[0] as { requestId: string; scriptContent: string }; expect(firstReq.scriptContent).toBe('first'); // Complete first → second should dispatch messageHandler?.({ type: 'macroResult', requestId: firstReq.requestId, html: 'result-1' }); const r1 = await p1; expect(r1.html).toBe('result-1'); await new Promise((r) => setTimeout(r, 10)); expect(postMessages.length).toBe(2); const secondReq = postMessages[1] as { requestId: string; scriptContent: string }; expect(secondReq.scriptContent).toBe('second'); messageHandler?.({ type: 'macroResult', requestId: secondReq.requestId, html: 'result-2' }); const r2 = await p2; expect(r2.html).toBe('result-2'); }); it('should reject queued requests on dispose', async () => { const { factory } = createManualFactory(); runtime = new PythonMacroWorkerRuntime(factory); const p1 = runtime.renderMacro({ scriptContent: '', entrypoint: 'x', contextJson: '{}', timeoutMs: 5000, }); await new Promise((r) => setTimeout(r, 20)); runtime.dispose(); await expect(p1).rejects.toThrow('Python macro worker runtime disposed'); }); it('should recover from worker crash and accept new requests', async () => { let workerCount = 0; let messageHandler: ((msg: unknown) => void) | null = null; const factory: WorkerFactory = () => { workerCount++; const currentWorker = workerCount; const worker: WorkerLike = { on(event: string, handler: (...args: unknown[]) => void) { if (event === 'message') { messageHandler = handler as (msg: unknown) => void; } }, postMessage(message: unknown) { const msg = message as { type: string; requestId: string; scriptContent: string }; if (msg.type === 'renderMacro' && msg.scriptContent !== 'hang') { setTimeout(() => { messageHandler?.({ type: 'macroResult', requestId: msg.requestId, html: `result-from-worker-${currentWorker}`, }); }, 5); } }, terminate() {}, removeAllListeners() { messageHandler = null; }, }; setTimeout(() => messageHandler?.({ type: 'ready' }), 0); return worker; }; runtime = new PythonMacroWorkerRuntime(factory); // First request succeeds const r1 = await runtime.renderMacro({ scriptContent: 'ok', entrypoint: 'x', contextJson: '{}', timeoutMs: 3000, }); expect(r1.html).toBe('result-from-worker-1'); // Force crash by timing out (no response for 'hang') const crashPromise = runtime.renderMacro({ scriptContent: 'hang', entrypoint: 'x', contextJson: '{}', timeoutMs: 30, }); await expect(crashPromise).rejects.toThrow('timed out'); // New request should create a new worker and succeed const r2 = await runtime.renderMacro({ scriptContent: 'ok', entrypoint: 'x', contextJson: '{}', timeoutMs: 3000, }); expect(r2.html).toBe('result-from-worker-2'); expect(workerCount).toBe(2); }); it('should forward warnings from worker result', async () => { let messageHandler: ((msg: unknown) => void) | null = null; const factory: WorkerFactory = () => { const worker: WorkerLike = { on(event: string, handler: (...args: unknown[]) => void) { if (event === 'message') { messageHandler = handler as (msg: unknown) => void; } }, postMessage(message: unknown) { const msg = message as { type: string; requestId: string }; if (msg.type === 'renderMacro') { setTimeout(() => { messageHandler?.({ type: 'macroResult', requestId: msg.requestId, html: '

ok

', warnings: ['deprecation notice', 'slow query'], }); }, 10); } }, terminate() {}, removeAllListeners() { messageHandler = null; }, }; setTimeout(() => messageHandler?.({ type: 'ready' }), 5); return worker; }; runtime = new PythonMacroWorkerRuntime(factory); const result = await runtime.renderMacro({ scriptContent: 'def render(ctx): return {"html": "

ok

", "warnings": ["deprecation notice"]}', entrypoint: 'render', contextJson: '{}', }); expect(result.warnings).toEqual(['deprecation notice', 'slow query']); }); });