From 415db24ba4ef30396903de1a05cbc360a3f57cc8 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 20 Apr 2026 14:49:49 +0200 Subject: [PATCH] [web] Add PythonExecutionContext with per-file output buffers (#32737) * [web] extract PythonExecutionContext and PythonRunner to manage pyodide execution per file * [web] define worker URL in python execution context in order to avoid breaking cjs-based tests * [web] use null check for doc contents to allow running empty python files * [web] flush buffered editor ops before refreshing snapshot for python execution * [web] catch getExecutionContext errors in python runner to prevent unhandled rejections * [web] add PythonRunner unit tests and extract shared WorkerMock * refactor: rename snapshot to state in PythonRunner * fix: remove unnecessary path normalization in PythonExecutionProvider * fix cypress tests GitOrigin-RevId: 9c55586d982fe8df5b90374227005c6b83e94d1f --- .../web/frontend/extracted-translations.json | 1 - .../editor/python/pyodide-worker-client.ts | 76 ++-- .../editor/python/pyodide-worker-messages.ts | 9 +- .../editor/python/pyodide.worker.ts | 26 +- .../editor/python/python-output-pane.tsx | 212 ++--------- .../components/editor/python/python-runner.ts | 213 +++++++++++ .../components/layout/python-editor-split.tsx | 37 +- .../context/python-execution-context.tsx | 140 ++++++++ services/web/locales/en.json | 5 +- .../components/python-output-pane.spec.tsx | 19 +- .../unit/editor/pyodide-worker-client.spec.ts | 178 +++++----- .../unit/editor/python-runner.spec.ts | 332 ++++++++++++++++++ .../ide-react/unit/editor/worker-mock.ts | 35 ++ 13 files changed, 938 insertions(+), 345 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts create mode 100644 services/web/frontend/js/features/ide-react/context/python-execution-context.tsx create mode 100644 services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts create mode 100644 services/web/test/frontend/features/ide-react/unit/editor/worker-mock.ts diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 1925d546a0..6325078a57 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -606,7 +606,6 @@ "essential_cookies_only": "", "event_type": "", "example_project": "", - "execution_stopped": "", "existing_plan_active_until_term_end": "", "expand": "", "experiment_full_check_back_soon": "", 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 668a43adc3..c31864692b 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 @@ -7,30 +7,45 @@ import type { export type OutputCallback = ( stream: 'stdout' | 'stderr', line: string, - requestId?: string + fileId: string, + executionId: string ) => void export type LifecycleCallback = ( event: | { type: 'loaded' } | { type: 'loading-failed'; error: string } - | { type: 'run-finished'; requestId: string; outputs: string[] } + | { + type: 'run-finished' + fileId: string + executionId: string + outputs: string[] + } ) => void export class PyodideWorkerClient { private worker: Worker private baseAssetPath: string + private createWorker: () => Worker private listening = false - private loaded = false private destroyed = false private loadingError: string | null = null private pendingMessages: PyodideWorkerRequest[] = [] - private outputCallback: OutputCallback | null = null - private lifecycleCallback: LifecycleCallback | null = null + private outputCallback: OutputCallback | null + private lifecycleCallback: LifecycleCallback | null - constructor(options: { baseAssetPath: string }) { + constructor(options: { + baseAssetPath: string + createWorker: () => Worker + onOutput?: OutputCallback + onLifecycle?: LifecycleCallback + }) { this.baseAssetPath = options.baseAssetPath + this.createWorker = options.createWorker + this.outputCallback = options.onOutput ?? null + this.lifecycleCallback = options.onLifecycle ?? null this.worker = this.createWorker() + this.worker.addEventListener('message', this.receive) this.queueMessage({ type: 'init', @@ -38,25 +53,9 @@ export class PyodideWorkerClient { }) } - setOutputCallback(callback: OutputCallback) { - this.outputCallback = callback - } - - setLifecycleCallback(handler: LifecycleCallback) { - this.lifecycleCallback = handler - - if (this.loaded) { - handler({ type: 'loaded' }) - return - } - if (this.loadingError) { - handler({ type: 'loading-failed', error: this.loadingError }) - } - } - runCode( code: string, - options: { requestId: string; files: ProjectFileData[] } + options: { fileId: string; executionId: string; files: ProjectFileData[] } ): void { if (this.destroyed) { throw new Error('Pyodide worker client has been destroyed') @@ -69,12 +68,13 @@ export class PyodideWorkerClient { this.queueMessage({ type: 'run-code', code, - id: options.requestId, + fileId: options.fileId, + executionId: options.executionId, files: options.files, }) } - stop(): void { + reset(): void { if (this.destroyed) { return } @@ -85,11 +85,11 @@ export class PyodideWorkerClient { // Reset state for the new worker this.listening = false - this.loaded = false this.loadingError = null // Create a fresh worker and re-initialize Pyodide this.worker = this.createWorker() + this.worker.addEventListener('message', this.receive) this.queueMessage({ type: 'init', baseAssetPath: this.baseAssetPath, @@ -104,23 +104,9 @@ export class PyodideWorkerClient { this.destroyed = true this.pendingMessages.length = 0 - if (!this.loaded && !this.loadingError) { - this.loadingError = 'Pyodide worker was destroyed before loading finished' - } - this.worker.terminate() } - private createWorker(): Worker { - const worker = new Worker( - /* webpackChunkName: "pyodide-worker" */ - new URL('./pyodide.worker.ts', import.meta.url), - { type: 'module' } - ) - worker.addEventListener('message', this.receive) - return worker - } - private queueMessage(message: PyodideWorkerRequest) { if (this.listening) { this.worker.postMessage(message) @@ -147,7 +133,6 @@ export class PyodideWorkerClient { return case 'loaded': - this.loaded = true this.lifecycleCallback?.({ type: 'loaded' }) return @@ -164,17 +149,18 @@ export class PyodideWorkerClient { this.outputCallback?.( response.stream, response.line, - response.requestId + response.fileId, + response.executionId ) - break + return case 'run-code-result': this.lifecycleCallback?.({ type: 'run-finished', - requestId: response.id, + fileId: response.fileId, + executionId: response.executionId, 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 9647517b3b..c362d0b058 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 @@ -12,7 +12,8 @@ export type InitRequest = { export type RunCodeRequest = { type: 'run-code' - id: string + fileId: string + executionId: string code: string files: ProjectFileData[] } @@ -29,7 +30,8 @@ export type OutputLineEvent = { type: 'output-line' stream: 'stdout' | 'stderr' line: string - requestId?: string + fileId: string + executionId: string } export type PyodideWorkerEvent = @@ -42,7 +44,8 @@ export type PyodideWorkerEvent = export type RunCodeResult = { type: 'run-code-result' - id: string + fileId: string + executionId: string outputs: string[] } 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 a1210fb724..5254f50eb2 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 @@ -84,17 +84,20 @@ async function handleInit(msg: { baseAssetPath: string }) { } async function handleRunCode(msg: RunCodeRequest) { + const { fileId, executionId } = msg + if (!pyodideModule) { - const error = 'Pyodide is not initialized' self.postMessage({ type: 'output-line', stream: 'stderr', - line: error, - requestId: msg.id, + line: 'Pyodide is not initialized', + fileId, + executionId, }) self.postMessage({ type: 'run-code-result', - id: msg.id, + fileId, + executionId, outputs: [], }) return @@ -112,7 +115,8 @@ async function handleRunCode(msg: RunCodeRequest) { type: 'output-line', stream: 'stdout', line, - requestId: msg.id, + fileId, + executionId, }) }, }) @@ -122,7 +126,8 @@ async function handleRunCode(msg: RunCodeRequest) { type: 'output-line', stream: 'stderr', line, - requestId: msg.id, + fileId, + executionId, }) }, }) @@ -153,7 +158,8 @@ async function handleRunCode(msg: RunCodeRequest) { type: 'output-line', stream: 'stdout', line: String(result), - requestId: msg.id, + fileId, + executionId, }) } } catch (runError) { @@ -164,14 +170,16 @@ async function handleRunCode(msg: RunCodeRequest) { type: 'output-line', stream: 'stderr', line: errorMessage, - requestId: msg.id, + fileId, + executionId, }) } finally { fs.write = originalWrite const outputs = [...writtenPaths].sort() self.postMessage({ type: 'run-code-result', - id: msg.id, + fileId, + executionId, outputs, }) } diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx index 7cf3f25fae..f69cce748e 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx +++ b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx @@ -1,174 +1,32 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useMemo, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' -import path from 'path-browserify' import OLButton from '@/shared/components/ol/ol-button' import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar' import MaterialIcon from '@/shared/components/material-icon' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' -import { useProjectContext } from '@/shared/context/project-context' -import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' -import { debugConsole } from '@/utils/debugging' -import getMeta from '@/utils/meta' -import { PyodideWorkerClient } from './pyodide-worker-client' +import { usePythonExecutionContext } from '@/features/ide-react/context/python-execution-context' +import { DEFAULT_STATE } from './python-runner' + +const emptySubscribe = () => () => {} +const getDefaultState = () => DEFAULT_STATE export default function PythonOutputPane() { const { t } = useTranslation() - const { currentDocument, currentDocumentId } = useEditorOpenDocContext() - const { pathInFolder } = useFileTreePathContext() - const { projectSnapshot } = useProjectContext() - const clientRef = useRef(null) - const currentRequestIdRef = useRef(null) - const [isReady, setIsReady] = useState(false) - const [output, setOutput] = useState([]) - const [isRunning, setIsRunning] = useState(false) - const [error, setError] = useState(null) - const [isLoadingPyodide, setIsLoadingPyodide] = useState(true) + const { currentDocumentId } = useEditorOpenDocContext() + const { getPythonRunner } = usePythonExecutionContext() + const pythonRunner = useMemo( + () => (currentDocumentId ? getPythonRunner(currentDocumentId) : null), + [currentDocumentId, getPythonRunner] + ) - const appendOutput = useCallback((line: string) => { - setOutput(previousOutput => [...previousOutput, line]) - }, []) + const { output, error, status } = useSyncExternalStore( + pythonRunner ? pythonRunner.subscribe : emptySubscribe, + pythonRunner ? pythonRunner.getState : getDefaultState + ) - useEffect(() => { - const baseAssetPath = new URL( - getMeta('ol-baseAssetPath'), - window.location.href - ).toString() - const client = new PyodideWorkerClient({ baseAssetPath }) - clientRef.current = client - let cancelled = false - - client.setLifecycleCallback(event => { - if (cancelled) { - return - } - - switch (event.type) { - case 'loaded': - setIsReady(true) - setIsLoadingPyodide(false) - return - - case 'loading-failed': - debugConsole.error('Failed to load Python runtime', event.error) - setIsLoadingPyodide(false) - setError(formatError(event.error)) - setIsRunning(false) - return - - case 'run-finished': - if (event.requestId !== currentRequestIdRef.current) { - return - } - currentRequestIdRef.current = null - setIsRunning(false) - break - } - }) - - client.setOutputCallback((_stream, line, requestId) => { - if (!requestId || requestId !== currentRequestIdRef.current) { - return - } - appendOutput(line) - }) - - return () => { - cancelled = true - currentRequestIdRef.current = null - client.destroy() - clientRef.current = null - } - }, [appendOutput]) - - useEffect(() => { - currentRequestIdRef.current = null - setIsRunning(false) - setOutput([]) - setError(null) - }, [currentDocumentId]) - - const buildCurrentDocumentSyncFile = useCallback(() => { - if (!currentDocument || !currentDocumentId) { - return null - } - - const content = currentDocument.getSnapshot() - if (typeof content !== 'string') { - return null - } - - const currentPath = pathInFolder(currentDocumentId) - if (!currentPath) { - throw new Error( - 'Unable to resolve current document path for Python sync.' - ) - } - - return { - relativePath: path.posix.normalize(currentPath), - content, - } - }, [currentDocument, currentDocumentId, pathInFolder]) - - const getLatestProjectFiles = useCallback(async () => { - await projectSnapshot.refresh() - return projectSnapshot.getDocPaths().reduce( - (files, relativePath) => { - const content = projectSnapshot.getDocContents(relativePath) - if (content !== null) { - files.push({ relativePath, content }) - } - return files - }, - [] as { relativePath: string; content: string }[] - ) - }, [projectSnapshot]) - - const handleRun = useCallback(async () => { - const client = clientRef.current - if (!client || !isReady) { - return - } - - const syncFile = buildCurrentDocumentSyncFile() - if (!syncFile) { - return - } - - setOutput([]) - setError(null) - - const requestId = syncFile.relativePath - currentRequestIdRef.current = requestId - setIsRunning(true) - - const isCancelled = () => currentRequestIdRef.current !== requestId - - try { - const files = await getLatestProjectFiles() - if (isCancelled()) return - client.runCode(syncFile.content, { requestId, files }) - } catch (runError) { - if (isCancelled()) return - currentRequestIdRef.current = null - setIsRunning(false) - setError(formatError(runError)) - } - }, [buildCurrentDocumentSyncFile, getLatestProjectFiles, isReady]) - - const handleStop = useCallback(() => { - const client = clientRef.current - if (!client) { - return - } - - currentRequestIdRef.current = null - client.stop() - setIsRunning(false) - setIsReady(false) - setIsLoadingPyodide(true) - appendOutput(t('execution_stopped')) - }, [appendOutput, t]) + if (!pythonRunner) { + return null + } return (
@@ -176,17 +34,25 @@ export default function PythonOutputPane() {
{ + if (status === 'running') { + pythonRunner.interrupt() + } else { + pythonRunner.run() + } + }} + variant={status === 'running' ? 'danger' : 'primary'} className="compile-button align-items-center py-0 px-3" - disabled={!isRunning && !isReady} + disabled={status === 'loading'} aria-label={ - isRunning ? t('stop_python_execution') : t('run_python_code') + status === 'running' + ? t('stop_python_execution') + : t('run_python_code') } > - {isRunning ? t('stop') : t('run')} + {status === 'running' ? t('stop') : t('run')} @@ -195,12 +61,12 @@ export default function PythonOutputPane() {
- {isLoadingPyodide && ( + {status === 'loading' && (
{t('loading_python_runtime')}
)} - {!isLoadingPyodide && !error && output.length === 0 && ( + {status !== 'loading' && !error && output.length === 0 && (
{t('run_current_script_to_see_output')}
@@ -217,11 +83,3 @@ export default function PythonOutputPane() {
) } - -function formatError(error: unknown): string { - if (error instanceof Error) { - return error.message - } - - return String(error) -} diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts b/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts new file mode 100644 index 0000000000..7f126f6062 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts @@ -0,0 +1,213 @@ +// Per-file Python execution manager. Each PythonRunner owns a PyodideWorkerClient +// and exposes a subscribe/getState API for use with useSyncExternalStore, +// so React components can reactively read execution status and output. +import { v4 as uuid } from 'uuid' +import { debugConsole } from '@/utils/debugging' +import { PyodideWorkerClient } from './pyodide-worker-client' + +const MAX_OUTPUT_LINES = 100 + +export type ExecutionStatus = + | 'loading' + | 'idle' + | 'running' + | 'finished' + | 'errored' + +export type ExecutionContext = { + code: string + files: { relativePath: string; content: string }[] +} + +type Listener = () => void + +export type PythonRunnerState = { + output: string[] + status: ExecutionStatus + error: string | null +} + +export const DEFAULT_STATE: PythonRunnerState = { + output: [], + status: 'loading', + error: null, +} + +export class PythonRunner { + readonly fileId: string + private client: PyodideWorkerClient | null = null + private readonly baseAssetPath: string + private readonly createWorker: () => Worker + private readonly getExecutionContext: () => Promise + private listeners = new Set() + + private activeExecutionId: string | null = null + private state: PythonRunnerState = DEFAULT_STATE + + constructor( + fileId: string, + baseAssetPath: string, + getExecutionContext: () => Promise, + createWorker: () => Worker + ) { + this.fileId = fileId + this.baseAssetPath = baseAssetPath + this.createWorker = createWorker + this.getExecutionContext = getExecutionContext + } + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + getState = () => this.state + + private updateState(fields: Partial) { + const prev = this.state + const output = fields.output ?? prev.output + const status = fields.status ?? prev.status + const error = fields.error !== undefined ? fields.error : prev.error + + if ( + output === prev.output && + status === prev.status && + error === prev.error + ) { + return + } + + this.state = { output, status, error } + + for (const listener of this.listeners) { + listener() + } + } + + init() { + if (this.client) { + return + } + + this.updateState({ status: 'loading', error: null }) + + this.client = new PyodideWorkerClient({ + baseAssetPath: this.baseAssetPath, + createWorker: this.createWorker, + onLifecycle: event => { + switch (event.type) { + case 'loaded': + this.updateState({ status: 'idle', error: null }) + return + + case 'loading-failed': + debugConsole.error('Failed to load Python runtime', event.error) + this.updateState({ status: 'errored', error: event.error }) + return + + case 'run-finished': + if ( + event.fileId !== this.fileId || + this.activeExecutionId !== event.executionId + ) { + return + } + this.activeExecutionId = null + this.updateState({ status: 'finished' }) + } + }, + onOutput: (_stream, line, fileId, executionId) => { + if (fileId !== this.fileId || this.activeExecutionId !== executionId) { + return + } + this.updateState({ output: appendCapped(this.state.output, line) }) + }, + }) + } + + async run() { + if (!this.client || this.state.status === 'loading') { + return + } + + let context: ExecutionContext | null + try { + context = await this.getExecutionContext() + } catch (err) { + debugConsole.error('Failed to build execution context', err) + this.updateState({ status: 'errored', error: formatError(err) }) + return + } + + // Re-check after await — status may have changed but TypeScript + // still narrows from the pre-await check, so we cast back. + if ( + !context || + !this.client || + (this.state.status as ExecutionStatus) === 'loading' + ) { + return + } + + const { code, files } = context + + const executionId = uuid() + this.activeExecutionId = executionId + this.updateState({ status: 'running', output: [], error: null }) + + try { + this.client.runCode(code, { + fileId: this.fileId, + executionId, + files, + }) + } catch (runError) { + if (this.activeExecutionId !== executionId) { + return + } + this.activeExecutionId = null + this.updateState({ status: 'errored', error: formatError(runError) }) + } + } + + interrupt() { + if (!this.client) { + return + } + + this.client.reset() + this.activeExecutionId = null + + // The worker is terminated and recreated by reset(), so it needs to + // reload Pyodide. The 'loaded' lifecycle callback will transition + // back to 'idle'. + this.updateState({ + status: 'loading', + output: + this.state.status === 'running' + ? appendCapped(this.state.output, 'Execution interrupted') + : this.state.output, + }) + } + + destroy() { + if (this.client) { + this.client.destroy() + this.client = null + } + } +} + +function appendCapped(existing: string[], line: string): string[] { + const updated = [...existing, line] + return updated.length > MAX_OUTPUT_LINES + ? updated.slice(-MAX_OUTPUT_LINES) + : updated +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.message + } + return String(error) +} diff --git a/services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx b/services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx index a78c4feeb2..e73cd950a7 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx @@ -2,26 +2,29 @@ import { Panel, PanelGroup } from 'react-resizable-panels' import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle' import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane' import SourceEditor from '@/features/source-editor/components/source-editor' +import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context' export const PythonEditorSplit = () => { return ( - - - - - - + - - - + + + + + + + + + ) } diff --git a/services/web/frontend/js/features/ide-react/context/python-execution-context.tsx b/services/web/frontend/js/features/ide-react/context/python-execution-context.tsx new file mode 100644 index 0000000000..417bbd79cd --- /dev/null +++ b/services/web/frontend/js/features/ide-react/context/python-execution-context.tsx @@ -0,0 +1,140 @@ +import { + createContext, + FC, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react' +import getMeta from '@/utils/meta' +import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' +import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useProjectContext } from '@/shared/context/project-context' +import { + PythonRunner, + ExecutionContext, +} from '@/features/ide-react/components/editor/python/python-runner' + +// Worker factory lives here (a .tsx file) so that the full +// `new Worker(new URL(..., import.meta.url))` expression is in a single place +// where webpack 5 can statically detect it and create a proper worker bundle. +// Keeping import.meta.url out of .ts files also avoids Node.js 24 switching to +// ESM mode and breaking CJS-based test loading via @babel/register. +const createPyodideWorker = () => + new Worker( + /* webpackChunkName: "pyodide-worker" */ + new URL('../components/editor/python/pyodide.worker.ts', import.meta.url), + { type: 'module' } + ) + +export interface PythonExecutionContextValue { + getPythonRunner: (fileId: string) => PythonRunner +} + +export const PythonExecutionContext = createContext< + PythonExecutionContextValue | undefined +>(undefined) + +export const PythonExecutionProvider: FC = ({ + children, +}) => { + const { openDocs } = useEditorManagerContext() + const { projectSnapshot } = useProjectContext() + const { pathInFolder } = useFileTreePathContext() + const runnersRef = useRef(new Map()) + const baseAssetPathRef = useRef(null) + + const pathInFolderRef = useRef(pathInFolder) + pathInFolderRef.current = pathInFolder + + // Refreshes the project snapshot and resolves the source code and all project + // files for the given fileId, to be passed to the executor for running. + const getExecutionContext = useCallback( + async (fileId: string): Promise => { + await openDocs.awaitBufferedOps(AbortSignal.timeout(5000)) + await projectSnapshot.refresh() + + const relativePath = pathInFolderRef.current(fileId) + if (!relativePath) { + return null + } + + const code = projectSnapshot.getDocContents(relativePath) + if (code == null) { + return null + } + + const docPaths = projectSnapshot.getDocPaths() + const files = docPaths + .map(docPath => { + const content = projectSnapshot.getDocContents(docPath) + return content != null ? { relativePath: docPath, content } : null + }) + .filter( + (f): f is { relativePath: string; content: string } => f != null + ) + + return { code, files } + }, + [openDocs, projectSnapshot] + ) + + const getPythonRunner = useCallback( + (fileId: string): PythonRunner => { + const existing = runnersRef.current.get(fileId) + if (existing) { + return existing + } + + if (!baseAssetPathRef.current) { + baseAssetPathRef.current = new URL( + getMeta('ol-baseAssetPath'), + window.location.href + ).toString() + } + + const runner = new PythonRunner( + fileId, + baseAssetPathRef.current, + () => getExecutionContext(fileId), + createPyodideWorker + ) + runner.init() + runnersRef.current.set(fileId, runner) + return runner + }, + [getExecutionContext] + ) + + useEffect(() => { + const runners = runnersRef.current + return () => { + for (const runner of runners.values()) { + runner.destroy() + } + runners.clear() + } + }, []) + + const value = useMemo(() => ({ getPythonRunner }), [getPythonRunner]) + + return ( + + {children} + + ) +} + +export const usePythonExecutionContext = (): PythonExecutionContextValue => { + const context = useContext(PythonExecutionContext) + + if (!context) { + throw new Error( + 'usePythonExecutionContext is only available inside PythonExecutionContext.Provider' + ) + } + + return context +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 40bde5c909..2cc51684fe 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -808,7 +808,6 @@ "examples_lowercase": "examples", "examples_to_help_you_learn": "Examples to help you learn how to use powerful LaTeX packages and techniques.", "exclusive_access_with_labs": "Exclusive access to early-stage experiments", - "execution_stopped": "Execution stopped.", "existing_plan_active_until_term_end": "Your existing plan and its features will remain active until the end of the current billing period.", "expand": "Expand", "experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.", @@ -2127,7 +2126,7 @@ "ru": "Russian", "run": "Run", "run_current_script_to_see_output": "Run the current script to see output.", - "run_python_code": "Run Python Code", + "run_python_code": "Run Python code", "saml": "SAML", "saml_auth_error": "Sorry, your identity provider responded with an error. Please contact your administrator for more information.", "saml_authentication_required_error": "Other login methods have been disabled by your group administrator. Please use your group SSO login.", @@ -2417,7 +2416,7 @@ "stop_on_first_error_enabled_description": "<0>“Stop on first error” is enabled. Disabling it may allow the compiler to produce a PDF (but your project will still have errors).", "stop_on_first_error_enabled_title": "No PDF: Stop on first error enabled", "stop_on_validation_error": "Check syntax before compile", - "stop_python_execution": "Stop Python Execution", + "stop_python_execution": "Stop Python execution", "stops_compiling_after_the_first_error_so_you_can_fix_issues_one_at_a_time": "Stops compiling after the first error so you can fix issues one at a time", "store_your_work": "Store your work on your own infrastructure", "stretch_width_to_text": "Stretch width to text", diff --git a/services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx b/services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx index 74eaec52e8..6d53882a00 100644 --- a/services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx +++ b/services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx @@ -7,6 +7,7 @@ import { import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' import { ProjectContext } from '@/shared/context/project-context' import { ProjectSnapshot } from '@/infrastructure/project-snapshot' +import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context' const pythonExecutableScript: Record = { file_id: 'test-py-doc-id', @@ -83,11 +84,13 @@ describe('', function () { }} providers={{ FileTreePathProvider, ProjectProvider }} > - + + + ) - cy.findByRole('button', { name: 'Run Python Code' }) + cy.findByRole('button', { name: 'Run Python code' }) .should('not.be.disabled') .click() cy.findByText('hello!').should('exist') @@ -122,11 +125,13 @@ describe('', function () { }} providers={{ FileTreePathProvider, ProjectProvider }} > - + + + ) - cy.findByRole('button', { name: 'Run Python Code' }) + cy.findByRole('button', { name: 'Run Python code' }) .should('not.be.disabled') .click() cy.findByText('hello!').should('exist') @@ -195,11 +200,13 @@ describe('', function () { ProjectProvider, }} > - + + + ) - cy.findByRole('button', { name: 'Run Python Code' }) + cy.findByRole('button', { name: 'Run Python code' }) .should('not.be.disabled') .click() cy.findByText('name,type').should('exist') 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 e0661d0392..c0f3f69fc7 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 @@ -1,64 +1,24 @@ import { expect } from 'chai' import { PyodideWorkerClient } from '@/features/ide-react/components/editor/python/pyodide-worker-client' - -type WorkerMessageListener = (event: MessageEvent) => void +import { WorkerMock, createWorker } from './worker-mock' const BASE_ASSET_PATH = 'https://assets.example.test/' -class WorkerMock { - static instances: WorkerMock[] = [] - - readonly postedMessages: any[] = [] - terminated = false - private messageListeners: WorkerMessageListener[] = [] - - constructor() { - WorkerMock.instances.push(this) - } - - addEventListener(type: string, listener: WorkerMessageListener) { - if (type === 'message') { - this.messageListeners.push(listener) - } - } - - postMessage(message: unknown) { - this.postedMessages.push(message) - } - - terminate() { - this.terminated = true - } - - emitMessage(message: unknown) { - for (const listener of this.messageListeners) { - listener({ data: message, target: this } as unknown as MessageEvent) - } - } -} - describe('PyodideWorkerClient', function () { - let originalWorker: typeof Worker | undefined - beforeEach(function () { - originalWorker = window.Worker - // @ts-ignore - allow mocking Worker - window.Worker = WorkerMock WorkerMock.instances.length = 0 }) - afterEach(function () { - if (originalWorker) { - window.Worker = originalWorker - } - }) - it('queues runCode until the worker reports listening', function () { - const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH }) + const client = new PyodideWorkerClient({ + baseAssetPath: BASE_ASSET_PATH, + createWorker, + }) const worker = WorkerMock.instances[0] client.runCode('print("ok")', { - requestId: 'main.py', + fileId: 'main.py', + executionId: 'exec-1', files: [{ relativePath: 'main.py', content: 'print("ok")' }], }) expect(worker.postedMessages).to.have.length(0) @@ -74,7 +34,8 @@ describe('PyodideWorkerClient', function () { ) expect(runRequest).to.include({ type: 'run-code', - id: 'main.py', + fileId: 'main.py', + executionId: 'exec-1', code: 'print("ok")', }) expect(runRequest.files).to.deep.equal([ @@ -83,29 +44,44 @@ describe('PyodideWorkerClient', function () { }) it('sends runCode as fire-and-forget', function () { - const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH }) + const client = new PyodideWorkerClient({ + baseAssetPath: BASE_ASSET_PATH, + createWorker, + }) const worker = WorkerMock.instances[0] worker.emitMessage({ type: 'listening' }) client.runCode('raise RuntimeError("boom")', { - requestId: 'boom.py', + fileId: 'boom.py', + executionId: 'exec-2', files: [], }) const runRequest = worker.postedMessages.find( message => message.type === 'run-code' ) - expect(runRequest).to.include({ type: 'run-code', id: 'boom.py' }) + expect(runRequest).to.include({ + type: 'run-code', + fileId: 'boom.py', + executionId: 'exec-2', + }) }) function setupClientWithLifecycleTracking() { - const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH }) - const worker = WorkerMock.instances[0] - const lifecycleEvents: Array<{ + const lifecycleEvents: { type: string - requestId?: string + fileId?: string + executionId?: string outputs?: string[] - }> = [] - client.setLifecycleCallback(event => lifecycleEvents.push(event)) + }[] = [] + + const client = new PyodideWorkerClient({ + baseAssetPath: BASE_ASSET_PATH, + createWorker, + onLifecycle: event => { + lifecycleEvents.push(event) + }, + }) + const worker = WorkerMock.instances[0] worker.emitMessage({ type: 'listening' }) return { client, worker, lifecycleEvents } } @@ -114,16 +90,22 @@ describe('PyodideWorkerClient', function () { const { client, worker, lifecycleEvents } = setupClientWithLifecycleTracking() - client.runCode('print("ok")', { requestId: 'main.py', files: [] }) + client.runCode('print("ok")', { + fileId: 'main.py', + executionId: 'exec-3', + files: [], + }) worker.emitMessage({ type: 'run-code-result', - id: 'main.py', + fileId: 'main.py', + executionId: 'exec-3', outputs: ['/project/output.txt'], }) expect(lifecycleEvents).to.deep.include({ type: 'run-finished', - requestId: 'main.py', + fileId: 'main.py', + executionId: 'exec-3', outputs: ['/project/output.txt'], }) }) @@ -132,16 +114,22 @@ describe('PyodideWorkerClient', function () { const { client, worker, lifecycleEvents } = setupClientWithLifecycleTracking() - client.runCode('write_files()', { requestId: 'main.py', files: [] }) + client.runCode('write_files()', { + fileId: 'main.py', + executionId: 'exec-4', + files: [], + }) worker.emitMessage({ type: 'run-code-result', - id: 'main.py', + fileId: 'main.py', + executionId: 'exec-4', outputs: ['/project/fig1.png', '/project/results/data.csv'], }) expect(lifecycleEvents).to.deep.include({ type: 'run-finished', - requestId: 'main.py', + fileId: 'main.py', + executionId: 'exec-4', outputs: ['/project/fig1.png', '/project/results/data.csv'], }) }) @@ -150,28 +138,37 @@ describe('PyodideWorkerClient', function () { const { client, worker, lifecycleEvents } = setupClientWithLifecycleTracking() - client.runCode('print("no writes")', { requestId: 'main.py', files: [] }) + client.runCode('print("no writes")', { + fileId: 'main.py', + executionId: 'exec-5', + files: [], + }) worker.emitMessage({ type: 'run-code-result', - id: 'main.py', + fileId: 'main.py', + executionId: 'exec-5', outputs: [], }) expect(lifecycleEvents).to.deep.include({ type: 'run-finished', - requestId: 'main.py', + fileId: 'main.py', + executionId: 'exec-5', outputs: [], }) }) it('reports lifecycle failure and rejects future run requests when loading fails', function () { - const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH }) - const worker = WorkerMock.instances[0] const lifecycleEvents: Array<{ type: string; error?: string }> = [] - client.setLifecycleCallback(event => { - lifecycleEvents.push(event) + const client = new PyodideWorkerClient({ + baseAssetPath: BASE_ASSET_PATH, + createWorker, + onLifecycle: event => { + lifecycleEvents.push(event) + }, }) + const worker = WorkerMock.instances[0] worker.emitMessage({ type: 'loading-failed', @@ -182,12 +179,19 @@ describe('PyodideWorkerClient', function () { { type: 'loading-failed', error: 'runtime unavailable' }, ]) expect(() => - client.runCode('print("ok")', { requestId: 'main.py', files: [] }) + client.runCode('print("ok")', { + fileId: 'main.py', + executionId: 'exec-4', + files: [], + }) ).to.throw('runtime unavailable') }) it('terminates the worker even when destroy is called after loading failure', function () { - const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH }) + const client = new PyodideWorkerClient({ + baseAssetPath: BASE_ASSET_PATH, + createWorker, + }) const worker = WorkerMock.instances[0] worker.emitMessage({ @@ -199,30 +203,32 @@ describe('PyodideWorkerClient', function () { expect(worker.terminated).to.equal(true) }) - describe('stop', function () { + describe('reset', function () { it('terminates the current worker and creates a new one', function () { const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH, + createWorker, }) const originalWorker = WorkerMock.instances[0] originalWorker.emitMessage({ type: 'listening' }) originalWorker.emitMessage({ type: 'loaded' }) - client.stop() + client.reset() expect(originalWorker.terminated).to.equal(true) expect(WorkerMock.instances).to.have.length(2) }) - it('sends init to the new worker once it reports listening', function () { + it('sends init to the new worker once it reports listening after reset', function () { const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH, + createWorker, }) const originalWorker = WorkerMock.instances[0] originalWorker.emitMessage({ type: 'listening' }) originalWorker.emitMessage({ type: 'loaded' }) - client.stop() + client.reset() const newWorker = WorkerMock.instances[1] expect(newWorker.postedMessages).to.have.length(0) @@ -233,22 +239,24 @@ describe('PyodideWorkerClient', function () { ]) }) - it('allows running code on the new worker after stop', function () { + it('allows running code on the new worker after reset', function () { const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH, + createWorker, }) const originalWorker = WorkerMock.instances[0] originalWorker.emitMessage({ type: 'listening' }) originalWorker.emitMessage({ type: 'loaded' }) - client.stop() + client.reset() const newWorker = WorkerMock.instances[1] newWorker.emitMessage({ type: 'listening' }) newWorker.emitMessage({ type: 'loaded' }) - client.runCode('print("after stop")', { - requestId: 'main.py', + client.runCode('print("after reset")', { + fileId: 'main.py', + executionId: 'exec-5', files: [], }) @@ -257,21 +265,23 @@ describe('PyodideWorkerClient', function () { ) expect(runRequest).to.include({ type: 'run-code', - id: 'main.py', - code: 'print("after stop")', + fileId: 'main.py', + executionId: 'exec-5', + code: 'print("after reset")', }) }) - it('is a no-op after destroy', function () { + it('reset is a no-op after destroy', function () { const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH, + createWorker, }) const originalWorker = WorkerMock.instances[0] originalWorker.emitMessage({ type: 'listening' }) originalWorker.emitMessage({ type: 'loaded' }) client.destroy() - client.stop() + client.reset() // No new worker should have been created expect(WorkerMock.instances).to.have.length(1) diff --git a/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts b/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts new file mode 100644 index 0000000000..c2aa79b5d4 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts @@ -0,0 +1,332 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { + PythonRunner, + DEFAULT_STATE, + ExecutionContext, +} from '@/features/ide-react/components/editor/python/python-runner' +import { WorkerMock, createWorker } from './worker-mock' + +const BASE_ASSET_PATH = 'https://assets.example.test/' +const FILE_ID = 'file-1' + +function createRunner( + overrides: { + fileId?: string + getExecutionContext?: () => Promise + } = {} +) { + const fileId = overrides.fileId ?? FILE_ID + const getExecutionContext = + overrides.getExecutionContext ?? + (() => + Promise.resolve({ + code: 'print("hello")', + files: [{ relativePath: 'main.py', content: 'print("hello")' }], + })) + + const runner = new PythonRunner( + fileId, + BASE_ASSET_PATH, + getExecutionContext, + createWorker + ) + return runner +} + +function initAndLoad(runner: PythonRunner) { + runner.init() + const worker = WorkerMock.instances[WorkerMock.instances.length - 1] + worker.emitMessage({ type: 'listening' }) + worker.emitMessage({ type: 'loaded' }) + return worker +} + +describe('PythonRunner', function () { + beforeEach(function () { + WorkerMock.instances.length = 0 + }) + + describe('initial state', function () { + it('starts with default snapshot before init', function () { + const runner = createRunner() + expect(runner.getState()).to.deep.equal(DEFAULT_STATE) + }) + }) + + describe('init and lifecycle', function () { + it('transitions to loading on init', function () { + const runner = createRunner() + runner.init() + expect(runner.getState().status).to.equal('loading') + }) + + it('transitions to idle when worker reports loaded', function () { + const runner = createRunner() + initAndLoad(runner) + expect(runner.getState().status).to.equal('idle') + }) + + it('transitions to errored on loading failure', function () { + const runner = createRunner() + runner.init() + const worker = WorkerMock.instances[0] + worker.emitMessage({ type: 'listening' }) + worker.emitMessage({ + type: 'loading-failed', + error: 'network error', + }) + expect(runner.getState().status).to.equal('errored') + expect(runner.getState().error).to.equal('network error') + }) + + it('clears error on successful load after failure', function () { + const runner = createRunner() + runner.init() + const worker = WorkerMock.instances[0] + worker.emitMessage({ type: 'listening' }) + worker.emitMessage({ type: 'loaded' }) + expect(runner.getState().error).to.equal(null) + }) + + it('is a no-op if already initialized', function () { + const runner = createRunner() + runner.init() + runner.init() + expect(WorkerMock.instances).to.have.length(1) + }) + }) + + describe('run', function () { + it('transitions to running then finished', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + expect(runner.getState().status).to.equal('running') + + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + worker.emitMessage({ + type: 'run-code-result', + fileId: FILE_ID, + executionId: runMsg.executionId, + outputs: [], + }) + + expect(runner.getState().status).to.equal('finished') + }) + + it('clears previous output on new run', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + worker.emitMessage({ + type: 'output-line', + stream: 'stdout', + line: 'first run output', + fileId: FILE_ID, + executionId: runMsg.executionId, + }) + worker.emitMessage({ + type: 'run-code-result', + fileId: FILE_ID, + executionId: runMsg.executionId, + outputs: [], + }) + expect(runner.getState().output).to.deep.equal(['first run output']) + + await runner.run() + expect(runner.getState().output).to.deep.equal([]) + }) + + it('is a no-op while still loading', async function () { + const runner = createRunner() + runner.init() + await runner.run() + expect(runner.getState().status).to.equal('loading') + }) + + it('is a no-op when getExecutionContext returns null', async function () { + const runner = createRunner({ + getExecutionContext: () => Promise.resolve(null), + }) + initAndLoad(runner) + + await runner.run() + expect(runner.getState().status).to.equal('idle') + }) + + it('transitions to errored when getExecutionContext rejects', async function () { + const runner = createRunner({ + getExecutionContext: () => Promise.reject(new Error('network failure')), + }) + initAndLoad(runner) + + await runner.run() + expect(runner.getState().status).to.equal('errored') + expect(runner.getState().error).to.equal('network failure') + }) + }) + + describe('output', function () { + it('accumulates output lines for the matching file', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + + worker.emitMessage({ + type: 'output-line', + stream: 'stdout', + line: 'line 1', + fileId: FILE_ID, + executionId: runMsg.executionId, + }) + worker.emitMessage({ + type: 'output-line', + stream: 'stderr', + line: 'line 2', + fileId: FILE_ID, + executionId: runMsg.executionId, + }) + + expect(runner.getState().output).to.deep.equal(['line 1', 'line 2']) + }) + + it('ignores output for a different fileId', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + + worker.emitMessage({ + type: 'output-line', + stream: 'stdout', + line: 'other file output', + fileId: 'different-file', + executionId: runMsg.executionId, + }) + + expect(runner.getState().output).to.deep.equal([]) + }) + + it('ignores output for a stale executionId', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + + worker.emitMessage({ + type: 'output-line', + stream: 'stdout', + line: 'stale output', + fileId: FILE_ID, + executionId: 'old-execution-id', + }) + + expect(runner.getState().output).to.deep.equal([]) + }) + + it('caps output at 100 lines', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + + for (let i = 0; i < 110; i++) { + worker.emitMessage({ + type: 'output-line', + stream: 'stdout', + line: `line ${i}`, + fileId: FILE_ID, + executionId: runMsg.executionId, + }) + } + + const output = runner.getState().output + expect(output).to.have.length(100) + expect(output[0]).to.equal('line 10') + expect(output[99]).to.equal('line 109') + }) + }) + + describe('interrupt', function () { + it('appends interrupted message and transitions to loading when running', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + worker.emitMessage({ + type: 'output-line', + stream: 'stdout', + line: 'partial output', + fileId: FILE_ID, + executionId: runMsg.executionId, + }) + + runner.interrupt() + + expect(runner.getState().status).to.equal('loading') + expect(runner.getState().output).to.deep.equal([ + 'partial output', + 'Execution interrupted', + ]) + }) + + it('does not append interrupted message when not running', function () { + const runner = createRunner() + initAndLoad(runner) + + runner.interrupt() + + expect(runner.getState().status).to.equal('loading') + expect(runner.getState().output).to.deep.equal([]) + }) + }) + + describe('subscribe', function () { + it('notifies listeners on state changes', function () { + const runner = createRunner() + const listener = sinon.stub() + + runner.subscribe(listener) + initAndLoad(runner) + + expect(listener.callCount).to.be.greaterThan(0) + }) + + it('stops notifying after unsubscribe', function () { + const runner = createRunner() + const listener = sinon.stub() + + const unsubscribe = runner.subscribe(listener) + runner.init() + const countAfterInit = listener.callCount + + unsubscribe() + const worker = WorkerMock.instances[0] + worker.emitMessage({ type: 'listening' }) + worker.emitMessage({ type: 'loaded' }) + + expect(listener.callCount).to.equal(countAfterInit) + }) + }) + + describe('destroy', function () { + it('terminates the worker', function () { + const runner = createRunner() + initAndLoad(runner) + + runner.destroy() + + const worker = WorkerMock.instances[0] + expect(worker.terminated).to.equal(true) + }) + }) +}) diff --git a/services/web/test/frontend/features/ide-react/unit/editor/worker-mock.ts b/services/web/test/frontend/features/ide-react/unit/editor/worker-mock.ts new file mode 100644 index 0000000000..b14697f0a4 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/editor/worker-mock.ts @@ -0,0 +1,35 @@ +type WorkerMessageListener = (event: MessageEvent) => void + +export class WorkerMock { + static instances: WorkerMock[] = [] + + readonly postedMessages: any[] = [] + terminated = false + private messageListeners: WorkerMessageListener[] = [] + + constructor() { + WorkerMock.instances.push(this) + } + + addEventListener(type: string, listener: WorkerMessageListener) { + if (type === 'message') { + this.messageListeners.push(listener) + } + } + + postMessage(message: unknown) { + this.postedMessages.push(message) + } + + terminate() { + this.terminated = true + } + + emitMessage(message: unknown) { + for (const listener of this.messageListeners) { + listener({ data: message, target: this } as unknown as MessageEvent) + } + } +} + +export const createWorker = () => new WorkerMock() as unknown as Worker