[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
+13
-6
@@ -7,6 +7,7 @@ import {
|
||||
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { ProjectContext } from '@/shared/context/project-context'
|
||||
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||
import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context'
|
||||
|
||||
const pythonExecutableScript: Record<string, string> = {
|
||||
file_id: 'test-py-doc-id',
|
||||
@@ -83,11 +84,13 @@ describe('<PythonOutputPane />', function () {
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonOutputPane />
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python Code' })
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByText('hello!').should('exist')
|
||||
@@ -122,11 +125,13 @@ describe('<PythonOutputPane />', function () {
|
||||
}}
|
||||
providers={{ FileTreePathProvider, ProjectProvider }}
|
||||
>
|
||||
<PythonOutputPane />
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python Code' })
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByText('hello!').should('exist')
|
||||
@@ -195,11 +200,13 @@ describe('<PythonOutputPane />', function () {
|
||||
ProjectProvider,
|
||||
}}
|
||||
>
|
||||
<PythonOutputPane />
|
||||
<PythonExecutionProvider>
|
||||
<PythonOutputPane />
|
||||
</PythonExecutionProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Run Python Code' })
|
||||
cy.findByRole('button', { name: 'Run Python code' })
|
||||
.should('not.be.disabled')
|
||||
.click()
|
||||
cy.findByText('name,type').should('exist')
|
||||
|
||||
+94
-84
@@ -1,64 +1,24 @@
|
||||
import { expect } from 'chai'
|
||||
import { PyodideWorkerClient } from '@/features/ide-react/components/editor/python/pyodide-worker-client'
|
||||
|
||||
type WorkerMessageListener = (event: MessageEvent) => void
|
||||
import { WorkerMock, createWorker } from './worker-mock'
|
||||
|
||||
const BASE_ASSET_PATH = 'https://assets.example.test/'
|
||||
|
||||
class WorkerMock {
|
||||
static instances: WorkerMock[] = []
|
||||
|
||||
readonly postedMessages: any[] = []
|
||||
terminated = false
|
||||
private messageListeners: WorkerMessageListener[] = []
|
||||
|
||||
constructor() {
|
||||
WorkerMock.instances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: WorkerMessageListener) {
|
||||
if (type === 'message') {
|
||||
this.messageListeners.push(listener)
|
||||
}
|
||||
}
|
||||
|
||||
postMessage(message: unknown) {
|
||||
this.postedMessages.push(message)
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true
|
||||
}
|
||||
|
||||
emitMessage(message: unknown) {
|
||||
for (const listener of this.messageListeners) {
|
||||
listener({ data: message, target: this } as unknown as MessageEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('PyodideWorkerClient', function () {
|
||||
let originalWorker: typeof Worker | undefined
|
||||
|
||||
beforeEach(function () {
|
||||
originalWorker = window.Worker
|
||||
// @ts-ignore - allow mocking Worker
|
||||
window.Worker = WorkerMock
|
||||
WorkerMock.instances.length = 0
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
if (originalWorker) {
|
||||
window.Worker = originalWorker
|
||||
}
|
||||
})
|
||||
|
||||
it('queues runCode until the worker reports listening', function () {
|
||||
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
|
||||
client.runCode('print("ok")', {
|
||||
requestId: 'main.py',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-1',
|
||||
files: [{ relativePath: 'main.py', content: 'print("ok")' }],
|
||||
})
|
||||
expect(worker.postedMessages).to.have.length(0)
|
||||
@@ -74,7 +34,8 @@ describe('PyodideWorkerClient', function () {
|
||||
)
|
||||
expect(runRequest).to.include({
|
||||
type: 'run-code',
|
||||
id: 'main.py',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-1',
|
||||
code: 'print("ok")',
|
||||
})
|
||||
expect(runRequest.files).to.deep.equal([
|
||||
@@ -83,29 +44,44 @@ describe('PyodideWorkerClient', function () {
|
||||
})
|
||||
|
||||
it('sends runCode as fire-and-forget', function () {
|
||||
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
worker.emitMessage({ type: 'listening' })
|
||||
|
||||
client.runCode('raise RuntimeError("boom")', {
|
||||
requestId: 'boom.py',
|
||||
fileId: 'boom.py',
|
||||
executionId: 'exec-2',
|
||||
files: [],
|
||||
})
|
||||
const runRequest = worker.postedMessages.find(
|
||||
message => message.type === 'run-code'
|
||||
)
|
||||
expect(runRequest).to.include({ type: 'run-code', id: 'boom.py' })
|
||||
expect(runRequest).to.include({
|
||||
type: 'run-code',
|
||||
fileId: 'boom.py',
|
||||
executionId: 'exec-2',
|
||||
})
|
||||
})
|
||||
|
||||
function setupClientWithLifecycleTracking() {
|
||||
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
|
||||
const worker = WorkerMock.instances[0]
|
||||
const lifecycleEvents: Array<{
|
||||
const lifecycleEvents: {
|
||||
type: string
|
||||
requestId?: string
|
||||
fileId?: string
|
||||
executionId?: string
|
||||
outputs?: string[]
|
||||
}> = []
|
||||
client.setLifecycleCallback(event => lifecycleEvents.push(event))
|
||||
}[] = []
|
||||
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
onLifecycle: event => {
|
||||
lifecycleEvents.push(event)
|
||||
},
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
worker.emitMessage({ type: 'listening' })
|
||||
return { client, worker, lifecycleEvents }
|
||||
}
|
||||
@@ -114,16 +90,22 @@ describe('PyodideWorkerClient', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('print("ok")', { requestId: 'main.py', files: [] })
|
||||
client.runCode('print("ok")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-3',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
id: 'main.py',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-3',
|
||||
outputs: ['/project/output.txt'],
|
||||
})
|
||||
|
||||
expect(lifecycleEvents).to.deep.include({
|
||||
type: 'run-finished',
|
||||
requestId: 'main.py',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-3',
|
||||
outputs: ['/project/output.txt'],
|
||||
})
|
||||
})
|
||||
@@ -132,16 +114,22 @@ describe('PyodideWorkerClient', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('write_files()', { requestId: 'main.py', files: [] })
|
||||
client.runCode('write_files()', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-4',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
id: 'main.py',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-4',
|
||||
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
||||
})
|
||||
|
||||
expect(lifecycleEvents).to.deep.include({
|
||||
type: 'run-finished',
|
||||
requestId: 'main.py',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-4',
|
||||
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
||||
})
|
||||
})
|
||||
@@ -150,28 +138,37 @@ describe('PyodideWorkerClient', function () {
|
||||
const { client, worker, lifecycleEvents } =
|
||||
setupClientWithLifecycleTracking()
|
||||
|
||||
client.runCode('print("no writes")', { requestId: 'main.py', files: [] })
|
||||
client.runCode('print("no writes")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
files: [],
|
||||
})
|
||||
worker.emitMessage({
|
||||
type: 'run-code-result',
|
||||
id: 'main.py',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
outputs: [],
|
||||
})
|
||||
|
||||
expect(lifecycleEvents).to.deep.include({
|
||||
type: 'run-finished',
|
||||
requestId: 'main.py',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
outputs: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports lifecycle failure and rejects future run requests when loading fails', function () {
|
||||
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
|
||||
const worker = WorkerMock.instances[0]
|
||||
const lifecycleEvents: Array<{ type: string; error?: string }> = []
|
||||
|
||||
client.setLifecycleCallback(event => {
|
||||
lifecycleEvents.push(event)
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
onLifecycle: event => {
|
||||
lifecycleEvents.push(event)
|
||||
},
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'loading-failed',
|
||||
@@ -182,12 +179,19 @@ describe('PyodideWorkerClient', function () {
|
||||
{ type: 'loading-failed', error: 'runtime unavailable' },
|
||||
])
|
||||
expect(() =>
|
||||
client.runCode('print("ok")', { requestId: 'main.py', files: [] })
|
||||
client.runCode('print("ok")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-4',
|
||||
files: [],
|
||||
})
|
||||
).to.throw('runtime unavailable')
|
||||
})
|
||||
|
||||
it('terminates the worker even when destroy is called after loading failure', function () {
|
||||
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
})
|
||||
const worker = WorkerMock.instances[0]
|
||||
|
||||
worker.emitMessage({
|
||||
@@ -199,30 +203,32 @@ describe('PyodideWorkerClient', function () {
|
||||
expect(worker.terminated).to.equal(true)
|
||||
})
|
||||
|
||||
describe('stop', function () {
|
||||
describe('reset', function () {
|
||||
it('terminates the current worker and creates a new one', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
})
|
||||
const originalWorker = WorkerMock.instances[0]
|
||||
originalWorker.emitMessage({ type: 'listening' })
|
||||
originalWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.stop()
|
||||
client.reset()
|
||||
|
||||
expect(originalWorker.terminated).to.equal(true)
|
||||
expect(WorkerMock.instances).to.have.length(2)
|
||||
})
|
||||
|
||||
it('sends init to the new worker once it reports listening', function () {
|
||||
it('sends init to the new worker once it reports listening after reset', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
})
|
||||
const originalWorker = WorkerMock.instances[0]
|
||||
originalWorker.emitMessage({ type: 'listening' })
|
||||
originalWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.stop()
|
||||
client.reset()
|
||||
|
||||
const newWorker = WorkerMock.instances[1]
|
||||
expect(newWorker.postedMessages).to.have.length(0)
|
||||
@@ -233,22 +239,24 @@ describe('PyodideWorkerClient', function () {
|
||||
])
|
||||
})
|
||||
|
||||
it('allows running code on the new worker after stop', function () {
|
||||
it('allows running code on the new worker after reset', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
})
|
||||
const originalWorker = WorkerMock.instances[0]
|
||||
originalWorker.emitMessage({ type: 'listening' })
|
||||
originalWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.stop()
|
||||
client.reset()
|
||||
|
||||
const newWorker = WorkerMock.instances[1]
|
||||
newWorker.emitMessage({ type: 'listening' })
|
||||
newWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.runCode('print("after stop")', {
|
||||
requestId: 'main.py',
|
||||
client.runCode('print("after reset")', {
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
files: [],
|
||||
})
|
||||
|
||||
@@ -257,21 +265,23 @@ describe('PyodideWorkerClient', function () {
|
||||
)
|
||||
expect(runRequest).to.include({
|
||||
type: 'run-code',
|
||||
id: 'main.py',
|
||||
code: 'print("after stop")',
|
||||
fileId: 'main.py',
|
||||
executionId: 'exec-5',
|
||||
code: 'print("after reset")',
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op after destroy', function () {
|
||||
it('reset is a no-op after destroy', function () {
|
||||
const client = new PyodideWorkerClient({
|
||||
baseAssetPath: BASE_ASSET_PATH,
|
||||
createWorker,
|
||||
})
|
||||
const originalWorker = WorkerMock.instances[0]
|
||||
originalWorker.emitMessage({ type: 'listening' })
|
||||
originalWorker.emitMessage({ type: 'loaded' })
|
||||
|
||||
client.destroy()
|
||||
client.stop()
|
||||
client.reset()
|
||||
|
||||
// No new worker should have been created
|
||||
expect(WorkerMock.instances).to.have.length(1)
|
||||
|
||||
@@ -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