fix: second work-over
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user