python-runner.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import { expect } from 'chai'
  2. import sinon from 'sinon'
  3. import {
  4. PythonRunner,
  5. PythonRunnerState,
  6. DEFAULT_STATE,
  7. ExecutionContext,
  8. type FileUploader,
  9. } from '@/features/ide-react/components/editor/python/python-runner'
  10. import { WorkerMock, createWorker } from './worker-mock'
  11. const BASE_ASSET_PATH = 'https://assets.example.test/'
  12. const FILE_ID = 'file-1'
  13. function createRunner(
  14. overrides: {
  15. fileId?: string
  16. getExecutionContext?: () => Promise<ExecutionContext | null>
  17. fileUploader?: FileUploader
  18. } = {}
  19. ) {
  20. const fileId = overrides.fileId ?? FILE_ID
  21. const getExecutionContext =
  22. overrides.getExecutionContext ??
  23. (() =>
  24. Promise.resolve({
  25. code: 'print("hello")',
  26. files: [{ relativePath: 'main.py', content: 'print("hello")' }],
  27. }))
  28. const fileUploader = overrides.fileUploader ?? sinon.stub().resolves([])
  29. const runner = new PythonRunner(
  30. fileId,
  31. BASE_ASSET_PATH,
  32. getExecutionContext,
  33. createWorker,
  34. fileUploader
  35. )
  36. return runner
  37. }
  38. function initAndLoad(runner: PythonRunner) {
  39. runner.init()
  40. const worker = WorkerMock.instances[WorkerMock.instances.length - 1]
  41. worker.emitMessage({ type: 'listening' })
  42. worker.emitMessage({ type: 'loaded' })
  43. return worker
  44. }
  45. function waitForState(
  46. runner: PythonRunner,
  47. predicate: (state: PythonRunnerState) => boolean
  48. ): Promise<PythonRunnerState> {
  49. return new Promise(resolve => {
  50. if (predicate(runner.getState())) {
  51. resolve(runner.getState())
  52. return
  53. }
  54. const unsubscribe = runner.subscribe(() => {
  55. if (predicate(runner.getState())) {
  56. unsubscribe()
  57. resolve(runner.getState())
  58. }
  59. })
  60. })
  61. }
  62. describe('PythonRunner', function () {
  63. beforeEach(function () {
  64. WorkerMock.instances.length = 0
  65. })
  66. describe('initial state', function () {
  67. it('starts with default snapshot before init', function () {
  68. const runner = createRunner()
  69. expect(runner.getState()).to.deep.equal(DEFAULT_STATE)
  70. })
  71. })
  72. describe('init and lifecycle', function () {
  73. it('transitions to loading on init', function () {
  74. const runner = createRunner()
  75. runner.init()
  76. expect(runner.getState().status).to.equal('loading')
  77. })
  78. it('transitions to idle when worker reports loaded', function () {
  79. const runner = createRunner()
  80. initAndLoad(runner)
  81. expect(runner.getState().status).to.equal('idle')
  82. })
  83. it('transitions to errored on loading failure', function () {
  84. const runner = createRunner()
  85. runner.init()
  86. const worker = WorkerMock.instances[0]
  87. worker.emitMessage({ type: 'listening' })
  88. worker.emitMessage({
  89. type: 'loading-failed',
  90. error: 'network error',
  91. })
  92. expect(runner.getState().status).to.equal('errored')
  93. expect(runner.getState().error).to.equal('network error')
  94. })
  95. it('clears error on successful load after failure', function () {
  96. const runner = createRunner()
  97. runner.init()
  98. const worker = WorkerMock.instances[0]
  99. worker.emitMessage({ type: 'listening' })
  100. worker.emitMessage({ type: 'loaded' })
  101. expect(runner.getState().error).to.equal(null)
  102. })
  103. it('is a no-op if already initialized', function () {
  104. const runner = createRunner()
  105. runner.init()
  106. runner.init()
  107. expect(WorkerMock.instances).to.have.length(1)
  108. })
  109. })
  110. describe('run', function () {
  111. it('transitions to running then finished', async function () {
  112. const runner = createRunner()
  113. const worker = initAndLoad(runner)
  114. await runner.run()
  115. expect(runner.getState().status).to.equal('running')
  116. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  117. worker.emitMessage({
  118. type: 'run-code-result',
  119. fileId: FILE_ID,
  120. executionId: runMsg.executionId,
  121. success: true,
  122. outputs: [],
  123. outputFiles: [],
  124. imports: [],
  125. })
  126. await waitForState(runner, s => s.status === 'finished')
  127. expect(runner.getState().status).to.equal('finished')
  128. })
  129. it('clears previous output on new run', async function () {
  130. const runner = createRunner()
  131. const worker = initAndLoad(runner)
  132. await runner.run()
  133. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  134. worker.emitMessage({
  135. type: 'output-line',
  136. stream: 'stdout',
  137. line: 'first run output',
  138. fileId: FILE_ID,
  139. executionId: runMsg.executionId,
  140. })
  141. worker.emitMessage({
  142. type: 'run-code-result',
  143. fileId: FILE_ID,
  144. executionId: runMsg.executionId,
  145. success: true,
  146. outputs: [],
  147. outputFiles: [],
  148. imports: [],
  149. })
  150. expect(runner.getState().output).to.deep.equal([
  151. { stream: 'stdout', line: 'first run output' },
  152. ])
  153. await runner.run()
  154. expect(runner.getState().output).to.deep.equal([])
  155. })
  156. it('is a no-op while still loading', async function () {
  157. const runner = createRunner()
  158. runner.init()
  159. await runner.run()
  160. expect(runner.getState().status).to.equal('loading')
  161. })
  162. it('is a no-op when getExecutionContext returns null', async function () {
  163. const runner = createRunner({
  164. getExecutionContext: () => Promise.resolve(null),
  165. })
  166. initAndLoad(runner)
  167. await runner.run()
  168. expect(runner.getState().status).to.equal('idle')
  169. })
  170. it('transitions to errored when getExecutionContext rejects', async function () {
  171. const runner = createRunner({
  172. getExecutionContext: () => Promise.reject(new Error('network failure')),
  173. })
  174. initAndLoad(runner)
  175. await runner.run()
  176. expect(runner.getState().status).to.equal('errored')
  177. expect(runner.getState().error).to.equal('network failure')
  178. })
  179. })
  180. describe('output', function () {
  181. it('accumulates output lines for the matching file', async function () {
  182. const runner = createRunner()
  183. const worker = initAndLoad(runner)
  184. await runner.run()
  185. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  186. worker.emitMessage({
  187. type: 'output-line',
  188. stream: 'stdout',
  189. line: 'line 1',
  190. fileId: FILE_ID,
  191. executionId: runMsg.executionId,
  192. })
  193. worker.emitMessage({
  194. type: 'output-line',
  195. stream: 'stderr',
  196. line: 'line 2',
  197. fileId: FILE_ID,
  198. executionId: runMsg.executionId,
  199. })
  200. expect(runner.getState().output).to.deep.equal([
  201. { stream: 'stdout', line: 'line 1' },
  202. { stream: 'stderr', line: 'line 2' },
  203. ])
  204. })
  205. it('ignores output for a different fileId', async function () {
  206. const runner = createRunner()
  207. const worker = initAndLoad(runner)
  208. await runner.run()
  209. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  210. worker.emitMessage({
  211. type: 'output-line',
  212. stream: 'stdout',
  213. line: 'other file output',
  214. fileId: 'different-file',
  215. executionId: runMsg.executionId,
  216. })
  217. expect(runner.getState().output).to.deep.equal([])
  218. })
  219. it('ignores output for a stale executionId', async function () {
  220. const runner = createRunner()
  221. const worker = initAndLoad(runner)
  222. await runner.run()
  223. worker.emitMessage({
  224. type: 'output-line',
  225. stream: 'stdout',
  226. line: 'stale output',
  227. fileId: FILE_ID,
  228. executionId: 'old-execution-id',
  229. })
  230. expect(runner.getState().output).to.deep.equal([])
  231. })
  232. it('caps output at 100 lines', async function () {
  233. const runner = createRunner()
  234. const worker = initAndLoad(runner)
  235. await runner.run()
  236. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  237. for (let i = 0; i < 110; i++) {
  238. worker.emitMessage({
  239. type: 'output-line',
  240. stream: 'stdout',
  241. line: `line ${i}`,
  242. fileId: FILE_ID,
  243. executionId: runMsg.executionId,
  244. })
  245. }
  246. const output = runner.getState().output
  247. expect(output).to.have.length(100)
  248. expect(output[0]).to.deep.equal({ stream: 'stdout', line: 'line 10' })
  249. expect(output[99]).to.deep.equal({ stream: 'stdout', line: 'line 109' })
  250. })
  251. })
  252. describe('interrupt', function () {
  253. it('appends interrupted message and transitions to loading when running', async function () {
  254. const runner = createRunner()
  255. const worker = initAndLoad(runner)
  256. await runner.run()
  257. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  258. worker.emitMessage({
  259. type: 'output-line',
  260. stream: 'stdout',
  261. line: 'partial output',
  262. fileId: FILE_ID,
  263. executionId: runMsg.executionId,
  264. })
  265. runner.interrupt()
  266. expect(runner.getState().status).to.equal('loading')
  267. expect(runner.getState().output).to.deep.equal([
  268. { stream: 'stdout', line: 'partial output' },
  269. { stream: 'info', line: 'Execution interrupted' },
  270. ])
  271. })
  272. it('does not append interrupted message when not running', function () {
  273. const runner = createRunner()
  274. initAndLoad(runner)
  275. runner.interrupt()
  276. expect(runner.getState().status).to.equal('loading')
  277. expect(runner.getState().output).to.deep.equal([])
  278. })
  279. })
  280. describe('subscribe', function () {
  281. it('notifies listeners on state changes', function () {
  282. const runner = createRunner()
  283. const listener = sinon.stub()
  284. runner.subscribe(listener)
  285. initAndLoad(runner)
  286. expect(listener.callCount).to.be.greaterThan(0)
  287. })
  288. it('stops notifying after unsubscribe', function () {
  289. const runner = createRunner()
  290. const listener = sinon.stub()
  291. const unsubscribe = runner.subscribe(listener)
  292. runner.init()
  293. const countAfterInit = listener.callCount
  294. unsubscribe()
  295. const worker = WorkerMock.instances[0]
  296. worker.emitMessage({ type: 'listening' })
  297. worker.emitMessage({ type: 'loaded' })
  298. expect(listener.callCount).to.equal(countAfterInit)
  299. })
  300. })
  301. describe('destroy', function () {
  302. it('terminates the worker', function () {
  303. const runner = createRunner()
  304. initAndLoad(runner)
  305. runner.destroy()
  306. const worker = WorkerMock.instances[0]
  307. expect(worker.terminated).to.equal(true)
  308. })
  309. })
  310. })