diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx index a03d60ae20..7cf3f25fae 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx +++ b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx @@ -5,6 +5,7 @@ import OLButton from '@/shared/components/ol/ol-button' import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar' import MaterialIcon from '@/shared/components/material-icon' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' +import { useProjectContext } from '@/shared/context/project-context' import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' import { debugConsole } from '@/utils/debugging' import getMeta from '@/utils/meta' @@ -14,6 +15,7 @@ export default function PythonOutputPane() { const { t } = useTranslation() const { currentDocument, currentDocumentId } = useEditorOpenDocContext() const { pathInFolder } = useFileTreePathContext() + const { projectSnapshot } = useProjectContext() const clientRef = useRef(null) const currentRequestIdRef = useRef(null) const [isReady, setIsReady] = useState(false) @@ -108,7 +110,21 @@ export default function PythonOutputPane() { } }, [currentDocument, currentDocumentId, pathInFolder]) - const handleRun = useCallback(() => { + const getLatestProjectFiles = useCallback(async () => { + await projectSnapshot.refresh() + return projectSnapshot.getDocPaths().reduce( + (files, relativePath) => { + const content = projectSnapshot.getDocContents(relativePath) + if (content !== null) { + files.push({ relativePath, content }) + } + return files + }, + [] as { relativePath: string; content: string }[] + ) + }, [projectSnapshot]) + + const handleRun = useCallback(async () => { const client = clientRef.current if (!client || !isReady) { return @@ -126,17 +142,19 @@ export default function PythonOutputPane() { currentRequestIdRef.current = requestId setIsRunning(true) + const isCancelled = () => currentRequestIdRef.current !== requestId + try { - client.runCode(syncFile.content, { requestId, files: [syncFile] }) + const files = await getLatestProjectFiles() + if (isCancelled()) return + client.runCode(syncFile.content, { requestId, files }) } catch (runError) { - if (currentRequestIdRef.current !== requestId) { - return - } + if (isCancelled()) return currentRequestIdRef.current = null setIsRunning(false) setError(formatError(runError)) } - }, [buildCurrentDocumentSyncFile, isReady]) + }, [buildCurrentDocumentSyncFile, getLatestProjectFiles, isReady]) const handleStop = useCallback(() => { const client = clientRef.current diff --git a/services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx b/services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx new file mode 100644 index 0000000000..74eaec52e8 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx @@ -0,0 +1,210 @@ +import React, { FC, PropsWithChildren } from 'react' +import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane' +import { + EditorProviders, + projectDefaults, +} from '../../../helpers/editor-providers' +import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' +import { ProjectContext } from '@/shared/context/project-context' +import { ProjectSnapshot } from '@/infrastructure/project-snapshot' + +const pythonExecutableScript: Record = { + file_id: 'test-py-doc-id', + filename: 'test.py', +} + +const FileTreePathProvider: FC = ({ children }) => { + return ( + pythonExecutableScript.filename, + findEntityByPath: () => null, + previewByPath: () => null, + dirname: () => null, + }} + > + {children} + + ) +} + +function makeProjectProvider(fileContents: Record) { + const ProjectProvider: FC = ({ children }) => { + const projectSnapshot = { + refresh: async () => {}, + getDocPaths: () => Object.keys(fileContents), + getDocContents: (path: string) => fileContents[path] ?? null, + } as unknown as ProjectSnapshot + + return ( + {}, + updateProject: () => {}, + joinedOnce: true, + projectSnapshot, + tags: [], + features: projectDefaults.features, + name: projectDefaults.name, + }} + > + {children} + + ) + } + return ProjectProvider +} + +describe('', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/') + }) + + it('executes a Python script and displays its output', function () { + const executablePythonFileContents = "print('hello!')" + const projectFiles = { + [pythonExecutableScript.filename]: executablePythonFileContents, + } + const ProjectProvider = makeProjectProvider(projectFiles) + + cy.mount( + executablePythonFileContents, + }, + currentDocumentId: pythonExecutableScript.file_id, + openDocName: pythonExecutableScript.filename, + }, + }} + providers={{ FileTreePathProvider, ProjectProvider }} + > + + + ) + + cy.findByRole('button', { name: 'Run Python Code' }) + .should('not.be.disabled') + .click() + cy.findByText('hello!').should('exist') + }) + + it('can import and use values from other project Python files', function () { + const executablePythonFileContents = + 'from message import message\nprint(message)' + + const importedPythonFile = { + filename: 'message.py', + file_contents: "message = 'hello!'", + } + + const projectFiles = { + [pythonExecutableScript.filename]: executablePythonFileContents, + [importedPythonFile.filename]: importedPythonFile.file_contents, + } + const ProjectProvider = makeProjectProvider(projectFiles) + + cy.mount( + executablePythonFileContents, + }, + currentDocumentId: pythonExecutableScript.file_id, + openDocName: pythonExecutableScript.filename, + }, + }} + providers={{ FileTreePathProvider, ProjectProvider }} + > + + + ) + + cy.findByRole('button', { name: 'Run Python Code' }) + .should('not.be.disabled') + .click() + cy.findByText('hello!').should('exist') + }) + + it('can import files from different directories relative to the executable script', function () { + const executablePythonFileContents = [ + 'from scripts.data_importers.csv_importer import print_data', + 'print_data()', + ].join('\n') + + const csvImporterFile = { + filename: 'scripts/data_importers/csv_importer.py', + file_contents: [ + 'import csv', + '', + 'def print_data():', + ' with open("food_items.csv", "r") as f:', + ' reader = csv.reader(f)', + ' for row in reader:', + ' print(",".join(row))', + ].join('\n'), + } + + const csvDataFile = { + filename: 'food_items.csv', + file_contents: 'name,type\nPizza,Italian\nSushi,Japanese\nTacos,Mexican', + } + + const projectFiles = { + 'scripts/test.py': executablePythonFileContents, + [csvImporterFile.filename]: csvImporterFile.file_contents, + [csvDataFile.filename]: csvDataFile.file_contents, + } + const ProjectProvider = makeProjectProvider(projectFiles) + + const NestedFileTreePathProvider: FC = ({ + children, + }) => ( + 'scripts/test.py', + findEntityByPath: () => null, + previewByPath: () => null, + dirname: () => null, + }} + > + {children} + + ) + + cy.mount( + executablePythonFileContents, + }, + currentDocumentId: pythonExecutableScript.file_id, + openDocName: 'test.py', + }, + }} + providers={{ + FileTreePathProvider: NestedFileTreePathProvider, + ProjectProvider, + }} + > + + + ) + + cy.findByRole('button', { name: 'Run Python Code' }) + .should('not.be.disabled') + .click() + cy.findByText('name,type').should('exist') + cy.findByText('Pizza,Italian').should('exist') + cy.findByText('Sushi,Japanese').should('exist') + cy.findByText('Tacos,Mexican').should('exist') + }) +})