feat: add Python support with Pyodide integration

GitOrigin-RevId: 382ce102c43050aace691dd89d825a94abf347a8
This commit is contained in:
Domagoj Kriskovic
2026-02-27 14:43:05 +01:00
committed by Copybot
parent bb5d90a332
commit 138f7f8023
12 changed files with 920 additions and 1 deletions

49
package-lock.json generated
View File

@@ -4602,6 +4602,20 @@
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
@@ -9056,6 +9070,18 @@
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lit-labs/react": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.2.1.tgz",
@@ -16044,6 +16070,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -39596,6 +39629,20 @@
"node": ">=6"
}
},
"node_modules/pyodide": {
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.3.tgz",
"integrity": "sha512-22UBuhOJawj7vKUnS7/F3xK+515LJdjiMAHoCfuS6/PbHiOrSQVnYwDe+2sbVwiOZ3sMMexdXICew6NqOMQGgA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"@types/emscripten": "^1.41.4",
"ws": "^8.5.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/qified": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
@@ -51152,6 +51199,7 @@
"@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
"@codemirror/commands": "6.10.1",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-python": "6.2.1",
"@codemirror/language": "6.12.1",
"@codemirror/lint": "6.9.2",
"@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd",
@@ -51315,6 +51363,7 @@
"postcss": "^8.4.31",
"postcss-loader": "^7.3.3",
"prop-types": "^15.7.2",
"pyodide": "^0.29.0",
"qrcode": "^1.4.4",
"react": "^18.3.1",
"react-bootstrap": "^2.10.10",

View File

@@ -0,0 +1,148 @@
import type {
ProjectFileData,
PyodideWorkerRequest,
PyodideWorkerResponse,
} from './pyodide-worker-messages'
export type OutputCallback = (
stream: 'stdout' | 'stderr',
line: string,
requestId?: string
) => void
export type LifecycleCallback = (
event:
| { type: 'loaded' }
| { type: 'loading-failed'; error: string }
| { type: 'run-finished'; requestId: string }
) => void
export class PyodideWorkerClient {
private worker: 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
constructor(options: { baseAssetPath: string }) {
const { baseAssetPath } = options
this.worker = new Worker(
/* webpackChunkName: "pyodide-worker" */
new URL('./pyodide.worker.ts', import.meta.url),
{ type: 'module' }
)
this.worker.addEventListener('message', this.receive.bind(this))
this.queueMessage({
type: 'init',
baseAssetPath,
})
}
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[] }
): void {
if (this.destroyed) {
throw new Error('Pyodide worker client has been destroyed')
}
if (this.loadingError) {
throw new Error(this.loadingError)
}
this.queueMessage({
type: 'run-code',
code,
id: options.requestId,
files: options.files,
})
}
destroy() {
if (this.destroyed) {
return
}
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 queueMessage(message: PyodideWorkerRequest) {
if (this.listening) {
this.worker.postMessage(message)
} else {
this.pendingMessages.push(message)
}
}
private receive(event: MessageEvent<PyodideWorkerResponse>) {
const response = event.data
switch (response.type) {
case 'listening':
this.listening = true
for (const message of this.pendingMessages) {
this.worker.postMessage(message)
}
this.pendingMessages.length = 0
return
case 'loaded':
this.loaded = true
this.lifecycleCallback?.({ type: 'loaded' })
return
case 'loading-failed':
this.loadingError = response.error
this.pendingMessages.length = 0
this.lifecycleCallback?.({
type: 'loading-failed',
error: response.error,
})
return
case 'output-line':
this.outputCallback?.(
response.stream,
response.line,
response.requestId
)
break
case 'run-code-result':
this.lifecycleCallback?.({
type: 'run-finished',
requestId: response.id,
})
break
}
}
}

View File

@@ -0,0 +1,48 @@
export type ProjectFileData = {
relativePath: string
content: string
}
// Main thread -> Worker messages
export type InitRequest = {
type: 'init'
baseAssetPath: string
}
export type RunCodeRequest = {
type: 'run-code'
id: string
code: string
files: ProjectFileData[]
}
export type PyodideWorkerRequest = InitRequest | RunCodeRequest
// Worker -> Main thread lifecycle and streaming events
export type ListeningEvent = { type: 'listening' }
export type LoadedEvent = { type: 'loaded' }
export type LoadingFailedEvent = { type: 'loading-failed'; error: string }
export type OutputLineEvent = {
type: 'output-line'
stream: 'stdout' | 'stderr'
line: string
requestId?: string
}
export type PyodideWorkerEvent =
| ListeningEvent
| LoadedEvent
| LoadingFailedEvent
| OutputLineEvent
// Worker -> Main thread ID responses
export type RunCodeResult = {
type: 'run-code-result'
id: string
}
export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult

View File

@@ -0,0 +1,216 @@
import path from 'path-browserify'
import type {
ProjectFileData,
PyodideWorkerRequest,
RunCodeRequest,
} from './pyodide-worker-messages'
type PyodideRuntimeModule = {
loadPyodide: (options: {
indexURL: string
packageBaseUrl?: string
env?: Record<string, string>
packages?: string[]
}) => Promise<PyodideInstance>
}
type PyodideInstance = {
FS: unknown
runPythonAsync: (code: string) => Promise<unknown>
setStdout: (options: { batched: (line: string) => void }) => void
setStderr: (options: { batched: (line: string) => void }) => void
}
type PyodideFs = {
analyzePath: (filePath: string) => { exists: boolean }
mkdir: (filePath: string) => void
writeFile: (
filePath: string,
content: string | ArrayBuffer | Uint8Array
) => void
chdir: (filePath: string) => void
}
const PROJECT_FS_ROOT = '/project'
const PYODIDE_INDEX_PATH = 'js/libs/pyodide/'
function getPyodideIndexUrl(baseAssetPath: string): string {
return new URL(PYODIDE_INDEX_PATH, baseAssetPath).toString()
}
function toRuntimeProjectPath(relativePath: string): string {
return path.posix.join(PROJECT_FS_ROOT, path.posix.normalize(relativePath))
}
function ensureProjectRootExists(fs: PyodideFs) {
try {
const projectRootAnalysis = fs.analyzePath(PROJECT_FS_ROOT)
if (!projectRootAnalysis.exists) {
fs.mkdir(PROJECT_FS_ROOT)
}
} catch {
fs.mkdir(PROJECT_FS_ROOT)
}
}
function ensureDirectoryExists(fs: PyodideFs, filePath: string) {
const directory = path.dirname(filePath)
if (directory === '.' || directory === '/') {
return
}
let currentPath = directory.startsWith('/') ? '/' : ''
for (const part of directory.split('/').filter(Boolean)) {
currentPath = path.posix.join(currentPath, part)
try {
const analysis = fs.analyzePath(currentPath)
if (!analysis.exists) {
fs.mkdir(currentPath)
}
} catch {
// Ignore failures when a directory already exists.
}
}
}
function syncProjectFiles(fs: PyodideFs, files: ProjectFileData[]) {
ensureProjectRootExists(fs)
for (const file of files) {
const runtimePath = toRuntimeProjectPath(file.relativePath)
ensureDirectoryExists(fs, runtimePath)
fs.writeFile(runtimePath, file.content)
}
fs.chdir(PROJECT_FS_ROOT)
}
let pyodideInstance: PyodideInstance | null = null
let activeRunRequestId: string | null = null
async function loadPyodideModule(
pyodideIndexUrl: string
): Promise<PyodideRuntimeModule> {
const runtimeModuleUrl = `${pyodideIndexUrl}pyodide.mjs`
try {
return (await import(
/* webpackIgnore: true */ runtimeModuleUrl
)) as PyodideRuntimeModule
} catch (loadError) {
const loadErrorMessage =
loadError instanceof Error ? loadError.message : String(loadError)
throw new Error(
`Unable to load Pyodide module from ${runtimeModuleUrl}. Original error: ${loadErrorMessage}`
)
}
}
async function handleInit(msg: { baseAssetPath: string }) {
const pyodideIndexUrl = getPyodideIndexUrl(msg.baseAssetPath)
try {
const pyodideModule = await loadPyodideModule(pyodideIndexUrl)
const instance = await pyodideModule.loadPyodide({
indexURL: pyodideIndexUrl,
packageBaseUrl: pyodideIndexUrl,
env: { MPLBACKEND: 'Agg' },
})
instance.setStdout({
batched: (line: string) => {
self.postMessage({
type: 'output-line',
stream: 'stdout',
line,
requestId: activeRunRequestId ?? undefined,
})
},
})
instance.setStderr({
batched: (line: string) => {
self.postMessage({
type: 'output-line',
stream: 'stderr',
line,
requestId: activeRunRequestId ?? undefined,
})
},
})
pyodideInstance = instance
self.postMessage({ type: 'loaded' })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Pyodide initialization failed', error)
self.postMessage({
type: 'loading-failed',
error: errorMessage,
})
}
}
async function handleRunCode(msg: RunCodeRequest) {
if (!pyodideInstance) {
const error = 'Pyodide is not initialized'
self.postMessage({
type: 'output-line',
stream: 'stderr',
line: error,
requestId: msg.id,
})
self.postMessage({
type: 'run-code-result',
id: msg.id,
})
return
}
activeRunRequestId = msg.id
try {
if (msg.files.length > 0) {
const fs = pyodideInstance.FS as PyodideFs
syncProjectFiles(fs, msg.files)
}
const result = await pyodideInstance.runPythonAsync(msg.code)
if (result !== undefined) {
self.postMessage({
type: 'output-line',
stream: 'stdout',
line: String(result),
requestId: activeRunRequestId ?? undefined,
})
}
} catch (runError) {
const errorMessage =
runError instanceof Error ? runError.message : String(runError)
self.postMessage({
type: 'output-line',
stream: 'stderr',
line: errorMessage,
requestId: activeRunRequestId ?? undefined,
})
} finally {
activeRunRequestId = null
}
self.postMessage({
type: 'run-code-result',
id: msg.id,
})
}
self.addEventListener('message', async event => {
const msg = event.data as PyodideWorkerRequest
switch (msg.type) {
case 'init':
await handleInit(msg)
break
case 'run-code':
await handleRunCode(msg)
break
}
})
self.postMessage({ type: 'listening' })

View File

@@ -0,0 +1,189 @@
import { useCallback, useEffect, useRef, useState } from 'react'
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 { 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'
export default function PythonOutputPane() {
const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
const { pathInFolder } = useFileTreePathContext()
const clientRef = useRef<PyodideWorkerClient | null>(null)
const currentRequestIdRef = useRef<string | null>(null)
const [isReady, setIsReady] = useState(false)
const [output, setOutput] = useState<string[]>([])
const [isRunning, setIsRunning] = useState(false)
const [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
const [error, setError] = useState<string | null>(null)
const appendOutput = useCallback((line: string) => {
setOutput(previousOutput => [...previousOutput, line])
}, [])
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)
setError(formatError(event.error))
setIsLoadingPyodide(false)
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 handleRun = useCallback(() => {
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)
try {
client.runCode(syncFile.content, { requestId, files: [syncFile] })
} catch (runError) {
if (currentRequestIdRef.current !== requestId) {
return
}
currentRequestIdRef.current = null
setIsRunning(false)
setError(formatError(runError))
}
}, [buildCurrentDocumentSyncFile, isReady])
return (
<div className="ide-redesign-python-output-pane">
<OLButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
<div className="toolbar-pdf-left">
<div className="compile-button-group">
<OLButton
onClick={handleRun}
variant="primary"
className="compile-button align-items-center py-0 px-3"
disabled={!isReady || isLoadingPyodide || isRunning}
aria-label="Run Python Code"
style={{ borderRadius: '12px' }}
>
{isRunning ? 'Running...' : 'Run'}
<MaterialIcon type="play_arrow" className="ml-2" />
</OLButton>
</div>
</div>
</OLButtonToolbar>
<div className="ide-redesign-python-output-pane-body">
{isLoadingPyodide && (
<div className="ide-redesign-python-output-pane-placeholder">
Loading Python runtime...
</div>
)}
{!isLoadingPyodide && !error && output.length === 0 && (
<div className="ide-redesign-python-output-pane-placeholder">
Run the current script to see output.
</div>
)}
{error && (
<div className="ide-redesign-python-output-pane-error">{error}</div>
)}
{output.map((line, index) => (
<div className="ide-redesign-python-output-pane-line" key={index}>
{line}
</div>
))}
</div>
</div>
)
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}

View File

@@ -9,12 +9,16 @@ import { Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { PythonEditorSplit } from '@/features/ide-react/components/layout/python-editor-split'
export const Editor = () => {
const { opening, errorState, showSymbolPalette } =
useEditorPropertiesContext()
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
const { currentDocumentId, currentDocument } = useEditorOpenDocContext()
const isPythonDocument =
openEntity?.type === 'doc' &&
openEntity.entity.name.toLowerCase().endsWith('.py')
if (!currentDocumentId) {
return null
@@ -39,7 +43,7 @@ export const Editor = () => {
order={1}
className="ide-redesign-editor-panel"
>
<SourceEditor />
{isPythonDocument ? <PythonEditorSplit /> : <SourceEditor />}
{isLoading && <EditorLoadingPane />}
</Panel>
{showSymbolPalette && (

View File

@@ -0,0 +1,27 @@
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'
export const PythonEditorSplit = () => {
return (
<PanelGroup
autoSaveId="ide-redesign-editor-python-output"
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>
</PanelGroup>
)
}

View File

@@ -65,4 +65,11 @@ export const languages = [
return import('./markdown').then(m => m.markdown())
},
}),
LanguageDescription.of({
name: 'python',
extensions: ['py'],
load: () => {
return import('@codemirror/lang-python').then(m => m.python())
},
}),
]

View File

@@ -54,6 +54,47 @@
height: 100%;
}
.ide-redesign-python-editor-split {
height: 100%;
}
.ide-redesign-python-output-pane {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--ide-redesign-background);
color: var(--ide-redesign-color);
border-top: 1px solid var(--border-divider);
}
.ide-redesign-python-output-pane-body {
flex: 1;
overflow: auto;
padding: var(--spacing-06);
font-family: 'DM Mono', monospace;
font-size: var(--font-size-02);
line-height: var(--line-height-03);
}
.ide-redesign-python-output-pane-placeholder {
color: var(--content-secondary);
}
.ide-redesign-python-output-pane-line {
white-space: pre-wrap;
word-break: break-word;
}
.ide-redesign-python-output-pane-placeholder,
.ide-redesign-python-output-pane-error {
white-space: pre-wrap;
}
.ide-redesign-python-output-pane-error {
margin-bottom: var(--spacing-03);
color: var(--red-50);
}
.ide-redesign-labs-user-beta-promo {
position: absolute;
top: 60px;

View File

@@ -205,6 +205,7 @@
"@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5",
"@codemirror/commands": "6.10.1",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-python": "6.2.1",
"@codemirror/language": "6.12.1",
"@codemirror/lint": "6.9.2",
"@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd",
@@ -368,6 +369,7 @@
"postcss": "^8.4.31",
"postcss-loader": "^7.3.3",
"prop-types": "^15.7.2",
"pyodide": "^0.29.0",
"qrcode": "^1.4.4",
"react": "^18.3.1",
"react-bootstrap": "^2.10.10",

View File

@@ -0,0 +1,156 @@
import { expect } from 'chai'
import { PyodideWorkerClient } from '@/features/ide-react/components/editor/python/pyodide-worker-client'
type WorkerMessageListener = (event: MessageEvent) => void
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 } 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 worker = WorkerMock.instances[0]
client.runCode('print("ok")', {
requestId: 'main.py',
files: [{ relativePath: 'main.py', content: 'print("ok")' }],
})
expect(worker.postedMessages).to.have.length(0)
worker.emitMessage({ type: 'listening' })
expect(worker.postedMessages.map(message => message.type)).to.deep.equal([
'init',
'run-code',
])
const runRequest = worker.postedMessages.find(
message => message.type === 'run-code'
)
expect(runRequest).to.include({
type: 'run-code',
id: 'main.py',
code: 'print("ok")',
})
expect(runRequest.files).to.deep.equal([
{ relativePath: 'main.py', content: 'print("ok")' },
])
})
it('sends runCode as fire-and-forget', function () {
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
const worker = WorkerMock.instances[0]
worker.emitMessage({ type: 'listening' })
client.runCode('raise RuntimeError("boom")', {
requestId: 'boom.py',
files: [],
})
const runRequest = worker.postedMessages.find(
message => message.type === 'run-code'
)
expect(runRequest).to.include({ type: 'run-code', id: 'boom.py' })
})
it('emits run-finished lifecycle event from run-code-result', function () {
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
const worker = WorkerMock.instances[0]
const lifecycleEvents: Array<{ type: string; requestId?: string }> = []
client.setLifecycleCallback(event => {
lifecycleEvents.push(event)
})
worker.emitMessage({ type: 'listening' })
client.runCode('print("ok")', { requestId: 'main.py', files: [] })
worker.emitMessage({
type: 'run-code-result',
id: 'main.py',
})
expect(lifecycleEvents).to.deep.include({
type: 'run-finished',
requestId: 'main.py',
})
})
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)
})
worker.emitMessage({
type: 'loading-failed',
error: 'runtime unavailable',
})
expect(lifecycleEvents).to.deep.equal([
{ type: 'loading-failed', error: 'runtime unavailable' },
])
expect(() =>
client.runCode('print("ok")', { requestId: 'main.py', 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 worker = WorkerMock.instances[0]
worker.emitMessage({
type: 'loading-failed',
error: 'runtime unavailable',
})
client.destroy()
expect(worker.terminated).to.equal(true)
})
})

View File

@@ -62,6 +62,7 @@ function getModuleDirectory(moduleName) {
const mathjaxDir = getModuleDirectory('mathjax')
const pdfjsDir = getModuleDirectory('pdfjs-dist')
const dictionariesDir = getModuleDirectory('@overleaf/dictionaries')
const pyodideDir = getModuleDirectory('pyodide')
const vendorDir = path.join(__dirname, 'frontend/js/vendor')
@@ -407,6 +408,37 @@ module.exports = {
toType: 'dir',
context: `${dictionariesDir}/dictionaries`,
},
// Copy Pyodide runtime assets from npm package for local serving.
{
from: 'pyodide.mjs',
to: 'js/libs/pyodide',
toType: 'dir',
context: pyodideDir,
},
{
from: 'pyodide.asm.js',
to: 'js/libs/pyodide',
toType: 'dir',
context: pyodideDir,
},
{
from: 'pyodide.asm.wasm',
to: 'js/libs/pyodide',
toType: 'dir',
context: pyodideDir,
},
{
from: 'python_stdlib.zip',
to: 'js/libs/pyodide',
toType: 'dir',
context: pyodideDir,
},
{
from: 'pyodide-lock.json',
to: 'js/libs/pyodide',
toType: 'dir',
context: pyodideDir,
},
// Copy CMap files (used to provide support for non-Latin characters),
// wasm, ICC profiles, fonts and images from pdfjs-dist package to build output.
{