| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366 |
- import { expect } from 'chai'
- import sinon from 'sinon'
- import {
- PythonRunner,
- PythonRunnerState,
- DEFAULT_STATE,
- ExecutionContext,
- type FileUploader,
- } 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>
- 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: [],
- })
- 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: [],
- })
- 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('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)
- })
- })
- })
|