feat: python scripting phase 0
This commit is contained in:
194
tests/renderer/python/PythonRuntimeManager.test.ts
Normal file
194
tests/renderer/python/PythonRuntimeManager.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PythonRuntimeManager } from '../../../src/renderer/python/PythonRuntimeManager';
|
||||
|
||||
class MockWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
terminated = false;
|
||||
postedMessages: unknown[] = [];
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.postedMessages.push(message);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
emitMessage(data: unknown): void {
|
||||
this.onmessage?.({ data } as MessageEvent);
|
||||
}
|
||||
|
||||
emitError(error: Error): void {
|
||||
this.onerror?.({ error } as ErrorEvent);
|
||||
}
|
||||
}
|
||||
|
||||
describe('PythonRuntimeManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('initializes worker once and resolves on ready signal', async () => {
|
||||
const createdWorkers: MockWorker[] = [];
|
||||
const manager = new PythonRuntimeManager(() => {
|
||||
const worker = new MockWorker();
|
||||
createdWorkers.push(worker);
|
||||
return worker as unknown as Worker;
|
||||
});
|
||||
|
||||
const initA = manager.initialize();
|
||||
const initB = manager.initialize();
|
||||
|
||||
expect(initA).toBe(initB);
|
||||
expect(createdWorkers).toHaveLength(1);
|
||||
|
||||
createdWorkers[0].emitMessage({ type: 'ready' });
|
||||
await expect(initA).resolves.toBeUndefined();
|
||||
expect(manager.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when worker emits an error before ready', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitError(new Error('bootstrap failed'));
|
||||
|
||||
await expect(initPromise).rejects.toThrow('bootstrap failed');
|
||||
expect(manager.isReady()).toBe(false);
|
||||
});
|
||||
|
||||
it('executes code and returns stdout and result', 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 runPromise = manager.execute('print("hello")\n1 + 1');
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { type: string; requestId: string; code: string };
|
||||
|
||||
worker.emitMessage({ type: 'stdout', requestId: request.requestId, chunk: 'hello\n' });
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '2' });
|
||||
|
||||
await expect(runPromise).resolves.toEqual({ result: '2', stdout: 'hello\n' });
|
||||
});
|
||||
|
||||
it('rejects when runtime returns run error', 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 runPromise = manager.execute('raise RuntimeError("boom")');
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'runError', requestId: request.requestId, error: 'boom' });
|
||||
|
||||
await expect(runPromise).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('terminates timed out worker and recovers with a new worker', async () => {
|
||||
const workers: MockWorker[] = [];
|
||||
const manager = new PythonRuntimeManager(() => {
|
||||
const worker = new MockWorker();
|
||||
workers.push(worker);
|
||||
return worker as unknown as Worker;
|
||||
});
|
||||
|
||||
const firstInit = manager.initialize();
|
||||
workers[0].emitMessage({ type: 'ready' });
|
||||
await firstInit;
|
||||
|
||||
const timedOutRun = manager.execute('while True: pass', { timeoutMs: 100 });
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(101);
|
||||
await expect(timedOutRun).rejects.toThrow('timed out');
|
||||
expect(workers[0].terminated).toBe(true);
|
||||
expect(manager.isReady()).toBe(false);
|
||||
|
||||
const secondInit = manager.initialize();
|
||||
workers[1].emitMessage({ type: 'ready' });
|
||||
await secondInit;
|
||||
|
||||
const secondRun = manager.execute('40 + 2');
|
||||
await Promise.resolve();
|
||||
const request = workers[1].postedMessages[0] as { requestId: string };
|
||||
workers[1].emitMessage({ type: 'runResult', requestId: request.requestId, result: '42' });
|
||||
|
||||
await expect(secondRun).resolves.toEqual({ result: '42', stdout: '' });
|
||||
});
|
||||
|
||||
it('rejects macro execution when ABI context is invalid on caller side', 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 runPromise = manager.renderMacroV1('def render(context):\n return {"html": "<p>ok</p>"}', {
|
||||
env: {
|
||||
isPreview: 'yes',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runPromise).rejects.toThrow('Invalid macro context');
|
||||
expect(worker.postedMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns validated macro result and stdout', 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 runPromise = manager.renderMacroV1('def render(context):\n return {"html": "<p>ok</p>"}', {
|
||||
env: {
|
||||
isPreview: true,
|
||||
},
|
||||
params: {
|
||||
title: 'Hello',
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'stdout', requestId: request.requestId, chunk: 'rendering\n' });
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: '<p>ok</p>' } });
|
||||
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: 'rendering\n' });
|
||||
});
|
||||
|
||||
it('rejects macro execution when worker result violates ABI schema', 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 runPromise = manager.renderMacroV1('def render(context):\n return {"html": "<p>ok</p>"}', {
|
||||
env: {
|
||||
isPreview: true,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: 42 } });
|
||||
|
||||
await expect(runPromise).rejects.toThrow('Invalid macro result');
|
||||
});
|
||||
});
|
||||
45
tests/renderer/python/pythonRuntimeBenchmark.test.ts
Normal file
45
tests/renderer/python/pythonRuntimeBenchmark.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { summarizeDurations, runPythonRuntimeBenchmark } from '../../../src/renderer/python/pythonRuntimeBenchmark';
|
||||
|
||||
describe('pythonRuntimeBenchmark', () => {
|
||||
it('computes p50 and p95 summary metrics', () => {
|
||||
const summary = summarizeDurations([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
|
||||
expect(summary.count).toBe(10);
|
||||
expect(summary.minMs).toBe(1);
|
||||
expect(summary.maxMs).toBe(10);
|
||||
expect(summary.meanMs).toBe(5.5);
|
||||
expect(summary.p50Ms).toBe(5.5);
|
||||
expect(summary.p95Ms).toBe(9.55);
|
||||
});
|
||||
|
||||
it('runs benchmark phases and returns measured durations', async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = {
|
||||
async runPythonAsync(code: string): Promise<string> {
|
||||
calls.push(code);
|
||||
return 'ok';
|
||||
},
|
||||
};
|
||||
|
||||
const timestamps = [0, 100, 100, 110, 110, 111, 111, 113, 113, 116, 116, 120];
|
||||
let index = 0;
|
||||
|
||||
const result = await runPythonRuntimeBenchmark({
|
||||
iterations: 4,
|
||||
loadRuntime: async () => runtime,
|
||||
now: () => {
|
||||
const value = timestamps[index];
|
||||
index += 1;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.coldStartMs).toBe(100);
|
||||
expect(result.warmRunMs).toBe(10);
|
||||
expect(result.repeatedMacro.samplesMs).toEqual([1, 2, 3, 4]);
|
||||
expect(result.repeatedMacro.stats.p50Ms).toBe(2.5);
|
||||
expect(result.repeatedMacro.stats.p95Ms).toBe(3.85);
|
||||
expect(calls).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user