fix: second work-over
This commit is contained in:
2
API.md
2
API.md
@@ -4,6 +4,8 @@ Contract version: 1.6.0
|
||||
|
||||
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
||||
|
||||
`bds_api` is available in both **macro scripts** (executed during preview and page generation) and **transform scripts** (executed during blogmark import). In macro entrypoints, API calls run in the same runtime context as the macro and can be used to fetch posts, media, tags, or other application data.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import * as path from 'path';
|
||||
import { Worker } from 'worker_threads';
|
||||
|
||||
export interface BlogmarkWorkerLike {
|
||||
on(event: string, listener: (...args: unknown[]) => void): void;
|
||||
postMessage(message: unknown): void;
|
||||
terminate(): void;
|
||||
removeAllListeners(): void;
|
||||
}
|
||||
|
||||
export type BlogmarkWorkerFactory = (workerPath: string) => BlogmarkWorkerLike;
|
||||
|
||||
interface WorkerRunTransformRequest {
|
||||
type: 'runTransform';
|
||||
requestId: string;
|
||||
@@ -45,7 +54,7 @@ interface ActiveRequest extends QueuedRequest {
|
||||
}
|
||||
|
||||
export class BlogmarkPythonWorkerRuntime {
|
||||
private worker: Worker | null = null;
|
||||
private worker: BlogmarkWorkerLike | null = null;
|
||||
private workerReady = false;
|
||||
private workerStartPromise: Promise<void> | null = null;
|
||||
private workerStartResolve: (() => void) | null = null;
|
||||
@@ -53,6 +62,11 @@ export class BlogmarkPythonWorkerRuntime {
|
||||
private activeRequest: ActiveRequest | null = null;
|
||||
private queue: QueuedRequest[] = [];
|
||||
private requestCounter = 0;
|
||||
private readonly workerFactory: BlogmarkWorkerFactory;
|
||||
|
||||
constructor(workerFactory?: BlogmarkWorkerFactory) {
|
||||
this.workerFactory = workerFactory ?? ((workerPath: string) => new Worker(workerPath) as unknown as BlogmarkWorkerLike);
|
||||
}
|
||||
|
||||
async executeTransform(params: {
|
||||
scriptContent: string;
|
||||
@@ -96,6 +110,12 @@ export class BlogmarkPythonWorkerRuntime {
|
||||
|
||||
await this.ensureWorkerStarted();
|
||||
|
||||
// Re-check guard after await — another dispatchNext() may have
|
||||
// activated a request while we were waiting for the worker.
|
||||
if (this.activeRequest || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRequest = this.queue.shift();
|
||||
if (!nextRequest) {
|
||||
return;
|
||||
@@ -131,18 +151,20 @@ export class BlogmarkPythonWorkerRuntime {
|
||||
}
|
||||
|
||||
const workerPath = path.join(__dirname, 'blogmarkPython.worker.js');
|
||||
this.worker = new Worker(workerPath);
|
||||
this.worker = this.workerFactory(workerPath);
|
||||
this.workerReady = false;
|
||||
|
||||
this.worker.on('message', (message: WorkerResponseMessage) => {
|
||||
this.handleWorkerMessage(message);
|
||||
this.worker.on('message', (...args: unknown[]) => {
|
||||
this.handleWorkerMessage(args[0] as WorkerResponseMessage);
|
||||
});
|
||||
|
||||
this.worker.on('error', (error) => {
|
||||
this.worker.on('error', (...args: unknown[]) => {
|
||||
const error = args[0];
|
||||
this.handleWorkerCrash(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
|
||||
this.worker.on('exit', (code) => {
|
||||
this.worker.on('exit', (...args: unknown[]) => {
|
||||
const code = args[0] as number;
|
||||
if (code !== 0) {
|
||||
this.handleWorkerCrash(new Error(`Python worker exited with code ${code}`));
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ function validateParamValue(methodName: string, param: PythonApiParamContractV1,
|
||||
|
||||
type EngineGetter = () => Record<string, (...args: unknown[]) => unknown>;
|
||||
|
||||
const ENGINE_MAP: Record<string, EngineGetter> = {
|
||||
export const ENGINE_MAP: Record<string, EngineGetter> = {
|
||||
posts: () => {
|
||||
const { getPostEngine } = require('../engine/PostEngine');
|
||||
return getPostEngine();
|
||||
|
||||
@@ -772,6 +772,9 @@ export function registerIpcHandlers(): void {
|
||||
return engine.getAllScripts();
|
||||
});
|
||||
|
||||
// Internal: used by the editor macro plugin to detect known Python macros.
|
||||
// Intentionally excluded from the Python API contract and API.md because
|
||||
// it is an internal renderer helper, not a user-facing scripting API.
|
||||
safeHandle('scripts:getEnabledMacroSlugs', async () => {
|
||||
const engine = getScriptEngine();
|
||||
const scripts = await engine.getEnabledMacroScripts();
|
||||
|
||||
@@ -589,6 +589,7 @@ export interface ElectronAPI {
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
get: (id: string) => Promise<ScriptData | null>;
|
||||
getAll: () => Promise<ScriptData[]>;
|
||||
/** Internal: editor macro plugin helper. Not exposed via Python API contract. */
|
||||
getEnabledMacroSlugs: () => Promise<string[]>;
|
||||
rebuildFromFiles: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import { createDeferredEventGate } from './navigation/deferredEventGate';
|
||||
import { createAndFocusPost } from './navigation/postCreation';
|
||||
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
|
||||
import { addWindowEventListener, BDS_EVENT_SCRIPTS_CHANGED } from './utils/windowEvents';
|
||||
import { refreshPythonMacroSlugs } from './macros';
|
||||
import { refreshPythonMacroSlugs, wirePythonMacroPreview, invalidatePythonMacroScriptCache } from './macros';
|
||||
import { useI18n } from './i18n';
|
||||
import './App.css';
|
||||
|
||||
@@ -109,6 +109,9 @@ const App: React.FC = () => {
|
||||
|
||||
// Load known Python macro slugs for editor detection
|
||||
await refreshPythonMacroSlugs();
|
||||
|
||||
// Wire Python macro resolver/renderer for editor preview
|
||||
wirePythonMacroPreview();
|
||||
} catch (error) {
|
||||
console.error('Failed to load initial data:', error);
|
||||
} finally {
|
||||
@@ -565,6 +568,7 @@ const App: React.FC = () => {
|
||||
// Refresh Python macro slugs when scripts change
|
||||
unsubscribers.push(
|
||||
addWindowEventListener(BDS_EVENT_SCRIPTS_CHANGED, () => {
|
||||
invalidatePythonMacroScriptCache();
|
||||
void refreshPythonMacroSlugs();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -45,3 +45,8 @@ export {
|
||||
setPythonMacroResolver,
|
||||
refreshPythonMacroSlugs,
|
||||
} from './registry';
|
||||
|
||||
export {
|
||||
wirePythonMacroPreview,
|
||||
invalidatePythonMacroScriptCache,
|
||||
} from './pythonMacroPreview';
|
||||
|
||||
72
src/renderer/macros/pythonMacroPreview.ts
Normal file
72
src/renderer/macros/pythonMacroPreview.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { PythonMacroInfo, PythonMacroResolver, PythonMacroRendererFn } from './types';
|
||||
import { setPythonMacroResolver } from './registry';
|
||||
import { getPythonRuntimeManager } from '../python/runtimeManagerInstance';
|
||||
|
||||
interface ScriptLike {
|
||||
id: string;
|
||||
slug: string;
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
content: string;
|
||||
entrypoint: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
let cachedMacroScripts: ScriptLike[] | null = null;
|
||||
|
||||
export function invalidatePythonMacroScriptCache(): void {
|
||||
cachedMacroScripts = null;
|
||||
}
|
||||
|
||||
async function loadMacroScripts(): Promise<ScriptLike[]> {
|
||||
if (cachedMacroScripts) return cachedMacroScripts;
|
||||
|
||||
const allScripts = await window.electronAPI?.scripts.getAll();
|
||||
cachedMacroScripts = (allScripts ?? []).filter(
|
||||
(s: ScriptLike) => s.kind === 'macro' && s.enabled,
|
||||
);
|
||||
return cachedMacroScripts;
|
||||
}
|
||||
|
||||
const resolvePythonMacro: PythonMacroResolver = async (macroName) => {
|
||||
const scripts = await loadMacroScripts();
|
||||
const lower = macroName.toLowerCase();
|
||||
const script = scripts.find((s) => s.slug.toLowerCase() === lower);
|
||||
if (!script) return null;
|
||||
|
||||
return {
|
||||
scriptId: script.id,
|
||||
slug: script.slug,
|
||||
code: script.content,
|
||||
entrypoint: script.entrypoint,
|
||||
version: script.version,
|
||||
};
|
||||
};
|
||||
|
||||
const renderPythonMacro: PythonMacroRendererFn = async (info, params, context) => {
|
||||
const manager = getPythonRuntimeManager();
|
||||
const macroContext = {
|
||||
env: {
|
||||
isPreview: context.isPreview,
|
||||
source: { kind: 'script', id: info.scriptId },
|
||||
},
|
||||
params: params as Record<string, unknown>,
|
||||
};
|
||||
|
||||
const result = await manager.renderMacroV1(info.code, macroContext, {
|
||||
entrypoint: info.entrypoint,
|
||||
cacheKey: `${info.scriptId}:v${info.version}`,
|
||||
macroSource: { kind: 'script', id: info.scriptId },
|
||||
});
|
||||
|
||||
return result.result.html;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire the Python macro resolver and renderer into the macro registry
|
||||
* so that the editor preview can render Python-backed macros.
|
||||
* Call once on startup after refreshPythonMacroSlugs().
|
||||
*/
|
||||
export function wirePythonMacroPreview(): void {
|
||||
setPythonMacroResolver(resolvePythonMacro, renderPythonMacro);
|
||||
}
|
||||
350
tests/engine/BlogmarkPythonWorkerRuntime.test.ts
Normal file
350
tests/engine/BlogmarkPythonWorkerRuntime.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
BlogmarkPythonWorkerRuntime,
|
||||
type BlogmarkWorkerLike,
|
||||
type BlogmarkWorkerFactory,
|
||||
} from '../../src/main/engine/BlogmarkPythonWorkerRuntime';
|
||||
|
||||
function createMockWorkerFactory(): {
|
||||
factory: BlogmarkWorkerFactory;
|
||||
postMessages: unknown[];
|
||||
triggerReady: () => void;
|
||||
triggerResult: (requestId: string, output: unknown, toasts?: string[]) => void;
|
||||
triggerError: (requestId: string, error: string) => void;
|
||||
triggerFatalError: (error: string) => void;
|
||||
} {
|
||||
const postMessages: unknown[] = [];
|
||||
let messageHandler: ((msg: unknown) => void) | null = null;
|
||||
let readyCallback: (() => void) | null = null;
|
||||
|
||||
const triggerReady = () => {
|
||||
messageHandler?.({ type: 'ready' });
|
||||
};
|
||||
|
||||
const triggerResult = (requestId: string, output: unknown, toasts: string[] = []) => {
|
||||
messageHandler?.({ type: 'transformResult', requestId, output, toasts });
|
||||
};
|
||||
|
||||
const triggerError = (requestId: string, error: string) => {
|
||||
messageHandler?.({ type: 'transformError', requestId, error });
|
||||
};
|
||||
|
||||
const triggerFatalError = (error: string) => {
|
||||
messageHandler?.({ type: 'error', error });
|
||||
};
|
||||
|
||||
const factory: BlogmarkWorkerFactory = () => {
|
||||
const worker: BlogmarkWorkerLike = {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
readyCallback = () => triggerReady();
|
||||
|
||||
// Auto-ready after microtask
|
||||
setTimeout(() => readyCallback?.(), 5);
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
return { factory, postMessages, triggerReady, triggerResult, triggerError, triggerFatalError };
|
||||
}
|
||||
|
||||
function createAutoRespondFactory(): {
|
||||
factory: BlogmarkWorkerFactory;
|
||||
postMessages: unknown[];
|
||||
} {
|
||||
const postMessages: unknown[] = [];
|
||||
|
||||
const factory: BlogmarkWorkerFactory = () => {
|
||||
let messageHandler: ((msg: unknown) => void) | null = null;
|
||||
|
||||
const worker: BlogmarkWorkerLike = {
|
||||
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 === 'runTransform') {
|
||||
setTimeout(() => {
|
||||
messageHandler?.({
|
||||
type: 'transformResult',
|
||||
requestId: msg.requestId,
|
||||
output: { transformed: true },
|
||||
toasts: ['done'],
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
},
|
||||
terminate() {},
|
||||
removeAllListeners() {
|
||||
messageHandler = null;
|
||||
},
|
||||
};
|
||||
|
||||
setTimeout(() => messageHandler?.({ type: 'ready' }), 5);
|
||||
return worker;
|
||||
};
|
||||
|
||||
return { factory, postMessages };
|
||||
}
|
||||
|
||||
describe('BlogmarkPythonWorkerRuntime', () => {
|
||||
let runtime: BlogmarkPythonWorkerRuntime;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute a transform successfully', async () => {
|
||||
const { factory } = createAutoRespondFactory();
|
||||
runtime = new BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
const result = await runtime.executeTransform({
|
||||
scriptContent: 'def transform(payload): return payload',
|
||||
entrypoint: 'transform',
|
||||
payloadJson: JSON.stringify({ url: 'https://example.com' }),
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
|
||||
expect(result.output).toEqual({ transformed: true });
|
||||
expect(result.toasts).toEqual(['done']);
|
||||
});
|
||||
|
||||
it('should pass correct request shape to worker', async () => {
|
||||
const { factory, postMessages } = createAutoRespondFactory();
|
||||
runtime = new BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
await runtime.executeTransform({
|
||||
scriptContent: 'def t(p): return p',
|
||||
entrypoint: 'transform',
|
||||
payloadJson: '{}',
|
||||
});
|
||||
|
||||
const request = postMessages[0] as Record<string, unknown>;
|
||||
expect(request.type).toBe('runTransform');
|
||||
expect(request.scriptContent).toBe('def t(p): return p');
|
||||
expect(request.entrypoint).toBe('transform');
|
||||
expect(request.payloadJson).toBe('{}');
|
||||
expect(request.requestId).toMatch(/^blogmark-py-/);
|
||||
});
|
||||
|
||||
it('should reject when worker returns an error', async () => {
|
||||
const { factory, triggerError } = createMockWorkerFactory();
|
||||
runtime = new BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
const promise = runtime.executeTransform({
|
||||
scriptContent: 'def bad(): raise Exception("fail")',
|
||||
entrypoint: 'bad',
|
||||
payloadJson: '{}',
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
|
||||
// Wait for worker ready + dispatch
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
triggerError('blogmark-py-1', 'Script execution failed');
|
||||
|
||||
await expect(promise).rejects.toThrow('Script execution failed');
|
||||
});
|
||||
|
||||
it('should reject on timeout', async () => {
|
||||
const { factory } = createMockWorkerFactory();
|
||||
runtime = new BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
const promise = runtime.executeTransform({
|
||||
scriptContent: 'import time; time.sleep(999)',
|
||||
entrypoint: 'slow',
|
||||
payloadJson: '{}',
|
||||
timeoutMs: 50,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('Python transform timed out after 50ms');
|
||||
});
|
||||
|
||||
it('should reject on fatal worker error', async () => {
|
||||
const { factory, triggerFatalError } = createMockWorkerFactory();
|
||||
runtime = new BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
const promise = runtime.executeTransform({
|
||||
scriptContent: '',
|
||||
entrypoint: 'x',
|
||||
payloadJson: '{}',
|
||||
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 () => {
|
||||
// Use a factory where we manually control responses, worker starts ready
|
||||
const postMessages: unknown[] = [];
|
||||
let messageHandler: ((msg: unknown) => void) | null = null;
|
||||
|
||||
const factory: BlogmarkWorkerFactory = () => {
|
||||
const worker: BlogmarkWorkerLike = {
|
||||
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 BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
// Prime the worker so it's ready
|
||||
const p0 = runtime.executeTransform({
|
||||
scriptContent: 'init', entrypoint: 'run', payloadJson: '{}', timeoutMs: 3000,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const initReq = postMessages[0] as { requestId: string };
|
||||
messageHandler?.({ type: 'transformResult', requestId: initReq.requestId, output: 'ok', toasts: [] });
|
||||
await p0;
|
||||
postMessages.length = 0;
|
||||
|
||||
// Now enqueue two concurrent requests on an active worker
|
||||
const p1 = runtime.executeTransform({ scriptContent: 'first', entrypoint: 'run', payloadJson: '{}', timeoutMs: 3000 });
|
||||
const p2 = runtime.executeTransform({ scriptContent: 'second', entrypoint: 'run', payloadJson: '{}', 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: 'transformResult', requestId: firstReq.requestId, output: 'result-1', toasts: [] });
|
||||
const r1 = await p1;
|
||||
expect(r1.output).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: 'transformResult', requestId: secondReq.requestId, output: 'result-2', toasts: [] });
|
||||
const r2 = await p2;
|
||||
expect(r2.output).toBe('result-2');
|
||||
});
|
||||
|
||||
it('should dispose without errors', async () => {
|
||||
const { factory } = createAutoRespondFactory();
|
||||
runtime = new BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
await runtime.executeTransform({
|
||||
scriptContent: 'def t(p): return p',
|
||||
entrypoint: 't',
|
||||
payloadJson: '{}',
|
||||
});
|
||||
|
||||
expect(() => runtime.dispose()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject queued requests on dispose', async () => {
|
||||
const { factory } = createMockWorkerFactory();
|
||||
runtime = new BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
const p1 = runtime.executeTransform({
|
||||
scriptContent: '',
|
||||
entrypoint: 'x',
|
||||
payloadJson: '{}',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
// Wait for worker start
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
runtime.dispose();
|
||||
|
||||
await expect(p1).rejects.toThrow('Python 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: BlogmarkWorkerFactory = () => {
|
||||
workerCount++;
|
||||
const currentWorker = workerCount;
|
||||
|
||||
const worker: BlogmarkWorkerLike = {
|
||||
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 === 'runTransform' && msg.scriptContent !== 'hang') {
|
||||
setTimeout(() => {
|
||||
messageHandler?.({
|
||||
type: 'transformResult',
|
||||
requestId: msg.requestId,
|
||||
output: `result-from-worker-${currentWorker}`,
|
||||
toasts: [],
|
||||
});
|
||||
}, 5);
|
||||
}
|
||||
// 'hang' requests get no response — they will time out
|
||||
},
|
||||
terminate() {},
|
||||
removeAllListeners() { messageHandler = null; },
|
||||
};
|
||||
|
||||
setTimeout(() => messageHandler?.({ type: 'ready' }), 0);
|
||||
return worker;
|
||||
};
|
||||
|
||||
runtime = new BlogmarkPythonWorkerRuntime(factory);
|
||||
|
||||
// First request succeeds
|
||||
const r1 = await runtime.executeTransform({
|
||||
scriptContent: 'ok',
|
||||
entrypoint: 'x',
|
||||
payloadJson: '{}',
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
expect(r1.output).toBe('result-from-worker-1');
|
||||
|
||||
// Force crash by timing out the next request (no response for 'hang')
|
||||
const crashPromise = runtime.executeTransform({
|
||||
scriptContent: 'hang',
|
||||
entrypoint: 'x',
|
||||
payloadJson: '{}',
|
||||
timeoutMs: 30,
|
||||
});
|
||||
|
||||
await expect(crashPromise).rejects.toThrow('timed out');
|
||||
|
||||
// New request should create a new worker and succeed
|
||||
const r2 = await runtime.executeTransform({
|
||||
scriptContent: 'ok',
|
||||
entrypoint: 'x',
|
||||
payloadJson: '{}',
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
expect(r2.output).toBe('result-from-worker-2');
|
||||
expect(workerCount).toBe(2); // original + recovery after timeout reset
|
||||
});
|
||||
});
|
||||
320
tests/engine/mainProcessPythonApiInvoker.test.ts
Normal file
320
tests/engine/mainProcessPythonApiInvoker.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { invokeMainProcessPythonApi, ENGINE_MAP } from '../../src/main/engine/mainProcessPythonApiInvoker';
|
||||
|
||||
// ── Mock engines ───────────────────────────────────────────────────
|
||||
|
||||
const mockPostEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
getPost: vi.fn().mockResolvedValue({ id: 'p1', title: 'Test' }),
|
||||
createPost: vi.fn().mockResolvedValue({ id: 'p2' }),
|
||||
getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }),
|
||||
searchPosts: vi.fn().mockResolvedValue([]),
|
||||
getPostsByStatus: vi.fn().mockResolvedValue([]),
|
||||
updatePost: vi.fn().mockResolvedValue(null),
|
||||
deletePost: vi.fn().mockResolvedValue(true),
|
||||
publishPost: vi.fn().mockResolvedValue(null),
|
||||
discardChanges: vi.fn().mockResolvedValue(null),
|
||||
hasPublishedVersion: vi.fn().mockResolvedValue(false),
|
||||
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||
reindexText: vi.fn().mockResolvedValue(undefined),
|
||||
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||
getAvailableTags: vi.fn().mockResolvedValue([]),
|
||||
getAvailableCategories: vi.fn().mockResolvedValue([]),
|
||||
getPostsByYearMonth: vi.fn().mockResolvedValue([]),
|
||||
getDashboardStats: vi.fn().mockResolvedValue({}),
|
||||
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
||||
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
|
||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||
rebuildAllPostLinks: vi.fn().mockResolvedValue(undefined),
|
||||
isSlugAvailable: vi.fn().mockResolvedValue(true),
|
||||
generateUniqueSlug: vi.fn().mockResolvedValue('slug-1'),
|
||||
};
|
||||
|
||||
const mockScriptEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
createScript: vi.fn().mockResolvedValue({ id: 's1' }),
|
||||
updateScript: vi.fn().mockResolvedValue(null),
|
||||
deleteScript: vi.fn().mockResolvedValue(true),
|
||||
getScript: vi.fn().mockResolvedValue(null),
|
||||
getAllScripts: vi.fn().mockResolvedValue([]),
|
||||
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockTagEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
getAllTags: vi.fn().mockResolvedValue([]),
|
||||
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
||||
getTag: vi.fn().mockResolvedValue(null),
|
||||
getTagByName: vi.fn().mockResolvedValue(null),
|
||||
createTag: vi.fn().mockResolvedValue({ id: 't1' }),
|
||||
updateTag: vi.fn().mockResolvedValue(null),
|
||||
deleteTag: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
mergeTags: vi.fn().mockResolvedValue({ merged: 0 }),
|
||||
renameTag: vi.fn().mockResolvedValue({ renamed: true }),
|
||||
getPostsWithTag: vi.fn().mockResolvedValue([]),
|
||||
syncTagsFromPosts: vi.fn().mockResolvedValue({ created: 0, deleted: 0 }),
|
||||
};
|
||||
|
||||
const mockMediaEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
importMedia: vi.fn().mockResolvedValue({ id: 'm1' }),
|
||||
updateMedia: vi.fn().mockResolvedValue(null),
|
||||
replaceMediaFile: vi.fn().mockResolvedValue(null),
|
||||
deleteMedia: vi.fn().mockResolvedValue(true),
|
||||
getMedia: vi.fn().mockResolvedValue(null),
|
||||
getRelativePath: vi.fn().mockResolvedValue(null),
|
||||
getAllMedia: vi.fn().mockResolvedValue([]),
|
||||
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||
reindexText: vi.fn().mockResolvedValue(undefined),
|
||||
getThumbnailDataUrl: vi.fn().mockResolvedValue(null),
|
||||
generateThumbnails: vi.fn().mockResolvedValue(null),
|
||||
regenerateMissingThumbnails: vi.fn().mockResolvedValue({ processed: 0, generated: 0, failed: 0 }),
|
||||
getMediaFiltered: vi.fn().mockResolvedValue([]),
|
||||
searchMedia: vi.fn().mockResolvedValue([]),
|
||||
getMediaByYearMonth: vi.fn().mockResolvedValue([]),
|
||||
getAvailableTags: vi.fn().mockResolvedValue([]),
|
||||
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const mockMetaEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
getTags: vi.fn().mockResolvedValue([]),
|
||||
getCategories: vi.fn().mockResolvedValue([]),
|
||||
addTag: vi.fn().mockResolvedValue([]),
|
||||
removeTag: vi.fn().mockResolvedValue([]),
|
||||
addCategory: vi.fn().mockResolvedValue([]),
|
||||
removeCategory: vi.fn().mockResolvedValue([]),
|
||||
syncOnStartup: vi.fn().mockResolvedValue({ tags: [], categories: [], projectMetadata: null }),
|
||||
getProjectMetadata: vi.fn().mockResolvedValue(null),
|
||||
setProjectMetadata: vi.fn().mockResolvedValue(null),
|
||||
updateProjectMetadata: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const mockProjectEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
createProject: vi.fn().mockResolvedValue({ id: 'prj1' }),
|
||||
updateProject: vi.fn().mockResolvedValue(null),
|
||||
deleteProject: vi.fn().mockResolvedValue(true),
|
||||
deleteProjectWithData: vi.fn().mockResolvedValue(true),
|
||||
getProject: vi.fn().mockResolvedValue(null),
|
||||
getAllProjects: vi.fn().mockResolvedValue([]),
|
||||
getActiveProject: vi.fn().mockResolvedValue(null),
|
||||
setActiveProject: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const mockTaskManager: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
getAllTasks: vi.fn().mockResolvedValue([]),
|
||||
getRunningTasks: vi.fn().mockResolvedValue([]),
|
||||
cancelTask: vi.fn().mockResolvedValue(true),
|
||||
clearCompletedTasks: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// ── Override ENGINE_MAP for testing ────────────────────────────────
|
||||
|
||||
const originalEngineMap: Record<string, typeof ENGINE_MAP[string]> = {};
|
||||
|
||||
describe('invokeMainProcessPythonApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Save originals and inject test engines
|
||||
for (const key of Object.keys(ENGINE_MAP)) {
|
||||
originalEngineMap[key] = ENGINE_MAP[key];
|
||||
}
|
||||
ENGINE_MAP.posts = () => mockPostEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||
ENGINE_MAP.media = () => mockMediaEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||
ENGINE_MAP.projects = () => mockProjectEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||
ENGINE_MAP.meta = () => mockMetaEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||
ENGINE_MAP.tags = () => mockTagEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||
ENGINE_MAP.scripts = () => mockScriptEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||
ENGINE_MAP.tasks = () => mockTaskManager as Record<string, (...args: unknown[]) => unknown>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore originals
|
||||
for (const [key, value] of Object.entries(originalEngineMap)) {
|
||||
ENGINE_MAP[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Method routing ───────────────────────────────────────────────
|
||||
|
||||
describe('method routing', () => {
|
||||
it('routes posts.get to PostEngine.getPost', async () => {
|
||||
await invokeMainProcessPythonApi('posts.get', { postId: 'p1' });
|
||||
expect(mockPostEngine.getPost).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('routes posts.create to PostEngine.createPost', async () => {
|
||||
const data = { title: 'New', content: 'body' };
|
||||
await invokeMainProcessPythonApi('posts.create', { data });
|
||||
expect(mockPostEngine.createPost).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
it('routes posts.search to PostEngine.searchPosts', async () => {
|
||||
await invokeMainProcessPythonApi('posts.search', { query: 'hello' });
|
||||
expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('routes scripts.create to ScriptEngine.createScript', async () => {
|
||||
const data = { title: 'My Script', kind: 'macro', content: 'print(1)' };
|
||||
await invokeMainProcessPythonApi('scripts.create', { data });
|
||||
expect(mockScriptEngine.createScript).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
it('routes scripts.delete to ScriptEngine.deleteScript', async () => {
|
||||
await invokeMainProcessPythonApi('scripts.delete', { id: 's1' });
|
||||
expect(mockScriptEngine.deleteScript).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
|
||||
it('routes tags.getAll to TagEngine.getAllTags', async () => {
|
||||
await invokeMainProcessPythonApi('tags.getAll', {});
|
||||
expect(mockTagEngine.getAllTags).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('routes tasks.cancel to TaskManager.cancelTask', async () => {
|
||||
await invokeMainProcessPythonApi('tasks.cancel', { taskId: 't1' });
|
||||
expect(mockTaskManager.cancelTask).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('routes meta.getProjectMetadata to MetaEngine.getProjectMetadata', async () => {
|
||||
await invokeMainProcessPythonApi('meta.getProjectMetadata', {});
|
||||
expect(mockMetaEngine.getProjectMetadata).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('routes media.get to MediaEngine.getMedia', async () => {
|
||||
await invokeMainProcessPythonApi('media.get', { id: 'm1' });
|
||||
expect(mockMediaEngine.getMedia).toHaveBeenCalledWith('m1');
|
||||
});
|
||||
|
||||
it('routes projects.getActive to ProjectEngine.getActiveProject', async () => {
|
||||
await invokeMainProcessPythonApi('projects.getActive', {});
|
||||
expect(mockProjectEngine.getActiveProject).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('passes optional params as undefined when omitted', async () => {
|
||||
await invokeMainProcessPythonApi('posts.getAll', {});
|
||||
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('passes optional params when provided', async () => {
|
||||
const opts = { limit: 10, offset: 5 };
|
||||
await invokeMainProcessPythonApi('posts.getAll', { options: opts });
|
||||
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith(opts);
|
||||
});
|
||||
|
||||
it('returns engine method result', async () => {
|
||||
mockPostEngine.getPost.mockResolvedValueOnce({ id: 'p1', title: 'Found' });
|
||||
const result = await invokeMainProcessPythonApi('posts.get', { postId: 'p1' });
|
||||
expect(result).toEqual({ id: 'p1', title: 'Found' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unknown/unsupported methods ──────────────────────────────────
|
||||
|
||||
describe('unknown methods', () => {
|
||||
it('rejects completely unknown methods', async () => {
|
||||
await expect(invokeMainProcessPythonApi('foo.bar', {})).rejects.toThrow(
|
||||
'Unsupported Python API method: foo.bar',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown member on known namespace', async () => {
|
||||
await expect(invokeMainProcessPythonApi('posts.unknown', {})).rejects.toThrow(
|
||||
'Unsupported Python API method: posts.unknown',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when engine method does not exist', async () => {
|
||||
ENGINE_MAP.posts = () => ({ noSuchMethod: vi.fn() }) as unknown as Record<string, (...args: unknown[]) => unknown>;
|
||||
await expect(invokeMainProcessPythonApi('posts.get', { postId: 'p1' })).rejects.toThrow(
|
||||
"engine method 'getPost' not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Blocked/unsafe methods ───────────────────────────────────────
|
||||
|
||||
describe('blocked unsafe methods', () => {
|
||||
// Note: media.importDialog and media.replaceFileDialog are NOT in the API contract,
|
||||
// so they are rejected at contract lookup before reaching the blocked-methods check.
|
||||
// They are listed in unsafeMethods as defense in depth.
|
||||
const unsafeMethods = [
|
||||
'media.getFilePath',
|
||||
'app.openFolder',
|
||||
'app.selectFolder',
|
||||
'app.showItemInFolder',
|
||||
'app.getTitleBarMetrics',
|
||||
'app.notifyRendererReady',
|
||||
'app.triggerMenuAction',
|
||||
'app.getBlogmarkBookmarklet',
|
||||
'app.copyToClipboard',
|
||||
'chat.sendMessage',
|
||||
'chat.abortMessage',
|
||||
'chat.analyzeTaxonomy',
|
||||
'chat.analyzeMediaImage',
|
||||
'sync.configure',
|
||||
'sync.start',
|
||||
'sync.stopAutoSync',
|
||||
];
|
||||
|
||||
for (const method of unsafeMethods) {
|
||||
it(`rejects blocked method: ${method}`, async () => {
|
||||
await expect(invokeMainProcessPythonApi(method, {})).rejects.toThrow(
|
||||
`Python API method '${method}' is not available in main-process macro context`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Parameter validation ─────────────────────────────────────────
|
||||
|
||||
describe('parameter validation', () => {
|
||||
it('rejects missing required string param', async () => {
|
||||
await expect(invokeMainProcessPythonApi('posts.get', {})).rejects.toThrow(
|
||||
'posts.get requires string arg postId',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-string for required string param', async () => {
|
||||
await expect(invokeMainProcessPythonApi('posts.get', { postId: 42 })).rejects.toThrow(
|
||||
'posts.get requires string arg postId',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty string for required string param', async () => {
|
||||
await expect(invokeMainProcessPythonApi('posts.get', { postId: '' })).rejects.toThrow(
|
||||
'posts.get requires string arg postId',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-object for required object param', async () => {
|
||||
await expect(invokeMainProcessPythonApi('posts.create', { data: 'not-obj' })).rejects.toThrow(
|
||||
'posts.create requires object arg data',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects array for required object param', async () => {
|
||||
await expect(invokeMainProcessPythonApi('posts.create', { data: [1, 2] })).rejects.toThrow(
|
||||
'posts.create requires object arg data',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-array for required array param', async () => {
|
||||
await expect(invokeMainProcessPythonApi('tags.merge', { sourceTagIds: 'not-arr', targetTagId: 't1' })).rejects.toThrow(
|
||||
'tags.merge requires array arg sourceTagIds',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid array param', async () => {
|
||||
await invokeMainProcessPythonApi('tags.merge', { sourceTagIds: ['a', 'b'], targetTagId: 't1' });
|
||||
expect(mockTagEngine.mergeTags).toHaveBeenCalledWith(['a', 'b'], 't1');
|
||||
});
|
||||
|
||||
it('allows optional params to be omitted', async () => {
|
||||
await invokeMainProcessPythonApi('posts.isSlugAvailable', { slug: 'test' });
|
||||
expect(mockPostEngine.isSlugAvailable).toHaveBeenCalledWith('test', undefined);
|
||||
});
|
||||
|
||||
it('handles null args gracefully (normalizes to empty record)', async () => {
|
||||
await expect(
|
||||
invokeMainProcessPythonApi('posts.get', null as unknown as Record<string, unknown>),
|
||||
).rejects.toThrow('posts.get requires string arg postId');
|
||||
});
|
||||
});
|
||||
});
|
||||
253
tests/renderer/macros/pythonMacroPreview.test.ts
Normal file
253
tests/renderer/macros/pythonMacroPreview.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
wirePythonMacroPreview,
|
||||
invalidatePythonMacroScriptCache,
|
||||
} from '../../../src/renderer/macros/pythonMacroPreview';
|
||||
import {
|
||||
clearMacros,
|
||||
setPythonMacroResolver,
|
||||
renderMacro,
|
||||
} from '../../../src/renderer/macros/registry';
|
||||
import type { ParsedMacro, MacroRenderContext } from '../../../src/renderer/macros/types';
|
||||
|
||||
// Mock PythonRuntimeManager
|
||||
const mockRenderMacroV1 = vi.fn().mockResolvedValue({
|
||||
result: { html: '<div>Python Preview Output</div>' },
|
||||
stdout: '',
|
||||
});
|
||||
|
||||
vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({
|
||||
getPythonRuntimeManager: () => ({
|
||||
renderMacroV1: mockRenderMacroV1,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('pythonMacroPreview', () => {
|
||||
const context: MacroRenderContext = { isPreview: true };
|
||||
|
||||
beforeEach(() => {
|
||||
clearMacros();
|
||||
setPythonMacroResolver(null, null);
|
||||
invalidatePythonMacroScriptCache();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should resolve a Python macro from scripts.getAll and render via PythonRuntimeManager', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: {
|
||||
scripts: {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'script-1',
|
||||
slug: 'data_table',
|
||||
kind: 'macro',
|
||||
enabled: true,
|
||||
content: 'def render(ctx, post): return {"html": "<table/>"}',
|
||||
entrypoint: 'render',
|
||||
version: 3,
|
||||
},
|
||||
{
|
||||
id: 'script-2',
|
||||
slug: 'utility_script',
|
||||
kind: 'utility',
|
||||
enabled: true,
|
||||
content: '',
|
||||
entrypoint: 'run',
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
wirePythonMacroPreview();
|
||||
|
||||
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('<div>Python Preview Output</div>');
|
||||
expect(mockRenderMacroV1).toHaveBeenCalledWith(
|
||||
'def render(ctx, post): return {"html": "<table/>"}',
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
isPreview: true,
|
||||
source: { kind: 'script', id: 'script-1' },
|
||||
}),
|
||||
params: { source: 'posts' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
entrypoint: 'render',
|
||||
cacheKey: 'script-1:v3',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return unknown macro error when no script matches', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: {
|
||||
scripts: {
|
||||
getAll: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
wirePythonMacroPreview();
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'nonexistent',
|
||||
params: {},
|
||||
rawText: '[[nonexistent]]',
|
||||
start: 0,
|
||||
end: 15,
|
||||
};
|
||||
|
||||
const result = await renderMacro(macro, context);
|
||||
expect(result).toContain('macro-error');
|
||||
expect(result).toContain('Unknown macro');
|
||||
});
|
||||
|
||||
it('should skip disabled scripts', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: {
|
||||
scripts: {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'script-1',
|
||||
slug: 'widget',
|
||||
kind: 'macro',
|
||||
enabled: false,
|
||||
content: 'def render(ctx, post): return {"html": ""}',
|
||||
entrypoint: 'render',
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
wirePythonMacroPreview();
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'widget',
|
||||
params: {},
|
||||
rawText: '[[widget]]',
|
||||
start: 0,
|
||||
end: 10,
|
||||
};
|
||||
|
||||
const result = await renderMacro(macro, context);
|
||||
expect(result).toContain('macro-error');
|
||||
expect(result).toContain('Unknown macro');
|
||||
});
|
||||
|
||||
it('should cache scripts and not refetch on second resolve', async () => {
|
||||
const mockGetAll = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 's1',
|
||||
slug: 'chart',
|
||||
kind: 'macro',
|
||||
enabled: true,
|
||||
content: 'code',
|
||||
entrypoint: 'render',
|
||||
version: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: { scripts: { getAll: mockGetAll } },
|
||||
});
|
||||
|
||||
wirePythonMacroPreview();
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'chart',
|
||||
params: {},
|
||||
rawText: '[[chart]]',
|
||||
start: 0,
|
||||
end: 9,
|
||||
};
|
||||
|
||||
await renderMacro(macro, context);
|
||||
await renderMacro(macro, context);
|
||||
|
||||
expect(mockGetAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should refetch after invalidatePythonMacroScriptCache', async () => {
|
||||
const mockGetAll = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 's1',
|
||||
slug: 'chart',
|
||||
kind: 'macro',
|
||||
enabled: true,
|
||||
content: 'code',
|
||||
entrypoint: 'render',
|
||||
version: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: { scripts: { getAll: mockGetAll } },
|
||||
});
|
||||
|
||||
wirePythonMacroPreview();
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'chart',
|
||||
params: {},
|
||||
rawText: '[[chart]]',
|
||||
start: 0,
|
||||
end: 9,
|
||||
};
|
||||
|
||||
await renderMacro(macro, context);
|
||||
invalidatePythonMacroScriptCache();
|
||||
await renderMacro(macro, context);
|
||||
|
||||
expect(mockGetAll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should match slugs case-insensitively', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: {
|
||||
scripts: {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 's1',
|
||||
slug: 'MyWidget',
|
||||
kind: 'macro',
|
||||
enabled: true,
|
||||
content: 'code',
|
||||
entrypoint: 'render',
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
wirePythonMacroPreview();
|
||||
|
||||
const macro: ParsedMacro = {
|
||||
name: 'mywidget',
|
||||
params: {},
|
||||
rawText: '[[mywidget]]',
|
||||
start: 0,
|
||||
end: 12,
|
||||
};
|
||||
|
||||
const result = await renderMacro(macro, context);
|
||||
expect(result).toBe('<div>Python Preview Output</div>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user