mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
Python script runner: initial commit
- restore upstream code removed by commit b90a850
- migrate restored code to modules
- improve Run / Stop button design
This commit is contained in:
+481
@@ -0,0 +1,481 @@
|
||||
import React, { FC, PropsWithChildren } from 'react'
|
||||
import PythonOutputPane from '../../../frontend/js/components/editor/python/python-output-pane'
|
||||
import {
|
||||
EditorProviders,
|
||||
projectDefaults,
|
||||
} from '../../../helpers/editor-providers'
|
||||
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { ProjectContext } from '@/shared/context/project-context'
|
||||
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||
import { PythonExecutionProvider } from '../../../frontend/js/context/python-execution-context'
|
||||
|
||||
const pythonExecutableScript: Record<string, string> = {
|
||||
file_id: 'test-py-doc-id',
|
||||
filename: 'test.py',
|
||||
}
|
||||
|
||||
const FileTreePathProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<FileTreePathContext.Provider
|
||||
value={{
|
||||
pathInFolder: () => pythonExecutableScript.filename,
|
||||
findEntityByPath: () => null,
|
||||
previewByPath: () => null,
|
||||
dirname: () => null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FileTreePathContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function makeProjectProvider(fileContents: Record<string, string>) {
|
||||
const ProjectProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const projectSnapshot = {
|
||||
refresh: async () => {},
|
||||
getDocPaths: () => Object.keys(fileContents),
|
||||
getDocContents: (path: string) => fileContents[path] ?? null,
|
||||
} as unknown as ProjectSnapshot
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider
|
||||
value={{
|
||||
projectId: projectDefaults._id,
|
||||
project: projectDefaults,
|
||||
joinProject: () => {},
|
||||
updateProject: () => {},
|
||||
joinedOnce: true,
|
||||
projectSnapshot,
|
||||
tags: [],
|
||||
features: projectDefaults.features,
|
||||
name: projectDefaults.name,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ProjectContext.Provider>
|
||||
)
|
||||
}
|
||||
return ProjectProvider
|
||||
}
|
||||
|
||||
describe('<PythonOutputPane />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/')
|
||||
})
|
||||
|
||||
it('executes a Python script and displays its output', function () {
|
||||
const executablePythonFileContents = "print('hello!')"
|
||||
const projectFiles = {
|
||||
[pythonExecutableScript.filename]: executablePythonFileContents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: pythonExecutableScript.filename,
|
||||
},
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByText('hello!').should('exist')
|
||||
})
|
||||
|
||||
it('can import and use values from other project Python files', function () {
|
||||
const executablePythonFileContents =
|
||||
'from message import message\nprint(message)'
|
||||
|
||||
const importedPythonFile = {
|
||||
filename: 'message.py',
|
||||
file_contents: "message = 'hello!'",
|
||||
}
|
||||
|
||||
const projectFiles = {
|
||||
[pythonExecutableScript.filename]: executablePythonFileContents,
|
||||
[importedPythonFile.filename]: importedPythonFile.file_contents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: pythonExecutableScript.filename,
|
||||
},
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByText('hello!').should('exist')
|
||||
})
|
||||
|
||||
it('can import files from different directories relative to the executable script', function () {
|
||||
const executablePythonFileContents = [
|
||||
'from scripts.data_importers.csv_importer import print_data',
|
||||
'print_data()',
|
||||
].join('\n')
|
||||
|
||||
const csvImporterFile = {
|
||||
filename: 'scripts/data_importers/csv_importer.py',
|
||||
file_contents: [
|
||||
'import csv',
|
||||
'',
|
||||
'def print_data():',
|
||||
' with open("food_items.csv", "r") as f:',
|
||||
' reader = csv.reader(f)',
|
||||
' for row in reader:',
|
||||
' print(",".join(row))',
|
||||
].join('\n'),
|
||||
}
|
||||
|
||||
const csvDataFile = {
|
||||
filename: 'food_items.csv',
|
||||
file_contents: 'name,type\nPizza,Italian\nSushi,Japanese\nTacos,Mexican',
|
||||
}
|
||||
|
||||
const projectFiles = {
|
||||
'scripts/test.py': executablePythonFileContents,
|
||||
[csvImporterFile.filename]: csvImporterFile.file_contents,
|
||||
[csvDataFile.filename]: csvDataFile.file_contents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
const NestedFileTreePathProvider: FC<PropsWithChildren> = ({
|
||||
children,
|
||||
}) => (
|
||||
<FileTreePathContext.Provider
|
||||
value={{
|
||||
pathInFolder: () => 'scripts/test.py',
|
||||
findEntityByPath: () => null,
|
||||
previewByPath: () => null,
|
||||
dirname: () => null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FileTreePathContext.Provider>
|
||||
)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: 'test.py',
|
||||
},
|
||||
}}
|
||||
providers={{
|
||||
FileTreePathProvider: NestedFileTreePathProvider,
|
||||
ProjectProvider,
|
||||
}}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByText('name,type').should('exist')
|
||||
cy.findByText('Pizza,Italian').should('exist')
|
||||
cy.findByText('Sushi,Japanese').should('exist')
|
||||
cy.findByText('Tacos,Mexican').should('exist')
|
||||
})
|
||||
|
||||
it('renders stderr output with the stderr line class', function () {
|
||||
const executablePythonFileContents = [
|
||||
'import sys',
|
||||
"print('hello!')",
|
||||
"sys.stderr.write('boom\\n')",
|
||||
].join('\n')
|
||||
const projectFiles = {
|
||||
[pythonExecutableScript.filename]: executablePythonFileContents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: pythonExecutableScript.filename,
|
||||
},
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
|
||||
cy.findByText('hello!')
|
||||
.should('have.class', 'ide-redesign-python-output-pane-line-stdout')
|
||||
.and('not.have.class', 'ide-redesign-python-output-pane-line-stderr')
|
||||
cy.findByText('boom').should(
|
||||
'have.class',
|
||||
'ide-redesign-python-output-pane-line-stderr'
|
||||
)
|
||||
cy.findByText("Only Pyodide's built-in packages", { exact: false }).should(
|
||||
'not.exist'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the interrupt message as an info line', function () {
|
||||
const executablePythonFileContents = 'while True:\n pass\n'
|
||||
const projectFiles = {
|
||||
[pythonExecutableScript.filename]: executablePythonFileContents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: pythonExecutableScript.filename,
|
||||
},
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByRole('button', { name: 'Stop Python execution' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
|
||||
cy.findByText('Execution interrupted').should(
|
||||
'have.class',
|
||||
'ide-redesign-python-output-pane-line-info'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders stdout, stderr, and info lines with visually distinct CSS classes', function () {
|
||||
const executablePythonFileContents = [
|
||||
'import sys',
|
||||
"print('hello!')",
|
||||
"sys.stderr.write('boom\\n')",
|
||||
'while True:',
|
||||
' pass',
|
||||
].join('\n')
|
||||
const projectFiles = {
|
||||
[pythonExecutableScript.filename]: executablePythonFileContents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: pythonExecutableScript.filename,
|
||||
},
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
|
||||
cy.findByText('hello!')
|
||||
.should('have.class', 'ide-redesign-python-output-pane-line-stdout')
|
||||
.and('not.have.class', 'ide-redesign-python-output-pane-line-stderr')
|
||||
.and('not.have.class', 'ide-redesign-python-output-pane-line-info')
|
||||
cy.findByText('boom')
|
||||
.should('have.class', 'ide-redesign-python-output-pane-line-stderr')
|
||||
.and('not.have.class', 'ide-redesign-python-output-pane-line-stdout')
|
||||
.and('not.have.class', 'ide-redesign-python-output-pane-line-info')
|
||||
|
||||
cy.findByRole('button', { name: 'Stop Python execution' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
|
||||
cy.findByText('Execution interrupted')
|
||||
.should('have.class', 'ide-redesign-python-output-pane-line-info')
|
||||
.and('not.have.class', 'ide-redesign-python-output-pane-line-stdout')
|
||||
.and('not.have.class', 'ide-redesign-python-output-pane-line-stderr')
|
||||
})
|
||||
|
||||
it('can load common python data analysis packages on code execution', function () {
|
||||
const executablePythonFileContents = [
|
||||
'import tomli',
|
||||
'',
|
||||
"print(tomli.loads('greeting = \"hello from tomli\"')['greeting'])",
|
||||
].join('\n')
|
||||
|
||||
const projectFiles = {
|
||||
[pythonExecutableScript.filename]: executablePythonFileContents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: pythonExecutableScript.filename,
|
||||
},
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByText("ModuleNotFoundError: No module named 'tomli'").should(
|
||||
'not.exist'
|
||||
)
|
||||
cy.findByText('hello from tomli').should('exist')
|
||||
})
|
||||
|
||||
it('auto-installs python packages imported by the executing script', function () {
|
||||
const executablePythonFileContents = [
|
||||
'import tomli',
|
||||
'',
|
||||
"print(tomli.loads('greeting = \"hello from tomli\"')['greeting'])",
|
||||
].join('\n')
|
||||
|
||||
const projectFiles = {
|
||||
[pythonExecutableScript.filename]: executablePythonFileContents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: pythonExecutableScript.filename,
|
||||
},
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByText("ModuleNotFoundError: No module named 'tomli'").should(
|
||||
'not.exist'
|
||||
)
|
||||
cy.findByText('hello from tomli').should('exist')
|
||||
})
|
||||
|
||||
it('augments ModuleNotFoundError output with help text about supported Pyodide packages', function () {
|
||||
const executablePythonFileContents =
|
||||
'import this_module_does_not_exist_in_pyodide\n'
|
||||
const projectFiles = {
|
||||
[pythonExecutableScript.filename]: executablePythonFileContents,
|
||||
}
|
||||
const ProjectProvider = makeProjectProvider(projectFiles)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
scope={{
|
||||
editor: {
|
||||
sharejs_doc: {
|
||||
doc_id: pythonExecutableScript.file_id,
|
||||
getSnapshot: () => executablePythonFileContents,
|
||||
},
|
||||
currentDocumentId: pythonExecutableScript.file_id,
|
||||
openDocName: pythonExecutableScript.filename,
|
||||
},
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
|
||||
cy.findByText('ModuleNotFoundError', { exact: false }).should('exist')
|
||||
cy.findByText("Only Pyodide's built-in packages", { exact: false }).should(
|
||||
'have.class',
|
||||
'ide-redesign-python-output-pane-line-stderr'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,778 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import {
|
||||
PyodideWorkerClient,
|
||||
type LifecycleCallback,
|
||||
type OutputCallback,
|
||||
} from '../../../frontend/js/components/editor/python/pyodide-worker-client'
|
||||
import type { FileUploader } from '../../../frontend/js/components/editor/python/python-runner'
|
||||
import { WorkerMock, createWorker } from './worker-mock'
|
||||
|
||||
const BASE_ASSET_PATH = 'https://assets.example.test/'
|
||||
|
||||
const fileUploaderStub: FileUploader = () => Promise.resolve([])
|
||||
|
||||
describe('PyodideWorkerClient', function () {
|
||||
beforeEach(function () {
|
||||
WorkerMock.instances.length = 0
|
||||
})
|
||||
|
||||
it('queues runCode until the worker reports listening', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
|
||||
client.runCode('print("ok")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-1',
|
||||
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',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-1',
|
||||
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,
|
||||
createWorker,
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
worker.emitMessage({ type: 'listening' })
|
||||
|
||||
client.runCode('raise RuntimeError("boom")', {
|
||||
fileId: 'boom.py',
|
||||
executionId: 'exec-2',
|
||||
files: [],
|
||||
})
|
||||
const runRequest = worker.postedMessages.find(
|
||||
message => message.type === 'run-code'
|
||||
)
|
||||
expect(runRequest).to.include({
|
||||
type: 'run-code',
|
||||
fileId: 'boom.py',
|
||||
executionId: 'exec-2',
|
||||
})
|
||||
})
|
||||
|
||||
function setupClientWithLifecycleTracking() {
|
||||
const lifecycleEvents: Parameters<LifecycleCallback>[0][] = []
|
||||
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
onLifecycle: event => {
|
||||
lifecycleEvents.push(event)
|
||||
},
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
worker.emitMessage({ type: 'listening' })
|
||||
return { client, worker, lifecycleEvents }
|
||||
}
|
||||
|
||||
function setupClientWithUploadTracking(options: {
|
||||
fileUploader: FileUploader
|
||||
}) {
|
||||
const lifecycleEvents: Parameters<LifecycleCallback>[0][] = []
|
||||
const outputCalls: Parameters<OutputCallback>[] = []
|
||||
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
onLifecycle: event => {
|
||||
lifecycleEvents.push(event)
|
||||
},
|
||||
onOutput: (...args) => {
|
||||
outputCalls.push(args)
|
||||
},
|
||||
fileUploader: options.fileUploader,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
worker.emitMessage({ type: 'listening' })
|
||||
return { client, worker, lifecycleEvents, outputCalls }
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 200) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('waitFor timed out')
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
it('emits run-finished lifecycle event from run-code-result', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('print("ok")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-3',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-3',
|
||||
success: true,
|
||||
outputs: ['/project/output.txt'],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
})
|
||||
|
||||
expect(lifecycleEvents).to.deep.equal([
|
||||
{
|
||||
type: 'run-finished',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-3',
|
||||
success: true,
|
||||
outputs: ['/project/output.txt'],
|
||||
failedUploads: [],
|
||||
imports: [],
|
||||
errorType: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces outputs array from run-code-result with multiple files', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('write_files()', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-4',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-4',
|
||||
success: true,
|
||||
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
})
|
||||
|
||||
expect(lifecycleEvents).to.deep.equal([
|
||||
{
|
||||
type: 'run-finished',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-4',
|
||||
success: true,
|
||||
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
||||
failedUploads: [],
|
||||
imports: [],
|
||||
errorType: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces empty outputs when no project files were written', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('print("no writes")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
success: true,
|
||||
outputs: [],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
})
|
||||
|
||||
expect(lifecycleEvents).to.deep.equal([
|
||||
{
|
||||
type: 'run-finished',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
success: true,
|
||||
outputs: [],
|
||||
failedUploads: [],
|
||||
imports: [],
|
||||
errorType: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces success and outputFiles from run-code-result', async function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('write_files()', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-success',
|
||||
files: [],
|
||||
})
|
||||
const csvContent = new Uint8Array([1, 2, 3])
|
||||
const pngContent = new Uint8Array([4, 5, 6, 7])
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-success',
|
||||
success: true,
|
||||
outputs: ['/project/data.csv', '/project/plot.png'],
|
||||
outputFiles: [
|
||||
{ relativePath: 'data.csv', content: csvContent },
|
||||
{ relativePath: 'plot.png', content: pngContent },
|
||||
],
|
||||
imports: [],
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
Boolean(lifecycleEvents.find(e => e.type === 'run-finished'))
|
||||
)
|
||||
|
||||
const finished = lifecycleEvents.find(e => e.type === 'run-finished')
|
||||
expect(finished).to.deep.equal({
|
||||
type: 'run-finished',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-success',
|
||||
success: true,
|
||||
outputs: ['/project/data.csv', '/project/plot.png'],
|
||||
failedUploads: [],
|
||||
imports: [],
|
||||
errorType: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates ModuleNotFoundError errorType on run-finished', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('import nope', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-mnfe',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-mnfe',
|
||||
success: false,
|
||||
outputs: [],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
errorType: 'ModuleNotFoundError',
|
||||
})
|
||||
|
||||
const finished = lifecycleEvents.find(e => e.type === 'run-finished')
|
||||
expect(finished).to.deep.equal({
|
||||
type: 'run-finished',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-mnfe',
|
||||
success: false,
|
||||
outputs: [],
|
||||
failedUploads: [],
|
||||
imports: [],
|
||||
errorType: 'ModuleNotFoundError',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces success: false with empty outputFiles on script error', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('raise RuntimeError("boom")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-error',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-error',
|
||||
success: false,
|
||||
outputs: [],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
errorType: 'generic',
|
||||
})
|
||||
|
||||
const finished = lifecycleEvents.find(e => e.type === 'run-finished')
|
||||
expect(finished).to.deep.equal({
|
||||
type: 'run-finished',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-error',
|
||||
success: false,
|
||||
outputs: [],
|
||||
failedUploads: [],
|
||||
imports: [],
|
||||
errorType: 'generic',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces empty outputFiles when success but no files were written', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('print("no writes")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-nowrites',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-nowrites',
|
||||
success: true,
|
||||
outputs: [],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
})
|
||||
|
||||
const finished = lifecycleEvents.find(e => e.type === 'run-finished')
|
||||
expect(finished).to.deep.equal({
|
||||
type: 'run-finished',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-nowrites',
|
||||
success: true,
|
||||
outputs: [],
|
||||
failedUploads: [],
|
||||
imports: [],
|
||||
errorType: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports lifecycle failure and rejects future run requests when loading fails', function () {
|
||||
const lifecycleEvents: { type: string; error?: string }[] = []
|
||||
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
onLifecycle: event => {
|
||||
lifecycleEvents.push(event)
|
||||
},
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'loading-failed',
|
||||
error: 'runtime unavailable',
|
||||
})
|
||||
|
||||
expect(lifecycleEvents).to.deep.equal([
|
||||
{ type: 'loading-failed', error: 'runtime unavailable' },
|
||||
])
|
||||
expect(() =>
|
||||
client.runCode('print("ok")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-4',
|
||||
files: [],
|
||||
})
|
||||
).to.throw('runtime unavailable')
|
||||
})
|
||||
|
||||
it('terminates the worker even when destroy is called after loading failure', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'loading-failed',
|
||||
error: 'runtime unavailable',
|
||||
})
|
||||
client.destroy()
|
||||
|
||||
expect(worker.terminated).to.equal(true)
|
||||
})
|
||||
|
||||
context('upload behavior', function () {
|
||||
const successResult = (name: string, relativePath: string) => ({
|
||||
status: 'success' as const,
|
||||
name,
|
||||
relativePath,
|
||||
data: { success: true },
|
||||
})
|
||||
const errorResult = (
|
||||
name: string,
|
||||
relativePath: string,
|
||||
error: string
|
||||
) => ({
|
||||
status: 'error' as const,
|
||||
name,
|
||||
relativePath,
|
||||
error,
|
||||
})
|
||||
|
||||
function emitRunResult(
|
||||
worker: WorkerMock,
|
||||
executionId: string,
|
||||
outputFiles: Array<{ relativePath: string; content: Uint8Array }>,
|
||||
success = true
|
||||
) {
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: 'main.py',
|
||||
executionId,
|
||||
success,
|
||||
outputs: [],
|
||||
outputFiles,
|
||||
imports: [],
|
||||
})
|
||||
}
|
||||
|
||||
const findFinished = (
|
||||
lifecycleEvents: Parameters<LifecycleCallback>[0][]
|
||||
) => lifecycleEvents.find(e => e.type === 'run-finished')
|
||||
|
||||
it('invokes fileUploader with mapped items when run-code-result has output files', async function () {
|
||||
const uploader = sinon
|
||||
.stub()
|
||||
.resolves([successResult('data.csv', 'output/data.csv')])
|
||||
const { worker, lifecycleEvents } = setupClientWithUploadTracking({
|
||||
fileUploader: uploader,
|
||||
})
|
||||
|
||||
emitRunResult(worker, 'exec-up-1', [
|
||||
{
|
||||
relativePath: 'output/data.csv',
|
||||
content: new TextEncoder().encode('a,b\n1,2'),
|
||||
},
|
||||
])
|
||||
await waitFor(() => Boolean(findFinished(lifecycleEvents)))
|
||||
|
||||
expect(uploader.calledOnce).to.be.true
|
||||
const [items] = uploader.firstCall.args
|
||||
expect(items).to.have.lengthOf(1)
|
||||
expect(items[0].name).to.equal('data.csv')
|
||||
expect(items[0].relativePath).to.equal('output/data.csv')
|
||||
expect(items[0].file).to.be.instanceOf(Blob)
|
||||
})
|
||||
|
||||
it('emits success: true and empty failedUploads when all uploads succeed', async function () {
|
||||
const uploader = sinon
|
||||
.stub()
|
||||
.resolves([
|
||||
successResult('a.csv', 'a.csv'),
|
||||
successResult('b.csv', 'b.csv'),
|
||||
])
|
||||
const { worker, lifecycleEvents } = setupClientWithUploadTracking({
|
||||
fileUploader: uploader,
|
||||
})
|
||||
|
||||
emitRunResult(worker, 'exec-up-2', [
|
||||
{ relativePath: 'a.csv', content: new TextEncoder().encode('1') },
|
||||
{ relativePath: 'b.csv', content: new TextEncoder().encode('2') },
|
||||
])
|
||||
await waitFor(() => Boolean(findFinished(lifecycleEvents)))
|
||||
|
||||
const finished = findFinished(lifecycleEvents)
|
||||
expect(finished).to.deep.include({ success: true, failedUploads: [] })
|
||||
})
|
||||
|
||||
it('flips success to false and lists failed paths when an upload fails', async function () {
|
||||
const uploader = sinon
|
||||
.stub()
|
||||
.resolves([
|
||||
successResult('good.csv', 'good.csv'),
|
||||
errorResult('bad.csv', 'output/bad.csv', 'duplicate_file_name'),
|
||||
])
|
||||
const { worker, lifecycleEvents, outputCalls } =
|
||||
setupClientWithUploadTracking({ fileUploader: uploader })
|
||||
|
||||
emitRunResult(worker, 'exec-up-3', [
|
||||
{ relativePath: 'good.csv', content: new TextEncoder().encode('1') },
|
||||
{
|
||||
relativePath: 'output/bad.csv',
|
||||
content: new TextEncoder().encode('2'),
|
||||
},
|
||||
])
|
||||
await waitFor(() => Boolean(findFinished(lifecycleEvents)))
|
||||
|
||||
const finished = findFinished(lifecycleEvents)
|
||||
expect(finished).to.deep.include({
|
||||
success: false,
|
||||
failedUploads: ['output/bad.csv'],
|
||||
errorType: 'UploadFileError',
|
||||
})
|
||||
|
||||
// user-facing stderr line surfaced via outputCallback
|
||||
expect(outputCalls).to.have.lengthOf(1)
|
||||
const [stream, line] = outputCalls[0]
|
||||
expect(stream).to.equal('stderr')
|
||||
expect(line).to.include('output/bad.csv')
|
||||
expect(line).to.include('duplicate_file_name')
|
||||
})
|
||||
|
||||
it('lists every file in failedUploads and surfaces a single stderr line when fileUploader rejects', async function () {
|
||||
const uploader = sinon.stub().rejects(new Error('network down'))
|
||||
const { worker, lifecycleEvents, outputCalls } =
|
||||
setupClientWithUploadTracking({ fileUploader: uploader })
|
||||
|
||||
emitRunResult(worker, 'exec-up-4', [
|
||||
{ relativePath: 'a.csv', content: new TextEncoder().encode('1') },
|
||||
{ relativePath: 'b.csv', content: new TextEncoder().encode('2') },
|
||||
])
|
||||
await waitFor(() => Boolean(findFinished(lifecycleEvents)))
|
||||
|
||||
const finished = findFinished(lifecycleEvents)
|
||||
expect(finished).to.deep.include({
|
||||
success: false,
|
||||
errorType: 'UploadFileError',
|
||||
})
|
||||
expect(finished)
|
||||
.to.have.property('failedUploads')
|
||||
.that.has.members(['a.csv', 'b.csv'])
|
||||
|
||||
expect(outputCalls).to.have.lengthOf(1)
|
||||
const [stream, line] = outputCalls[0]
|
||||
expect(stream).to.equal('stderr')
|
||||
expect(line).to.include('network down')
|
||||
})
|
||||
|
||||
it('does not invoke fileUploader when run-code-result has success: false', async function () {
|
||||
const uploader = sinon.stub().resolves([])
|
||||
const { worker, lifecycleEvents } = setupClientWithUploadTracking({
|
||||
fileUploader: uploader,
|
||||
})
|
||||
|
||||
emitRunResult(
|
||||
worker,
|
||||
'exec-up-5',
|
||||
[{ relativePath: 'a.csv', content: new TextEncoder().encode('1') }],
|
||||
false
|
||||
)
|
||||
await waitFor(() => Boolean(findFinished(lifecycleEvents)))
|
||||
|
||||
expect(uploader.called).to.be.false
|
||||
expect(findFinished(lifecycleEvents)).to.deep.include({
|
||||
success: false,
|
||||
failedUploads: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not invoke fileUploader when outputFiles is empty', async function () {
|
||||
const uploader = sinon.stub().resolves([])
|
||||
const { worker, lifecycleEvents } = setupClientWithUploadTracking({
|
||||
fileUploader: uploader,
|
||||
})
|
||||
|
||||
emitRunResult(worker, 'exec-up-6', [])
|
||||
await waitFor(() => Boolean(findFinished(lifecycleEvents)))
|
||||
|
||||
expect(uploader.called).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('output-line forwarding', function () {
|
||||
function setupClientWithOutputTracking() {
|
||||
const outputCalls: Parameters<OutputCallback>[] = []
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
onOutput: (...args) => {
|
||||
outputCalls.push(args)
|
||||
},
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
worker.emitMessage({ type: 'listening' })
|
||||
return { client, worker, outputCalls }
|
||||
}
|
||||
|
||||
it('forwards stdout output-line events to the output callback', function () {
|
||||
const { worker, outputCalls } = setupClientWithOutputTracking()
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stdout',
|
||||
line: 'hello!',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-stdout',
|
||||
})
|
||||
|
||||
expect(outputCalls).to.deep.equal([
|
||||
['stdout', 'hello!', 'main.py', 'exec-stdout'],
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards stderr output-line events to the output callback', function () {
|
||||
const { worker, outputCalls } = setupClientWithOutputTracking()
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stderr',
|
||||
line: 'boom',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-stderr',
|
||||
})
|
||||
|
||||
expect(outputCalls).to.deep.equal([
|
||||
['stderr', 'boom', 'main.py', 'exec-stderr'],
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards info output-line events to the output callback', function () {
|
||||
const { worker, outputCalls } = setupClientWithOutputTracking()
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'output-line',
|
||||
stream: 'info',
|
||||
line: 'Loading numpy from package index',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-info',
|
||||
})
|
||||
|
||||
expect(outputCalls).to.deep.equal([
|
||||
['info', 'Loading numpy from package index', 'main.py', 'exec-info'],
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves stream type when forwarding stdout, stderr, and info in sequence', function () {
|
||||
const { worker, outputCalls } = setupClientWithOutputTracking()
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'output-line',
|
||||
stream: 'info',
|
||||
line: 'Loading package',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-mixed',
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stdout',
|
||||
line: 'result',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-mixed',
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'output-line',
|
||||
stream: 'stderr',
|
||||
line: 'warning',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-mixed',
|
||||
})
|
||||
|
||||
expect(outputCalls.map(call => call[0])).to.deep.equal([
|
||||
'info',
|
||||
'stdout',
|
||||
'stderr',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', function () {
|
||||
it('terminates the current worker and creates a new one', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const originalWorker = WorkerMock.instances[0]
|
||||
originalWorker.emitMessage({ type: 'listening' })
|
||||
originalWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.reset()
|
||||
|
||||
expect(originalWorker.terminated).to.equal(true)
|
||||
expect(WorkerMock.instances).to.have.length(2)
|
||||
})
|
||||
|
||||
it('sends init to the new worker once it reports listening after reset', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const originalWorker = WorkerMock.instances[0]
|
||||
originalWorker.emitMessage({ type: 'listening' })
|
||||
originalWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.reset()
|
||||
|
||||
const newWorker = WorkerMock.instances[1]
|
||||
expect(newWorker.postedMessages).to.have.length(0)
|
||||
|
||||
newWorker.emitMessage({ type: 'listening' })
|
||||
expect(newWorker.postedMessages).to.deep.equal([
|
||||
{
|
||||
type: 'init',
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('allows running code on the new worker after reset', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const originalWorker = WorkerMock.instances[0]
|
||||
originalWorker.emitMessage({ type: 'listening' })
|
||||
originalWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.reset()
|
||||
|
||||
const newWorker = WorkerMock.instances[1]
|
||||
newWorker.emitMessage({ type: 'listening' })
|
||||
newWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.runCode('print("after reset")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
files: [],
|
||||
})
|
||||
|
||||
const runRequest = newWorker.postedMessages.find(
|
||||
(message: any) => message.type === 'run-code'
|
||||
)
|
||||
expect(runRequest).to.include({
|
||||
type: 'run-code',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
code: 'print("after reset")',
|
||||
})
|
||||
})
|
||||
|
||||
it('reset is a no-op after destroy', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
fileUploader: fileUploaderStub,
|
||||
})
|
||||
const originalWorker = WorkerMock.instances[0]
|
||||
originalWorker.emitMessage({ type: 'listening' })
|
||||
originalWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.destroy()
|
||||
client.reset()
|
||||
|
||||
// No new worker should have been created
|
||||
expect(WorkerMock.instances).to.have.length(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
MAX_OUTPUT_FILES,
|
||||
MAX_OUTPUT_FILE_BYTES,
|
||||
MAX_OUTPUT_TOTAL_BYTES,
|
||||
checkOutputLimits,
|
||||
} from '../../../frontend/js/components/editor/python/pyodide-worker-output-limits'
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
|
||||
function makeFiles(
|
||||
count: number,
|
||||
sizePerFile: number
|
||||
): { path: string; size: number }[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
path: `/project/file${i}.bin`,
|
||||
size: sizePerFile,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('checkOutputLimits', function () {
|
||||
it('returns null when both limits are within bounds', function () {
|
||||
const files = makeFiles(10, 1024)
|
||||
expect(checkOutputLimits(files)).to.equal(null)
|
||||
})
|
||||
|
||||
it('returns null for an empty file list', function () {
|
||||
expect(checkOutputLimits([])).to.equal(null)
|
||||
})
|
||||
|
||||
it('returns null at exactly the file count limit', function () {
|
||||
const files = makeFiles(MAX_OUTPUT_FILES, 1024)
|
||||
expect(checkOutputLimits(files)).to.equal(null)
|
||||
})
|
||||
|
||||
it('returns a count violation reporting the actual file count when the file count exceeds the limit', function () {
|
||||
const files = makeFiles(73, 1)
|
||||
const violation = checkOutputLimits(files)
|
||||
expect(violation).to.deep.equal({
|
||||
kind: 'count',
|
||||
message: 'Output limit exceeded: 73 files generated (max 50)',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null at exactly the per-file size limit', function () {
|
||||
const files = [{ path: '/project/big.bin', size: MAX_OUTPUT_FILE_BYTES }]
|
||||
expect(checkOutputLimits(files)).to.equal(null)
|
||||
})
|
||||
|
||||
it('returns a single-file-size violation reporting the offending file path and size when one file exceeds the per-file limit', function () {
|
||||
const files = [
|
||||
{ path: '/project/small.bin', size: 1 * BYTES_PER_MB },
|
||||
{ path: '/project/huge.bin', size: 80 * BYTES_PER_MB },
|
||||
]
|
||||
const violation = checkOutputLimits(files)
|
||||
expect(violation).to.deep.equal({
|
||||
kind: 'single-file-size',
|
||||
message:
|
||||
'Output limit exceeded: /project/huge.bin is 80MB (max 50MB per file)',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a total-output-size violation when the summed size exceeds the total limit', function () {
|
||||
const files = [
|
||||
{ path: '/project/a.bin', size: 40 * BYTES_PER_MB },
|
||||
{ path: '/project/b.bin', size: 40 * BYTES_PER_MB },
|
||||
{ path: '/project/c.bin', size: 40 * BYTES_PER_MB },
|
||||
]
|
||||
const violation = checkOutputLimits(files)
|
||||
expect(violation).to.not.equal(null)
|
||||
expect(violation!.kind).to.equal('total-output-size')
|
||||
expect(violation!.message).to.equal(
|
||||
'Output limit exceeded: 120MB total (max 100MB)'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null at exactly the total size limit', function () {
|
||||
const files = [
|
||||
{ path: '/project/a.bin', size: 50 * BYTES_PER_MB },
|
||||
{ path: '/project/b.bin', size: 50 * BYTES_PER_MB },
|
||||
]
|
||||
expect(checkOutputLimits(files)).to.equal(null)
|
||||
const total = files.reduce((acc, f) => acc + f.size, 0)
|
||||
expect(total).to.equal(MAX_OUTPUT_TOTAL_BYTES)
|
||||
})
|
||||
|
||||
it('reports the count violation when both limits are exceeded', function () {
|
||||
const files = makeFiles(MAX_OUTPUT_FILES + 10, MAX_OUTPUT_TOTAL_BYTES)
|
||||
const violation = checkOutputLimits(files)
|
||||
expect(violation).to.not.equal(null)
|
||||
expect(violation!.kind).to.equal('count')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,499 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import {
|
||||
PythonRunner,
|
||||
PythonRunnerState,
|
||||
DEFAULT_STATE,
|
||||
ExecutionContext,
|
||||
type FileUploader,
|
||||
} from '../../../frontend/js/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>
|
||||
fileUploader?: FileUploader
|
||||
} = {}
|
||||
) {
|
||||
const fileId = overrides.fileId ?? FILE_ID
|
||||
const getExecutionContext =
|
||||
overrides.getExecutionContext ??
|
||||
(() =>
|
||||
Promise.resolve({
|
||||
code: 'print("hello")',
|
||||
files: [{ relativePath: 'main.py', content: 'print("hello")' }],
|
||||
}))
|
||||
|
||||
const fileUploader = overrides.fileUploader ?? sinon.stub().resolves([])
|
||||
|
||||
const runner = new PythonRunner(
|
||||
fileId,
|
||||
BASE_ASSET_PATH,
|
||||
getExecutionContext,
|
||||
createWorker,
|
||||
fileUploader
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
function waitForState(
|
||||
runner: PythonRunner,
|
||||
predicate: (state: PythonRunnerState) => boolean
|
||||
): Promise<PythonRunnerState> {
|
||||
return new Promise(resolve => {
|
||||
if (predicate(runner.getState())) {
|
||||
resolve(runner.getState())
|
||||
return
|
||||
}
|
||||
const unsubscribe = runner.subscribe(() => {
|
||||
if (predicate(runner.getState())) {
|
||||
unsubscribe()
|
||||
resolve(runner.getState())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
success: true,
|
||||
outputs: [],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
})
|
||||
|
||||
await waitForState(runner, s => s.status === 'finished')
|
||||
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,
|
||||
success: true,
|
||||
outputs: [],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
})
|
||||
expect(runner.getState().output).to.deep.equal([
|
||||
{ stream: 'stdout', line: '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('files-saved toast', function () {
|
||||
let toastEvents: CustomEvent[]
|
||||
let toastListener: (event: Event) => void
|
||||
|
||||
beforeEach(function () {
|
||||
toastEvents = []
|
||||
toastListener = event => {
|
||||
toastEvents.push(event as CustomEvent)
|
||||
}
|
||||
window.addEventListener('ide:show-toast', toastListener)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.removeEventListener('ide:show-toast', toastListener)
|
||||
})
|
||||
|
||||
it('dispatches a files-saved toast with successfully uploaded paths', 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: 'run-code-result',
|
||||
fileId: FILE_ID,
|
||||
executionId: runMsg.executionId,
|
||||
success: true,
|
||||
outputs: ['/project/foo.txt', '/project/bar.csv'],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
failedUploads: [],
|
||||
})
|
||||
|
||||
await waitForState(runner, s => s.status === 'finished')
|
||||
|
||||
expect(toastEvents).to.have.length(1)
|
||||
expect(toastEvents[0].detail).to.deep.equal({
|
||||
key: 'python:files-saved',
|
||||
paths: ['foo.txt', 'bar.csv'],
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes failed uploads from the toast', async function () {
|
||||
const fileUploader = sinon.stub().resolves([
|
||||
{ status: 'success', name: 'foo.txt', relativePath: 'foo.txt' },
|
||||
{
|
||||
status: 'error',
|
||||
name: 'bar.csv',
|
||||
relativePath: 'bar.csv',
|
||||
error: 'boom',
|
||||
},
|
||||
])
|
||||
const runner = createRunner({ fileUploader })
|
||||
const worker = initAndLoad(runner)
|
||||
|
||||
await runner.run()
|
||||
const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: FILE_ID,
|
||||
executionId: runMsg.executionId,
|
||||
success: true,
|
||||
outputs: ['/project/foo.txt', '/project/bar.csv'],
|
||||
outputFiles: [
|
||||
{ relativePath: 'foo.txt', content: new Uint8Array() },
|
||||
{ relativePath: 'bar.csv', content: new Uint8Array() },
|
||||
],
|
||||
imports: [],
|
||||
})
|
||||
|
||||
await waitForState(runner, s => s.status === 'finished')
|
||||
|
||||
expect(toastEvents).to.have.length(1)
|
||||
expect(toastEvents[0].detail).to.deep.equal({
|
||||
key: 'python:files-saved',
|
||||
paths: ['foo.txt'],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not dispatch a toast when no outputs were written', 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: 'run-code-result',
|
||||
fileId: FILE_ID,
|
||||
executionId: runMsg.executionId,
|
||||
success: true,
|
||||
outputs: [],
|
||||
outputFiles: [],
|
||||
imports: [],
|
||||
failedUploads: [],
|
||||
})
|
||||
|
||||
await waitForState(runner, s => s.status === 'finished')
|
||||
|
||||
expect(toastEvents).to.have.length(0)
|
||||
})
|
||||
|
||||
it('does not dispatch a toast when every output failed to upload', async function () {
|
||||
const fileUploader = sinon.stub().resolves([
|
||||
{
|
||||
status: 'error',
|
||||
name: 'foo.txt',
|
||||
relativePath: 'foo.txt',
|
||||
error: 'boom',
|
||||
},
|
||||
])
|
||||
const runner = createRunner({ fileUploader })
|
||||
const worker = initAndLoad(runner)
|
||||
|
||||
await runner.run()
|
||||
const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
fileId: FILE_ID,
|
||||
executionId: runMsg.executionId,
|
||||
success: true,
|
||||
outputs: ['/project/foo.txt'],
|
||||
outputFiles: [{ relativePath: 'foo.txt', content: new Uint8Array() }],
|
||||
imports: [],
|
||||
})
|
||||
|
||||
await waitForState(runner, s => s.status === 'finished')
|
||||
|
||||
expect(toastEvents).to.have.length(0)
|
||||
})
|
||||
})
|
||||
|
||||
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([
|
||||
{ stream: 'stdout', line: 'line 1' },
|
||||
{ stream: 'stderr', line: '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.deep.equal({ stream: 'stdout', line: 'line 10' })
|
||||
expect(output[99]).to.deep.equal({ stream: 'stdout', line: '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([
|
||||
{ stream: 'stdout', line: 'partial output' },
|
||||
{ stream: 'info', line: '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