python-output-pane.spec.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import React, { FC, PropsWithChildren } from 'react'
  2. import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane'
  3. import {
  4. EditorProviders,
  5. projectDefaults,
  6. } from '../../../helpers/editor-providers'
  7. import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
  8. import { ProjectContext } from '@/shared/context/project-context'
  9. import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
  10. import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context'
  11. const pythonExecutableScript: Record<string, string> = {
  12. file_id: 'test-py-doc-id',
  13. filename: 'test.py',
  14. }
  15. const FileTreePathProvider: FC<PropsWithChildren> = ({ children }) => {
  16. return (
  17. <FileTreePathContext.Provider
  18. value={{
  19. pathInFolder: () => pythonExecutableScript.filename,
  20. findEntityByPath: () => null,
  21. previewByPath: () => null,
  22. dirname: () => null,
  23. }}
  24. >
  25. {children}
  26. </FileTreePathContext.Provider>
  27. )
  28. }
  29. function makeProjectProvider(fileContents: Record<string, string>) {
  30. const ProjectProvider: FC<PropsWithChildren> = ({ children }) => {
  31. const projectSnapshot = {
  32. refresh: async () => {},
  33. getDocPaths: () => Object.keys(fileContents),
  34. getDocContents: (path: string) => fileContents[path] ?? null,
  35. } as unknown as ProjectSnapshot
  36. return (
  37. <ProjectContext.Provider
  38. value={{
  39. projectId: projectDefaults._id,
  40. project: projectDefaults,
  41. joinProject: () => {},
  42. updateProject: () => {},
  43. joinedOnce: true,
  44. projectSnapshot,
  45. tags: [],
  46. features: projectDefaults.features,
  47. name: projectDefaults.name,
  48. }}
  49. >
  50. {children}
  51. </ProjectContext.Provider>
  52. )
  53. }
  54. return ProjectProvider
  55. }
  56. describe('<PythonOutputPane />', function () {
  57. beforeEach(function () {
  58. window.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/')
  59. })
  60. it('executes a Python script and displays its output', function () {
  61. const executablePythonFileContents = "print('hello!')"
  62. const projectFiles = {
  63. [pythonExecutableScript.filename]: executablePythonFileContents,
  64. }
  65. const ProjectProvider = makeProjectProvider(projectFiles)
  66. cy.mount(
  67. <EditorProviders
  68. scope={{
  69. editor: {
  70. sharejs_doc: {
  71. doc_id: pythonExecutableScript.file_id,
  72. getSnapshot: () => executablePythonFileContents,
  73. },
  74. currentDocumentId: pythonExecutableScript.file_id,
  75. openDocName: pythonExecutableScript.filename,
  76. },
  77. }}
  78. providers={{ FileTreePathProvider, ProjectProvider }}
  79. >
  80. <PythonExecutionProvider>
  81. <PythonOutputPane />
  82. </PythonExecutionProvider>
  83. </EditorProviders>
  84. )
  85. cy.findByRole('button', { name: 'Run Python code' })
  86. .should('not.be.disabled')
  87. .click()
  88. cy.findByText('hello!').should('exist')
  89. })
  90. it('can import and use values from other project Python files', function () {
  91. const executablePythonFileContents =
  92. 'from message import message\nprint(message)'
  93. const importedPythonFile = {
  94. filename: 'message.py',
  95. file_contents: "message = 'hello!'",
  96. }
  97. const projectFiles = {
  98. [pythonExecutableScript.filename]: executablePythonFileContents,
  99. [importedPythonFile.filename]: importedPythonFile.file_contents,
  100. }
  101. const ProjectProvider = makeProjectProvider(projectFiles)
  102. cy.mount(
  103. <EditorProviders
  104. scope={{
  105. editor: {
  106. sharejs_doc: {
  107. doc_id: pythonExecutableScript.file_id,
  108. getSnapshot: () => executablePythonFileContents,
  109. },
  110. currentDocumentId: pythonExecutableScript.file_id,
  111. openDocName: pythonExecutableScript.filename,
  112. },
  113. }}
  114. providers={{ FileTreePathProvider, ProjectProvider }}
  115. >
  116. <PythonExecutionProvider>
  117. <PythonOutputPane />
  118. </PythonExecutionProvider>
  119. </EditorProviders>
  120. )
  121. cy.findByRole('button', { name: 'Run Python code' })
  122. .should('not.be.disabled')
  123. .click()
  124. cy.findByText('hello!').should('exist')
  125. })
  126. it('can import files from different directories relative to the executable script', function () {
  127. const executablePythonFileContents = [
  128. 'from scripts.data_importers.csv_importer import print_data',
  129. 'print_data()',
  130. ].join('\n')
  131. const csvImporterFile = {
  132. filename: 'scripts/data_importers/csv_importer.py',
  133. file_contents: [
  134. 'import csv',
  135. '',
  136. 'def print_data():',
  137. ' with open("food_items.csv", "r") as f:',
  138. ' reader = csv.reader(f)',
  139. ' for row in reader:',
  140. ' print(",".join(row))',
  141. ].join('\n'),
  142. }
  143. const csvDataFile = {
  144. filename: 'food_items.csv',
  145. file_contents: 'name,type\nPizza,Italian\nSushi,Japanese\nTacos,Mexican',
  146. }
  147. const projectFiles = {
  148. 'scripts/test.py': executablePythonFileContents,
  149. [csvImporterFile.filename]: csvImporterFile.file_contents,
  150. [csvDataFile.filename]: csvDataFile.file_contents,
  151. }
  152. const ProjectProvider = makeProjectProvider(projectFiles)
  153. const NestedFileTreePathProvider: FC<PropsWithChildren> = ({
  154. children,
  155. }) => (
  156. <FileTreePathContext.Provider
  157. value={{
  158. pathInFolder: () => 'scripts/test.py',
  159. findEntityByPath: () => null,
  160. previewByPath: () => null,
  161. dirname: () => null,
  162. }}
  163. >
  164. {children}
  165. </FileTreePathContext.Provider>
  166. )
  167. cy.mount(
  168. <EditorProviders
  169. scope={{
  170. editor: {
  171. sharejs_doc: {
  172. doc_id: pythonExecutableScript.file_id,
  173. getSnapshot: () => executablePythonFileContents,
  174. },
  175. currentDocumentId: pythonExecutableScript.file_id,
  176. openDocName: 'test.py',
  177. },
  178. }}
  179. providers={{
  180. FileTreePathProvider: NestedFileTreePathProvider,
  181. ProjectProvider,
  182. }}
  183. >
  184. <PythonExecutionProvider>
  185. <PythonOutputPane />
  186. </PythonExecutionProvider>
  187. </EditorProviders>
  188. )
  189. cy.findByRole('button', { name: 'Run Python code' })
  190. .should('not.be.disabled')
  191. .click()
  192. cy.findByText('name,type').should('exist')
  193. cy.findByText('Pizza,Italian').should('exist')
  194. cy.findByText('Sushi,Japanese').should('exist')
  195. cy.findByText('Tacos,Mexican').should('exist')
  196. })
  197. it('renders stderr output with the stderr line class', function () {
  198. const executablePythonFileContents = [
  199. 'import sys',
  200. "print('hello!')",
  201. "sys.stderr.write('boom\\n')",
  202. ].join('\n')
  203. const projectFiles = {
  204. [pythonExecutableScript.filename]: executablePythonFileContents,
  205. }
  206. const ProjectProvider = makeProjectProvider(projectFiles)
  207. cy.mount(
  208. <EditorProviders
  209. scope={{
  210. editor: {
  211. sharejs_doc: {
  212. doc_id: pythonExecutableScript.file_id,
  213. getSnapshot: () => executablePythonFileContents,
  214. },
  215. currentDocumentId: pythonExecutableScript.file_id,
  216. openDocName: pythonExecutableScript.filename,
  217. },
  218. }}
  219. providers={{ FileTreePathProvider, ProjectProvider }}
  220. >
  221. <PythonExecutionProvider>
  222. <PythonOutputPane />
  223. </PythonExecutionProvider>
  224. </EditorProviders>
  225. )
  226. cy.findByRole('button', { name: 'Run Python code' })
  227. .should('not.be.disabled')
  228. .click()
  229. cy.findByText('hello!')
  230. .should('have.class', 'ide-redesign-python-output-pane-line-stdout')
  231. .and('not.have.class', 'ide-redesign-python-output-pane-line-stderr')
  232. cy.findByText('boom').should(
  233. 'have.class',
  234. 'ide-redesign-python-output-pane-line-stderr'
  235. )
  236. cy.findByText("Only Pyodide's built-in packages", { exact: false }).should(
  237. 'not.exist'
  238. )
  239. })
  240. it('renders the interrupt message as an info line', function () {
  241. const executablePythonFileContents = 'while True:\n pass\n'
  242. const projectFiles = {
  243. [pythonExecutableScript.filename]: executablePythonFileContents,
  244. }
  245. const ProjectProvider = makeProjectProvider(projectFiles)
  246. cy.mount(
  247. <EditorProviders
  248. scope={{
  249. editor: {
  250. sharejs_doc: {
  251. doc_id: pythonExecutableScript.file_id,
  252. getSnapshot: () => executablePythonFileContents,
  253. },
  254. currentDocumentId: pythonExecutableScript.file_id,
  255. openDocName: pythonExecutableScript.filename,
  256. },
  257. }}
  258. providers={{ FileTreePathProvider, ProjectProvider }}
  259. >
  260. <PythonExecutionProvider>
  261. <PythonOutputPane />
  262. </PythonExecutionProvider>
  263. </EditorProviders>
  264. )
  265. cy.findByRole('button', { name: 'Run Python code' })
  266. .should('not.be.disabled')
  267. .click()
  268. cy.findByRole('button', { name: 'Stop Python execution' })
  269. .should('not.be.disabled')
  270. .click()
  271. cy.findByText('Execution interrupted').should(
  272. 'have.class',
  273. 'ide-redesign-python-output-pane-line-info'
  274. )
  275. })
  276. it('renders stdout, stderr, and info lines with visually distinct CSS classes', function () {
  277. const executablePythonFileContents = [
  278. 'import sys',
  279. "print('hello!')",
  280. "sys.stderr.write('boom\\n')",
  281. 'while True:',
  282. ' pass',
  283. ].join('\n')
  284. const projectFiles = {
  285. [pythonExecutableScript.filename]: executablePythonFileContents,
  286. }
  287. const ProjectProvider = makeProjectProvider(projectFiles)
  288. cy.mount(
  289. <EditorProviders
  290. scope={{
  291. editor: {
  292. sharejs_doc: {
  293. doc_id: pythonExecutableScript.file_id,
  294. getSnapshot: () => executablePythonFileContents,
  295. },
  296. currentDocumentId: pythonExecutableScript.file_id,
  297. openDocName: pythonExecutableScript.filename,
  298. },
  299. }}
  300. providers={{ FileTreePathProvider, ProjectProvider }}
  301. >
  302. <PythonExecutionProvider>
  303. <PythonOutputPane />
  304. </PythonExecutionProvider>
  305. </EditorProviders>
  306. )
  307. cy.findByRole('button', { name: 'Run Python code' })
  308. .should('not.be.disabled')
  309. .click()
  310. cy.findByText('hello!')
  311. .should('have.class', 'ide-redesign-python-output-pane-line-stdout')
  312. .and('not.have.class', 'ide-redesign-python-output-pane-line-stderr')
  313. .and('not.have.class', 'ide-redesign-python-output-pane-line-info')
  314. cy.findByText('boom')
  315. .should('have.class', 'ide-redesign-python-output-pane-line-stderr')
  316. .and('not.have.class', 'ide-redesign-python-output-pane-line-stdout')
  317. .and('not.have.class', 'ide-redesign-python-output-pane-line-info')
  318. cy.findByRole('button', { name: 'Stop Python execution' })
  319. .should('not.be.disabled')
  320. .click()
  321. cy.findByText('Execution interrupted')
  322. .should('have.class', 'ide-redesign-python-output-pane-line-info')
  323. .and('not.have.class', 'ide-redesign-python-output-pane-line-stdout')
  324. .and('not.have.class', 'ide-redesign-python-output-pane-line-stderr')
  325. })
  326. it('can load common python data analysis packages on code execution', function () {
  327. const executablePythonFileContents = [
  328. 'import tomli',
  329. '',
  330. "print(tomli.loads('greeting = \"hello from tomli\"')['greeting'])",
  331. ].join('\n')
  332. const projectFiles = {
  333. [pythonExecutableScript.filename]: executablePythonFileContents,
  334. }
  335. const ProjectProvider = makeProjectProvider(projectFiles)
  336. cy.mount(
  337. <EditorProviders
  338. scope={{
  339. editor: {
  340. sharejs_doc: {
  341. doc_id: pythonExecutableScript.file_id,
  342. getSnapshot: () => executablePythonFileContents,
  343. },
  344. currentDocumentId: pythonExecutableScript.file_id,
  345. openDocName: pythonExecutableScript.filename,
  346. },
  347. }}
  348. providers={{ FileTreePathProvider, ProjectProvider }}
  349. >
  350. <PythonExecutionProvider>
  351. <PythonOutputPane />
  352. </PythonExecutionProvider>
  353. </EditorProviders>
  354. )
  355. cy.findByRole('button', { name: 'Run Python code' })
  356. .should('not.be.disabled')
  357. .click()
  358. cy.findByText("ModuleNotFoundError: No module named 'tomli'").should(
  359. 'not.exist'
  360. )
  361. cy.findByText('hello from tomli').should('exist')
  362. })
  363. it('auto-installs python packages imported by the executing script', function () {
  364. const executablePythonFileContents = [
  365. 'import tomli',
  366. '',
  367. "print(tomli.loads('greeting = \"hello from tomli\"')['greeting'])",
  368. ].join('\n')
  369. const projectFiles = {
  370. [pythonExecutableScript.filename]: executablePythonFileContents,
  371. }
  372. const ProjectProvider = makeProjectProvider(projectFiles)
  373. cy.mount(
  374. <EditorProviders
  375. scope={{
  376. editor: {
  377. sharejs_doc: {
  378. doc_id: pythonExecutableScript.file_id,
  379. getSnapshot: () => executablePythonFileContents,
  380. },
  381. currentDocumentId: pythonExecutableScript.file_id,
  382. openDocName: pythonExecutableScript.filename,
  383. },
  384. }}
  385. providers={{ FileTreePathProvider, ProjectProvider }}
  386. >
  387. <PythonExecutionProvider>
  388. <PythonOutputPane />
  389. </PythonExecutionProvider>
  390. </EditorProviders>
  391. )
  392. cy.findByRole('button', { name: 'Run Python code' })
  393. .should('not.be.disabled')
  394. .click()
  395. cy.findByText("ModuleNotFoundError: No module named 'tomli'").should(
  396. 'not.exist'
  397. )
  398. cy.findByText('hello from tomli').should('exist')
  399. })
  400. it('augments ModuleNotFoundError output with help text about supported Pyodide packages', function () {
  401. const executablePythonFileContents =
  402. 'import this_module_does_not_exist_in_pyodide\n'
  403. const projectFiles = {
  404. [pythonExecutableScript.filename]: executablePythonFileContents,
  405. }
  406. const ProjectProvider = makeProjectProvider(projectFiles)
  407. cy.mount(
  408. <EditorProviders
  409. scope={{
  410. editor: {
  411. sharejs_doc: {
  412. doc_id: pythonExecutableScript.file_id,
  413. getSnapshot: () => executablePythonFileContents,
  414. },
  415. currentDocumentId: pythonExecutableScript.file_id,
  416. openDocName: pythonExecutableScript.filename,
  417. },
  418. }}
  419. providers={{ FileTreePathProvider, ProjectProvider }}
  420. >
  421. <PythonExecutionProvider>
  422. <PythonOutputPane />
  423. </PythonExecutionProvider>
  424. </EditorProviders>
  425. )
  426. cy.findByRole('button', { name: 'Run Python code' })
  427. .should('not.be.disabled')
  428. .click()
  429. cy.findByText('ModuleNotFoundError', { exact: false }).should('exist')
  430. cy.findByText("Only Pyodide's built-in packages", { exact: false }).should(
  431. 'have.class',
  432. 'ide-redesign-python-output-pane-line-stderr'
  433. )
  434. })
  435. })