From 008da6ac9ee82746d38e1885cc80fce53c6cdcd3 Mon Sep 17 00:00:00 2001 From: Adrian Lansdown Date: Fri, 12 Jun 2026 20:12:53 +0100 Subject: [PATCH] Allow Pyodide learners to clear the text console via os.system Patch os.system("clear"/"cls") in the Pyodide worker so mid-run Python output can wipe the on-screen text console, flushing batched stdout/stderr first so no-newline prints do not reappear after clearing. Co-authored-by: Cursor --- cypress/e2e/spec-wc-pyodide.cy.js | 17 +++++++++++++- src/PyodideWorker.js | 16 +++++++++++++ .../PyodideRunner/PyodideRunner.jsx | 9 +++++++- .../PyodideRunner/PyodideRunner.test.js | 22 ++++++++++++++++++ .../PyodideRunner/PyodideWorker.test.js | 23 +++++++++++++++++++ 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/spec-wc-pyodide.cy.js b/cypress/e2e/spec-wc-pyodide.cy.js index 371900470..4c499f47b 100644 --- a/cypress/e2e/spec-wc-pyodide.cy.js +++ b/cypress/e2e/spec-wc-pyodide.cy.js @@ -13,7 +13,8 @@ import { stopProject, } from "../helpers/editor.js"; -const origin = "http://localhost:3011/web-component.html"; +const origin = + Cypress.env("EDITOR_ORIGIN") || "http://localhost:3011/web-component.html"; beforeEach(() => { cy.intercept("*", (req) => { @@ -226,6 +227,20 @@ print(text_out) ); }); + it("clears the console when os.system('clear') is called", () => { + runCode('print("before")\nimport os\nos.system("clear")\nprint("after")'); + getPythonConsoleOutput().should("contain", "after"); + getPythonConsoleOutput().should("not.contain.text", "before"); + }); + + it("clears buffered output written without a newline before clearing", () => { + runCode( + 'import os\nprint("partial", end="")\nos.system("clear")\nprint("after")', + ); + getPythonConsoleOutput().should("contain", "after"); + getPythonConsoleOutput().should("not.contain.text", "partial"); + }); + it("clears user-defined variables between code runs", () => { runCode("a = 1\nprint(a)"); getPythonConsoleOutput().should("contain", "1"); diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js index 561b5d995..f3f6b4f29 100644 --- a/src/PyodideWorker.js +++ b/src/PyodideWorker.js @@ -410,6 +410,7 @@ const PyodideWorker = () => { postMessage({ method: "handleFileWrite", filename, content, mode }); }, locals: () => pyodide.runPython("globals()"), + clear_console: () => postMessage({ method: "handleClear" }), }, }; @@ -443,6 +444,21 @@ const PyodideWorker = () => { pyodide.registerJsModule("basthon", fakeBasthonPackage); + await pyodide.runPythonAsync(` + import os as _os, sys as _sys, basthon as _basthon + _real_system = _os.system + def _patched_system(command): + if isinstance(command, str) and command.strip().lower() in ("clear", "cls"): + _sys.stdout.write("\\n") + _sys.stderr.write("\\n") + _sys.stdout.flush() + _sys.stderr.flush() + _basthon.kernel.clear_console() + return 0 + return _real_system(command) + _os.system = _patched_system + `); + await pyodide.runPythonAsync(` __old_input__ = input def __patched_input__(prompt=False): diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index ebdcf013c..fac329dd4 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -114,6 +114,9 @@ const PyodideRunner = ({ case "handleOutput": handleOutput(data.stream, data.content); break; + case "handleClear": + clearConsole(); + break; case "handleError": handleError( data.file, @@ -210,6 +213,10 @@ const PyodideRunner = ({ node.scrollTop = node.scrollHeight; }; + const clearConsole = () => { + output.current.innerHTML = ""; + }; + const handleError = (file, line, mistake, type, info) => { let errorMessage; @@ -290,7 +297,7 @@ const PyodideRunner = ({ }; const handleRun = async () => { - output.current.innerHTML = ""; + clearConsole(); dispatch(setError("")); dispatch(setFriendlyError(null)); setVisuals([]); diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js index 582abdcd4..6d2e71eb1 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js @@ -260,6 +260,28 @@ describe("When output is received", () => { }); }); +describe("When a clear console message is received", () => { + beforeEach(() => { + render( + + , + , + ); + + const worker = PyodideWorker.getLastInstance(); + worker.postMessageFromWorker({ + method: "handleOutput", + stream: "stdout", + content: "hello", + }); + worker.postMessageFromWorker({ method: "handleClear" }); + }); + + test("it clears previously printed output", () => { + expect(screen.queryByText("hello")).not.toBeInTheDocument(); + }); +}); + describe("When file write event is received", () => { let worker; beforeEach(() => { diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js index 4c39a5462..53c0f6fd6 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js @@ -96,6 +96,29 @@ describe("PyodideWorker", () => { ); }); + test("it patches os.system to handle clear/cls", async () => { + expect(pyodide.runPythonAsync).toHaveBeenCalledWith( + expect.stringMatching(/os\.system/), + ); + }); + + test("it forces batched output to flush before clearing", async () => { + expect(pyodide.runPythonAsync).toHaveBeenCalledWith( + expect.stringMatching(/_sys\.stdout\.write\("\\n"\)/), + ); + expect(pyodide.runPythonAsync).toHaveBeenCalledWith( + expect.stringMatching(/_sys\.stderr\.write\("\\n"\)/), + ); + }); + + test("the basthon clear_console bridge posts handleClear", () => { + const basthonCall = pyodide.registerJsModule.mock.calls.find( + ([name]) => name === "basthon", + ); + basthonCall[1].kernel.clear_console(); + expect(global.postMessage).toHaveBeenCalledWith({ method: "handleClear" }); + }); + test("it patches urllib and requests modules", async () => { expect(pyodide.runPythonAsync).toHaveBeenCalledWith( expect.stringMatching(/pyodide_http.patch_all()/),