python-runner.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  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('files-saved toast', function () {
  181. let toastEvents: CustomEvent[]
  182. let toastListener: (event: Event) => void
  183. beforeEach(function () {
  184. toastEvents = []
  185. toastListener = event => {
  186. toastEvents.push(event as CustomEvent)
  187. }
  188. window.addEventListener('ide:show-toast', toastListener)
  189. })
  190. afterEach(function () {
  191. window.removeEventListener('ide:show-toast', toastListener)
  192. })
  193. it('dispatches a files-saved toast with successfully uploaded paths', async function () {
  194. const runner = createRunner()
  195. const worker = initAndLoad(runner)
  196. await runner.run()
  197. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  198. worker.emitMessage({
  199. type: 'run-code-result',
  200. fileId: FILE_ID,
  201. executionId: runMsg.executionId,
  202. success: true,
  203. outputs: ['/project/foo.txt', '/project/bar.csv'],
  204. outputFiles: [],
  205. imports: [],
  206. failedUploads: [],
  207. })
  208. await waitForState(runner, s => s.status === 'finished')
  209. expect(toastEvents).to.have.length(1)
  210. expect(toastEvents[0].detail).to.deep.equal({
  211. key: 'python:files-saved',
  212. paths: ['foo.txt', 'bar.csv'],
  213. })
  214. })
  215. it('excludes failed uploads from the toast', async function () {
  216. const fileUploader = sinon.stub().resolves([
  217. { status: 'success', name: 'foo.txt', relativePath: 'foo.txt' },
  218. {
  219. status: 'error',
  220. name: 'bar.csv',
  221. relativePath: 'bar.csv',
  222. error: 'boom',
  223. },
  224. ])
  225. const runner = createRunner({ fileUploader })
  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: 'run-code-result',
  231. fileId: FILE_ID,
  232. executionId: runMsg.executionId,
  233. success: true,
  234. outputs: ['/project/foo.txt', '/project/bar.csv'],
  235. outputFiles: [
  236. { relativePath: 'foo.txt', content: new Uint8Array() },
  237. { relativePath: 'bar.csv', content: new Uint8Array() },
  238. ],
  239. imports: [],
  240. })
  241. await waitForState(runner, s => s.status === 'finished')
  242. expect(toastEvents).to.have.length(1)
  243. expect(toastEvents[0].detail).to.deep.equal({
  244. key: 'python:files-saved',
  245. paths: ['foo.txt'],
  246. })
  247. })
  248. it('does not dispatch a toast when no outputs were written', async function () {
  249. const runner = createRunner()
  250. const worker = initAndLoad(runner)
  251. await runner.run()
  252. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  253. worker.emitMessage({
  254. type: 'run-code-result',
  255. fileId: FILE_ID,
  256. executionId: runMsg.executionId,
  257. success: true,
  258. outputs: [],
  259. outputFiles: [],
  260. imports: [],
  261. failedUploads: [],
  262. })
  263. await waitForState(runner, s => s.status === 'finished')
  264. expect(toastEvents).to.have.length(0)
  265. })
  266. it('does not dispatch a toast when every output failed to upload', async function () {
  267. const fileUploader = sinon.stub().resolves([
  268. {
  269. status: 'error',
  270. name: 'foo.txt',
  271. relativePath: 'foo.txt',
  272. error: 'boom',
  273. },
  274. ])
  275. const runner = createRunner({ fileUploader })
  276. const worker = initAndLoad(runner)
  277. await runner.run()
  278. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  279. worker.emitMessage({
  280. type: 'run-code-result',
  281. fileId: FILE_ID,
  282. executionId: runMsg.executionId,
  283. success: true,
  284. outputs: ['/project/foo.txt'],
  285. outputFiles: [{ relativePath: 'foo.txt', content: new Uint8Array() }],
  286. imports: [],
  287. })
  288. await waitForState(runner, s => s.status === 'finished')
  289. expect(toastEvents).to.have.length(0)
  290. })
  291. })
  292. describe('output', function () {
  293. it('accumulates output lines for the matching file', async function () {
  294. const runner = createRunner()
  295. const worker = initAndLoad(runner)
  296. await runner.run()
  297. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  298. worker.emitMessage({
  299. type: 'output-line',
  300. stream: 'stdout',
  301. line: 'line 1',
  302. fileId: FILE_ID,
  303. executionId: runMsg.executionId,
  304. })
  305. worker.emitMessage({
  306. type: 'output-line',
  307. stream: 'stderr',
  308. line: 'line 2',
  309. fileId: FILE_ID,
  310. executionId: runMsg.executionId,
  311. })
  312. expect(runner.getState().output).to.deep.equal([
  313. { stream: 'stdout', line: 'line 1' },
  314. { stream: 'stderr', line: 'line 2' },
  315. ])
  316. })
  317. it('ignores output for a different fileId', async function () {
  318. const runner = createRunner()
  319. const worker = initAndLoad(runner)
  320. await runner.run()
  321. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  322. worker.emitMessage({
  323. type: 'output-line',
  324. stream: 'stdout',
  325. line: 'other file output',
  326. fileId: 'different-file',
  327. executionId: runMsg.executionId,
  328. })
  329. expect(runner.getState().output).to.deep.equal([])
  330. })
  331. it('ignores output for a stale executionId', async function () {
  332. const runner = createRunner()
  333. const worker = initAndLoad(runner)
  334. await runner.run()
  335. worker.emitMessage({
  336. type: 'output-line',
  337. stream: 'stdout',
  338. line: 'stale output',
  339. fileId: FILE_ID,
  340. executionId: 'old-execution-id',
  341. })
  342. expect(runner.getState().output).to.deep.equal([])
  343. })
  344. it('caps output at 100 lines', async function () {
  345. const runner = createRunner()
  346. const worker = initAndLoad(runner)
  347. await runner.run()
  348. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  349. for (let i = 0; i < 110; i++) {
  350. worker.emitMessage({
  351. type: 'output-line',
  352. stream: 'stdout',
  353. line: `line ${i}`,
  354. fileId: FILE_ID,
  355. executionId: runMsg.executionId,
  356. })
  357. }
  358. const output = runner.getState().output
  359. expect(output).to.have.length(100)
  360. expect(output[0]).to.deep.equal({ stream: 'stdout', line: 'line 10' })
  361. expect(output[99]).to.deep.equal({ stream: 'stdout', line: 'line 109' })
  362. })
  363. })
  364. describe('interrupt', function () {
  365. it('appends interrupted message and transitions to loading when running', async function () {
  366. const runner = createRunner()
  367. const worker = initAndLoad(runner)
  368. await runner.run()
  369. const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
  370. worker.emitMessage({
  371. type: 'output-line',
  372. stream: 'stdout',
  373. line: 'partial output',
  374. fileId: FILE_ID,
  375. executionId: runMsg.executionId,
  376. })
  377. runner.interrupt()
  378. expect(runner.getState().status).to.equal('loading')
  379. expect(runner.getState().output).to.deep.equal([
  380. { stream: 'stdout', line: 'partial output' },
  381. { stream: 'info', line: 'Execution interrupted' },
  382. ])
  383. })
  384. it('does not append interrupted message when not running', function () {
  385. const runner = createRunner()
  386. initAndLoad(runner)
  387. runner.interrupt()
  388. expect(runner.getState().status).to.equal('loading')
  389. expect(runner.getState().output).to.deep.equal([])
  390. })
  391. })
  392. describe('subscribe', function () {
  393. it('notifies listeners on state changes', function () {
  394. const runner = createRunner()
  395. const listener = sinon.stub()
  396. runner.subscribe(listener)
  397. initAndLoad(runner)
  398. expect(listener.callCount).to.be.greaterThan(0)
  399. })
  400. it('stops notifying after unsubscribe', function () {
  401. const runner = createRunner()
  402. const listener = sinon.stub()
  403. const unsubscribe = runner.subscribe(listener)
  404. runner.init()
  405. const countAfterInit = listener.callCount
  406. unsubscribe()
  407. const worker = WorkerMock.instances[0]
  408. worker.emitMessage({ type: 'listening' })
  409. worker.emitMessage({ type: 'loaded' })
  410. expect(listener.callCount).to.equal(countAfterInit)
  411. })
  412. })
  413. describe('destroy', function () {
  414. it('terminates the worker', function () {
  415. const runner = createRunner()
  416. initAndLoad(runner)
  417. runner.destroy()
  418. const worker = WorkerMock.instances[0]
  419. expect(worker.terminated).to.equal(true)
  420. })
  421. })
  422. })