From 3e03fc87e73138f9b5a21edc558ed97213b75829 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 29 Apr 2024 20:47:00 +0200 Subject: [PATCH] feat: python error formatting (#54185) Co-authored-by: Naomi --- e2e/editor.spec.ts | 67 ++++++++++++++----- .../browser-scripts/python-worker.ts | 29 +++++++- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index d8d78acf86b..cbae33884b9 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -1,10 +1,26 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; -test.beforeEach(async ({ page }) => { - await page.goto( - '/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3' - ); -}); +async function focusEditor({ + page, + isMobile, + browserName +}: { + page: Page; + isMobile: boolean; + browserName: string; +}) { + const monacoEditor = page.getByLabel('Editor content'); + + // The editor has an overlay div, which prevents the click event from bubbling up in iOS Safari. + // This is a quirk in this browser-OS combination, and the workaround here is to use `.focus()` + // in place of `.click()` to focus on the editor. + // Ref: https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html + if (isMobile && browserName === 'webkit') { + await monacoEditor.focus(); + } else { + await monacoEditor.click(); + } +} test.describe('Editor Component', () => { test('should allow the user to insert text', async ({ @@ -12,19 +28,38 @@ test.describe('Editor Component', () => { isMobile, browserName }) => { - const monacoEditor = page.getByLabel('Editor content'); + await page.goto( + '/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3' + ); - // The editor has an overlay div, which prevents the click event from bubbling up in iOS Safari. - // This is a quirk in this browser-OS combination, and the workaround here is to use `.focus()` - // in place of `.click()` to focus on the editor. - // Ref: https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html - if (isMobile && browserName === 'webkit') { - await monacoEditor.focus(); - } else { - await monacoEditor.click(); - } + await focusEditor({ page, isMobile, browserName }); await page.keyboard.insertText('

FreeCodeCamp

'); const text = page.getByText('

FreeCodeCamp

'); await expect(text).toBeVisible(); }); }); + +test.describe('Python Terminal', () => { + test('should display error message when the user enters invalid code', async ({ + page, + isMobile, + browserName + }) => { + await page.goto( + 'learn/scientific-computing-with-python/learn-string-manipulation-by-building-a-cipher/step-2' + ); + + await focusEditor({ page, isMobile, browserName }); + // First clear the editor + await page.keyboard.press('Control+a'); + await page.keyboard.press('Backspace'); + // Then enter invalid code + await page.keyboard.insertText('def'); + const preview = page.getByTestId('preview-pane'); + + // While it's displayed on multiple lines, the string itself has no newlines, hence: + const error = `>>> Traceback (most recent call last): File "main.py", line 1 def ^SyntaxError: invalid syntax`; + // It shouldn't take this long, but the Python worker can be slow to respond. + await expect(preview).toContainText(error, { timeout: 15000 }); + }); +}); diff --git a/tools/client-plugins/browser-scripts/python-worker.ts b/tools/client-plugins/browser-scripts/python-worker.ts index 818209e4ebd..7d1d30badc2 100644 --- a/tools/client-plugins/browser-scripts/python-worker.ts +++ b/tools/client-plugins/browser-scripts/python-worker.ts @@ -3,6 +3,7 @@ import { loadPyodide, type PyodideInterface } from 'pyodide/pyodide.js'; import pkg from 'pyodide/package.json'; import type { PyProxy, PythonError } from 'pyodide/ffi'; +import * as helpers from '@freecodecamp/curriculum-helpers'; const ctx: Worker & typeof globalThis = self as unknown as Worker & typeof globalThis; @@ -53,6 +54,15 @@ async function setupPyodide() { // pyodide modifies self while loading. Object.freeze(self); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + pyodide.FS.writeFile( + '/home/pyodide/ast_helpers.py', + helpers.python.astHelpers, + { + encoding: 'utf8' + } + ); + ignoreRunMessages = true; postMessage({ type: 'stopped' }); } @@ -134,10 +144,19 @@ function initRunPython() { else: return "" `); + runPython(` +def print_exception(): + from ast_helpers import format_exception + formatted = format_exception(exception=sys.last_value, traceback=sys.last_traceback, filename="", new_filename="main.py") + print(formatted) +`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const printException = globals.get('print_exception') as PyProxy & + (() => string); // eslint-disable-next-line @typescript-eslint/no-unsafe-call const getResetId = globals.get('__get_reset_id') as PyProxy & (() => string); - return { runPython, getResetId, globals }; + return { runPython, getResetId, globals, printException }; } ctx.onmessage = (e: PythonRunEvent | ListenRequestEvent | CancelEvent) => { @@ -166,13 +185,16 @@ function handleRunRequest(data: PythonRunEvent['data']) { // TODO: use reset-terminal for clarity? postMessage({ type: 'reset' }); - const { runPython, getResetId, globals } = initRunPython(); + const { runPython, getResetId, globals, printException } = initRunPython(); // use pyodide.runPythonAsync if we want top-level await try { runPython(code); } catch (e) { const err = e as PythonError; - console.error(e); + // the formatted exception is printed to the terminal + printException(); + // but the full error is logged to the console for debugging + console.error(err); const resetId = getResetId(); // TODO: if a user raises a KeyboardInterrupt with a custom message this // will be treated as a reset, the client will resend their code and this @@ -187,6 +209,7 @@ function handleRunRequest(data: PythonRunEvent['data']) { } } finally { getResetId.destroy(); + printException.destroy(); globals.destroy(); } }