Add comprehensive tests for Python macro integration
Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com>
This commit is contained in:
@@ -60,8 +60,17 @@ interface ActiveRequest extends QueuedRequest {
|
|||||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
timeoutId: ReturnType<typeof setTimeout> | 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 {
|
export class PythonMacroWorkerRuntime {
|
||||||
private worker: Worker | null = null;
|
private worker: WorkerLike | null = null;
|
||||||
private workerReady = false;
|
private workerReady = false;
|
||||||
private workerStartPromise: Promise<void> | null = null;
|
private workerStartPromise: Promise<void> | null = null;
|
||||||
private workerStartResolve: (() => void) | null = null;
|
private workerStartResolve: (() => void) | null = null;
|
||||||
@@ -72,6 +81,11 @@ export class PythonMacroWorkerRuntime {
|
|||||||
private _macroCount = 0;
|
private _macroCount = 0;
|
||||||
private _errorCount = 0;
|
private _errorCount = 0;
|
||||||
private _timeoutCount = 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<MacroRenderResult> {
|
async renderMacro(params: MacroRenderParams): Promise<MacroRenderResult> {
|
||||||
const requestId = this.nextRequestId();
|
const requestId = this.nextRequestId();
|
||||||
@@ -165,7 +179,7 @@ export class PythonMacroWorkerRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workerPath = path.join(__dirname, 'pythonMacro.worker.js');
|
const workerPath = path.join(__dirname, 'pythonMacro.worker.js');
|
||||||
this.worker = new Worker(workerPath);
|
this.worker = this.workerFactory(workerPath);
|
||||||
this.workerReady = false;
|
this.workerReady = false;
|
||||||
|
|
||||||
this.worker.on('message', (message: WorkerResponseMessage) => {
|
this.worker.on('message', (message: WorkerResponseMessage) => {
|
||||||
|
|||||||
252
tests/engine/PageRenderer.pythonMacros.test.ts
Normal file
252
tests/engine/PageRenderer.pythonMacros.test.ts
Normal file
@@ -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": "<div>Widget</div>"}',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
] satisfies PythonMacroScript[]),
|
||||||
|
renderMacro: vi.fn().mockResolvedValue({
|
||||||
|
html: '<div class="python-widget">Hello from Python</div>',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await replaceAllMacrosAsync(
|
||||||
|
'Before [[my_widget title="Hello"]] After',
|
||||||
|
'post-1',
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
'en',
|
||||||
|
mockRenderer,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Before <div class="python-widget">Hello from Python</div> After');
|
||||||
|
expect(mockRenderer.renderMacro).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
scriptContent: 'def render(ctx): return {"html": "<div>Widget</div>"}',
|
||||||
|
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": "<aside>box</aside>"}',
|
||||||
|
version: 3,
|
||||||
|
},
|
||||||
|
] satisfies PythonMacroScript[]),
|
||||||
|
renderMacro: vi.fn().mockResolvedValue({
|
||||||
|
html: '<aside class="custom-box">Custom Content</aside>',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
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('<aside class="custom-box">Custom Content</aside>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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<typeof vi.fn>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
tests/engine/PythonMacroWorkerRuntime.test.ts
Normal file
118
tests/engine/PythonMacroWorkerRuntime.test.ts
Normal file
@@ -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: '<p>Python macro output</p>',
|
||||||
|
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<typeof createMockWorkerFactory>;
|
||||||
|
|
||||||
|
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": "<p>output</p>"}',
|
||||||
|
entrypoint: 'render',
|
||||||
|
contextJson: JSON.stringify({ env: { isPreview: false } }),
|
||||||
|
timeoutMs: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.html).toBe('<p>Python macro output</p>');
|
||||||
|
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<string, unknown>;
|
||||||
|
expect(lastMessage.type).toBe('renderMacro');
|
||||||
|
expect(lastMessage.cacheKey).toBe('script-1:v2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -286,4 +286,95 @@ describe('ScriptEngine', () => {
|
|||||||
expect(result.deleted).toBe(1);
|
expect(result.deleted).toBe(1);
|
||||||
expect(result.processedFiles).toBe(3);
|
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": "<p>hi</p>"}',
|
||||||
|
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": "<div>widget</div>"}',
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
260
tests/renderer/macros/pythonMacroCoexistence.test.ts
Normal file
260
tests/renderer/macros/pythonMacroCoexistence.test.ts
Normal file
@@ -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: () => '<div>JS Widget</div>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolver: PythonMacroResolver = vi.fn().mockResolvedValue({
|
||||||
|
scriptId: 'py-1',
|
||||||
|
slug: 'widget',
|
||||||
|
code: 'def render(ctx): return {"html": "<div>Python Widget</div>"}',
|
||||||
|
entrypoint: 'render',
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('<div>Python Widget</div>');
|
||||||
|
|
||||||
|
setPythonMacroResolver(resolver, renderer);
|
||||||
|
|
||||||
|
const macro: ParsedMacro = {
|
||||||
|
name: 'widget',
|
||||||
|
params: {},
|
||||||
|
rawText: '[[widget]]',
|
||||||
|
start: 0,
|
||||||
|
end: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await renderMacro(macro, context);
|
||||||
|
expect(result).toBe('<div>JS Widget</div>');
|
||||||
|
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": "<table>...</table>"}',
|
||||||
|
entrypoint: 'render',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolver: PythonMacroResolver = vi.fn().mockResolvedValue(pythonInfo);
|
||||||
|
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('<table class="python-table"><tr><td>Data</td></tr></table>');
|
||||||
|
|
||||||
|
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('<table class="python-table"><tr><td>Data</td></tr></table>');
|
||||||
|
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) => `<span>Hello ${params.name || 'World'}</span>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolver: PythonMacroResolver = vi.fn().mockImplementation(async (name: string) => {
|
||||||
|
if (name === 'chart') {
|
||||||
|
return {
|
||||||
|
scriptId: 'py-chart',
|
||||||
|
slug: 'chart',
|
||||||
|
code: 'def render(ctx): return {"html": "<canvas>chart</canvas>"}',
|
||||||
|
entrypoint: 'render',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('<canvas>Rendered Chart</canvas>');
|
||||||
|
|
||||||
|
setPythonMacroResolver(resolver, renderer);
|
||||||
|
|
||||||
|
const input = 'Intro [[hello name="Alice"]] middle [[chart type="bar"]] end';
|
||||||
|
const result = await renderAllMacros(input, context);
|
||||||
|
|
||||||
|
expect(result).toContain('<span>Hello Alice</span>');
|
||||||
|
expect(result).toContain('<canvas>Rendered Chart</canvas>');
|
||||||
|
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 `<div class="${info.slug}">Output ${callCount}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPythonMacroResolver(resolver, renderer);
|
||||||
|
|
||||||
|
const input = '[[box_a]] [[box_b]]';
|
||||||
|
const result = await renderAllMacros(input, context);
|
||||||
|
|
||||||
|
expect(result).toContain('<div class="box_a">Output 1</div>');
|
||||||
|
expect(result).toContain('<div class="box_b">Output 2</div>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('precedence and dispatch documentation', () => {
|
||||||
|
it('JS built-in macros always take priority over Python scripts', async () => {
|
||||||
|
registerMacro({
|
||||||
|
name: 'gallery',
|
||||||
|
description: 'JS Gallery',
|
||||||
|
render: () => '<div class="js-gallery">Built-in</div>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolver: PythonMacroResolver = vi.fn().mockResolvedValue({
|
||||||
|
scriptId: 'py-gal',
|
||||||
|
slug: 'gallery',
|
||||||
|
code: 'def render(ctx): return {"html": "<div class=py-gallery>Custom</div>"}',
|
||||||
|
entrypoint: 'render',
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('<div class="py-gallery">Custom</div>');
|
||||||
|
|
||||||
|
setPythonMacroResolver(resolver, renderer);
|
||||||
|
|
||||||
|
const macro: ParsedMacro = {
|
||||||
|
name: 'gallery',
|
||||||
|
params: {},
|
||||||
|
rawText: '[[gallery]]',
|
||||||
|
start: 0,
|
||||||
|
end: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await renderMacro(macro, context);
|
||||||
|
expect(result).toBe('<div class="js-gallery">Built-in</div>');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user