mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[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
This commit is contained in:
committed by
Copybot
parent
e30a2a5beb
commit
415db24ba4
@@ -606,7 +606,6 @@
|
|||||||
"essential_cookies_only": "",
|
"essential_cookies_only": "",
|
||||||
"event_type": "",
|
"event_type": "",
|
||||||
"example_project": "",
|
"example_project": "",
|
||||||
"execution_stopped": "",
|
|
||||||
"existing_plan_active_until_term_end": "",
|
"existing_plan_active_until_term_end": "",
|
||||||
"expand": "",
|
"expand": "",
|
||||||
"experiment_full_check_back_soon": "",
|
"experiment_full_check_back_soon": "",
|
||||||
|
|||||||
+31
-45
@@ -7,30 +7,45 @@ import type {
|
|||||||
export type OutputCallback = (
|
export type OutputCallback = (
|
||||||
stream: 'stdout' | 'stderr',
|
stream: 'stdout' | 'stderr',
|
||||||
line: string,
|
line: string,
|
||||||
requestId?: string
|
fileId: string,
|
||||||
|
executionId: string
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
export type LifecycleCallback = (
|
export type LifecycleCallback = (
|
||||||
event:
|
event:
|
||||||
| { type: 'loaded' }
|
| { type: 'loaded' }
|
||||||
| { type: 'loading-failed'; error: string }
|
| { type: 'loading-failed'; error: string }
|
||||||
| { type: 'run-finished'; requestId: string; outputs: string[] }
|
| {
|
||||||
|
type: 'run-finished'
|
||||||
|
fileId: string
|
||||||
|
executionId: string
|
||||||
|
outputs: string[]
|
||||||
|
}
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
export class PyodideWorkerClient {
|
export class PyodideWorkerClient {
|
||||||
private worker: Worker
|
private worker: Worker
|
||||||
private baseAssetPath: string
|
private baseAssetPath: string
|
||||||
|
private createWorker: () => Worker
|
||||||
private listening = false
|
private listening = false
|
||||||
private loaded = false
|
|
||||||
private destroyed = false
|
private destroyed = false
|
||||||
private loadingError: string | null = null
|
private loadingError: string | null = null
|
||||||
private pendingMessages: PyodideWorkerRequest[] = []
|
private pendingMessages: PyodideWorkerRequest[] = []
|
||||||
private outputCallback: OutputCallback | null = null
|
private outputCallback: OutputCallback | null
|
||||||
private lifecycleCallback: LifecycleCallback | null = null
|
private lifecycleCallback: LifecycleCallback | null
|
||||||
|
|
||||||
constructor(options: { baseAssetPath: string }) {
|
constructor(options: {
|
||||||
|
baseAssetPath: string
|
||||||
|
createWorker: () => Worker
|
||||||
|
onOutput?: OutputCallback
|
||||||
|
onLifecycle?: LifecycleCallback
|
||||||
|
}) {
|
||||||
this.baseAssetPath = options.baseAssetPath
|
this.baseAssetPath = options.baseAssetPath
|
||||||
|
this.createWorker = options.createWorker
|
||||||
|
this.outputCallback = options.onOutput ?? null
|
||||||
|
this.lifecycleCallback = options.onLifecycle ?? null
|
||||||
this.worker = this.createWorker()
|
this.worker = this.createWorker()
|
||||||
|
this.worker.addEventListener('message', this.receive)
|
||||||
|
|
||||||
this.queueMessage({
|
this.queueMessage({
|
||||||
type: 'init',
|
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(
|
runCode(
|
||||||
code: string,
|
code: string,
|
||||||
options: { requestId: string; files: ProjectFileData[] }
|
options: { fileId: string; executionId: string; files: ProjectFileData[] }
|
||||||
): void {
|
): void {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
throw new Error('Pyodide worker client has been destroyed')
|
throw new Error('Pyodide worker client has been destroyed')
|
||||||
@@ -69,12 +68,13 @@ export class PyodideWorkerClient {
|
|||||||
this.queueMessage({
|
this.queueMessage({
|
||||||
type: 'run-code',
|
type: 'run-code',
|
||||||
code,
|
code,
|
||||||
id: options.requestId,
|
fileId: options.fileId,
|
||||||
|
executionId: options.executionId,
|
||||||
files: options.files,
|
files: options.files,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
reset(): void {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -85,11 +85,11 @@ export class PyodideWorkerClient {
|
|||||||
|
|
||||||
// Reset state for the new worker
|
// Reset state for the new worker
|
||||||
this.listening = false
|
this.listening = false
|
||||||
this.loaded = false
|
|
||||||
this.loadingError = null
|
this.loadingError = null
|
||||||
|
|
||||||
// Create a fresh worker and re-initialize Pyodide
|
// Create a fresh worker and re-initialize Pyodide
|
||||||
this.worker = this.createWorker()
|
this.worker = this.createWorker()
|
||||||
|
this.worker.addEventListener('message', this.receive)
|
||||||
this.queueMessage({
|
this.queueMessage({
|
||||||
type: 'init',
|
type: 'init',
|
||||||
baseAssetPath: this.baseAssetPath,
|
baseAssetPath: this.baseAssetPath,
|
||||||
@@ -104,23 +104,9 @@ export class PyodideWorkerClient {
|
|||||||
this.destroyed = true
|
this.destroyed = true
|
||||||
this.pendingMessages.length = 0
|
this.pendingMessages.length = 0
|
||||||
|
|
||||||
if (!this.loaded && !this.loadingError) {
|
|
||||||
this.loadingError = 'Pyodide worker was destroyed before loading finished'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.worker.terminate()
|
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) {
|
private queueMessage(message: PyodideWorkerRequest) {
|
||||||
if (this.listening) {
|
if (this.listening) {
|
||||||
this.worker.postMessage(message)
|
this.worker.postMessage(message)
|
||||||
@@ -147,7 +133,6 @@ export class PyodideWorkerClient {
|
|||||||
return
|
return
|
||||||
|
|
||||||
case 'loaded':
|
case 'loaded':
|
||||||
this.loaded = true
|
|
||||||
this.lifecycleCallback?.({ type: 'loaded' })
|
this.lifecycleCallback?.({ type: 'loaded' })
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -164,17 +149,18 @@ export class PyodideWorkerClient {
|
|||||||
this.outputCallback?.(
|
this.outputCallback?.(
|
||||||
response.stream,
|
response.stream,
|
||||||
response.line,
|
response.line,
|
||||||
response.requestId
|
response.fileId,
|
||||||
|
response.executionId
|
||||||
)
|
)
|
||||||
break
|
return
|
||||||
|
|
||||||
case 'run-code-result':
|
case 'run-code-result':
|
||||||
this.lifecycleCallback?.({
|
this.lifecycleCallback?.({
|
||||||
type: 'run-finished',
|
type: 'run-finished',
|
||||||
requestId: response.id,
|
fileId: response.fileId,
|
||||||
|
executionId: response.executionId,
|
||||||
outputs: response.outputs,
|
outputs: response.outputs,
|
||||||
})
|
})
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -12,7 +12,8 @@ export type InitRequest = {
|
|||||||
|
|
||||||
export type RunCodeRequest = {
|
export type RunCodeRequest = {
|
||||||
type: 'run-code'
|
type: 'run-code'
|
||||||
id: string
|
fileId: string
|
||||||
|
executionId: string
|
||||||
code: string
|
code: string
|
||||||
files: ProjectFileData[]
|
files: ProjectFileData[]
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,8 @@ export type OutputLineEvent = {
|
|||||||
type: 'output-line'
|
type: 'output-line'
|
||||||
stream: 'stdout' | 'stderr'
|
stream: 'stdout' | 'stderr'
|
||||||
line: string
|
line: string
|
||||||
requestId?: string
|
fileId: string
|
||||||
|
executionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PyodideWorkerEvent =
|
export type PyodideWorkerEvent =
|
||||||
@@ -42,7 +44,8 @@ export type PyodideWorkerEvent =
|
|||||||
|
|
||||||
export type RunCodeResult = {
|
export type RunCodeResult = {
|
||||||
type: 'run-code-result'
|
type: 'run-code-result'
|
||||||
id: string
|
fileId: string
|
||||||
|
executionId: string
|
||||||
outputs: string[]
|
outputs: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -84,17 +84,20 @@ async function handleInit(msg: { baseAssetPath: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRunCode(msg: RunCodeRequest) {
|
async function handleRunCode(msg: RunCodeRequest) {
|
||||||
|
const { fileId, executionId } = msg
|
||||||
|
|
||||||
if (!pyodideModule) {
|
if (!pyodideModule) {
|
||||||
const error = 'Pyodide is not initialized'
|
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'output-line',
|
type: 'output-line',
|
||||||
stream: 'stderr',
|
stream: 'stderr',
|
||||||
line: error,
|
line: 'Pyodide is not initialized',
|
||||||
requestId: msg.id,
|
fileId,
|
||||||
|
executionId,
|
||||||
})
|
})
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
id: msg.id,
|
fileId,
|
||||||
|
executionId,
|
||||||
outputs: [],
|
outputs: [],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -112,7 +115,8 @@ async function handleRunCode(msg: RunCodeRequest) {
|
|||||||
type: 'output-line',
|
type: 'output-line',
|
||||||
stream: 'stdout',
|
stream: 'stdout',
|
||||||
line,
|
line,
|
||||||
requestId: msg.id,
|
fileId,
|
||||||
|
executionId,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -122,7 +126,8 @@ async function handleRunCode(msg: RunCodeRequest) {
|
|||||||
type: 'output-line',
|
type: 'output-line',
|
||||||
stream: 'stderr',
|
stream: 'stderr',
|
||||||
line,
|
line,
|
||||||
requestId: msg.id,
|
fileId,
|
||||||
|
executionId,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -153,7 +158,8 @@ async function handleRunCode(msg: RunCodeRequest) {
|
|||||||
type: 'output-line',
|
type: 'output-line',
|
||||||
stream: 'stdout',
|
stream: 'stdout',
|
||||||
line: String(result),
|
line: String(result),
|
||||||
requestId: msg.id,
|
fileId,
|
||||||
|
executionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (runError) {
|
} catch (runError) {
|
||||||
@@ -164,14 +170,16 @@ async function handleRunCode(msg: RunCodeRequest) {
|
|||||||
type: 'output-line',
|
type: 'output-line',
|
||||||
stream: 'stderr',
|
stream: 'stderr',
|
||||||
line: errorMessage,
|
line: errorMessage,
|
||||||
requestId: msg.id,
|
fileId,
|
||||||
|
executionId,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
fs.write = originalWrite
|
fs.write = originalWrite
|
||||||
const outputs = [...writtenPaths].sort()
|
const outputs = [...writtenPaths].sort()
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
id: msg.id,
|
fileId,
|
||||||
|
executionId,
|
||||||
outputs,
|
outputs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-177
@@ -1,174 +1,32 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useMemo, useSyncExternalStore } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import path from 'path-browserify'
|
|
||||||
import OLButton from '@/shared/components/ol/ol-button'
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
|
import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
|
||||||
import MaterialIcon from '@/shared/components/material-icon'
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||||
import { useProjectContext } from '@/shared/context/project-context'
|
import { usePythonExecutionContext } from '@/features/ide-react/context/python-execution-context'
|
||||||
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
import { DEFAULT_STATE } from './python-runner'
|
||||||
import { debugConsole } from '@/utils/debugging'
|
|
||||||
import getMeta from '@/utils/meta'
|
const emptySubscribe = () => () => {}
|
||||||
import { PyodideWorkerClient } from './pyodide-worker-client'
|
const getDefaultState = () => DEFAULT_STATE
|
||||||
|
|
||||||
export default function PythonOutputPane() {
|
export default function PythonOutputPane() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
|
const { currentDocumentId } = useEditorOpenDocContext()
|
||||||
const { pathInFolder } = useFileTreePathContext()
|
const { getPythonRunner } = usePythonExecutionContext()
|
||||||
const { projectSnapshot } = useProjectContext()
|
const pythonRunner = useMemo(
|
||||||
const clientRef = useRef<PyodideWorkerClient | null>(null)
|
() => (currentDocumentId ? getPythonRunner(currentDocumentId) : null),
|
||||||
const currentRequestIdRef = useRef<string | null>(null)
|
[currentDocumentId, getPythonRunner]
|
||||||
const [isReady, setIsReady] = useState(false)
|
)
|
||||||
const [output, setOutput] = useState<string[]>([])
|
|
||||||
const [isRunning, setIsRunning] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
|
|
||||||
|
|
||||||
const appendOutput = useCallback((line: string) => {
|
const { output, error, status } = useSyncExternalStore(
|
||||||
setOutput(previousOutput => [...previousOutput, line])
|
pythonRunner ? pythonRunner.subscribe : emptySubscribe,
|
||||||
}, [])
|
pythonRunner ? pythonRunner.getState : getDefaultState
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
if (!pythonRunner) {
|
||||||
const baseAssetPath = new URL(
|
return null
|
||||||
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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ide-redesign-python-output-pane">
|
<div className="ide-redesign-python-output-pane">
|
||||||
@@ -176,17 +34,25 @@ export default function PythonOutputPane() {
|
|||||||
<div className="toolbar-pdf-left">
|
<div className="toolbar-pdf-left">
|
||||||
<div className="compile-button-group">
|
<div className="compile-button-group">
|
||||||
<OLButton
|
<OLButton
|
||||||
onClick={isRunning ? handleStop : handleRun}
|
onClick={() => {
|
||||||
variant={isRunning ? 'danger' : 'primary'}
|
if (status === 'running') {
|
||||||
|
pythonRunner.interrupt()
|
||||||
|
} else {
|
||||||
|
pythonRunner.run()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant={status === 'running' ? 'danger' : 'primary'}
|
||||||
className="compile-button align-items-center py-0 px-3"
|
className="compile-button align-items-center py-0 px-3"
|
||||||
disabled={!isRunning && !isReady}
|
disabled={status === 'loading'}
|
||||||
aria-label={
|
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')}
|
||||||
<MaterialIcon
|
<MaterialIcon
|
||||||
type={isRunning ? 'stop' : 'play_arrow'}
|
type={status === 'running' ? 'stop' : 'play_arrow'}
|
||||||
className="ml-2"
|
className="ml-2"
|
||||||
/>
|
/>
|
||||||
</OLButton>
|
</OLButton>
|
||||||
@@ -195,12 +61,12 @@ export default function PythonOutputPane() {
|
|||||||
</OLButtonToolbar>
|
</OLButtonToolbar>
|
||||||
|
|
||||||
<div className="ide-redesign-python-output-pane-body">
|
<div className="ide-redesign-python-output-pane-body">
|
||||||
{isLoadingPyodide && (
|
{status === 'loading' && (
|
||||||
<div className="ide-redesign-python-output-pane-placeholder">
|
<div className="ide-redesign-python-output-pane-placeholder">
|
||||||
{t('loading_python_runtime')}
|
{t('loading_python_runtime')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoadingPyodide && !error && output.length === 0 && (
|
{status !== 'loading' && !error && output.length === 0 && (
|
||||||
<div className="ide-redesign-python-output-pane-placeholder">
|
<div className="ide-redesign-python-output-pane-placeholder">
|
||||||
{t('run_current_script_to_see_output')}
|
{t('run_current_script_to_see_output')}
|
||||||
</div>
|
</div>
|
||||||
@@ -217,11 +83,3 @@ export default function PythonOutputPane() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(error: unknown): string {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(error)
|
|
||||||
}
|
|
||||||
|
|||||||
+213
@@ -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<ExecutionContext | null>
|
||||||
|
private listeners = new Set<Listener>()
|
||||||
|
|
||||||
|
private activeExecutionId: string | null = null
|
||||||
|
private state: PythonRunnerState = DEFAULT_STATE
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
fileId: string,
|
||||||
|
baseAssetPath: string,
|
||||||
|
getExecutionContext: () => Promise<ExecutionContext | null>,
|
||||||
|
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<PythonRunnerState>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
+20
-17
@@ -2,26 +2,29 @@ import { Panel, PanelGroup } from 'react-resizable-panels'
|
|||||||
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
|
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
|
||||||
import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane'
|
import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane'
|
||||||
import SourceEditor from '@/features/source-editor/components/source-editor'
|
import SourceEditor from '@/features/source-editor/components/source-editor'
|
||||||
|
import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context'
|
||||||
|
|
||||||
export const PythonEditorSplit = () => {
|
export const PythonEditorSplit = () => {
|
||||||
return (
|
return (
|
||||||
<PanelGroup
|
<PythonExecutionProvider>
|
||||||
autoSaveId="ide-redesign-editor-python-output"
|
<PanelGroup
|
||||||
direction="vertical"
|
autoSaveId="ide-redesign-editor-python-output"
|
||||||
className="ide-redesign-python-editor-split"
|
direction="vertical"
|
||||||
>
|
className="ide-redesign-python-editor-split"
|
||||||
<Panel id="ide-redesign-panel-source-editor-content" order={1}>
|
|
||||||
<SourceEditor />
|
|
||||||
</Panel>
|
|
||||||
<VerticalResizeHandle id="ide-redesign-editor-python-output" />
|
|
||||||
<Panel
|
|
||||||
id="ide-redesign-panel-python-output"
|
|
||||||
order={2}
|
|
||||||
defaultSize={35}
|
|
||||||
minSize={10}
|
|
||||||
>
|
>
|
||||||
<PythonOutputPane />
|
<Panel id="ide-redesign-panel-source-editor-content" order={1}>
|
||||||
</Panel>
|
<SourceEditor />
|
||||||
</PanelGroup>
|
</Panel>
|
||||||
|
<VerticalResizeHandle id="ide-redesign-editor-python-output" />
|
||||||
|
<Panel
|
||||||
|
id="ide-redesign-panel-python-output"
|
||||||
|
order={2}
|
||||||
|
defaultSize={35}
|
||||||
|
minSize={10}
|
||||||
|
>
|
||||||
|
<PythonOutputPane />
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
</PythonExecutionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PropsWithChildren> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { openDocs } = useEditorManagerContext()
|
||||||
|
const { projectSnapshot } = useProjectContext()
|
||||||
|
const { pathInFolder } = useFileTreePathContext()
|
||||||
|
const runnersRef = useRef(new Map<string, PythonRunner>())
|
||||||
|
const baseAssetPathRef = useRef<string | null>(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<ExecutionContext | null> => {
|
||||||
|
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 (
|
||||||
|
<PythonExecutionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PythonExecutionContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePythonExecutionContext = (): PythonExecutionContextValue => {
|
||||||
|
const context = useContext(PythonExecutionContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'usePythonExecutionContext is only available inside PythonExecutionContext.Provider'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -808,7 +808,6 @@
|
|||||||
"examples_lowercase": "examples",
|
"examples_lowercase": "examples",
|
||||||
"examples_to_help_you_learn": "Examples to help you learn how to use powerful LaTeX packages and techniques.",
|
"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",
|
"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.",
|
"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",
|
"expand": "Expand",
|
||||||
"experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.",
|
"experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.",
|
||||||
@@ -2127,7 +2126,7 @@
|
|||||||
"ru": "Russian",
|
"ru": "Russian",
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
"run_current_script_to_see_output": "Run the current script to see output.",
|
"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": "SAML",
|
||||||
"saml_auth_error": "Sorry, your identity provider responded with an error. Please contact your administrator for more information.",
|
"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.",
|
"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.</0> Disabling it may allow the compiler to produce a PDF (but your project will still have errors).",
|
"stop_on_first_error_enabled_description": "<0>“Stop on first error” is enabled.</0> 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_first_error_enabled_title": "No PDF: Stop on first error enabled",
|
||||||
"stop_on_validation_error": "Check syntax before compile",
|
"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",
|
"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",
|
"store_your_work": "Store your work on your own infrastructure",
|
||||||
"stretch_width_to_text": "Stretch width to text",
|
"stretch_width_to_text": "Stretch width to text",
|
||||||
|
|||||||
+13
-6
@@ -7,6 +7,7 @@ import {
|
|||||||
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||||
import { ProjectContext } from '@/shared/context/project-context'
|
import { ProjectContext } from '@/shared/context/project-context'
|
||||||
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||||
|
import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context'
|
||||||
|
|
||||||
const pythonExecutableScript: Record<string, string> = {
|
const pythonExecutableScript: Record<string, string> = {
|
||||||
file_id: 'test-py-doc-id',
|
file_id: 'test-py-doc-id',
|
||||||
@@ -83,11 +84,13 @@ describe('<PythonOutputPane />', function () {
|
|||||||
}}
|
}}
|
||||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||||
>
|
>
|
||||||
<PythonOutputPane />
|
<PythonExecutionProvider>
|
||||||
|
<PythonOutputPane />
|
||||||
|
</PythonExecutionProvider>
|
||||||
</EditorProviders>
|
</EditorProviders>
|
||||||
)
|
)
|
||||||
|
|
||||||
cy.findByRole('button', { name: 'Run Python Code' })
|
cy.findByRole('button', { name: 'Run Python code' })
|
||||||
.should('not.be.disabled')
|
.should('not.be.disabled')
|
||||||
.click()
|
.click()
|
||||||
cy.findByText('hello!').should('exist')
|
cy.findByText('hello!').should('exist')
|
||||||
@@ -122,11 +125,13 @@ describe('<PythonOutputPane />', function () {
|
|||||||
}}
|
}}
|
||||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||||
>
|
>
|
||||||
<PythonOutputPane />
|
<PythonExecutionProvider>
|
||||||
|
<PythonOutputPane />
|
||||||
|
</PythonExecutionProvider>
|
||||||
</EditorProviders>
|
</EditorProviders>
|
||||||
)
|
)
|
||||||
|
|
||||||
cy.findByRole('button', { name: 'Run Python Code' })
|
cy.findByRole('button', { name: 'Run Python code' })
|
||||||
.should('not.be.disabled')
|
.should('not.be.disabled')
|
||||||
.click()
|
.click()
|
||||||
cy.findByText('hello!').should('exist')
|
cy.findByText('hello!').should('exist')
|
||||||
@@ -195,11 +200,13 @@ describe('<PythonOutputPane />', function () {
|
|||||||
ProjectProvider,
|
ProjectProvider,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PythonOutputPane />
|
<PythonExecutionProvider>
|
||||||
|
<PythonOutputPane />
|
||||||
|
</PythonExecutionProvider>
|
||||||
</EditorProviders>
|
</EditorProviders>
|
||||||
)
|
)
|
||||||
|
|
||||||
cy.findByRole('button', { name: 'Run Python Code' })
|
cy.findByRole('button', { name: 'Run Python code' })
|
||||||
.should('not.be.disabled')
|
.should('not.be.disabled')
|
||||||
.click()
|
.click()
|
||||||
cy.findByText('name,type').should('exist')
|
cy.findByText('name,type').should('exist')
|
||||||
|
|||||||
+94
-84
@@ -1,64 +1,24 @@
|
|||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { PyodideWorkerClient } from '@/features/ide-react/components/editor/python/pyodide-worker-client'
|
import { PyodideWorkerClient } from '@/features/ide-react/components/editor/python/pyodide-worker-client'
|
||||||
|
import { WorkerMock, createWorker } from './worker-mock'
|
||||||
type WorkerMessageListener = (event: MessageEvent) => void
|
|
||||||
|
|
||||||
const BASE_ASSET_PATH = 'https://assets.example.test/'
|
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 () {
|
describe('PyodideWorkerClient', function () {
|
||||||
let originalWorker: typeof Worker | undefined
|
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
originalWorker = window.Worker
|
|
||||||
// @ts-ignore - allow mocking Worker
|
|
||||||
window.Worker = WorkerMock
|
|
||||||
WorkerMock.instances.length = 0
|
WorkerMock.instances.length = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(function () {
|
|
||||||
if (originalWorker) {
|
|
||||||
window.Worker = originalWorker
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('queues runCode until the worker reports listening', function () {
|
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]
|
const worker = WorkerMock.instances[0]
|
||||||
|
|
||||||
client.runCode('print("ok")', {
|
client.runCode('print("ok")', {
|
||||||
requestId: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-1',
|
||||||
files: [{ relativePath: 'main.py', content: 'print("ok")' }],
|
files: [{ relativePath: 'main.py', content: 'print("ok")' }],
|
||||||
})
|
})
|
||||||
expect(worker.postedMessages).to.have.length(0)
|
expect(worker.postedMessages).to.have.length(0)
|
||||||
@@ -74,7 +34,8 @@ describe('PyodideWorkerClient', function () {
|
|||||||
)
|
)
|
||||||
expect(runRequest).to.include({
|
expect(runRequest).to.include({
|
||||||
type: 'run-code',
|
type: 'run-code',
|
||||||
id: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-1',
|
||||||
code: 'print("ok")',
|
code: 'print("ok")',
|
||||||
})
|
})
|
||||||
expect(runRequest.files).to.deep.equal([
|
expect(runRequest.files).to.deep.equal([
|
||||||
@@ -83,29 +44,44 @@ describe('PyodideWorkerClient', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('sends runCode as fire-and-forget', 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]
|
const worker = WorkerMock.instances[0]
|
||||||
worker.emitMessage({ type: 'listening' })
|
worker.emitMessage({ type: 'listening' })
|
||||||
|
|
||||||
client.runCode('raise RuntimeError("boom")', {
|
client.runCode('raise RuntimeError("boom")', {
|
||||||
requestId: 'boom.py',
|
fileId: 'boom.py',
|
||||||
|
executionId: 'exec-2',
|
||||||
files: [],
|
files: [],
|
||||||
})
|
})
|
||||||
const runRequest = worker.postedMessages.find(
|
const runRequest = worker.postedMessages.find(
|
||||||
message => message.type === 'run-code'
|
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() {
|
function setupClientWithLifecycleTracking() {
|
||||||
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
|
const lifecycleEvents: {
|
||||||
const worker = WorkerMock.instances[0]
|
|
||||||
const lifecycleEvents: Array<{
|
|
||||||
type: string
|
type: string
|
||||||
requestId?: string
|
fileId?: string
|
||||||
|
executionId?: string
|
||||||
outputs?: 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' })
|
worker.emitMessage({ type: 'listening' })
|
||||||
return { client, worker, lifecycleEvents }
|
return { client, worker, lifecycleEvents }
|
||||||
}
|
}
|
||||||
@@ -114,16 +90,22 @@ describe('PyodideWorkerClient', function () {
|
|||||||
const { client, worker, lifecycleEvents } =
|
const { client, worker, lifecycleEvents } =
|
||||||
setupClientWithLifecycleTracking()
|
setupClientWithLifecycleTracking()
|
||||||
|
|
||||||
client.runCode('print("ok")', { requestId: 'main.py', files: [] })
|
client.runCode('print("ok")', {
|
||||||
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-3',
|
||||||
|
files: [],
|
||||||
|
})
|
||||||
worker.emitMessage({
|
worker.emitMessage({
|
||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
id: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-3',
|
||||||
outputs: ['/project/output.txt'],
|
outputs: ['/project/output.txt'],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(lifecycleEvents).to.deep.include({
|
expect(lifecycleEvents).to.deep.include({
|
||||||
type: 'run-finished',
|
type: 'run-finished',
|
||||||
requestId: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-3',
|
||||||
outputs: ['/project/output.txt'],
|
outputs: ['/project/output.txt'],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -132,16 +114,22 @@ describe('PyodideWorkerClient', function () {
|
|||||||
const { client, worker, lifecycleEvents } =
|
const { client, worker, lifecycleEvents } =
|
||||||
setupClientWithLifecycleTracking()
|
setupClientWithLifecycleTracking()
|
||||||
|
|
||||||
client.runCode('write_files()', { requestId: 'main.py', files: [] })
|
client.runCode('write_files()', {
|
||||||
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-4',
|
||||||
|
files: [],
|
||||||
|
})
|
||||||
worker.emitMessage({
|
worker.emitMessage({
|
||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
id: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-4',
|
||||||
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(lifecycleEvents).to.deep.include({
|
expect(lifecycleEvents).to.deep.include({
|
||||||
type: 'run-finished',
|
type: 'run-finished',
|
||||||
requestId: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-4',
|
||||||
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -150,28 +138,37 @@ describe('PyodideWorkerClient', function () {
|
|||||||
const { client, worker, lifecycleEvents } =
|
const { client, worker, lifecycleEvents } =
|
||||||
setupClientWithLifecycleTracking()
|
setupClientWithLifecycleTracking()
|
||||||
|
|
||||||
client.runCode('print("no writes")', { requestId: 'main.py', files: [] })
|
client.runCode('print("no writes")', {
|
||||||
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-5',
|
||||||
|
files: [],
|
||||||
|
})
|
||||||
worker.emitMessage({
|
worker.emitMessage({
|
||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
id: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-5',
|
||||||
outputs: [],
|
outputs: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(lifecycleEvents).to.deep.include({
|
expect(lifecycleEvents).to.deep.include({
|
||||||
type: 'run-finished',
|
type: 'run-finished',
|
||||||
requestId: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-5',
|
||||||
outputs: [],
|
outputs: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports lifecycle failure and rejects future run requests when loading fails', function () {
|
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 }> = []
|
const lifecycleEvents: Array<{ type: string; error?: string }> = []
|
||||||
|
|
||||||
client.setLifecycleCallback(event => {
|
const client = new PyodideWorkerClient({
|
||||||
lifecycleEvents.push(event)
|
baseAssetPath: BASE_ASSET_PATH,
|
||||||
|
createWorker,
|
||||||
|
onLifecycle: event => {
|
||||||
|
lifecycleEvents.push(event)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
const worker = WorkerMock.instances[0]
|
||||||
|
|
||||||
worker.emitMessage({
|
worker.emitMessage({
|
||||||
type: 'loading-failed',
|
type: 'loading-failed',
|
||||||
@@ -182,12 +179,19 @@ describe('PyodideWorkerClient', function () {
|
|||||||
{ type: 'loading-failed', error: 'runtime unavailable' },
|
{ type: 'loading-failed', error: 'runtime unavailable' },
|
||||||
])
|
])
|
||||||
expect(() =>
|
expect(() =>
|
||||||
client.runCode('print("ok")', { requestId: 'main.py', files: [] })
|
client.runCode('print("ok")', {
|
||||||
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-4',
|
||||||
|
files: [],
|
||||||
|
})
|
||||||
).to.throw('runtime unavailable')
|
).to.throw('runtime unavailable')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('terminates the worker even when destroy is called after loading failure', function () {
|
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]
|
const worker = WorkerMock.instances[0]
|
||||||
|
|
||||||
worker.emitMessage({
|
worker.emitMessage({
|
||||||
@@ -199,30 +203,32 @@ describe('PyodideWorkerClient', function () {
|
|||||||
expect(worker.terminated).to.equal(true)
|
expect(worker.terminated).to.equal(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('stop', function () {
|
describe('reset', function () {
|
||||||
it('terminates the current worker and creates a new one', function () {
|
it('terminates the current worker and creates a new one', function () {
|
||||||
const client = new PyodideWorkerClient({
|
const client = new PyodideWorkerClient({
|
||||||
baseAssetPath: BASE_ASSET_PATH,
|
baseAssetPath: BASE_ASSET_PATH,
|
||||||
|
createWorker,
|
||||||
})
|
})
|
||||||
const originalWorker = WorkerMock.instances[0]
|
const originalWorker = WorkerMock.instances[0]
|
||||||
originalWorker.emitMessage({ type: 'listening' })
|
originalWorker.emitMessage({ type: 'listening' })
|
||||||
originalWorker.emitMessage({ type: 'loaded' })
|
originalWorker.emitMessage({ type: 'loaded' })
|
||||||
|
|
||||||
client.stop()
|
client.reset()
|
||||||
|
|
||||||
expect(originalWorker.terminated).to.equal(true)
|
expect(originalWorker.terminated).to.equal(true)
|
||||||
expect(WorkerMock.instances).to.have.length(2)
|
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({
|
const client = new PyodideWorkerClient({
|
||||||
baseAssetPath: BASE_ASSET_PATH,
|
baseAssetPath: BASE_ASSET_PATH,
|
||||||
|
createWorker,
|
||||||
})
|
})
|
||||||
const originalWorker = WorkerMock.instances[0]
|
const originalWorker = WorkerMock.instances[0]
|
||||||
originalWorker.emitMessage({ type: 'listening' })
|
originalWorker.emitMessage({ type: 'listening' })
|
||||||
originalWorker.emitMessage({ type: 'loaded' })
|
originalWorker.emitMessage({ type: 'loaded' })
|
||||||
|
|
||||||
client.stop()
|
client.reset()
|
||||||
|
|
||||||
const newWorker = WorkerMock.instances[1]
|
const newWorker = WorkerMock.instances[1]
|
||||||
expect(newWorker.postedMessages).to.have.length(0)
|
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({
|
const client = new PyodideWorkerClient({
|
||||||
baseAssetPath: BASE_ASSET_PATH,
|
baseAssetPath: BASE_ASSET_PATH,
|
||||||
|
createWorker,
|
||||||
})
|
})
|
||||||
const originalWorker = WorkerMock.instances[0]
|
const originalWorker = WorkerMock.instances[0]
|
||||||
originalWorker.emitMessage({ type: 'listening' })
|
originalWorker.emitMessage({ type: 'listening' })
|
||||||
originalWorker.emitMessage({ type: 'loaded' })
|
originalWorker.emitMessage({ type: 'loaded' })
|
||||||
|
|
||||||
client.stop()
|
client.reset()
|
||||||
|
|
||||||
const newWorker = WorkerMock.instances[1]
|
const newWorker = WorkerMock.instances[1]
|
||||||
newWorker.emitMessage({ type: 'listening' })
|
newWorker.emitMessage({ type: 'listening' })
|
||||||
newWorker.emitMessage({ type: 'loaded' })
|
newWorker.emitMessage({ type: 'loaded' })
|
||||||
|
|
||||||
client.runCode('print("after stop")', {
|
client.runCode('print("after reset")', {
|
||||||
requestId: 'main.py',
|
fileId: 'main.py',
|
||||||
|
executionId: 'exec-5',
|
||||||
files: [],
|
files: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -257,21 +265,23 @@ describe('PyodideWorkerClient', function () {
|
|||||||
)
|
)
|
||||||
expect(runRequest).to.include({
|
expect(runRequest).to.include({
|
||||||
type: 'run-code',
|
type: 'run-code',
|
||||||
id: 'main.py',
|
fileId: 'main.py',
|
||||||
code: 'print("after stop")',
|
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({
|
const client = new PyodideWorkerClient({
|
||||||
baseAssetPath: BASE_ASSET_PATH,
|
baseAssetPath: BASE_ASSET_PATH,
|
||||||
|
createWorker,
|
||||||
})
|
})
|
||||||
const originalWorker = WorkerMock.instances[0]
|
const originalWorker = WorkerMock.instances[0]
|
||||||
originalWorker.emitMessage({ type: 'listening' })
|
originalWorker.emitMessage({ type: 'listening' })
|
||||||
originalWorker.emitMessage({ type: 'loaded' })
|
originalWorker.emitMessage({ type: 'loaded' })
|
||||||
|
|
||||||
client.destroy()
|
client.destroy()
|
||||||
client.stop()
|
client.reset()
|
||||||
|
|
||||||
// No new worker should have been created
|
// No new worker should have been created
|
||||||
expect(WorkerMock.instances).to.have.length(1)
|
expect(WorkerMock.instances).to.have.length(1)
|
||||||
|
|||||||
@@ -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<ExecutionContext | null>
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user