python-runner.spec.ts 10 KB

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