From 7d032e73d63d34b7e1033ce55cecd917a2417971 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Thu, 9 Apr 2026 10:33:16 +0200 Subject: [PATCH] [web] include output tracking in run-finished lifecycle event in pyodide GitOrigin-RevId: 5c72abc35ea4045f9c6aa374a2c51490f8f6cd38 --- .../editor/python/pyodide-worker-client.ts | 3 +- .../editor/python/pyodide-worker-messages.ts | 1 + .../editor/python/pyodide.worker.ts | 32 +++++++++-- .../unit/editor/pyodide-worker-client.spec.ts | 57 +++++++++++++++++-- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts index 0bfb01556f..668a43adc3 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts +++ b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts @@ -14,7 +14,7 @@ export type LifecycleCallback = ( event: | { type: 'loaded' } | { type: 'loading-failed'; error: string } - | { type: 'run-finished'; requestId: string } + | { type: 'run-finished'; requestId: string; outputs: string[] } ) => void export class PyodideWorkerClient { @@ -172,6 +172,7 @@ export class PyodideWorkerClient { this.lifecycleCallback?.({ type: 'run-finished', requestId: response.id, + outputs: response.outputs, }) break } diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts index 2512531dca..9647517b3b 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts +++ b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts @@ -43,6 +43,7 @@ export type PyodideWorkerEvent = export type RunCodeResult = { type: 'run-code-result' id: string + outputs: string[] } export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts index 14d0e65ba0..09c82a1182 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts +++ b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts @@ -10,6 +10,7 @@ type PyodideFS = PyodideInterface['FS'] type PyodideModule = typeof import('pyodide') const PROJECT_FS_ROOT = '/project' +const PROJECT_FS_PREFIX = `${PROJECT_FS_ROOT}/` const PYODIDE_INDEX_PATH = 'js/libs/pyodide/' function ensureDirectoryExists(fs: PyodideFS, filePath: string) { @@ -94,6 +95,7 @@ async function handleRunCode(msg: RunCodeRequest) { self.postMessage({ type: 'run-code-result', id: msg.id, + outputs: [], }) return } @@ -102,6 +104,8 @@ async function handleRunCode(msg: RunCodeRequest) { env: { MPLBACKEND: 'Agg' }, }) + const writtenPaths = new Set() + instance.setStdout({ batched: (line: string) => { self.postMessage({ @@ -124,11 +128,25 @@ async function handleRunCode(msg: RunCodeRequest) { }) try { + const fs = instance.FS if (msg.files.length > 0) { - const fs = instance.FS syncProjectFiles(fs, msg.files) } + const originalWrite = fs.write as PyodideFS['write'] + fs.write = ((...args: Parameters) => { + const [stream] = args + // Only surface writes to the synced project workspace, not Pyodide internals. + if ( + typeof stream?.path === 'string' && + stream.path.startsWith(PROJECT_FS_PREFIX) + ) { + writtenPaths.add(stream.path) + } + + return originalWrite(...args) + }) as PyodideFS['write'] + const result = await instance.runPythonAsync(msg.code) if (result !== undefined) { self.postMessage({ @@ -148,12 +166,14 @@ async function handleRunCode(msg: RunCodeRequest) { line: errorMessage, requestId: msg.id, }) + } finally { + const outputs = [...writtenPaths].sort() + self.postMessage({ + type: 'run-code-result', + id: msg.id, + outputs, + }) } - - self.postMessage({ - type: 'run-code-result', - id: msg.id, - }) } self.addEventListener('message', async event => { diff --git a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts b/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts index 9af01d5189..e0661d0392 100644 --- a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts +++ b/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts @@ -97,25 +97,70 @@ describe('PyodideWorkerClient', function () { expect(runRequest).to.include({ type: 'run-code', id: 'boom.py' }) }) - it('emits run-finished lifecycle event from run-code-result', function () { + function setupClientWithLifecycleTracking() { const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH }) const worker = WorkerMock.instances[0] - const lifecycleEvents: Array<{ type: string; requestId?: string }> = [] - - client.setLifecycleCallback(event => { - lifecycleEvents.push(event) - }) + const lifecycleEvents: Array<{ + type: string + requestId?: string + outputs?: string[] + }> = [] + client.setLifecycleCallback(event => lifecycleEvents.push(event)) worker.emitMessage({ type: 'listening' }) + return { client, worker, lifecycleEvents } + } + + it('emits run-finished lifecycle event from run-code-result', function () { + const { client, worker, lifecycleEvents } = + setupClientWithLifecycleTracking() client.runCode('print("ok")', { requestId: 'main.py', files: [] }) worker.emitMessage({ type: 'run-code-result', id: 'main.py', + outputs: ['/project/output.txt'], }) expect(lifecycleEvents).to.deep.include({ type: 'run-finished', requestId: 'main.py', + outputs: ['/project/output.txt'], + }) + }) + + it('surfaces outputs array from run-code-result with multiple files', function () { + const { client, worker, lifecycleEvents } = + setupClientWithLifecycleTracking() + + client.runCode('write_files()', { requestId: 'main.py', files: [] }) + worker.emitMessage({ + type: 'run-code-result', + id: 'main.py', + outputs: ['/project/fig1.png', '/project/results/data.csv'], + }) + + expect(lifecycleEvents).to.deep.include({ + type: 'run-finished', + requestId: 'main.py', + outputs: ['/project/fig1.png', '/project/results/data.csv'], + }) + }) + + it('surfaces empty outputs when no project files were written', function () { + const { client, worker, lifecycleEvents } = + setupClientWithLifecycleTracking() + + client.runCode('print("no writes")', { requestId: 'main.py', files: [] }) + worker.emitMessage({ + type: 'run-code-result', + id: 'main.py', + outputs: [], + }) + + expect(lifecycleEvents).to.deep.include({ + type: 'run-finished', + requestId: 'main.py', + outputs: [], }) })