diff --git a/src/main/engine/PythonMacroWorkerRuntime.ts b/src/main/engine/PythonMacroWorkerRuntime.ts index 5792c88..30d2e89 100644 --- a/src/main/engine/PythonMacroWorkerRuntime.ts +++ b/src/main/engine/PythonMacroWorkerRuntime.ts @@ -60,8 +60,17 @@ interface ActiveRequest extends QueuedRequest { timeoutId: ReturnType | null; } +export interface WorkerLike { + on(event: string, listener: (...args: unknown[]) => void): void; + postMessage(message: unknown): void; + terminate(): void; + removeAllListeners(): void; +} + +export type WorkerFactory = (workerPath: string) => WorkerLike; + export class PythonMacroWorkerRuntime { - private worker: Worker | null = null; + private worker: WorkerLike | null = null; private workerReady = false; private workerStartPromise: Promise | null = null; private workerStartResolve: (() => void) | null = null; @@ -72,6 +81,11 @@ export class PythonMacroWorkerRuntime { private _macroCount = 0; private _errorCount = 0; private _timeoutCount = 0; + private readonly workerFactory: WorkerFactory; + + constructor(workerFactory?: WorkerFactory) { + this.workerFactory = workerFactory ?? ((workerPath: string) => new Worker(workerPath) as unknown as WorkerLike); + } async renderMacro(params: MacroRenderParams): Promise { const requestId = this.nextRequestId(); @@ -165,7 +179,7 @@ export class PythonMacroWorkerRuntime { } const workerPath = path.join(__dirname, 'pythonMacro.worker.js'); - this.worker = new Worker(workerPath); + this.worker = this.workerFactory(workerPath); this.workerReady = false; this.worker.on('message', (message: WorkerResponseMessage) => { diff --git a/tests/engine/PageRenderer.pythonMacros.test.ts b/tests/engine/PageRenderer.pythonMacros.test.ts new file mode 100644 index 0000000..04d53cc --- /dev/null +++ b/tests/engine/PageRenderer.pythonMacros.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + renderMacro, + replaceAllMacrosAsync, + isBuiltInMacro, + normalizeMacroName, + type PythonMacroRendererContract, + type PythonMacroScript, +} from '../../src/main/engine/PageRenderer'; + +describe('isBuiltInMacro', () => { + it('returns true for all known JS built-in macro names', () => { + expect(isBuiltInMacro('youtube')).toBe(true); + expect(isBuiltInMacro('vimeo')).toBe(true); + expect(isBuiltInMacro('gallery')).toBe(true); + expect(isBuiltInMacro('photo_archive')).toBe(true); + expect(isBuiltInMacro('tag_cloud')).toBe(true); + expect(isBuiltInMacro('photo_album')).toBe(true); + }); + + it('returns false for unknown macro names', () => { + expect(isBuiltInMacro('my_custom_macro')).toBe(false); + expect(isBuiltInMacro('python_report')).toBe(false); + expect(isBuiltInMacro('data_table')).toBe(false); + }); +}); + +describe('replaceAllMacrosAsync', () => { + it('replaces built-in JS macros without Python renderer', async () => { + const result = await replaceAllMacrosAsync( + 'Before [[youtube id="abc123"]] After', + 'post-1', + [], + null, + [], + 'en', + null, + ); + + expect(result).toContain('class="macro-youtube"'); + expect(result).toContain('abc123'); + expect(result).not.toContain('[[youtube'); + }); + + it('replaces Python macros when renderer is provided', async () => { + const mockRenderer: PythonMacroRendererContract = { + getEnabledMacroScripts: vi.fn().mockResolvedValue([ + { + id: 'script-1', + slug: 'my_widget', + entrypoint: 'render', + content: 'def render(ctx): return {"html": "
Widget
"}', + version: 1, + }, + ] satisfies PythonMacroScript[]), + renderMacro: vi.fn().mockResolvedValue({ + html: '
Hello from Python
', + }), + }; + + const result = await replaceAllMacrosAsync( + 'Before [[my_widget title="Hello"]] After', + 'post-1', + [], + null, + [], + 'en', + mockRenderer, + ); + + expect(result).toBe('Before
Hello from Python
After'); + expect(mockRenderer.renderMacro).toHaveBeenCalledWith( + expect.objectContaining({ + scriptContent: 'def render(ctx): return {"html": "
Widget
"}', + entrypoint: 'render', + cacheKey: 'script-1:1', + }), + ); + }); + + it('preserves JS macros alongside Python macros', async () => { + const mockRenderer: PythonMacroRendererContract = { + getEnabledMacroScripts: vi.fn().mockResolvedValue([ + { + id: 'script-2', + slug: 'custom_box', + entrypoint: 'render', + content: 'def render(ctx): return {"html": ""}', + version: 3, + }, + ] satisfies PythonMacroScript[]), + renderMacro: vi.fn().mockResolvedValue({ + html: '', + }), + }; + + const result = await replaceAllMacrosAsync( + '[[youtube id="xyz789"]] then [[custom_box]]', + 'post-1', + [], + null, + [], + 'en', + mockRenderer, + ); + + expect(result).toContain('class="macro-youtube"'); + expect(result).toContain('xyz789'); + expect(result).toContain(''); + }); + + it('returns empty string for unknown macros without Python renderer', async () => { + const result = await replaceAllMacrosAsync( + 'Before [[unknown_macro]] After', + 'post-1', + [], + null, + [], + 'en', + null, + ); + + expect(result).toBe('Before After'); + }); + + it('returns empty string for unmatched Python macros', async () => { + const mockRenderer: PythonMacroRendererContract = { + getEnabledMacroScripts: vi.fn().mockResolvedValue([]), + renderMacro: vi.fn(), + }; + + const result = await replaceAllMacrosAsync( + 'Before [[nonexistent_macro]] After', + 'post-1', + [], + null, + [], + 'en', + mockRenderer, + ); + + expect(result).toBe('Before After'); + expect(mockRenderer.renderMacro).not.toHaveBeenCalled(); + }); + + it('handles Python macro rendering errors gracefully', async () => { + const mockRenderer: PythonMacroRendererContract = { + getEnabledMacroScripts: vi.fn().mockResolvedValue([ + { + id: 'script-err', + slug: 'broken_macro', + entrypoint: 'render', + content: 'def render(ctx): raise Exception("oops")', + version: 1, + }, + ] satisfies PythonMacroScript[]), + renderMacro: vi.fn().mockRejectedValue(new Error('Python execution failed')), + }; + + const result = await replaceAllMacrosAsync( + 'Before [[broken_macro]] After', + 'post-1', + [], + null, + [], + 'en', + mockRenderer, + ); + + expect(result).toBe('Before After'); + }); + + it('handles script resolution errors gracefully', async () => { + const mockRenderer: PythonMacroRendererContract = { + getEnabledMacroScripts: vi.fn().mockRejectedValue(new Error('DB error')), + renderMacro: vi.fn(), + }; + + const result = await replaceAllMacrosAsync( + 'Before [[my_macro]] After', + 'post-1', + [], + null, + [], + 'en', + mockRenderer, + ); + + expect(result).toBe('Before After'); + }); + + it('does not look up Python scripts when all macros are built-in', async () => { + const mockRenderer: PythonMacroRendererContract = { + getEnabledMacroScripts: vi.fn().mockResolvedValue([]), + renderMacro: vi.fn(), + }; + + await replaceAllMacrosAsync( + '[[youtube id="a"]] [[vimeo id="1"]]', + 'post-1', + [], + null, + [], + 'en', + mockRenderer, + ); + + expect(mockRenderer.getEnabledMacroScripts).not.toHaveBeenCalled(); + }); + + it('passes correct context to Python macro renderer', async () => { + const mockRenderer: PythonMacroRendererContract = { + getEnabledMacroScripts: vi.fn().mockResolvedValue([ + { + id: 'ctx-script', + slug: 'ctx_test', + entrypoint: 'run', + content: 'def run(ctx): return {"html": "ok"}', + version: 2, + }, + ] satisfies PythonMacroScript[]), + renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }), + }; + + await replaceAllMacrosAsync( + '[[ctx_test name="Alice" count="5"]]', + 'post-42', + [], + null, + [], + 'de', + mockRenderer, + ); + + const call = (mockRenderer.renderMacro as ReturnType).mock.calls[0][0]; + const parsedContext = JSON.parse(call.contextJson); + + expect(parsedContext.env.isPreview).toBe(false); + expect(parsedContext.env.mainLanguage).toBe('de'); + expect(parsedContext.env.hook).toBe('ctx_test'); + expect(parsedContext.env.source).toEqual({ kind: 'macro', id: 'ctx-script' }); + expect(parsedContext.params).toEqual({ name: 'Alice', count: '5' }); + expect(call.entrypoint).toBe('run'); + expect(call.cacheKey).toBe('ctx-script:2'); + }); + + it('returns unchanged text when there are no macros', async () => { + const content = 'Just plain text with no macros'; + const result = await replaceAllMacrosAsync(content, '', [], null, [], 'en', null); + expect(result).toBe(content); + }); +}); diff --git a/tests/engine/PythonMacroWorkerRuntime.test.ts b/tests/engine/PythonMacroWorkerRuntime.test.ts new file mode 100644 index 0000000..618d824 --- /dev/null +++ b/tests/engine/PythonMacroWorkerRuntime.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PythonMacroWorkerRuntime, type WorkerLike, type WorkerFactory } from '../../src/main/engine/PythonMacroWorkerRuntime'; + +function createMockWorkerFactory(): { + 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() {}, + }; + + setTimeout(() => { + messageHandler?.({ type: 'ready' }); + }, 5); + + return worker; + }; + + return { factory, postMessages }; +} + +describe('PythonMacroWorkerRuntime', () => { + let runtime: PythonMacroWorkerRuntime; + let mockFactory: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockFactory = createMockWorkerFactory(); + runtime = new PythonMacroWorkerRuntime(mockFactory.factory); + }); + + it('should render a Python macro successfully', async () => { + 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 () => { + 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 () => { + 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 () => { + 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 () => { + await runtime.renderMacro({ + scriptContent: 'def render(ctx): return {"html": ""}', + entrypoint: 'render', + contextJson: '{}', + cacheKey: 'script-1:v2', + }); + + const lastMessage = mockFactory.postMessages[mockFactory.postMessages.length - 1] as Record; + expect(lastMessage.type).toBe('renderMacro'); + expect(lastMessage.cacheKey).toBe('script-1:v2'); + }); +}); diff --git a/tests/engine/ScriptEngine.test.ts b/tests/engine/ScriptEngine.test.ts index 0ed3393..fa450cb 100644 --- a/tests/engine/ScriptEngine.test.ts +++ b/tests/engine/ScriptEngine.test.ts @@ -286,4 +286,95 @@ describe('ScriptEngine', () => { expect(result.deleted).toBe(1); expect(result.processedFiles).toBe(3); }); + + describe('macro resolution', () => { + it('getEnabledMacroScripts returns only enabled macro scripts', async () => { + await scriptEngine.createScript({ + title: 'My Macro', + kind: 'macro', + content: 'def render(ctx): return {"html": "

hi

"}', + enabled: true, + }); + + vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-script-id-2'); + + await scriptEngine.createScript({ + title: 'My Transform', + kind: 'transform', + content: 'def transform(post): return post', + enabled: true, + }); + + vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-script-id-3'); + + await scriptEngine.createScript({ + title: 'Disabled Macro', + kind: 'macro', + content: 'def render(ctx): return {"html": ""}', + enabled: false, + }); + + const macros = await scriptEngine.getEnabledMacroScripts(); + expect(macros).toHaveLength(1); + expect(macros[0].kind).toBe('macro'); + expect(macros[0].enabled).toBe(true); + expect(macros[0].title).toBe('My Macro'); + }); + + it('getMacroScriptBySlug finds enabled macro by slug', async () => { + await scriptEngine.createScript({ + title: 'Widget Macro', + kind: 'macro', + content: 'def render(ctx): return {"html": "
widget
"}', + enabled: true, + }); + + const found = await scriptEngine.getMacroScriptBySlug('widget_macro'); + expect(found).not.toBeNull(); + expect(found?.slug).toBe('widget_macro'); + expect(found?.kind).toBe('macro'); + }); + + it('getMacroScriptBySlug returns null for non-macro scripts', async () => { + await scriptEngine.createScript({ + title: 'My Transform', + kind: 'transform', + content: 'def transform(post): return post', + enabled: true, + }); + + const found = await scriptEngine.getMacroScriptBySlug('my_transform'); + expect(found).toBeNull(); + }); + + it('getMacroScriptBySlug returns null for disabled macros', async () => { + await scriptEngine.createScript({ + title: 'Disabled Macro', + kind: 'macro', + content: 'def render(ctx): return {"html": ""}', + enabled: false, + }); + + const found = await scriptEngine.getMacroScriptBySlug('disabled_macro'); + expect(found).toBeNull(); + }); + + it('getMacroScriptBySlug is case-insensitive', async () => { + await scriptEngine.createScript({ + title: 'Case Test', + kind: 'macro', + content: 'def render(ctx): return {"html": ""}', + enabled: true, + }); + + const found = await scriptEngine.getMacroScriptBySlug('CASE_TEST'); + expect(found).not.toBeNull(); + expect(found?.slug).toBe('case_test'); + }); + + it('getMacroScriptBySlug returns null for non-existent slug', async () => { + const found = await scriptEngine.getMacroScriptBySlug('nonexistent'); + expect(found).toBeNull(); + }); + }); }); diff --git a/tests/renderer/macros/pythonMacroCoexistence.test.ts b/tests/renderer/macros/pythonMacroCoexistence.test.ts new file mode 100644 index 0000000..2f3c19d --- /dev/null +++ b/tests/renderer/macros/pythonMacroCoexistence.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + registerMacro, + clearMacros, + renderMacro, + renderAllMacros, + setPythonMacroResolver, +} from '../../../src/renderer/macros/registry'; +import type { + MacroRenderContext, + ParsedMacro, + PythonMacroInfo, + PythonMacroResolver, + PythonMacroRendererFn, +} from '../../../src/renderer/macros/types'; + +describe('Python macro coexistence in renderer registry', () => { + const context: MacroRenderContext = { isPreview: true }; + + beforeEach(() => { + clearMacros(); + setPythonMacroResolver(null, null); + }); + + describe('renderMacro with Python fallback', () => { + it('should prefer JS macro over Python macro with same name', async () => { + registerMacro({ + name: 'widget', + description: 'JS widget', + render: () => '
JS Widget
', + }); + + const resolver: PythonMacroResolver = vi.fn().mockResolvedValue({ + scriptId: 'py-1', + slug: 'widget', + code: 'def render(ctx): return {"html": "
Python Widget
"}', + entrypoint: 'render', + version: 1, + }); + const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('
Python Widget
'); + + setPythonMacroResolver(resolver, renderer); + + const macro: ParsedMacro = { + name: 'widget', + params: {}, + rawText: '[[widget]]', + start: 0, + end: 10, + }; + + const result = await renderMacro(macro, context); + expect(result).toBe('
JS Widget
'); + expect(resolver).not.toHaveBeenCalled(); + expect(renderer).not.toHaveBeenCalled(); + }); + + it('should fall back to Python macro when not in JS registry', async () => { + const pythonInfo: PythonMacroInfo = { + scriptId: 'py-2', + slug: 'data_table', + code: 'def render(ctx): return {"html": "...
"}', + entrypoint: 'render', + version: 1, + }; + + const resolver: PythonMacroResolver = vi.fn().mockResolvedValue(pythonInfo); + const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('
Data
'); + + setPythonMacroResolver(resolver, renderer); + + const macro: ParsedMacro = { + name: 'data_table', + params: { source: 'posts' }, + rawText: '[[data_table source="posts"]]', + start: 0, + end: 29, + }; + + const result = await renderMacro(macro, context); + expect(result).toBe('
Data
'); + expect(resolver).toHaveBeenCalledWith('data_table'); + expect(renderer).toHaveBeenCalledWith(pythonInfo, { source: 'posts' }, context); + }); + + it('should show error span when Python resolver returns null', async () => { + const resolver: PythonMacroResolver = vi.fn().mockResolvedValue(null); + const renderer: PythonMacroRendererFn = vi.fn(); + + setPythonMacroResolver(resolver, renderer); + + const macro: ParsedMacro = { + name: 'unknown_thing', + params: {}, + rawText: '[[unknown_thing]]', + start: 0, + end: 17, + }; + + const result = await renderMacro(macro, context); + expect(result).toContain('macro-error'); + expect(result).toContain('Unknown macro'); + expect(renderer).not.toHaveBeenCalled(); + }); + + it('should show error span when Python resolver throws', async () => { + const resolver: PythonMacroResolver = vi.fn().mockRejectedValue(new Error('Resolution failed')); + const renderer: PythonMacroRendererFn = vi.fn(); + + setPythonMacroResolver(resolver, renderer); + + const macro: ParsedMacro = { + name: 'broken_resolve', + params: {}, + rawText: '[[broken_resolve]]', + start: 0, + end: 18, + }; + + const result = await renderMacro(macro, context); + expect(result).toContain('macro-error'); + expect(result).toContain('Python macro error'); + }); + + it('should show unknown macro error when no Python resolver is set', async () => { + const macro: ParsedMacro = { + name: 'no_resolver', + params: {}, + rawText: '[[no_resolver]]', + start: 0, + end: 15, + }; + + const result = await renderMacro(macro, context); + expect(result).toContain('macro-error'); + expect(result).toContain('Unknown macro'); + }); + }); + + describe('renderAllMacros with mixed JS and Python', () => { + it('should render mixed JS and Python macros in one document', async () => { + registerMacro({ + name: 'hello', + description: 'Greeting', + render: (params) => `Hello ${params.name || 'World'}`, + }); + + const resolver: PythonMacroResolver = vi.fn().mockImplementation(async (name: string) => { + if (name === 'chart') { + return { + scriptId: 'py-chart', + slug: 'chart', + code: 'def render(ctx): return {"html": "chart"}', + entrypoint: 'render', + version: 1, + }; + } + return null; + }); + + const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('Rendered Chart'); + + setPythonMacroResolver(resolver, renderer); + + const input = 'Intro [[hello name="Alice"]] middle [[chart type="bar"]] end'; + const result = await renderAllMacros(input, context); + + expect(result).toContain('Hello Alice'); + expect(result).toContain('Rendered Chart'); + expect(result).toContain('Intro'); + expect(result).toContain('middle'); + expect(result).toContain('end'); + }); + + it('should handle multiple Python macros in sequence', async () => { + const resolver: PythonMacroResolver = vi.fn().mockImplementation(async (name: string) => { + if (name === 'box_a') { + return { scriptId: 'a', slug: 'box_a', code: '', entrypoint: 'render', version: 1 }; + } + if (name === 'box_b') { + return { scriptId: 'b', slug: 'box_b', code: '', entrypoint: 'render', version: 1 }; + } + return null; + }); + + let callCount = 0; + const renderer: PythonMacroRendererFn = vi.fn().mockImplementation(async (info: PythonMacroInfo) => { + callCount++; + return `
Output ${callCount}
`; + }); + + setPythonMacroResolver(resolver, renderer); + + const input = '[[box_a]] [[box_b]]'; + const result = await renderAllMacros(input, context); + + expect(result).toContain('
Output 1
'); + expect(result).toContain('
Output 2
'); + }); + }); + + describe('precedence and dispatch documentation', () => { + it('JS built-in macros always take priority over Python scripts', async () => { + registerMacro({ + name: 'gallery', + description: 'JS Gallery', + render: () => '', + }); + + const resolver: PythonMacroResolver = vi.fn().mockResolvedValue({ + scriptId: 'py-gal', + slug: 'gallery', + code: 'def render(ctx): return {"html": ""}', + entrypoint: 'render', + version: 1, + }); + const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue(''); + + setPythonMacroResolver(resolver, renderer); + + const macro: ParsedMacro = { + name: 'gallery', + params: {}, + rawText: '[[gallery]]', + start: 0, + end: 11, + }; + + const result = await renderMacro(macro, context); + expect(result).toBe(''); + expect(resolver).not.toHaveBeenCalled(); + }); + + it('Python macros are only used for names not in JS registry', async () => { + registerMacro({ + name: 'existing', + description: 'Existing JS', + render: () => 'JS', + }); + + const resolver: PythonMacroResolver = vi.fn().mockImplementation(async (name: string) => { + if (name === 'python_only') { + return { scriptId: 'p1', slug: 'python_only', code: '', entrypoint: 'render', version: 1 }; + } + return null; + }); + const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('Python Output'); + + setPythonMacroResolver(resolver, renderer); + + const input = '[[existing]] [[python_only]]'; + const result = await renderAllMacros(input, context); + + expect(result).toContain('JS'); + expect(result).toContain('Python Output'); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith('python_only'); + }); + }); +});