python-runner.spec.ts 9.5 KB

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