diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 0ce7eb09c4..caf594b870 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1041,6 +1041,7 @@ module.exports = { rootContextProviders: [], mainEditorLayoutModals: [], mainEditorLayoutPanels: [], + pythonRunner: [], langFeedbackLinkingWidgets: [], labsExperiments: [], integrationLinkingWidgets: [], diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts deleted file mode 100644 index cf4fe16b3b..0000000000 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts +++ /dev/null @@ -1,225 +0,0 @@ -import path from 'path-browserify' -import type { - ExecutionErrorType, - OutputStream, - ProjectFileData, - PyodideWorkerRequest, - PyodideWorkerResponse, -} from './pyodide-worker-messages' -import type { BatchUploadItem } from '@/infrastructure/batch-file-uploader' -import type { FileUploader } from './python-runner' - -export type OutputCallback = ( - stream: OutputStream, - line: string, - fileId: string, - executionId: string -) => void - -export type LifecycleCallback = ( - event: - | { type: 'loaded' } - | { type: 'loading-failed'; error: string } - | { - type: 'run-finished' - fileId: string - executionId: string - success: boolean - outputs: string[] - failedUploads: string[] - imports: string[] - errorType?: ExecutionErrorType - } -) => void - -export class PyodideWorkerClient { - private worker: Worker - private baseAssetPath: string - private createWorker: () => Worker - private listening = false - private destroyed = false - private loadingError: string | null = null - private pendingMessages: PyodideWorkerRequest[] = [] - private outputCallback: OutputCallback | null - private lifecycleCallback: LifecycleCallback | null - private fileUploader: FileUploader - - constructor(options: { - baseAssetPath: string - createWorker: () => Worker - onOutput?: OutputCallback - onLifecycle?: LifecycleCallback - fileUploader: FileUploader - }) { - this.baseAssetPath = options.baseAssetPath - this.createWorker = options.createWorker - this.outputCallback = options.onOutput ?? null - this.lifecycleCallback = options.onLifecycle ?? null - this.fileUploader = options.fileUploader - this.worker = this.createWorker() - this.worker.addEventListener('message', this.receive) - - this.queueMessage({ - type: 'init', - baseAssetPath: this.baseAssetPath, - }) - } - - runCode( - code: string, - options: { fileId: string; executionId: string; files: ProjectFileData[] } - ): void { - if (this.destroyed) { - throw new Error('Pyodide worker client has been destroyed') - } - - if (this.loadingError) { - throw new Error(this.loadingError) - } - - this.queueMessage({ - type: 'run-code', - code, - fileId: options.fileId, - executionId: options.executionId, - files: options.files, - }) - } - - reset(): void { - if (this.destroyed) { - return - } - - // Terminate the current worker immediately - this.worker.terminate() - this.pendingMessages.length = 0 - - // Reset state for the new worker - this.listening = false - this.loadingError = null - - // Create a fresh worker and re-initialize Pyodide - this.worker = this.createWorker() - this.worker.addEventListener('message', this.receive) - this.queueMessage({ - type: 'init', - baseAssetPath: this.baseAssetPath, - }) - } - - destroy() { - if (this.destroyed) { - return - } - - this.destroyed = true - this.pendingMessages.length = 0 - - this.worker.terminate() - } - - private queueMessage(message: PyodideWorkerRequest) { - if (this.listening) { - this.worker.postMessage(message) - } else { - this.pendingMessages.push(message) - } - } - - private receive = async (event: MessageEvent) => { - // Discard messages from a previously terminated worker - if (event.target !== this.worker) { - return - } - - const response = event.data - - switch (response.type) { - case 'listening': - this.listening = true - for (const message of this.pendingMessages) { - this.worker.postMessage(message) - } - this.pendingMessages.length = 0 - return - - case 'loaded': - this.lifecycleCallback?.({ type: 'loaded' }) - return - - case 'loading-failed': - this.loadingError = response.error - this.pendingMessages.length = 0 - this.lifecycleCallback?.({ - type: 'loading-failed', - error: response.error, - }) - return - - case 'output-line': - this.outputCallback?.( - response.stream, - response.line, - response.fileId, - response.executionId - ) - return - - case 'run-code-result': { - let success = response.success - const failedUploads: string[] = [] - - if (success && response.outputFiles.length > 0) { - const items: BatchUploadItem[] = response.outputFiles.map(file => ({ - file: new Blob([file.content as Uint8Array]), - name: path.basename(file.relativePath), - relativePath: file.relativePath, - })) - - try { - const results = await this.fileUploader(items) - for (const result of results) { - if (result.status === 'error') { - failedUploads.push(result.relativePath!) - this.outputCallback?.( - 'stderr', - `Failed to upload output file ${result.relativePath!}: ${result.error}`, - response.fileId, - response.executionId - ) - } - } - if (failedUploads.length > 0) { - success = false - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - this.outputCallback?.( - 'stderr', - `Failed to upload output files: ${message}`, - response.fileId, - response.executionId - ) - failedUploads.push(...items.map(item => item.relativePath!)) - success = false - } - } - - const errorType = - failedUploads.length > 0 ? 'UploadFileError' : response.errorType - - this.lifecycleCallback?.({ - type: 'run-finished', - fileId: response.fileId, - executionId: response.executionId, - success, - outputs: response.outputs, - failedUploads, - imports: response.imports, - errorType, - }) - } - } - } -} diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts deleted file mode 100644 index 9b448a74c3..0000000000 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts +++ /dev/null @@ -1,72 +0,0 @@ -export type OutputStream = 'stdout' | 'stderr' | 'info' - -export type ProjectFileData = { - relativePath: string - content: string -} - -export type OutputFileData = { - relativePath: string - content: Uint8Array -} - -// Main thread -> Worker messages - -export type InitRequest = { - type: 'init' - baseAssetPath: string -} - -export type RunCodeRequest = { - type: 'run-code' - fileId: string - executionId: string - code: string - files: ProjectFileData[] -} - -export type PyodideWorkerRequest = InitRequest | RunCodeRequest - -// Worker -> Main thread lifecycle and streaming events - -export type ListeningEvent = { type: 'listening' } -export type LoadedEvent = { type: 'loaded' } -export type LoadingFailedEvent = { type: 'loading-failed'; error: string } - -export type OutputLineEvent = { - type: 'output-line' - stream: OutputStream - line: string - fileId: string - executionId: string -} - -export type PyodideWorkerEvent = - | ListeningEvent - | LoadedEvent - | LoadingFailedEvent - | OutputLineEvent - -// Worker -> Main thread ID responses - -export type ExecutionErrorType = - | 'SyntaxError' - | 'ModuleNotFoundError' - | 'OutputLimitExceeded' - | 'UploadFileError' - | 'generic' - -export type ExecutionResult = 'success' | 'error' - -export type RunCodeResult = { - type: 'run-code-result' - fileId: string - executionId: string - success: boolean - outputs: string[] - outputFiles: OutputFileData[] - imports: string[] - errorType?: ExecutionErrorType -} - -export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-output-limits.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-output-limits.ts deleted file mode 100644 index 0897251e46..0000000000 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-output-limits.ts +++ /dev/null @@ -1,53 +0,0 @@ -const BYTES_PER_MB = 1024 * 1024 - -export const MAX_OUTPUT_FILES = 50 -export const MAX_OUTPUT_TOTAL_BYTES = 100 * BYTES_PER_MB -export const MAX_OUTPUT_FILE_BYTES = 50 * BYTES_PER_MB - -export type OutputLimitViolation = { - kind: 'count' | 'total-output-size' | 'single-file-size' - message: string -} - -export function checkOutputCount(count: number): OutputLimitViolation | null { - if (count > MAX_OUTPUT_FILES) { - return { - kind: 'count', - message: `Output limit exceeded: ${count} files generated (max ${MAX_OUTPUT_FILES})`, - } - } - return null -} - -export function checkOutputLimits( - files: { path: string; size: number }[] -): OutputLimitViolation | null { - const countViolation = checkOutputCount(files.length) - if (countViolation) { - return countViolation - } - - let totalBytes = 0 - for (const file of files) { - if (file.size > MAX_OUTPUT_FILE_BYTES) { - const fileMB = Math.ceil(file.size / BYTES_PER_MB) - const maxMB = MAX_OUTPUT_FILE_BYTES / BYTES_PER_MB - return { - kind: 'single-file-size', - message: `Output limit exceeded: ${file.path} is ${fileMB}MB (max ${maxMB}MB per file)`, - } - } - totalBytes += file.size - } - - if (totalBytes > MAX_OUTPUT_TOTAL_BYTES) { - const totalMB = Math.ceil(totalBytes / BYTES_PER_MB) - const maxMB = MAX_OUTPUT_TOTAL_BYTES / BYTES_PER_MB - return { - kind: 'total-output-size', - message: `Output limit exceeded: ${totalMB}MB total (max ${maxMB}MB)`, - } - } - - return null -} diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts deleted file mode 100644 index 0c942b819e..0000000000 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts +++ /dev/null @@ -1,309 +0,0 @@ -/// -import path from 'path-browserify' -import type { PyodideInterface } from 'pyodide' -import type { - ExecutionErrorType, - OutputFileData, - InitRequest, - ProjectFileData, - PyodideWorkerRequest, - RunCodeRequest, -} from './pyodide-worker-messages' -import { - checkOutputCount, - checkOutputLimits, -} from './pyodide-worker-output-limits' - -type PyodideFS = PyodideInterface['FS'] -type PyodideModule = typeof import('pyodide') - -const PROJECT_FS_ROOT = '/project' -const PROJECT_FS_PREFIX = `${PROJECT_FS_ROOT}/` -const PYODIDE_INDEX_PATH = 'js/libs/pyodide/' - -function classifyErrorType(errorMessage: string): ExecutionErrorType { - if (errorMessage.includes('ModuleNotFoundError')) { - return 'ModuleNotFoundError' - } - if (errorMessage.includes('SyntaxError')) { - return 'SyntaxError' - } - return 'generic' -} - -function moduleNotFoundHelpMessage(): string { - return ( - "Note: Only Pyodide's built-in packages are available in the browser. " + - 'Packages installed via pip cannot be used here. ' + - 'See https://pyodide.org/en/stable/usage/packages-in-pyodide.html ' + - 'for the list of supported packages.' - ) -} - -function ensureDirectoryExists(fs: PyodideFS, filePath: string) { - const directory = path.dirname(filePath) - if (directory === '.' || directory === '/') { - return - } - - let currentPath = directory.startsWith('/') ? '/' : '' - for (const part of directory.split('/').filter(Boolean)) { - currentPath = path.posix.join(currentPath, part) - try { - const analysis = fs.analyzePath(currentPath) - if (!analysis.exists) { - fs.mkdir(currentPath) - } - } catch { - // Ignore failures when a directory already exists. - } - } -} - -function syncProjectFiles(fs: PyodideFS, files: ProjectFileData[]) { - for (const file of files) { - const runtimePath = path.posix.join( - PROJECT_FS_ROOT, - path.posix.normalize(file.relativePath) - ) - ensureDirectoryExists(fs, runtimePath) - fs.writeFile(runtimePath, file.content) - } - - fs.chdir(PROJECT_FS_ROOT) -} - -let pyodideModule: PyodideModule | null = null -let pyodideIndexUrl: string | undefined - -async function loadPyodideModule(pyodideIndexUrl: string) { - const runtimeModuleUrl = `${pyodideIndexUrl}pyodide.mjs` - - try { - return (await import( - /* webpackIgnore: true */ runtimeModuleUrl - )) as PyodideModule - } catch (loadError) { - const loadErrorMessage = - loadError instanceof Error ? loadError.message : String(loadError) - throw new Error( - `Unable to load Pyodide module from ${runtimeModuleUrl}. Original error: ${loadErrorMessage}` - ) - } -} - -async function handleInit(msg: InitRequest) { - pyodideIndexUrl = new URL(PYODIDE_INDEX_PATH, msg.baseAssetPath).toString() - - try { - pyodideModule = await loadPyodideModule(pyodideIndexUrl) - self.postMessage({ type: 'loaded' }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error('Pyodide initialization failed', error) - self.postMessage({ - type: 'loading-failed', - error: errorMessage, - }) - } -} - -async function handleRunCode(msg: RunCodeRequest) { - const { fileId, executionId } = msg - - const writtenPaths = new Set() - const readPaths = new Set() - - const computeImports = () => - [...readPaths].filter(path => !writtenPaths.has(path)) - - const postFailure = ( - stream: 'stderr' | 'info', - line: string, - errorType: ExecutionErrorType = 'generic' - ) => { - self.postMessage({ - type: 'output-line', - stream, - line, - fileId, - executionId, - }) - self.postMessage({ - type: 'run-code-result', - fileId, - executionId, - success: false, - outputs: [], - outputFiles: [], - imports: computeImports(), - errorType, - }) - } - - if (!pyodideModule || !pyodideIndexUrl) { - postFailure('stderr', 'Pyodide is not initialized') - return - } - - const instance = await pyodideModule.loadPyodide({ - env: { MPLBACKEND: 'Agg' }, - packageBaseUrl: `${pyodideIndexUrl}${pyodideModule.version}/`, - }) - - instance.setStdout({ - batched: (line: string) => { - self.postMessage({ - type: 'output-line', - stream: 'stdout', - line, - fileId, - executionId, - }) - }, - }) - instance.setStderr({ - batched: (line: string) => { - self.postMessage({ - type: 'output-line', - stream: 'stderr', - line, - fileId, - executionId, - }) - }, - }) - - const fs = instance.FS - const originalWrite = fs.write as PyodideFS['write'] - const originalRead = fs.read as PyodideFS['read'] - let runError: unknown = null - try { - if (msg.files.length > 0) { - syncProjectFiles(fs, msg.files) - } - - fs.write = ((...args: Parameters) => { - const [stream] = args - // Only surface writes to the synced project workspace, not Pyodide internals. - if ( - typeof stream?.path === 'string' && - stream.path.startsWith(PROJECT_FS_PREFIX) - ) { - writtenPaths.add(stream.path) - } - - return originalWrite.call(fs, ...args) - }) as PyodideFS['write'] - - fs.read = ((...args: Parameters) => { - const [stream] = args - if ( - typeof stream?.path === 'string' && - stream.path.startsWith(PROJECT_FS_PREFIX) - ) { - readPaths.add(stream.path) - } - - return originalRead.call(fs, ...args) - }) as PyodideFS['read'] - - await instance.loadPackagesFromImports(msg.code) - const result = await instance.runPythonAsync(msg.code) - if (result !== undefined) { - self.postMessage({ - type: 'output-line', - stream: 'stdout', - line: String(result), - fileId, - executionId, - }) - } - } catch (e) { - runError = e - } - fs.write = originalWrite - fs.read = originalRead - - const paths = [...writtenPaths] - - if (runError) { - const errorMessage = - runError instanceof Error ? runError.message : String(runError) - const errorType = classifyErrorType(errorMessage) - const fullMessage = - errorType === 'ModuleNotFoundError' - ? `${errorMessage}\n${moduleNotFoundHelpMessage()}` - : errorMessage - postFailure('stderr', fullMessage, errorType) - return - } - - const countViolation = checkOutputCount(paths.length) - if (countViolation) { - postFailure('info', countViolation.message, 'OutputLimitExceeded') - return - } - - const filesWithSizes: { path: string; size: number }[] = [] - for (const writtenPath of paths) { - try { - filesWithSizes.push({ - path: writtenPath, - size: fs.stat(writtenPath).size, - }) - } catch { - // A script can write a file and later delete or rename it before the run - // finishes; fs.stat would then throw and we'd never post a - // run-code-result, leaving the UI stuck. Skip paths we can't stat. - } - } - - const sizeViolation = checkOutputLimits(filesWithSizes) - if (sizeViolation) { - postFailure('info', sizeViolation.message, 'OutputLimitExceeded') - return - } - - const outputFiles: OutputFileData[] = [] - const transferables: Transferable[] = [] - for (const { path: writtenPath } of filesWithSizes) { - const content = fs.readFile(writtenPath) - const relativePath = writtenPath.slice(PROJECT_FS_PREFIX.length) - outputFiles.push({ relativePath, content }) - if (content.buffer instanceof ArrayBuffer) { - transferables.push(content.buffer) - } - } - - // The transferables moves ownership of each ArrayBuffer to the main thread - // instead of structured-cloning it. The buffers are already referenced from - // outputFiles.content; listing them here just swaps copy for move, so file - // contents travel through once rather than being allocated on both sides. - self.postMessage( - { - type: 'run-code-result', - fileId, - executionId, - success: true, - outputs: filesWithSizes.map(f => f.path), - outputFiles, - imports: computeImports(), - }, - transferables - ) -} - -self.addEventListener('message', async event => { - const msg = event.data as PyodideWorkerRequest - switch (msg.type) { - case 'init': - await handleInit(msg) - break - case 'run-code': - await handleRunCode(msg) - break - } -}) - -self.postMessage({ type: 'listening' }) 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 deleted file mode 100644 index 156e8f613c..0000000000 --- a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { useTranslation } from 'react-i18next' -import classNames from 'classnames' -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 SplitTestBadge from '@/shared/components/split-test-badge' -import { sendMB } from '@/infrastructure/event-tracking' -import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' -import { usePythonExecutionContext } from '@/features/ide-react/context/python-execution-context' -import { DEFAULT_STATE } from './python-runner' - -const emptySubscribe = () => () => {} -const getDefaultState = () => DEFAULT_STATE - -export default function PythonOutputPane() { - const { t } = useTranslation() - const { currentDocumentId, openDocName } = useEditorOpenDocContext() - const { getPythonRunner } = usePythonExecutionContext() - const pythonRunner = useMemo( - () => (currentDocumentId ? getPythonRunner(currentDocumentId) : null), - [currentDocumentId, getPythonRunner] - ) - - useEffect(() => { - if (!currentDocumentId || !openDocName) { - return - } - sendMB('script-runner-opened', { - fileName: openDocName, - fileExtension: 'py', - editorMode: 'code', - }) - }, [currentDocumentId, openDocName]) - - const { output, error, status } = useSyncExternalStore( - pythonRunner ? pythonRunner.subscribe : emptySubscribe, - pythonRunner ? pythonRunner.getState : getDefaultState - ) - - if (!pythonRunner) { - return null - } - - return ( -
- -
-
- { - if (status === 'running') { - pythonRunner.interrupt() - } else { - pythonRunner.run() - } - }} - variant={status === 'running' ? 'danger' : 'primary'} - className="align-items-center py-0 px-3" - disabled={status === 'loading'} - aria-label={ - status === 'running' - ? t('stop_python_execution') - : t('run_python_code') - } - > - {status === 'running' ? t('stop') : t('run')} - - -
- -
-
- -
- {status === 'loading' && ( -
- {t('loading_python_runtime')} -
- )} - {status !== 'loading' && !error && output.length === 0 && ( -
- {t('run_current_script_to_see_output')} -
- )} - {error && ( -
{error}
- )} - {output.map((entry, index) => ( -
- {entry.line} -
- ))} -
-
- ) -} diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-toasts.tsx b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-toasts.tsx deleted file mode 100644 index c8b766041f..0000000000 --- a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-toasts.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts' -import { useTranslation } from 'react-i18next' - -const stripProjectPrefix = (path: string) => path.replace(/^\/project\/?/, '') - -const PythonFilesSavedToast = ({ paths }: { paths: string[] }) => { - const { t } = useTranslation() - if (paths.length === 1) { - return ( - - {t('x_saved_to_your_project', { - fileName: stripProjectPrefix(paths[0]), - })} - - ) - } - return ( - {t('x_files_saved_to_your_project', { count: paths.length })} - ) -} - -const isStringArray = (value: unknown): value is string[] => - Array.isArray(value) && value.every(v => typeof v === 'string') - -const generators: GlobalToastGeneratorEntry[] = [ - { - key: 'python:files-saved', - generator: ({ paths }) => ({ - content: ( - - ), - type: 'success', - autoHide: true, - delay: 5000, - isDismissible: true, - }), - }, -] - -export default generators - -export const showPythonFilesSavedToast = (paths: string[]) => { - window.dispatchEvent( - new CustomEvent('ide:show-toast', { - detail: { key: 'python:files-saved', paths }, - }) - ) -} diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts b/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts deleted file mode 100644 index 81fe7c042b..0000000000 --- a/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts +++ /dev/null @@ -1,305 +0,0 @@ -// Per-file Python execution manager. Each PythonRunner owns a PyodideWorkerClient -// and exposes a subscribe/getState API for use with useSyncExternalStore, -// so React components can reactively read execution status and output. -import path from 'path-browserify' -import { v4 as uuid } from 'uuid' -import { debugConsole } from '@/utils/debugging' -import { sendMB } from '@/infrastructure/event-tracking' -import { PyodideWorkerClient } from './pyodide-worker-client' -import { showPythonFilesSavedToast } from './python-output-toasts' -import type { OutputStream } from './pyodide-worker-messages' -import type { - BatchUploadItem, - UploadResult, -} from '@/infrastructure/batch-file-uploader' - -export type FileUploader = (items: BatchUploadItem[]) => Promise - -const MAX_OUTPUT_LINES = 100 -const PROJECT_FS_PREFIX = '/project/' - -function stripProjectFsPrefix(p: string): string { - return p.startsWith(PROJECT_FS_PREFIX) ? p.slice(PROJECT_FS_PREFIX.length) : p -} - -export type ExecutionStatus = - | 'loading' - | 'idle' - | 'running' - | 'finished' - | 'errored' - -export type ExecutionContext = { - code: string - files: { relativePath: string; content: string }[] -} - -type Listener = () => void - -export type OutputLine = { - stream: OutputStream - line: string -} - -export type PythonRunnerState = { - output: OutputLine[] - status: ExecutionStatus - error: string | null -} - -export const DEFAULT_STATE: PythonRunnerState = { - output: [], - status: 'loading', - error: null, -} - -export class PythonRunner { - readonly fileId: string - private client: PyodideWorkerClient | null = null - private readonly baseAssetPath: string - private readonly createWorker: () => Worker - private readonly getExecutionContext: () => Promise - private readonly fileUploader: FileUploader - - private listeners = new Set() - - private activeExecution: { id: string; startedAt: number } | null = null - private state: PythonRunnerState = DEFAULT_STATE - - constructor( - fileId: string, - baseAssetPath: string, - getExecutionContext: () => Promise, - createWorker: () => Worker, - fileUploader: FileUploader - ) { - this.fileId = fileId - this.baseAssetPath = baseAssetPath - this.createWorker = createWorker - this.getExecutionContext = getExecutionContext - this.fileUploader = fileUploader - } - - subscribe = (listener: Listener): (() => void) => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - getState = () => this.state - - private updateState(fields: Partial) { - const prev = this.state - const output = fields.output ?? prev.output - const status = fields.status ?? prev.status - const error = fields.error !== undefined ? fields.error : prev.error - - if ( - output === prev.output && - status === prev.status && - error === prev.error - ) { - return - } - - this.state = { output, status, error } - - for (const listener of this.listeners) { - listener() - } - } - - init() { - if (this.client) { - return - } - - this.updateState({ status: 'loading', error: null }) - - this.client = new PyodideWorkerClient({ - baseAssetPath: this.baseAssetPath, - createWorker: this.createWorker, - fileUploader: this.fileUploader, - onLifecycle: event => { - switch (event.type) { - case 'loaded': - this.updateState({ status: 'idle', error: null }) - return - - case 'loading-failed': - debugConsole.error('Failed to load Python runtime', event.error) - this.updateState({ status: 'errored', error: event.error }) - return - - case 'run-finished': { - const active = this.activeExecution - if ( - event.fileId !== this.fileId || - active?.id !== event.executionId - ) { - return - } - - this.activeExecution = null - - sendMB('script-runner-execution-completed', { - result: event.success ? 'success' : 'error', - errorType: event.success ? undefined : event.errorType, - executionTimeMs: Math.round(performance.now() - active.startedAt), - filesImportedCount: event.imports.length, - filesImportedExtensions: collectExtensions(event.imports), - filesWrittenCount: event.outputs.length, - filesWrittenExtensions: collectExtensions(event.outputs), - }) - - // event.outputs are full worker paths (/project/foo.txt) while - // event.failedUploads are relativePaths (foo.txt); strip the - // prefix before comparing. - const failed = new Set(event.failedUploads) - const uploadedPaths = event.outputs - .map(stripProjectFsPrefix) - .filter(p => !failed.has(p)) - if (uploadedPaths.length > 0) { - showPythonFilesSavedToast(uploadedPaths) - } - - this.updateState({ status: 'finished' }) - } - } - }, - onOutput: (stream, line, fileId, executionId) => { - if ( - fileId !== this.fileId || - this.activeExecution?.id !== executionId - ) { - return - } - this.updateState({ - output: appendCapped(this.state.output, { stream, line }), - }) - }, - }) - } - - async run() { - if (!this.client || this.state.status === 'loading') { - return - } - - let context: ExecutionContext | null - try { - context = await this.getExecutionContext() - } catch (err) { - debugConsole.error('Failed to build execution context', err) - this.updateState({ status: 'errored', error: formatError(err) }) - return - } - - // Re-check after await — status may have changed but TypeScript - // still narrows from the pre-await check, so we cast back. - if ( - !context || - !this.client || - (this.state.status as ExecutionStatus) === 'loading' - ) { - return - } - - const { code, files } = context - - sendMB('script-runner-run-clicked', { - scriptLineCount: countLines(code), - }) - - const executionId = uuid() - this.activeExecution = { id: executionId, startedAt: performance.now() } - this.updateState({ status: 'running', output: [], error: null }) - - try { - this.client.runCode(code, { - fileId: this.fileId, - executionId, - files, - }) - } catch (runError) { - if (this.activeExecution?.id !== executionId) { - return - } - this.activeExecution = null - this.updateState({ status: 'errored', error: formatError(runError) }) - } - } - - interrupt() { - if (!this.client) { - return - } - - if (this.state.status === 'running' && this.activeExecution) { - sendMB('script-runner-stop-clicked', { - timeBeforeStopMs: Math.round( - performance.now() - this.activeExecution.startedAt - ), - }) - } - - this.client.reset() - this.activeExecution = null - - // The worker is terminated and recreated by reset(), so it needs to - // reload Pyodide. The 'loaded' lifecycle callback will transition - // back to 'idle'. - this.updateState({ - status: 'loading', - output: - this.state.status === 'running' - ? appendCapped(this.state.output, { - stream: 'info', - line: 'Execution interrupted', - }) - : this.state.output, - }) - } - - destroy() { - if (this.client) { - this.client.destroy() - this.client = null - } - } -} - -function appendCapped(existing: OutputLine[], entry: OutputLine): OutputLine[] { - const updated = [...existing, entry] - return updated.length > MAX_OUTPUT_LINES - ? updated.slice(-MAX_OUTPUT_LINES) - : updated -} - -function formatError(error: unknown): string { - if (error instanceof Error) { - return error.message - } - return String(error) -} - -function countLines(code: string): number { - if (code.length === 0) { - return 0 - } - return code.split('\n').length -} - -function extractExtension(filePath: string): string { - return path.extname(filePath).slice(1).toLowerCase() -} - -function collectExtensions(filePaths: string[]): string { - const seen = new Set() - for (const filePath of filePaths) { - const ext = extractExtension(filePath) - if (ext) { - seen.add(ext) - } - } - return Array.from(seen).join(',') -} diff --git a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx index 5d37e6a04a..8e799d2a18 100644 --- a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx +++ b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx @@ -8,7 +8,6 @@ import { OLToastContainer } from '@/shared/components/ol/ol-toast-container' import clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts' import importDocumentFeedbackToastGenerators from '@/features/project-list/components/new-project-button/import-document-feedback-toast' import exportDocumentToastGenerators from '@/features/ide-react/components/toolbar/export-document-toasts' -import pythonOutputToastGenerators from '@/features/ide-react/components/editor/python/python-output-toasts' const moduleGeneratorsImport = importOverleafModules('toastGenerators') as { import: { default: GlobalToastGeneratorEntry[] } @@ -32,7 +31,6 @@ const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [ ...clipboardToastGenerators, ...importDocumentFeedbackToastGenerators, ...exportDocumentToastGenerators, - ...pythonOutputToastGenerators, ] const GENERATOR_MAP: Map = new Map( GENERATOR_LIST.map(({ key, generator }) => [key, generator]) diff --git a/services/web/frontend/js/features/ide-react/components/layout/editor.tsx b/services/web/frontend/js/features/ide-react/components/layout/editor.tsx index d208669fdc..5e757304e6 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/editor.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/editor.tsx @@ -5,22 +5,22 @@ import classNames from 'classnames' import SourceEditor from '@/features/source-editor/components/source-editor' import { Panel, PanelGroup } from 'react-resizable-panels' import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle' -import { Suspense } from 'react' +import { FC, Suspense } from 'react' import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner' import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane' import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' -import { PythonEditorSplit } from '@/features/ide-react/components/layout/python-editor-split' import { isSplitTestEnabled } from '@/utils/splitTestUtils' +import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' + +const [pythonRunnerModule] = importOverleafModules('pythonRunner') as { + import: { PythonEditorSplit: FC } +}[] export const Editor = () => { const { opening, errorState, showSymbolPalette } = useEditorPropertiesContext() const { selectedEntityCount, openEntity } = useFileTreeOpenContext() const { currentDocumentId, currentDocument } = useEditorOpenDocContext() - const isPythonDocument = - openEntity?.type === 'doc' && - openEntity.entity.name.toLowerCase().endsWith('.py') - const pythonExecutionEnabled = isSplitTestEnabled('overleaf-code') if (!currentDocumentId) { return null @@ -30,6 +30,10 @@ export const Editor = () => { (!currentDocument || opening) && !errorState && currentDocumentId ) + const isPythonDocument = + openEntity?.type === 'doc' && + openEntity.entity.name.toLowerCase().endsWith('.py') + return (
{ order={1} className="ide-redesign-editor-panel" > - {isPythonDocument && pythonExecutionEnabled ? ( - + {pythonRunnerModule && + isPythonDocument && + isSplitTestEnabled('overleaf-code') ? ( + ) : ( )} diff --git a/services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx b/services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx deleted file mode 100644 index e73cd950a7..0000000000 --- a/services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Panel, PanelGroup } from 'react-resizable-panels' -import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle' -import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane' -import SourceEditor from '@/features/source-editor/components/source-editor' -import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context' - -export const PythonEditorSplit = () => { - return ( - - - - - - - - - - - - ) -} diff --git a/services/web/frontend/js/features/ide-react/context/python-execution-context.tsx b/services/web/frontend/js/features/ide-react/context/python-execution-context.tsx deleted file mode 100644 index e7767e7b3a..0000000000 --- a/services/web/frontend/js/features/ide-react/context/python-execution-context.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - createContext, - FC, - PropsWithChildren, - useCallback, - useContext, - useEffect, - useMemo, - useRef, -} from 'react' -import getMeta from '@/utils/meta' -import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' -import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' -import { useProjectContext } from '@/shared/context/project-context' -import { useFileTreeData } from '@/shared/context/file-tree-data-context' -import { - uploadBatch, - BatchUploadItem, -} from '@/infrastructure/batch-file-uploader' -import { - PythonRunner, - ExecutionContext, -} from '@/features/ide-react/components/editor/python/python-runner' - -// Worker factory lives here (a .tsx file) so that the full -// `new Worker(new URL(..., import.meta.url))` expression is in a single place -// where webpack 5 can statically detect it and create a proper worker bundle. -// Keeping import.meta.url out of .ts files also avoids Node.js 24 switching to -// ESM mode and breaking CJS-based test loading via @babel/register. -const createPyodideWorker = () => - new Worker( - /* webpackChunkName: "pyodide-worker" */ - new URL('../components/editor/python/pyodide.worker.ts', import.meta.url), - { type: 'module' } - ) - -export interface PythonExecutionContextValue { - getPythonRunner: (fileId: string) => PythonRunner -} - -export const PythonExecutionContext = createContext< - PythonExecutionContextValue | undefined ->(undefined) - -export const PythonExecutionProvider: FC = ({ - children, -}) => { - const { openDocs } = useEditorManagerContext() - const { projectId, projectSnapshot } = useProjectContext() - const { pathInFolder } = useFileTreePathContext() - const { fileTreeData } = useFileTreeData() - const runnersRef = useRef(new Map()) - const baseAssetPathRef = useRef(null) - - const pathInFolderRef = useRef(pathInFolder) - pathInFolderRef.current = pathInFolder - - // Ref so the upload closure built into each PythonRunner reads the - // current value at call time rather than capturing a potentially-stale - // value from when the runner was constructed (fileTreeData may load - // after the runner is created). - const fileTreeDataRef = useRef(fileTreeData) - fileTreeDataRef.current = fileTreeData - - // Refreshes the project snapshot and resolves the source code and all project - // files for the given fileId, to be passed to the executor for running. - const getExecutionContext = useCallback( - async (fileId: string): Promise => { - await openDocs.awaitBufferedOps(AbortSignal.timeout(5000)) - await projectSnapshot.refresh() - - const relativePath = pathInFolderRef.current(fileId) - if (!relativePath) { - return null - } - - const code = projectSnapshot.getDocContents(relativePath) - if (code == null) { - return null - } - - const docPaths = projectSnapshot.getDocPaths() - const files = docPaths - .map(docPath => { - const content = projectSnapshot.getDocContents(docPath) - return content != null ? { relativePath: docPath, content } : null - }) - .filter( - (f): f is { relativePath: string; content: string } => f != null - ) - - return { code, files } - }, - [openDocs, projectSnapshot] - ) - - const getPythonRunner = useCallback( - (fileId: string): PythonRunner => { - const existing = runnersRef.current.get(fileId) - if (existing) { - return existing - } - - if (!baseAssetPathRef.current) { - baseAssetPathRef.current = new URL( - getMeta('ol-baseAssetPath'), - window.location.href - ).toString() - } - - const uploadOutputFiles = (items: BatchUploadItem[]) => { - const folderId = fileTreeDataRef.current?._id - if (!folderId) { - return Promise.reject( - new Error('File tree not loaded; cannot upload output files') - ) - } - return uploadBatch(items, { - projectId, - folderId, - }) - } - - const runner = new PythonRunner( - fileId, - baseAssetPathRef.current, - () => getExecutionContext(fileId), - createPyodideWorker, - uploadOutputFiles - ) - runner.init() - runnersRef.current.set(fileId, runner) - return runner - }, - [getExecutionContext, projectId] - ) - - useEffect(() => { - const runners = runnersRef.current - return () => { - for (const runner of runners.values()) { - runner.destroy() - } - runners.clear() - } - }, []) - - const value = useMemo(() => ({ getPythonRunner }), [getPythonRunner]) - - return ( - - {children} - - ) -} - -export const usePythonExecutionContext = (): PythonExecutionContextValue => { - const context = useContext(PythonExecutionContext) - - if (!context) { - throw new Error( - 'usePythonExecutionContext is only available inside PythonExecutionContext.Provider' - ) - } - - return context -} diff --git a/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss b/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss index cd8dc82ae6..cc863ee359 100644 --- a/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss +++ b/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss @@ -57,85 +57,6 @@ position: relative; } -.ide-redesign-python-editor-split { - height: 100%; -} - -.ide-redesign-python-output-pane { - height: 100%; - display: flex; - flex-direction: column; - background-color: var(--ide-redesign-background); - color: var(--ide-redesign-color); - border-top: 1px solid var(--border-divider); -} - -.ide-redesign-python-output-pane-toolbar { - display: flex; - align-items: center; - height: var(--toolbar-height); - border-bottom: 1px solid var(--toolbar-border-color); -} - -.ide-redesign-python-output-pane-toolbar-left { - display: flex; - align-items: center; - align-self: stretch; - gap: var(--spacing-02); -} - -.ide-redesign-python-output-pane-run-button-wrapper { - height: 24px; - background-color: var(--bg-accent-01); - border-radius: var(--ds-border-radius-300); - margin-left: var(--spacing-02); - - &.compile-button-group-running { - background-color: var(--bg-danger-01); - } - - .btn-primary:hover { - z-index: auto; - } -} - -.ide-redesign-python-output-pane-body { - flex: 1; - overflow: auto; - padding: var(--spacing-06); - font-family: 'DM Mono', monospace; - font-size: var(--font-size-02); - line-height: var(--line-height-03); -} - -.ide-redesign-python-output-pane-placeholder { - color: var(--content-secondary); -} - -.ide-redesign-python-output-pane-line { - white-space: pre-wrap; - word-break: break-word; - min-height: var(--line-height-03); -} - -.ide-redesign-python-output-pane-line-info { - color: var(--content-info); -} - -.ide-redesign-python-output-pane-line-stderr { - color: var(--red-50); -} - -.ide-redesign-python-output-pane-placeholder, -.ide-redesign-python-output-pane-error { - white-space: pre-wrap; -} - -.ide-redesign-python-output-pane-error { - margin-bottom: var(--spacing-03); - color: var(--red-50); -} - .ide-redesign-labs-user-beta-promo { position: absolute; top: 60px; 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 deleted file mode 100644 index fcf69cbb85..0000000000 --- a/services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx +++ /dev/null @@ -1,481 +0,0 @@ -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' -import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context' - -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') - }) - - it('renders stderr output with the stderr line class', function () { - const executablePythonFileContents = [ - 'import sys', - "print('hello!')", - "sys.stderr.write('boom\\n')", - ].join('\n') - 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('have.class', 'ide-redesign-python-output-pane-line-stdout') - .and('not.have.class', 'ide-redesign-python-output-pane-line-stderr') - cy.findByText('boom').should( - 'have.class', - 'ide-redesign-python-output-pane-line-stderr' - ) - cy.findByText("Only Pyodide's built-in packages", { exact: false }).should( - 'not.exist' - ) - }) - - it('renders the interrupt message as an info line', function () { - const executablePythonFileContents = 'while True:\n pass\n' - 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.findByRole('button', { name: 'Stop Python execution' }) - .should('not.be.disabled') - .click() - - cy.findByText('Execution interrupted').should( - 'have.class', - 'ide-redesign-python-output-pane-line-info' - ) - }) - - it('renders stdout, stderr, and info lines with visually distinct CSS classes', function () { - const executablePythonFileContents = [ - 'import sys', - "print('hello!')", - "sys.stderr.write('boom\\n')", - 'while True:', - ' pass', - ].join('\n') - 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('have.class', 'ide-redesign-python-output-pane-line-stdout') - .and('not.have.class', 'ide-redesign-python-output-pane-line-stderr') - .and('not.have.class', 'ide-redesign-python-output-pane-line-info') - cy.findByText('boom') - .should('have.class', 'ide-redesign-python-output-pane-line-stderr') - .and('not.have.class', 'ide-redesign-python-output-pane-line-stdout') - .and('not.have.class', 'ide-redesign-python-output-pane-line-info') - - cy.findByRole('button', { name: 'Stop Python execution' }) - .should('not.be.disabled') - .click() - - cy.findByText('Execution interrupted') - .should('have.class', 'ide-redesign-python-output-pane-line-info') - .and('not.have.class', 'ide-redesign-python-output-pane-line-stdout') - .and('not.have.class', 'ide-redesign-python-output-pane-line-stderr') - }) - - it('can load common python data analysis packages on code execution', function () { - const executablePythonFileContents = [ - 'import tomli', - '', - "print(tomli.loads('greeting = \"hello from tomli\"')['greeting'])", - ].join('\n') - - 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("ModuleNotFoundError: No module named 'tomli'").should( - 'not.exist' - ) - cy.findByText('hello from tomli').should('exist') - }) - - it('auto-installs python packages imported by the executing script', function () { - const executablePythonFileContents = [ - 'import tomli', - '', - "print(tomli.loads('greeting = \"hello from tomli\"')['greeting'])", - ].join('\n') - - 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("ModuleNotFoundError: No module named 'tomli'").should( - 'not.exist' - ) - cy.findByText('hello from tomli').should('exist') - }) - - it('augments ModuleNotFoundError output with help text about supported Pyodide packages', function () { - const executablePythonFileContents = - 'import this_module_does_not_exist_in_pyodide\n' - 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('ModuleNotFoundError', { exact: false }).should('exist') - cy.findByText("Only Pyodide's built-in packages", { exact: false }).should( - 'have.class', - 'ide-redesign-python-output-pane-line-stderr' - ) - }) -}) diff --git a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts b/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts deleted file mode 100644 index 81169136af..0000000000 --- a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts +++ /dev/null @@ -1,778 +0,0 @@ -import { expect } from 'chai' -import sinon from 'sinon' -import { - PyodideWorkerClient, - type LifecycleCallback, - type OutputCallback, -} from '@/features/ide-react/components/editor/python/pyodide-worker-client' -import type { FileUploader } from '@/features/ide-react/components/editor/python/python-runner' -import { WorkerMock, createWorker } from './worker-mock' - -const BASE_ASSET_PATH = 'https://assets.example.test/' - -const fileUploaderStub: FileUploader = () => Promise.resolve([]) - -describe('PyodideWorkerClient', function () { - beforeEach(function () { - WorkerMock.instances.length = 0 - }) - - it('queues runCode until the worker reports listening', function () { - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - fileUploader: fileUploaderStub, - }) - const worker = WorkerMock.instances[0] - - client.runCode('print("ok")', { - fileId: 'main.py', - executionId: 'exec-1', - files: [{ relativePath: 'main.py', content: 'print("ok")' }], - }) - expect(worker.postedMessages).to.have.length(0) - - worker.emitMessage({ type: 'listening' }) - expect(worker.postedMessages.map(message => message.type)).to.deep.equal([ - 'init', - 'run-code', - ]) - - const runRequest = worker.postedMessages.find( - message => message.type === 'run-code' - ) - expect(runRequest).to.include({ - type: 'run-code', - fileId: 'main.py', - executionId: 'exec-1', - code: 'print("ok")', - }) - expect(runRequest.files).to.deep.equal([ - { relativePath: 'main.py', content: 'print("ok")' }, - ]) - }) - - it('sends runCode as fire-and-forget', function () { - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - fileUploader: fileUploaderStub, - }) - const worker = WorkerMock.instances[0] - worker.emitMessage({ type: 'listening' }) - - client.runCode('raise RuntimeError("boom")', { - fileId: 'boom.py', - executionId: 'exec-2', - files: [], - }) - const runRequest = worker.postedMessages.find( - message => message.type === 'run-code' - ) - expect(runRequest).to.include({ - type: 'run-code', - fileId: 'boom.py', - executionId: 'exec-2', - }) - }) - - function setupClientWithLifecycleTracking() { - const lifecycleEvents: Parameters[0][] = [] - - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - onLifecycle: event => { - lifecycleEvents.push(event) - }, - fileUploader: fileUploaderStub, - }) - const worker = WorkerMock.instances[0] - worker.emitMessage({ type: 'listening' }) - return { client, worker, lifecycleEvents } - } - - function setupClientWithUploadTracking(options: { - fileUploader: FileUploader - }) { - const lifecycleEvents: Parameters[0][] = [] - const outputCalls: Parameters[] = [] - - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - onLifecycle: event => { - lifecycleEvents.push(event) - }, - onOutput: (...args) => { - outputCalls.push(args) - }, - fileUploader: options.fileUploader, - }) - const worker = WorkerMock.instances[0] - worker.emitMessage({ type: 'listening' }) - return { client, worker, lifecycleEvents, outputCalls } - } - - async function waitFor(predicate: () => boolean, timeoutMs = 200) { - const deadline = Date.now() + timeoutMs - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error('waitFor timed out') - } - await new Promise(resolve => setTimeout(resolve, 0)) - } - } - - it('emits run-finished lifecycle event from run-code-result', function () { - const { client, worker, lifecycleEvents } = - setupClientWithLifecycleTracking() - - client.runCode('print("ok")', { - fileId: 'main.py', - executionId: 'exec-3', - files: [], - }) - worker.emitMessage({ - type: 'run-code-result', - fileId: 'main.py', - executionId: 'exec-3', - success: true, - outputs: ['/project/output.txt'], - outputFiles: [], - imports: [], - }) - - expect(lifecycleEvents).to.deep.equal([ - { - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-3', - success: true, - outputs: ['/project/output.txt'], - failedUploads: [], - imports: [], - errorType: undefined, - }, - ]) - }) - - it('surfaces outputs array from run-code-result with multiple files', function () { - const { client, worker, lifecycleEvents } = - setupClientWithLifecycleTracking() - - client.runCode('write_files()', { - fileId: 'main.py', - executionId: 'exec-4', - files: [], - }) - worker.emitMessage({ - type: 'run-code-result', - fileId: 'main.py', - executionId: 'exec-4', - success: true, - outputs: ['/project/fig1.png', '/project/results/data.csv'], - outputFiles: [], - imports: [], - }) - - expect(lifecycleEvents).to.deep.equal([ - { - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-4', - success: true, - outputs: ['/project/fig1.png', '/project/results/data.csv'], - failedUploads: [], - imports: [], - errorType: undefined, - }, - ]) - }) - - it('surfaces empty outputs when no project files were written', function () { - const { client, worker, lifecycleEvents } = - setupClientWithLifecycleTracking() - - client.runCode('print("no writes")', { - fileId: 'main.py', - executionId: 'exec-5', - files: [], - }) - worker.emitMessage({ - type: 'run-code-result', - fileId: 'main.py', - executionId: 'exec-5', - success: true, - outputs: [], - outputFiles: [], - imports: [], - }) - - expect(lifecycleEvents).to.deep.equal([ - { - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-5', - success: true, - outputs: [], - failedUploads: [], - imports: [], - errorType: undefined, - }, - ]) - }) - - it('surfaces success and outputFiles from run-code-result', async function () { - const { client, worker, lifecycleEvents } = - setupClientWithLifecycleTracking() - - client.runCode('write_files()', { - fileId: 'main.py', - executionId: 'exec-success', - files: [], - }) - const csvContent = new Uint8Array([1, 2, 3]) - const pngContent = new Uint8Array([4, 5, 6, 7]) - worker.emitMessage({ - type: 'run-code-result', - fileId: 'main.py', - executionId: 'exec-success', - success: true, - outputs: ['/project/data.csv', '/project/plot.png'], - outputFiles: [ - { relativePath: 'data.csv', content: csvContent }, - { relativePath: 'plot.png', content: pngContent }, - ], - imports: [], - }) - - await waitFor(() => - Boolean(lifecycleEvents.find(e => e.type === 'run-finished')) - ) - - const finished = lifecycleEvents.find(e => e.type === 'run-finished') - expect(finished).to.deep.equal({ - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-success', - success: true, - outputs: ['/project/data.csv', '/project/plot.png'], - failedUploads: [], - imports: [], - errorType: undefined, - }) - }) - - it('propagates ModuleNotFoundError errorType on run-finished', function () { - const { client, worker, lifecycleEvents } = - setupClientWithLifecycleTracking() - - client.runCode('import nope', { - fileId: 'main.py', - executionId: 'exec-mnfe', - files: [], - }) - worker.emitMessage({ - type: 'run-code-result', - fileId: 'main.py', - executionId: 'exec-mnfe', - success: false, - outputs: [], - outputFiles: [], - imports: [], - errorType: 'ModuleNotFoundError', - }) - - const finished = lifecycleEvents.find(e => e.type === 'run-finished') - expect(finished).to.deep.equal({ - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-mnfe', - success: false, - outputs: [], - failedUploads: [], - imports: [], - errorType: 'ModuleNotFoundError', - }) - }) - - it('surfaces success: false with empty outputFiles on script error', function () { - const { client, worker, lifecycleEvents } = - setupClientWithLifecycleTracking() - - client.runCode('raise RuntimeError("boom")', { - fileId: 'main.py', - executionId: 'exec-error', - files: [], - }) - worker.emitMessage({ - type: 'run-code-result', - fileId: 'main.py', - executionId: 'exec-error', - success: false, - outputs: [], - outputFiles: [], - imports: [], - errorType: 'generic', - }) - - const finished = lifecycleEvents.find(e => e.type === 'run-finished') - expect(finished).to.deep.equal({ - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-error', - success: false, - outputs: [], - failedUploads: [], - imports: [], - errorType: 'generic', - }) - }) - - it('surfaces empty outputFiles when success but no files were written', function () { - const { client, worker, lifecycleEvents } = - setupClientWithLifecycleTracking() - - client.runCode('print("no writes")', { - fileId: 'main.py', - executionId: 'exec-nowrites', - files: [], - }) - worker.emitMessage({ - type: 'run-code-result', - fileId: 'main.py', - executionId: 'exec-nowrites', - success: true, - outputs: [], - outputFiles: [], - imports: [], - }) - - const finished = lifecycleEvents.find(e => e.type === 'run-finished') - expect(finished).to.deep.equal({ - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-nowrites', - success: true, - outputs: [], - failedUploads: [], - imports: [], - errorType: undefined, - }) - }) - - it('reports lifecycle failure and rejects future run requests when loading fails', function () { - const lifecycleEvents: { type: string; error?: string }[] = [] - - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - onLifecycle: event => { - lifecycleEvents.push(event) - }, - fileUploader: fileUploaderStub, - }) - const worker = WorkerMock.instances[0] - - worker.emitMessage({ - type: 'loading-failed', - error: 'runtime unavailable', - }) - - expect(lifecycleEvents).to.deep.equal([ - { type: 'loading-failed', error: 'runtime unavailable' }, - ]) - expect(() => - client.runCode('print("ok")', { - fileId: 'main.py', - executionId: 'exec-4', - files: [], - }) - ).to.throw('runtime unavailable') - }) - - it('terminates the worker even when destroy is called after loading failure', function () { - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - fileUploader: fileUploaderStub, - }) - const worker = WorkerMock.instances[0] - - worker.emitMessage({ - type: 'loading-failed', - error: 'runtime unavailable', - }) - client.destroy() - - expect(worker.terminated).to.equal(true) - }) - - context('upload behavior', function () { - const successResult = (name: string, relativePath: string) => ({ - status: 'success' as const, - name, - relativePath, - data: { success: true }, - }) - const errorResult = ( - name: string, - relativePath: string, - error: string - ) => ({ - status: 'error' as const, - name, - relativePath, - error, - }) - - function emitRunResult( - worker: WorkerMock, - executionId: string, - outputFiles: Array<{ relativePath: string; content: Uint8Array }>, - success = true - ) { - worker.emitMessage({ - type: 'run-code-result', - fileId: 'main.py', - executionId, - success, - outputs: [], - outputFiles, - imports: [], - }) - } - - const findFinished = ( - lifecycleEvents: Parameters[0][] - ) => lifecycleEvents.find(e => e.type === 'run-finished') - - it('invokes fileUploader with mapped items when run-code-result has output files', async function () { - const uploader = sinon - .stub() - .resolves([successResult('data.csv', 'output/data.csv')]) - const { worker, lifecycleEvents } = setupClientWithUploadTracking({ - fileUploader: uploader, - }) - - emitRunResult(worker, 'exec-up-1', [ - { - relativePath: 'output/data.csv', - content: new TextEncoder().encode('a,b\n1,2'), - }, - ]) - await waitFor(() => Boolean(findFinished(lifecycleEvents))) - - expect(uploader.calledOnce).to.be.true - const [items] = uploader.firstCall.args - expect(items).to.have.lengthOf(1) - expect(items[0].name).to.equal('data.csv') - expect(items[0].relativePath).to.equal('output/data.csv') - expect(items[0].file).to.be.instanceOf(Blob) - }) - - it('emits success: true and empty failedUploads when all uploads succeed', async function () { - const uploader = sinon - .stub() - .resolves([ - successResult('a.csv', 'a.csv'), - successResult('b.csv', 'b.csv'), - ]) - const { worker, lifecycleEvents } = setupClientWithUploadTracking({ - fileUploader: uploader, - }) - - emitRunResult(worker, 'exec-up-2', [ - { relativePath: 'a.csv', content: new TextEncoder().encode('1') }, - { relativePath: 'b.csv', content: new TextEncoder().encode('2') }, - ]) - await waitFor(() => Boolean(findFinished(lifecycleEvents))) - - const finished = findFinished(lifecycleEvents) - expect(finished).to.deep.include({ success: true, failedUploads: [] }) - }) - - it('flips success to false and lists failed paths when an upload fails', async function () { - const uploader = sinon - .stub() - .resolves([ - successResult('good.csv', 'good.csv'), - errorResult('bad.csv', 'output/bad.csv', 'duplicate_file_name'), - ]) - const { worker, lifecycleEvents, outputCalls } = - setupClientWithUploadTracking({ fileUploader: uploader }) - - emitRunResult(worker, 'exec-up-3', [ - { relativePath: 'good.csv', content: new TextEncoder().encode('1') }, - { - relativePath: 'output/bad.csv', - content: new TextEncoder().encode('2'), - }, - ]) - await waitFor(() => Boolean(findFinished(lifecycleEvents))) - - const finished = findFinished(lifecycleEvents) - expect(finished).to.deep.include({ - success: false, - failedUploads: ['output/bad.csv'], - errorType: 'UploadFileError', - }) - - // user-facing stderr line surfaced via outputCallback - expect(outputCalls).to.have.lengthOf(1) - const [stream, line] = outputCalls[0] - expect(stream).to.equal('stderr') - expect(line).to.include('output/bad.csv') - expect(line).to.include('duplicate_file_name') - }) - - it('lists every file in failedUploads and surfaces a single stderr line when fileUploader rejects', async function () { - const uploader = sinon.stub().rejects(new Error('network down')) - const { worker, lifecycleEvents, outputCalls } = - setupClientWithUploadTracking({ fileUploader: uploader }) - - emitRunResult(worker, 'exec-up-4', [ - { relativePath: 'a.csv', content: new TextEncoder().encode('1') }, - { relativePath: 'b.csv', content: new TextEncoder().encode('2') }, - ]) - await waitFor(() => Boolean(findFinished(lifecycleEvents))) - - const finished = findFinished(lifecycleEvents) - expect(finished).to.deep.include({ - success: false, - errorType: 'UploadFileError', - }) - expect(finished) - .to.have.property('failedUploads') - .that.has.members(['a.csv', 'b.csv']) - - expect(outputCalls).to.have.lengthOf(1) - const [stream, line] = outputCalls[0] - expect(stream).to.equal('stderr') - expect(line).to.include('network down') - }) - - it('does not invoke fileUploader when run-code-result has success: false', async function () { - const uploader = sinon.stub().resolves([]) - const { worker, lifecycleEvents } = setupClientWithUploadTracking({ - fileUploader: uploader, - }) - - emitRunResult( - worker, - 'exec-up-5', - [{ relativePath: 'a.csv', content: new TextEncoder().encode('1') }], - false - ) - await waitFor(() => Boolean(findFinished(lifecycleEvents))) - - expect(uploader.called).to.be.false - expect(findFinished(lifecycleEvents)).to.deep.include({ - success: false, - failedUploads: [], - }) - }) - - it('does not invoke fileUploader when outputFiles is empty', async function () { - const uploader = sinon.stub().resolves([]) - const { worker, lifecycleEvents } = setupClientWithUploadTracking({ - fileUploader: uploader, - }) - - emitRunResult(worker, 'exec-up-6', []) - await waitFor(() => Boolean(findFinished(lifecycleEvents))) - - expect(uploader.called).to.be.false - }) - }) - - describe('output-line forwarding', function () { - function setupClientWithOutputTracking() { - const outputCalls: Parameters[] = [] - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - onOutput: (...args) => { - outputCalls.push(args) - }, - fileUploader: fileUploaderStub, - }) - const worker = WorkerMock.instances[0] - worker.emitMessage({ type: 'listening' }) - return { client, worker, outputCalls } - } - - it('forwards stdout output-line events to the output callback', function () { - const { worker, outputCalls } = setupClientWithOutputTracking() - - worker.emitMessage({ - type: 'output-line', - stream: 'stdout', - line: 'hello!', - fileId: 'main.py', - executionId: 'exec-stdout', - }) - - expect(outputCalls).to.deep.equal([ - ['stdout', 'hello!', 'main.py', 'exec-stdout'], - ]) - }) - - it('forwards stderr output-line events to the output callback', function () { - const { worker, outputCalls } = setupClientWithOutputTracking() - - worker.emitMessage({ - type: 'output-line', - stream: 'stderr', - line: 'boom', - fileId: 'main.py', - executionId: 'exec-stderr', - }) - - expect(outputCalls).to.deep.equal([ - ['stderr', 'boom', 'main.py', 'exec-stderr'], - ]) - }) - - it('forwards info output-line events to the output callback', function () { - const { worker, outputCalls } = setupClientWithOutputTracking() - - worker.emitMessage({ - type: 'output-line', - stream: 'info', - line: 'Loading numpy from package index', - fileId: 'main.py', - executionId: 'exec-info', - }) - - expect(outputCalls).to.deep.equal([ - ['info', 'Loading numpy from package index', 'main.py', 'exec-info'], - ]) - }) - - it('preserves stream type when forwarding stdout, stderr, and info in sequence', function () { - const { worker, outputCalls } = setupClientWithOutputTracking() - - worker.emitMessage({ - type: 'output-line', - stream: 'info', - line: 'Loading package', - fileId: 'main.py', - executionId: 'exec-mixed', - }) - worker.emitMessage({ - type: 'output-line', - stream: 'stdout', - line: 'result', - fileId: 'main.py', - executionId: 'exec-mixed', - }) - worker.emitMessage({ - type: 'output-line', - stream: 'stderr', - line: 'warning', - fileId: 'main.py', - executionId: 'exec-mixed', - }) - - expect(outputCalls.map(call => call[0])).to.deep.equal([ - 'info', - 'stdout', - 'stderr', - ]) - }) - }) - - describe('reset', function () { - it('terminates the current worker and creates a new one', function () { - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - fileUploader: fileUploaderStub, - }) - const originalWorker = WorkerMock.instances[0] - originalWorker.emitMessage({ type: 'listening' }) - originalWorker.emitMessage({ type: 'loaded' }) - - client.reset() - - expect(originalWorker.terminated).to.equal(true) - expect(WorkerMock.instances).to.have.length(2) - }) - - it('sends init to the new worker once it reports listening after reset', function () { - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - fileUploader: fileUploaderStub, - }) - const originalWorker = WorkerMock.instances[0] - originalWorker.emitMessage({ type: 'listening' }) - originalWorker.emitMessage({ type: 'loaded' }) - - client.reset() - - const newWorker = WorkerMock.instances[1] - expect(newWorker.postedMessages).to.have.length(0) - - newWorker.emitMessage({ type: 'listening' }) - expect(newWorker.postedMessages).to.deep.equal([ - { - type: 'init', - baseAssetPath: BASE_ASSET_PATH, - }, - ]) - }) - - it('allows running code on the new worker after reset', function () { - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - fileUploader: fileUploaderStub, - }) - const originalWorker = WorkerMock.instances[0] - originalWorker.emitMessage({ type: 'listening' }) - originalWorker.emitMessage({ type: 'loaded' }) - - client.reset() - - const newWorker = WorkerMock.instances[1] - newWorker.emitMessage({ type: 'listening' }) - newWorker.emitMessage({ type: 'loaded' }) - - client.runCode('print("after reset")', { - fileId: 'main.py', - executionId: 'exec-5', - files: [], - }) - - const runRequest = newWorker.postedMessages.find( - (message: any) => message.type === 'run-code' - ) - expect(runRequest).to.include({ - type: 'run-code', - fileId: 'main.py', - executionId: 'exec-5', - code: 'print("after reset")', - }) - }) - - it('reset is a no-op after destroy', function () { - const client = new PyodideWorkerClient({ - baseAssetPath: BASE_ASSET_PATH, - createWorker, - fileUploader: fileUploaderStub, - }) - const originalWorker = WorkerMock.instances[0] - originalWorker.emitMessage({ type: 'listening' }) - originalWorker.emitMessage({ type: 'loaded' }) - - client.destroy() - client.reset() - - // No new worker should have been created - expect(WorkerMock.instances).to.have.length(1) - }) - }) -}) diff --git a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-output-limits.spec.ts b/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-output-limits.spec.ts deleted file mode 100644 index e24d3833b4..0000000000 --- a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-output-limits.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect } from 'chai' -import { - MAX_OUTPUT_FILES, - MAX_OUTPUT_FILE_BYTES, - MAX_OUTPUT_TOTAL_BYTES, - checkOutputLimits, -} from '@/features/ide-react/components/editor/python/pyodide-worker-output-limits' - -const BYTES_PER_MB = 1024 * 1024 - -function makeFiles( - count: number, - sizePerFile: number -): { path: string; size: number }[] { - return Array.from({ length: count }, (_, i) => ({ - path: `/project/file${i}.bin`, - size: sizePerFile, - })) -} - -describe('checkOutputLimits', function () { - it('returns null when both limits are within bounds', function () { - const files = makeFiles(10, 1024) - expect(checkOutputLimits(files)).to.equal(null) - }) - - it('returns null for an empty file list', function () { - expect(checkOutputLimits([])).to.equal(null) - }) - - it('returns null at exactly the file count limit', function () { - const files = makeFiles(MAX_OUTPUT_FILES, 1024) - expect(checkOutputLimits(files)).to.equal(null) - }) - - it('returns a count violation reporting the actual file count when the file count exceeds the limit', function () { - const files = makeFiles(73, 1) - const violation = checkOutputLimits(files) - expect(violation).to.deep.equal({ - kind: 'count', - message: 'Output limit exceeded: 73 files generated (max 50)', - }) - }) - - it('returns null at exactly the per-file size limit', function () { - const files = [{ path: '/project/big.bin', size: MAX_OUTPUT_FILE_BYTES }] - expect(checkOutputLimits(files)).to.equal(null) - }) - - it('returns a single-file-size violation reporting the offending file path and size when one file exceeds the per-file limit', function () { - const files = [ - { path: '/project/small.bin', size: 1 * BYTES_PER_MB }, - { path: '/project/huge.bin', size: 80 * BYTES_PER_MB }, - ] - const violation = checkOutputLimits(files) - expect(violation).to.deep.equal({ - kind: 'single-file-size', - message: - 'Output limit exceeded: /project/huge.bin is 80MB (max 50MB per file)', - }) - }) - - it('returns a total-output-size violation when the summed size exceeds the total limit', function () { - const files = [ - { path: '/project/a.bin', size: 40 * BYTES_PER_MB }, - { path: '/project/b.bin', size: 40 * BYTES_PER_MB }, - { path: '/project/c.bin', size: 40 * BYTES_PER_MB }, - ] - const violation = checkOutputLimits(files) - expect(violation).to.not.equal(null) - expect(violation!.kind).to.equal('total-output-size') - expect(violation!.message).to.equal( - 'Output limit exceeded: 120MB total (max 100MB)' - ) - }) - - it('returns null at exactly the total size limit', function () { - const files = [ - { path: '/project/a.bin', size: 50 * BYTES_PER_MB }, - { path: '/project/b.bin', size: 50 * BYTES_PER_MB }, - ] - expect(checkOutputLimits(files)).to.equal(null) - const total = files.reduce((acc, f) => acc + f.size, 0) - expect(total).to.equal(MAX_OUTPUT_TOTAL_BYTES) - }) - - it('reports the count violation when both limits are exceeded', function () { - const files = makeFiles(MAX_OUTPUT_FILES + 10, MAX_OUTPUT_TOTAL_BYTES) - const violation = checkOutputLimits(files) - expect(violation).to.not.equal(null) - expect(violation!.kind).to.equal('count') - }) -}) diff --git a/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts b/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts deleted file mode 100644 index 26e8fd0bb3..0000000000 --- a/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { expect } from 'chai' -import sinon from 'sinon' -import { - PythonRunner, - PythonRunnerState, - DEFAULT_STATE, - ExecutionContext, - type FileUploader, -} from '@/features/ide-react/components/editor/python/python-runner' -import { WorkerMock, createWorker } from './worker-mock' - -const BASE_ASSET_PATH = 'https://assets.example.test/' -const FILE_ID = 'file-1' - -function createRunner( - overrides: { - fileId?: string - getExecutionContext?: () => Promise - fileUploader?: FileUploader - } = {} -) { - const fileId = overrides.fileId ?? FILE_ID - const getExecutionContext = - overrides.getExecutionContext ?? - (() => - Promise.resolve({ - code: 'print("hello")', - files: [{ relativePath: 'main.py', content: 'print("hello")' }], - })) - - const fileUploader = overrides.fileUploader ?? sinon.stub().resolves([]) - - const runner = new PythonRunner( - fileId, - BASE_ASSET_PATH, - getExecutionContext, - createWorker, - fileUploader - ) - return runner -} - -function initAndLoad(runner: PythonRunner) { - runner.init() - const worker = WorkerMock.instances[WorkerMock.instances.length - 1] - worker.emitMessage({ type: 'listening' }) - worker.emitMessage({ type: 'loaded' }) - return worker -} - -function waitForState( - runner: PythonRunner, - predicate: (state: PythonRunnerState) => boolean -): Promise { - return new Promise(resolve => { - if (predicate(runner.getState())) { - resolve(runner.getState()) - return - } - const unsubscribe = runner.subscribe(() => { - if (predicate(runner.getState())) { - unsubscribe() - resolve(runner.getState()) - } - }) - }) -} - -describe('PythonRunner', function () { - beforeEach(function () { - WorkerMock.instances.length = 0 - }) - - describe('initial state', function () { - it('starts with default snapshot before init', function () { - const runner = createRunner() - expect(runner.getState()).to.deep.equal(DEFAULT_STATE) - }) - }) - - describe('init and lifecycle', function () { - it('transitions to loading on init', function () { - const runner = createRunner() - runner.init() - expect(runner.getState().status).to.equal('loading') - }) - - it('transitions to idle when worker reports loaded', function () { - const runner = createRunner() - initAndLoad(runner) - expect(runner.getState().status).to.equal('idle') - }) - - it('transitions to errored on loading failure', function () { - const runner = createRunner() - runner.init() - const worker = WorkerMock.instances[0] - worker.emitMessage({ type: 'listening' }) - worker.emitMessage({ - type: 'loading-failed', - error: 'network error', - }) - expect(runner.getState().status).to.equal('errored') - expect(runner.getState().error).to.equal('network error') - }) - - it('clears error on successful load after failure', function () { - const runner = createRunner() - runner.init() - const worker = WorkerMock.instances[0] - worker.emitMessage({ type: 'listening' }) - worker.emitMessage({ type: 'loaded' }) - expect(runner.getState().error).to.equal(null) - }) - - it('is a no-op if already initialized', function () { - const runner = createRunner() - runner.init() - runner.init() - expect(WorkerMock.instances).to.have.length(1) - }) - }) - - describe('run', function () { - it('transitions to running then finished', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - expect(runner.getState().status).to.equal('running') - - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - worker.emitMessage({ - type: 'run-code-result', - fileId: FILE_ID, - executionId: runMsg.executionId, - success: true, - outputs: [], - outputFiles: [], - imports: [], - }) - - await waitForState(runner, s => s.status === 'finished') - expect(runner.getState().status).to.equal('finished') - }) - - it('clears previous output on new run', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - worker.emitMessage({ - type: 'output-line', - stream: 'stdout', - line: 'first run output', - fileId: FILE_ID, - executionId: runMsg.executionId, - }) - worker.emitMessage({ - type: 'run-code-result', - fileId: FILE_ID, - executionId: runMsg.executionId, - success: true, - outputs: [], - outputFiles: [], - imports: [], - }) - expect(runner.getState().output).to.deep.equal([ - { stream: 'stdout', line: 'first run output' }, - ]) - - await runner.run() - expect(runner.getState().output).to.deep.equal([]) - }) - - it('is a no-op while still loading', async function () { - const runner = createRunner() - runner.init() - await runner.run() - expect(runner.getState().status).to.equal('loading') - }) - - it('is a no-op when getExecutionContext returns null', async function () { - const runner = createRunner({ - getExecutionContext: () => Promise.resolve(null), - }) - initAndLoad(runner) - - await runner.run() - expect(runner.getState().status).to.equal('idle') - }) - - it('transitions to errored when getExecutionContext rejects', async function () { - const runner = createRunner({ - getExecutionContext: () => Promise.reject(new Error('network failure')), - }) - initAndLoad(runner) - - await runner.run() - expect(runner.getState().status).to.equal('errored') - expect(runner.getState().error).to.equal('network failure') - }) - }) - - describe('files-saved toast', function () { - let toastEvents: CustomEvent[] - let toastListener: (event: Event) => void - - beforeEach(function () { - toastEvents = [] - toastListener = event => { - toastEvents.push(event as CustomEvent) - } - window.addEventListener('ide:show-toast', toastListener) - }) - - afterEach(function () { - window.removeEventListener('ide:show-toast', toastListener) - }) - - it('dispatches a files-saved toast with successfully uploaded paths', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - worker.emitMessage({ - type: 'run-code-result', - fileId: FILE_ID, - executionId: runMsg.executionId, - success: true, - outputs: ['/project/foo.txt', '/project/bar.csv'], - outputFiles: [], - imports: [], - failedUploads: [], - }) - - await waitForState(runner, s => s.status === 'finished') - - expect(toastEvents).to.have.length(1) - expect(toastEvents[0].detail).to.deep.equal({ - key: 'python:files-saved', - paths: ['foo.txt', 'bar.csv'], - }) - }) - - it('excludes failed uploads from the toast', async function () { - const fileUploader = sinon.stub().resolves([ - { status: 'success', name: 'foo.txt', relativePath: 'foo.txt' }, - { - status: 'error', - name: 'bar.csv', - relativePath: 'bar.csv', - error: 'boom', - }, - ]) - const runner = createRunner({ fileUploader }) - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - worker.emitMessage({ - type: 'run-code-result', - fileId: FILE_ID, - executionId: runMsg.executionId, - success: true, - outputs: ['/project/foo.txt', '/project/bar.csv'], - outputFiles: [ - { relativePath: 'foo.txt', content: new Uint8Array() }, - { relativePath: 'bar.csv', content: new Uint8Array() }, - ], - imports: [], - }) - - await waitForState(runner, s => s.status === 'finished') - - expect(toastEvents).to.have.length(1) - expect(toastEvents[0].detail).to.deep.equal({ - key: 'python:files-saved', - paths: ['foo.txt'], - }) - }) - - it('does not dispatch a toast when no outputs were written', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - worker.emitMessage({ - type: 'run-code-result', - fileId: FILE_ID, - executionId: runMsg.executionId, - success: true, - outputs: [], - outputFiles: [], - imports: [], - failedUploads: [], - }) - - await waitForState(runner, s => s.status === 'finished') - - expect(toastEvents).to.have.length(0) - }) - - it('does not dispatch a toast when every output failed to upload', async function () { - const fileUploader = sinon.stub().resolves([ - { - status: 'error', - name: 'foo.txt', - relativePath: 'foo.txt', - error: 'boom', - }, - ]) - const runner = createRunner({ fileUploader }) - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - worker.emitMessage({ - type: 'run-code-result', - fileId: FILE_ID, - executionId: runMsg.executionId, - success: true, - outputs: ['/project/foo.txt'], - outputFiles: [{ relativePath: 'foo.txt', content: new Uint8Array() }], - imports: [], - }) - - await waitForState(runner, s => s.status === 'finished') - - expect(toastEvents).to.have.length(0) - }) - }) - - describe('output', function () { - it('accumulates output lines for the matching file', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - - worker.emitMessage({ - type: 'output-line', - stream: 'stdout', - line: 'line 1', - fileId: FILE_ID, - executionId: runMsg.executionId, - }) - worker.emitMessage({ - type: 'output-line', - stream: 'stderr', - line: 'line 2', - fileId: FILE_ID, - executionId: runMsg.executionId, - }) - - expect(runner.getState().output).to.deep.equal([ - { stream: 'stdout', line: 'line 1' }, - { stream: 'stderr', line: 'line 2' }, - ]) - }) - - it('ignores output for a different fileId', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - - worker.emitMessage({ - type: 'output-line', - stream: 'stdout', - line: 'other file output', - fileId: 'different-file', - executionId: runMsg.executionId, - }) - - expect(runner.getState().output).to.deep.equal([]) - }) - - it('ignores output for a stale executionId', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - - worker.emitMessage({ - type: 'output-line', - stream: 'stdout', - line: 'stale output', - fileId: FILE_ID, - executionId: 'old-execution-id', - }) - - expect(runner.getState().output).to.deep.equal([]) - }) - - it('caps output at 100 lines', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - - for (let i = 0; i < 110; i++) { - worker.emitMessage({ - type: 'output-line', - stream: 'stdout', - line: `line ${i}`, - fileId: FILE_ID, - executionId: runMsg.executionId, - }) - } - - const output = runner.getState().output - expect(output).to.have.length(100) - expect(output[0]).to.deep.equal({ stream: 'stdout', line: 'line 10' }) - expect(output[99]).to.deep.equal({ stream: 'stdout', line: 'line 109' }) - }) - }) - - describe('interrupt', function () { - it('appends interrupted message and transitions to loading when running', async function () { - const runner = createRunner() - const worker = initAndLoad(runner) - - await runner.run() - const runMsg = worker.postedMessages.find(m => m.type === 'run-code') - worker.emitMessage({ - type: 'output-line', - stream: 'stdout', - line: 'partial output', - fileId: FILE_ID, - executionId: runMsg.executionId, - }) - - runner.interrupt() - - expect(runner.getState().status).to.equal('loading') - expect(runner.getState().output).to.deep.equal([ - { stream: 'stdout', line: 'partial output' }, - { stream: 'info', line: 'Execution interrupted' }, - ]) - }) - - it('does not append interrupted message when not running', function () { - const runner = createRunner() - initAndLoad(runner) - - runner.interrupt() - - expect(runner.getState().status).to.equal('loading') - expect(runner.getState().output).to.deep.equal([]) - }) - }) - - describe('subscribe', function () { - it('notifies listeners on state changes', function () { - const runner = createRunner() - const listener = sinon.stub() - - runner.subscribe(listener) - initAndLoad(runner) - - expect(listener.callCount).to.be.greaterThan(0) - }) - - it('stops notifying after unsubscribe', function () { - const runner = createRunner() - const listener = sinon.stub() - - const unsubscribe = runner.subscribe(listener) - runner.init() - const countAfterInit = listener.callCount - - unsubscribe() - const worker = WorkerMock.instances[0] - worker.emitMessage({ type: 'listening' }) - worker.emitMessage({ type: 'loaded' }) - - expect(listener.callCount).to.equal(countAfterInit) - }) - }) - - describe('destroy', function () { - it('terminates the worker', function () { - const runner = createRunner() - initAndLoad(runner) - - runner.destroy() - - const worker = WorkerMock.instances[0] - expect(worker.terminated).to.equal(true) - }) - }) -}) diff --git a/services/web/test/frontend/features/ide-react/unit/editor/worker-mock.ts b/services/web/test/frontend/features/ide-react/unit/editor/worker-mock.ts deleted file mode 100644 index b14697f0a4..0000000000 --- a/services/web/test/frontend/features/ide-react/unit/editor/worker-mock.ts +++ /dev/null @@ -1,35 +0,0 @@ -type WorkerMessageListener = (event: MessageEvent) => void - -export class WorkerMock { - static instances: WorkerMock[] = [] - - readonly postedMessages: any[] = [] - terminated = false - private messageListeners: WorkerMessageListener[] = [] - - constructor() { - WorkerMock.instances.push(this) - } - - addEventListener(type: string, listener: WorkerMessageListener) { - if (type === 'message') { - this.messageListeners.push(listener) - } - } - - postMessage(message: unknown) { - this.postedMessages.push(message) - } - - terminate() { - this.terminated = true - } - - emitMessage(message: unknown) { - for (const listener of this.messageListeners) { - listener({ data: message, target: this } as unknown as MessageEvent) - } - } -} - -export const createWorker = () => new WorkerMock() as unknown as Worker