feat(python): add queued worker runtime and configurable transform mode

This commit is contained in:
2026-02-23 22:26:54 +01:00
parent 8e8f099768
commit 838ea34ab7
21 changed files with 744 additions and 88 deletions

View File

@@ -63,7 +63,10 @@ describe('BlogmarkTransformService (Pyodide integration)', () => {
getScripts: async () => [createTransformScript()],
};
const service = new BlogmarkTransformService({ provider });
const service = new BlogmarkTransformService({
provider,
resolvePythonRuntimeMode: async () => 'main-thread',
});
const result = await service.applyTransforms(createInput());

View File

@@ -247,67 +247,53 @@ describe('BlogmarkTransformService', () => {
expect(result.toasts).toEqual(['Step finished', 'Step finished']);
});
it('invokes python transform entrypoint with post payload shape', async () => {
const globalsStore = new Map<string, unknown>();
const runPythonAsync = vi.fn(async (code: string) => {
if (code.includes('json.dumps(_result)')) {
const payload = JSON.parse(String(globalsStore.get('__bds_transform_payload_json')));
if (code.includes('_transform_fn(_payload)')) {
return JSON.stringify(payload);
}
return JSON.stringify({
...payload.post,
title: 'Normalized',
categories: ['spielelog', 'asides'],
tags: ['inbox', 'spielen'],
});
}
return null;
});
vi.doMock('pyodide', () => ({
loadPyodide: vi.fn(async () => ({
globals: {
set: (key: string, value: unknown) => {
globalsStore.set(key, value);
},
},
runPythonAsync,
})),
}));
const provider: BlogmarkTransformScriptProvider = {
getScripts: vi.fn(async () => [
createScript({
id: 'pyodide-transform',
slug: 'pyodide-transform',
title: 'Pyodide Transform',
kind: 'transform',
entrypoint: 'normalize_blogmark',
content: 'def normalize_blogmark(post):\n return post',
}),
]),
it('uses webworker executor when runtime mode resolves to webworker', async () => {
const webworkerExecutor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (_script, input) => ({ output: input.post, toasts: [] })),
};
const mainThreadExecutor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (_script, input) => ({ output: input.post, toasts: [] })),
};
const service = new BlogmarkTransformService({ provider });
const service = new BlogmarkTransformService({
provider: {
getScripts: async () => [createScript({ id: 'worker-script', slug: 'worker-script' })],
},
resolvePythonRuntimeMode: async () => 'webworker',
executors: {
webworker: webworkerExecutor,
'main-thread': mainThreadExecutor,
},
});
const result = await service.applyTransforms(createInput());
await service.applyTransforms(createInput());
const transformInvocationCode = runPythonAsync.mock.calls
.map((call) => call[0])
.find((code) => typeof code === 'string' && String(code).includes('json.dumps(_result)'));
expect(webworkerExecutor.runTransform).toHaveBeenCalledTimes(1);
expect(mainThreadExecutor.runTransform).not.toHaveBeenCalled();
});
expect(result.post.title).toBe('Normalized');
expect(result.post.categories).toEqual(['spielelog', 'asides']);
expect(result.post.tags).toEqual(['inbox', 'spielen']);
expect(transformInvocationCode).toBeDefined();
expect(String(transformInvocationCode)).not.toContain('import inspect');
expect(String(transformInvocationCode)).toContain('\ntry:\n');
expect(String(transformInvocationCode)).toContain('\nexcept TypeError:\n');
expect(String(transformInvocationCode)).not.toContain('\n try:\n');
expect(String(transformInvocationCode)).not.toContain('\n except TypeError:\n');
it('uses main-thread executor when runtime mode resolves to main-thread', async () => {
const webworkerExecutor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (_script, input) => ({ output: input.post, toasts: [] })),
};
const mainThreadExecutor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (_script, input) => ({ output: input.post, toasts: [] })),
};
const service = new BlogmarkTransformService({
provider: {
getScripts: async () => [createScript({ id: 'main-script', slug: 'main-script' })],
},
resolvePythonRuntimeMode: async () => 'main-thread',
executors: {
webworker: webworkerExecutor,
'main-thread': mainThreadExecutor,
},
});
await service.applyTransforms(createInput());
expect(mainThreadExecutor.runTransform).toHaveBeenCalledTimes(1);
expect(webworkerExecutor.runTransform).not.toHaveBeenCalled();
});
});

View File

@@ -739,6 +739,30 @@ describe('MetaEngine', () => {
expect((metadata as any)?.blogmarkCategory).toBe('article');
});
it('should set and get pythonRuntimeMode in project metadata', async () => {
await metaEngine.setProjectMetadata({
name: 'My Blog',
pythonRuntimeMode: 'main-thread',
} as any);
const metadata = await metaEngine.getProjectMetadata();
expect((metadata as any)?.pythonRuntimeMode).toBe('main-thread');
});
it('should persist pythonRuntimeMode to filesystem', async () => {
await metaEngine.setProjectMetadata({
name: 'Runtime Mode Project',
pythonRuntimeMode: 'webworker',
} as any);
const metaDir = metaEngine.getMetaDir();
const projectPath = normalizePath(`${metaDir}/project.json`);
const content = mockFiles.get(projectPath);
const parsed = JSON.parse(content!);
expect(parsed.pythonRuntimeMode).toBe('webworker');
});
it('should persist blogmarkCategory to filesystem', async () => {
await metaEngine.setProjectMetadata({
name: 'Test Project',

View File

@@ -117,6 +117,33 @@ describe('SettingsView Diff Preferences', () => {
);
});
it('includes python runtime mode in metadata save payload', async () => {
(window as any).electronAPI.meta.getProjectMetadata = vi.fn().mockResolvedValue({
maxPostsPerPage: 75,
publicUrl: 'https://example.com',
pythonRuntimeMode: 'main-thread',
categorySettings: {
article: { renderInLists: true, showTitle: true },
picture: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
},
});
render(<SettingsView />);
await screen.findByDisplayValue('Main Thread (Legacy)');
const saveButton = screen.getByRole('button', { name: /save project settings/i });
fireEvent.click(saveButton);
await new Promise((resolve) => setTimeout(resolve, 0));
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
expect.objectContaining({ pythonRuntimeMode: 'main-thread' })
);
});
it('renders category settings checkboxes with required defaults', async () => {
render(<SettingsView />);

View File

@@ -191,6 +191,32 @@ describe('PythonRuntimeManager', () => {
await expect(runPromise).rejects.toThrow('boom');
});
it('queues concurrent execute calls and sends the next request only after completion', async () => {
const worker = new MockWorker();
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
const initPromise = manager.initialize();
worker.emitMessage({ type: 'ready' });
await initPromise;
const firstRun = manager.execute('1 + 1');
const secondRun = manager.execute('2 + 2');
await Promise.resolve();
expect(worker.postedMessages).toHaveLength(1);
const firstRequest = worker.postedMessages[0] as { requestId: string };
worker.emitMessage({ type: 'runResult', requestId: firstRequest.requestId, result: '2' });
await expect(firstRun).resolves.toEqual({ result: '2', stdout: '' });
await Promise.resolve();
expect(worker.postedMessages).toHaveLength(2);
const secondRequest = worker.postedMessages[1] as { requestId: string };
worker.emitMessage({ type: 'runResult', requestId: secondRequest.requestId, result: '4' });
await expect(secondRun).resolves.toEqual({ result: '4', stdout: '' });
});
it('terminates timed out worker and recovers with a new worker', async () => {
const workers: MockWorker[] = [];
const manager = new PythonRuntimeManager(() => {