Переглянути джерело

[web] include output tracking in run-finished lifecycle event in pyodide

GitOrigin-RevId: 5c72abc35ea4045f9c6aa374a2c51490f8f6cd38
Domagoj Kriskovic 4 місяців тому
батько
коміт
7d032e73d6

+ 2 - 1
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
     }

+ 1 - 0
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

+ 26 - 6
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<string>()
+
   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<PyodideFS['write']>) => {
+      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 => {

+ 51 - 6
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: [],
     })
   })