python-runner.spec.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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(['first run output'])
  122. await runner.run()
  123. expect(runner.getState().output).to.deep.equal([])
  124. })
  125. it('is a no-op while still loading', async function () {
  126. const runner = createRunner()
  127. runner.init()
  128. await runner.run()
  129. expect(runner.getState().status).to.equal('loading')
  130. })
  131. it('is a no-op when getExecutionContext returns null', async function () {
  132. const runner = createRunner({
  133. getExecutionContext: () => Promise.resolve(null),
  134. })
  135. initAndLoad(runner)
  136. await runner.run()
  137. expect(runner.getState().status).to.equal('idle')
  138. })
  139. it('transitions to errored when getExecutionContext rejects', async function () {
  140. const runner = createRunner({
  141. getExecutionContext: () => Promise.reject(new Error('network failure')),
  142. })
  143. initAndLoad(runner)
  144. await runner.run()
  145. expect(runner.getState().status).to.equal('errored')
  146. expect(runner.getState().error).to.equal('network failure')
  147. })
  148. })
  149. describe('output', function () {
  150. it('accumulates output lines for the matching file', async function () {
  151. const runner = createRunner()
  152. const worker = initAndLoad(runner)
  153. await runner.run()
  154. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  155. worker.emitMessage({
  156. type: 'output-line',
  157. stream: 'stdout',
  158. line: 'line 1',
  159. fileId: FILE_ID,
  160. executionId: runMsg.executionId,
  161. })
  162. worker.emitMessage({
  163. type: 'output-line',
  164. stream: 'stderr',
  165. line: 'line 2',
  166. fileId: FILE_ID,
  167. executionId: runMsg.executionId,
  168. })
  169. expect(runner.getState().output).to.deep.equal(['line 1', 'line 2'])
  170. })
  171. it('ignores output for a different fileId', async function () {
  172. const runner = createRunner()
  173. const worker = initAndLoad(runner)
  174. await runner.run()
  175. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  176. worker.emitMessage({
  177. type: 'output-line',
  178. stream: 'stdout',
  179. line: 'other file output',
  180. fileId: 'different-file',
  181. executionId: runMsg.executionId,
  182. })
  183. expect(runner.getState().output).to.deep.equal([])
  184. })
  185. it('ignores output for a stale executionId', async function () {
  186. const runner = createRunner()
  187. const worker = initAndLoad(runner)
  188. await runner.run()
  189. worker.emitMessage({
  190. type: 'output-line',
  191. stream: 'stdout',
  192. line: 'stale output',
  193. fileId: FILE_ID,
  194. executionId: 'old-execution-id',
  195. })
  196. expect(runner.getState().output).to.deep.equal([])
  197. })
  198. it('caps output at 100 lines', async function () {
  199. const runner = createRunner()
  200. const worker = initAndLoad(runner)
  201. await runner.run()
  202. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  203. for (let i = 0; i < 110; i++) {
  204. worker.emitMessage({
  205. type: 'output-line',
  206. stream: 'stdout',
  207. line: `line ${i}`,
  208. fileId: FILE_ID,
  209. executionId: runMsg.executionId,
  210. })
  211. }
  212. const output = runner.getState().output
  213. expect(output).to.have.length(100)
  214. expect(output[0]).to.equal('line 10')
  215. expect(output[99]).to.equal('line 109')
  216. })
  217. })
  218. describe('interrupt', function () {
  219. it('appends interrupted message and transitions to loading when running', async function () {
  220. const runner = createRunner()
  221. const worker = initAndLoad(runner)
  222. await runner.run()
  223. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  224. worker.emitMessage({
  225. type: 'output-line',
  226. stream: 'stdout',
  227. line: 'partial output',
  228. fileId: FILE_ID,
  229. executionId: runMsg.executionId,
  230. })
  231. runner.interrupt()
  232. expect(runner.getState().status).to.equal('loading')
  233. expect(runner.getState().output).to.deep.equal([
  234. 'partial output',
  235. 'Execution interrupted',
  236. ])
  237. })
  238. it('does not append interrupted message when not running', function () {
  239. const runner = createRunner()
  240. initAndLoad(runner)
  241. runner.interrupt()
  242. expect(runner.getState().status).to.equal('loading')
  243. expect(runner.getState().output).to.deep.equal([])
  244. })
  245. })
  246. describe('subscribe', function () {
  247. it('notifies listeners on state changes', function () {
  248. const runner = createRunner()
  249. const listener = sinon.stub()
  250. runner.subscribe(listener)
  251. initAndLoad(runner)
  252. expect(listener.callCount).to.be.greaterThan(0)
  253. })
  254. it('stops notifying after unsubscribe', function () {
  255. const runner = createRunner()
  256. const listener = sinon.stub()
  257. const unsubscribe = runner.subscribe(listener)
  258. runner.init()
  259. const countAfterInit = listener.callCount
  260. unsubscribe()
  261. const worker = WorkerMock.instances[0]
  262. worker.emitMessage({ type: 'listening' })
  263. worker.emitMessage({ type: 'loaded' })
  264. expect(listener.callCount).to.equal(countAfterInit)
  265. })
  266. })
  267. describe('destroy', function () {
  268. it('terminates the worker', function () {
  269. const runner = createRunner()
  270. initAndLoad(runner)
  271. runner.destroy()
  272. const worker = WorkerMock.instances[0]
  273. expect(worker.terminated).to.equal(true)
  274. })
  275. })
  276. })