diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 8b7134c1b..bb741e939 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -95,6 +95,8 @@ jobs: yarn start wait-on: "http://localhost:3011" quiet: true + config-file: cypress.config.mjs + browser: chrome env: REACT_APP_API_ENDPOINT: "https://test-editor-api.raspberrypi.org" PUBLIC_URL: "http://localhost:3011" diff --git a/CHANGELOG.md b/CHANGELOG.md index 642224111..02e131398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed pyodide input test and cypress config to enable further pyodide tests (#1125) - Image sizing and wrapping in the sidebar (#1126) ## [0.28.4] - 2024-10-23 diff --git a/cypress.config.mjs b/cypress.config.mjs index 29e8c298e..f288ea033 100644 --- a/cypress.config.mjs +++ b/cypress.config.mjs @@ -17,6 +17,14 @@ export default defineConfig({ return null; }, }); + on("before:browser:launch", (browser = {}, launchOptions) => { + if (browser.name === "chrome") { + console.log("Applying Chrome launch options"); + launchOptions.args.push("--enable-features=SharedArrayBuffer"); + launchOptions.args.push("--disable-site-isolation-trials"); + } + return launchOptions; + }); }, retries: { runMode: 3, diff --git a/cypress/e2e/missionZero-wc.cy.js b/cypress/e2e/missionZero-wc.cy.js index f01027003..acc0fc21f 100644 --- a/cypress/e2e/missionZero-wc.cy.js +++ b/cypress/e2e/missionZero-wc.cy.js @@ -137,20 +137,23 @@ it("picks up calls to input()", () => { cy.get("editor-wc") .shadow() .find("div[class=cm-content]") - .invoke("text", "input()"); + .invoke("text", "name = input('What is your name?')\nprint('Hello', name)"); cy.get("editor-wc").shadow().find(".btn--run").click(); + cy.get("editor-wc").shadow().find(".btn--stop").should("be.visible"); cy.get("editor-wc") .shadow() - .find( - "div[class='pythonrunner-container skulptrunner skulptrunner--active']", - ) - .contains("Text output") + .find("div.pythonrunner-container.skulptrunner.skulptrunner--active") + .contains(".react-tabs__tab-text", "Text output") .click(); cy.get("editor-wc") .shadow() .find("span[contenteditable=true]") - .type("{enter}"); + .type("Scott{enter}"); cy.get("#results").should("contain", '"noInputEvents":false'); + cy.get("editor-wc") + .shadow() + .find(".pythonrunner-console-output-line") + .should("contain", "Hello Scott"); }); it("picks up calls to wait for motion", () => { @@ -208,7 +211,12 @@ it("returns duration of null if focus is lost", () => { "text", 'from sense_hat import SenseHat\nsense = SenseHat()\nsense.show_message("a")', ); - cy.get("editor-wc").shadow().find(".btn--run").click(); + cy.get("editor-wc") + .shadow() + .find(".btn--run") + .should("not.be.disabled") + .click(); + cy.get("editor-wc").shadow().find(".btn--stop").should("be.visible"); cy.window().blur(); cy.window().focus(); cy.get("#results").should("contain", '"duration":null'); diff --git a/cypress/e2e/spec-wc-pyodide.cy.js b/cypress/e2e/spec-wc-pyodide.cy.js index c9d3e51f9..858c308a9 100644 --- a/cypress/e2e/spec-wc-pyodide.cy.js +++ b/cypress/e2e/spec-wc-pyodide.cy.js @@ -3,7 +3,6 @@ const origin = "http://localhost:3011/web-component.html"; beforeEach(() => { cy.intercept("*", (req) => { req.headers["Origin"] = origin; - req.continue(); }); }); @@ -13,12 +12,22 @@ const runCode = (code) => { .shadow() .find("div[class=cm-content]") .invoke("text", `${code}\n`); - cy.get("editor-wc").shadow().find(".btn--run").click(); + cy.get("editor-wc") + .shadow() + .find(".btn--run") + .should("not.be.disabled") + .click(); }; describe("Running the code with pyodide", () => { beforeEach(() => { - cy.visit(origin); + cy.visit({ + url: origin, + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + }); cy.window().then((win) => { Object.defineProperty(win, "crossOriginIsolated", { value: true, @@ -29,6 +38,16 @@ describe("Running the code with pyodide", () => { it("runs a simple program", () => { runCode('print("Hello world")'); + cy.get("editor-wc") + .shadow() + .find(".pyodiderunner") + .contains(".react-tabs__tab", "Visual output") + .should("not.exist"); + cy.get("editor-wc") + .shadow() + .find(".pyodiderunner") + .find(".react-tabs__tab--selected") + .should("contain", "Text output"); cy.get("editor-wc") .shadow() .find(".pythonrunner-console-output-line") @@ -39,21 +58,33 @@ describe("Running the code with pyodide", () => { runCode( "from time import sleep\nfor i in range(100):\n\tprint(i)\n\tsleep(1)", ); - cy.get("editor-wc").shadow().find(".btn--stop").click(); + cy.get("editor-wc") + .shadow() + .find(".pythonrunner-console-output-line") + .should("contain", "3"); + cy.get("editor-wc") + .shadow() + .find(".btn--stop") + .should("be.visible") + .click(); cy.get("editor-wc") .shadow() .find(".error-message__content") .should("contain", "Execution interrupted"); }); - // skip this test for now until we get the headers set up - it.skip("runs a simple program with an input", () => { + it("runs a simple program with an input", () => { runCode('name = input("What is your name?")\nprint("Hello", name)'); + cy.get("editor-wc").shadow().find(".btn--stop").should("be.visible"); cy.get("editor-wc") .shadow() .find(".pythonrunner-console-output-line") .should("contain", "What is your name?"); - cy.get("editor-wc").shadow().find("#input").invoke("text", "Lois{enter}"); + cy.get("editor-wc") + .shadow() + .find("#input") + .should("be.visible") + .type("Lois{enter}"); cy.get("editor-wc") .shadow() .find(".pythonrunner-console-output-line") @@ -133,6 +164,34 @@ describe("Running the code with pyodide", () => { .should("contain", "4"); }); + it("runs a simple program with the py-enigma library", () => { + runCode( + ` +from enigma.machine import EnigmaMachine +# Sheet settings +ROTORS = "IV I V" +RINGS = "20 5 10" +PLUGBOARD = "KT AJ IV US NY HL GD XF PB CQ" +def use_enigma_machine(msg, rotor_start): + # Set up the Enigma machine + machine = EnigmaMachine.from_key_sheet(rotors=ROTORS, reflector="B", ring_settings=RINGS, plugboard_settings=PLUGBOARD) + # Set the initial position of the rotors + machine.set_display(rotor_start) + # Encrypt or decrypt the message + transformed_msg = machine.process_text(msg) + return(transformed_msg) +text_in = "This is a test message" +rotor_start = "FNZ" +text_out = use_enigma_machine(text_in, rotor_start) +print(text_out) + `, + ); + cy.get("editor-wc") + .shadow() + .find(".pythonrunner-console-output-line") + .should("contain", "ULRYQJMVHLFQKBEFUGEOFL"); + }); + it("errors when importing a non-existent module", () => { runCode("import i_do_not_exist"); cy.get("editor-wc") diff --git a/cypress/e2e/spec-wc-skulpt.cy.js b/cypress/e2e/spec-wc-skulpt.cy.js index 2c51f717b..6f5e05b9f 100644 --- a/cypress/e2e/spec-wc-skulpt.cy.js +++ b/cypress/e2e/spec-wc-skulpt.cy.js @@ -14,7 +14,11 @@ const runCode = (code) => { .find("div[class=cm-content]") .invoke("text", `${code}\n`); cy.wait(200); - cy.get("editor-wc").shadow().find(".btn--run").click(); + cy.get("editor-wc") + .shadow() + .find(".btn--run") + .should("not.be.disabled") + .click(); }; describe("Running the code with skulpt", () => { @@ -28,10 +32,38 @@ describe("Running the code with skulpt", () => { }); }); + it("runs a simple program", () => { + runCode("print('Hello world')"); + cy.get("editor-wc") + .shadow() + .find(".skulptrunner") + .contains(".react-tabs__tab", "Visual output") + .should("not.exist"); + cy.get("editor-wc") + .shadow() + .find(".skulptrunner") + .find(".react-tabs__tab--selected") + .should("contain", "Text output"); + cy.get("editor-wc") + .shadow() + .find(".pythonrunner-console-output-line") + .should("contain", "Hello world"); + }); + it("runs a simple p5 program", () => { runCode( "from p5 import *\n\ndef setup():\n\tsize(400, 400)\ndef draw():\n\tfill('cyan')\n\trect(0, 0, 400, 250)\nrun(frame_rate=2)", ); + cy.get("editor-wc") + .shadow() + .find(".skulptrunner") + .contains(".react-tabs__tab", "Text output") + .should("exist"); + cy.get("editor-wc") + .shadow() + .find(".skulptrunner") + .find(".react-tabs__tab--selected") + .should("contain", "Visual output"); cy.get("editor-wc").shadow().find(".p5Canvas").should("exist"); }); diff --git a/cypress/e2e/spec-wc.cy.js b/cypress/e2e/spec-wc.cy.js index 4b1dbe453..b8b089852 100644 --- a/cypress/e2e/spec-wc.cy.js +++ b/cypress/e2e/spec-wc.cy.js @@ -113,11 +113,18 @@ describe("when embedded, output_only & output_split_view are true", () => { // Check text output panel is visible and has a run button // Important to wait for this before making the negative assertions that follow - cy.get("editor-wc").shadow().contains("Text output").should("be.visible"); + const runnerContainer = cy + .get("editor-wc") + .shadow() + .find(".proj-runner-container"); + runnerContainer + .find(".react-tabs__tab--selected") + .should("contain", "Text output"); cy.get("editor-wc") .shadow() .find("button") .contains("Run") + .should("not.be.disabled") .should("be.visible"); // Check that the side bar is not displayed @@ -160,6 +167,7 @@ describe("when embedded, output_only & output_split_view are true", () => { .shadow() .find("button") .contains("Run") + .should("not.be.disabled") .should("be.visible"); // Check that the code has automatically run i.e. the HTML has been rendered diff --git a/src/assets/stylesheets/PythonRunner.scss b/src/assets/stylesheets/PythonRunner.scss index abfa7091b..66b7449b8 100644 --- a/src/assets/stylesheets/PythonRunner.scss +++ b/src/assets/stylesheets/PythonRunner.scss @@ -55,6 +55,22 @@ position: relative; } +.pyodiderunner { + display: none; + + &--active { + display: flex; + } +} + +.skulptrunner { + display: none; + + &--active { + display: flex; + } +} + .visual-output { flex: 1; display: flex; diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx index da8008d37..f5bcbd13e 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx @@ -11,6 +11,8 @@ import { showErrorModal, codeRunHandled, triggerCodeRun, + loadingRunner, + setLoadedRunner, } from "../../../../redux/EditorSlice"; import { @@ -166,6 +168,8 @@ function HtmlRunner() { useEffect(() => { eventListener(); + dispatch(loadingRunner("html")); + dispatch(setLoadedRunner("html")); }, []); let timeout; diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index 19a6e71a5..157451968 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -1,12 +1,13 @@ /* eslint-disable react-hooks/exhaustive-deps */ import "../../../../../assets/stylesheets/PythonRunner.scss"; -import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; +import classNames from "classnames"; import { setError, codeRunHandled, - loadingRunner, + setLoadedRunner, } from "../../../../../redux/EditorSlice"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import { useMediaQuery } from "react-responsive"; @@ -30,16 +31,23 @@ const getWorkerURL = (url) => { return URL.createObjectURL(blob); }; -const PyodideRunner = (props) => { - const { active } = props; +const PyodideRunner = ({ active }) => { + const [pyodideWorker, setPyodideWorker] = useState(null); - // Blob approach + targeted headers - no errors but headers required in host app to interrupt code - const workerUrl = getWorkerURL(`${process.env.PUBLIC_URL}/PyodideWorker.js`); - const pyodideWorker = useMemo(() => new Worker(workerUrl), []); + useEffect(() => { + if (active) { + const workerUrl = getWorkerURL( + `${process.env.PUBLIC_URL}/PyodideWorker.js`, + ); + const worker = new Worker(workerUrl); + setPyodideWorker(worker); + } + }, [active]); const interruptBuffer = useRef(); const stdinBuffer = useRef(); const stdinClosed = useRef(); + const loadedRunner = useSelector((state) => state.editor.loadedRunner); const projectImages = useSelector((s) => s.editor.project.image_list); const projectCode = useSelector((s) => s.editor.project.components); const projectIdentifier = useSelector((s) => s.editor.project.identifier); @@ -60,7 +68,6 @@ const PyodideRunner = (props) => { const showVisualTab = queryParams.get("show_visual_tab") === "true"; const [hasVisual, setHasVisual] = useState(showVisualTab || senseHatAlways); const [visuals, setVisuals] = useState([]); - const [showRunner, setShowRunner] = useState(active); useEffect(() => { if (pyodideWorker) { @@ -98,20 +105,13 @@ const PyodideRunner = (props) => { } }; } - }, []); + }, [pyodideWorker]); useEffect(() => { - if (codeRunTriggered && active) { - console.log("running with pyodide"); + if (codeRunTriggered && active && output.current) { handleRun(); } - }, [codeRunTriggered]); - - useEffect(() => { - if (codeRunTriggered) { - setShowRunner(active); - } - }, [codeRunTriggered]); + }, [codeRunTriggered, output.current]); useEffect(() => { if (codeRunStopped && active) { @@ -120,12 +120,15 @@ const PyodideRunner = (props) => { }, [codeRunStopped]); const handleLoading = () => { - dispatch(loadingRunner()); + return; }; const handleLoaded = (stdin, interrupt) => { stdinBuffer.current = stdin; interruptBuffer.current = interrupt; + if (loadedRunner !== "pyodide") { + dispatch(setLoadedRunner("pyodide")); + } dispatch(codeRunHandled()); disableInput(); }; @@ -312,17 +315,16 @@ const PyodideRunner = (props) => { } }; - if (!pyodideWorker) { - console.error("PyodideWorker is not initialized"); + if (!pyodideWorker && active) { + console.warn("PyodideWorker is not initialized"); return; } return (
{isSplitView ? ( <> diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js index 5b1dbb9a2..c8bd31f40 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js @@ -1,34 +1,68 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; -import configureStore from "redux-mock-store"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import PyodideRunner from "./PyodideRunner"; import { Provider } from "react-redux"; import PyodideWorker, { postMessage } from "./PyodideWorker.mock.js"; -import { setError } from "../../../../../redux/EditorSlice.js"; + +import { + resetState, + setError, + triggerCodeRun, + setProject, + setLoadedRunner, + stopCodeRun, +} from "../../../../../redux/EditorSlice.js"; +import store from "../../../../../app/store"; jest.mock("fs"); +global.fetch = jest.fn(); + +const project = { + components: [ + { name: "a", extension: "py", content: "print('a')" }, + { name: "main", extension: "py", content: "print('hello')" }, + ], + image_list: [ + { filename: "image1.jpg", url: "http://example.com/image1.jpg" }, + ], +}; -const middlewares = []; -const mockStore = configureStore(middlewares); -const initialState = { - editor: { - project: { - components: [ - { name: "a", extension: "py", content: "print('a')" }, - { name: "main", extension: "py", content: "print('hello')" }, - ], - image_list: [ - { filename: "image1.jpg", url: "http://example.com/image1.jpg" }, - ], - }, - codeRunTriggered: false, - }, - auth: {}, +window.crossOriginIsolated = true; +process.env.PUBLIC_URL = "."; + +const updateRunner = ({ project = {}, codeRunTriggered = false }) => { + act(() => { + if (project) { + store.dispatch(setProject(project)); + } + if (codeRunTriggered) { + store.dispatch(triggerCodeRun()); + } + }); }; +let dispatchSpy; + +beforeEach(() => { + store.dispatch(resetState()); + window.crossOriginIsolated = true; + dispatchSpy = jest.spyOn(store, "dispatch"); + fetch.mockClear(); +}); + +afterEach(() => { + dispatchSpy.mockRestore(); +}); + describe("When active and first loaded", () => { beforeEach(() => { - const store = mockStore(initialState); + window.crossOriginIsolated = true; render( , @@ -40,88 +74,82 @@ describe("When active and first loaded", () => { expect(screen.queryByText("output.textOutput")).toBeInTheDocument(); }); - test("it has style display: flex", () => { - expect(document.querySelector(".pyodiderunner")).toHaveStyle( - "display: flex", - ); + test("it does have active styles", () => { + const element = document.querySelector(".pyodiderunner"); + expect(element).toHaveClass("pyodiderunner--active"); }); }); describe("When a code run has been triggered", () => { beforeEach(() => { - jest.clearAllMocks(); - const fetchMock = jest.fn().mockResolvedValue({ - arrayBuffer: () => Promise.resolve("image data"), - }); - - global.fetch = fetchMock; - - const store = mockStore({ - ...initialState, - editor: { ...initialState.editor, codeRunTriggered: true }, + window.crossOriginIsolated = true; + global.fetch.mockResolvedValueOnce({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(1)), }); render( , , ); + updateRunner({ project, codeRunTriggered: true }); }); - test("it writes the current files to the worker", () => { - expect(postMessage).toHaveBeenCalledWith({ - method: "writeFile", - filename: "a.py", - content: "print('a')", - }); - expect(postMessage).toHaveBeenCalledWith({ - method: "writeFile", - filename: "main.py", - content: "print('hello')", + test("it writes the current files to the worker", async () => { + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith({ + method: "writeFile", + filename: "a.py", + content: "print('a')", + }); + expect(postMessage).toHaveBeenCalledWith({ + method: "writeFile", + filename: "main.py", + content: "print('hello')", + }); }); }); - test("it writes the images to the worker", () => { - expect(postMessage).toHaveBeenCalledWith({ - method: "writeFile", - filename: "image1.jpg", - content: "image data", + test("it writes the images to the worker", async () => { + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith({ + method: "writeFile", + filename: "image1.jpg", + content: expect.any(ArrayBuffer), + }); }); }); - test("it sends a message to the worker to run the python code", () => { - expect(postMessage).toHaveBeenCalledWith({ - method: "runPython", - python: "print('hello')", + test("it sends a message to the worker to run the python code", async () => { + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith({ + method: "runPython", + python: "print('hello')", + }); }); }); }); describe("When the code has been stopped", () => { - let store; - beforeEach(() => { - store = mockStore({ - ...initialState, - editor: { ...initialState.editor, codeRunStopped: true }, - }); render( , , ); + store.dispatch(stopCodeRun()); }); - test("it sends a message to the worker to stop the python code", () => { - expect(postMessage).toHaveBeenCalledWith({ - method: "stopPython", + test("it sends a message to the worker to stop the python code", async () => { + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith({ + method: "stopPython", + }); }); }); }); describe("When loading pyodide", () => { - let store; beforeEach(() => { - store = mockStore(initialState); render( , @@ -129,18 +157,23 @@ describe("When loading pyodide", () => { ); const worker = PyodideWorker.getLastInstance(); - worker.postMessageFromWorker({ method: "handleLoading" }); + worker.postMessageFromWorker({ method: "handleLoaded" }); }); test("it dispatches loadingRunner action", () => { - expect(store.getActions()).toEqual([{ type: "editor/loadingRunner" }]); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/setLoadedRunner", + payload: "pyodide", + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/codeRunHandled", + }); }); }); describe("When pyodide has loaded", () => { - let store; beforeEach(() => { - store = mockStore(initialState); + store.dispatch(setLoadedRunner("pyodide")); render( , @@ -152,15 +185,13 @@ describe("When pyodide has loaded", () => { }); test("it dispatches codeRunHandled action", () => { - expect(store.getActions()).toEqual([{ type: "editor/codeRunHandled" }]); + expect(dispatchSpy).toHaveBeenCalledWith({ type: "editor/codeRunHandled" }); }); }); describe("When input is required", () => { let input; - let store; beforeEach(() => { - store = mockStore(initialState); render( , @@ -199,9 +230,7 @@ describe("When input is required", () => { }); describe("When output is received", () => { - let store; beforeEach(() => { - store = mockStore(initialState); render( , @@ -209,6 +238,7 @@ describe("When output is received", () => { ); const worker = PyodideWorker.getLastInstance(); + worker.postMessage = jest.fn(); worker.postMessageFromWorker({ method: "handleOutput", stream: "stdout", @@ -222,9 +252,7 @@ describe("When output is received", () => { }); describe("When visual output is received", () => { - let store; beforeEach(() => { - store = mockStore(initialState); render( , @@ -242,9 +270,11 @@ describe("When visual output is received", () => { }); test("it displays the output view toggle", async () => { - expect( - screen.queryByText("outputViewToggle.buttonSplitLabel"), - ).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByText("outputViewToggle.buttonTabLabel"), + ).toBeInTheDocument(); + }); }); test("it shows the visual output tab", () => { @@ -254,9 +284,7 @@ describe("When visual output is received", () => { }); describe("When an error is received", () => { - let store; beforeEach(() => { - store = mockStore(initialState); render( , @@ -274,21 +302,17 @@ describe("When an error is received", () => { }); test("it dispatches action to set the error with correct message", () => { - expect(store.getActions()).toEqual([ - { - type: "editor/setError", - payload: "SyntaxError: something's wrong on line 2 of main.py", - }, - ]); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/setError", + payload: "SyntaxError: something's wrong on line 2 of main.py", + }); }); }); describe("When the code run is interrupted", () => { let input; - let store; beforeEach(() => { - store = mockStore(initialState); render( , @@ -309,15 +333,14 @@ describe("When the code run is interrupted", () => { }); test("it sets an interruption error", () => { - expect(store.getActions()).toEqual( - expect.arrayContaining([setError("output.errors.interrupted")]), + expect(dispatchSpy).toHaveBeenCalledWith( + setError("output.errors.interrupted"), ); }); }); describe("When not active and first loaded", () => { beforeEach(() => { - const store = mockStore(initialState); render( , @@ -325,24 +348,20 @@ describe("When not active and first loaded", () => { ); }); - test("it renders with display: none", () => { - expect(document.querySelector(".pyodiderunner")).toHaveStyle( - "display: none", - ); + test("it does not have active styles", () => { + const element = document.querySelector(".pyodiderunner"); + expect(element).not.toHaveClass("pyodiderunner--active"); }); }); describe("When not active and code run triggered", () => { beforeEach(() => { - const store = mockStore({ - ...initialState, - editor: { ...initialState.editor, codeRunTriggered: true }, - }); render( , , ); + updateRunner({ project, codeRunTriggered: true }); }); test("it does not send a message to the worker to run the python code", () => { diff --git a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx index 01bcf7ca5..cc34e65cb 100644 --- a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx @@ -2,9 +2,11 @@ import React, { useEffect, useState } from "react"; import PyodideRunner from "./PyodideRunner/PyodideRunner"; import SkulptRunner from "./SkulptRunner/SkulptRunner"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; +import { loadingRunner } from "../../../../redux/EditorSlice"; + const SKULPT_ONLY_MODULES = [ "p5", "py5", @@ -14,19 +16,33 @@ const SKULPT_ONLY_MODULES = [ ]; const PythonRunner = () => { + const dispatch = useDispatch(); + const project = useSelector((state) => state.editor.project); + const activeRunner = useSelector((state) => state.editor.activeRunner); const codeRunTriggered = useSelector( (state) => state.editor.codeRunTriggered, ); const senseHatAlwaysEnabled = useSelector( (state) => state.editor.senseHatAlwaysEnabled, ); - const [usePyodide, setUsePyodide] = useState(true); + const [usePyodide, setUsePyodide] = useState(null); + const [skulptFallback, setSkulptFallback] = useState(false); const { t } = useTranslation(); useEffect(() => { - if (!window.crossOriginIsolated) { + if (typeof usePyodide === "boolean") { + const runner = usePyodide ? "pyodide" : "skulpt"; + if (runner !== activeRunner) { + dispatch(loadingRunner(runner)); + } + } + }, [dispatch, usePyodide, activeRunner]); + + useEffect(() => { + if (!!!window?.crossOriginIsolated) { setUsePyodide(false); + setSkulptFallback(true); return; } const getImports = (code) => { @@ -59,7 +75,10 @@ const PythonRunner = () => { const hasSkulptOnlyModules = imports.some((name) => SKULPT_ONLY_MODULES.includes(name), ); - if (hasSkulptOnlyModules || senseHatAlwaysEnabled) { + if ( + !skulptFallback && + (hasSkulptOnlyModules || senseHatAlwaysEnabled) + ) { setUsePyodide(false); break; } else { @@ -70,11 +89,11 @@ const PythonRunner = () => { } } } - }, [project, codeRunTriggered, senseHatAlwaysEnabled, t]); + }, [project, codeRunTriggered, senseHatAlwaysEnabled, skulptFallback, t]); return ( <> - - + + ); }; diff --git a/src/components/Editor/Runners/PythonRunner/PythonRunner.test.js b/src/components/Editor/Runners/PythonRunner/PythonRunner.test.js index d301114f9..7a93dd81b 100644 --- a/src/components/Editor/Runners/PythonRunner/PythonRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PythonRunner.test.js @@ -1,13 +1,19 @@ import { render } from "@testing-library/react"; import { Provider } from "react-redux"; -import configureStore from "redux-mock-store"; +import { act } from "react-dom/test-utils"; import PythonRunner from "./PythonRunner"; +import { + triggerCodeRun, + setProject, + setSenseHatAlwaysEnabled, +} from "../../../../redux/EditorSlice"; +import store from "../../../../app/store"; -const middlewares = []; -const mockStore = configureStore(middlewares); const initialState = { editor: { project: { + name: "Blank project", + project_type: "python", components: [ { name: "main", @@ -26,29 +32,43 @@ const initialState = { auth: {}, }; -const renderRunnerWithCode = ({ +const updateRunner = ({ code = "", codeRunTriggered = false, senseHatAlwaysEnabled = false, }) => { - let state = initialState; - state.editor.project.components[0].content = code; - state.editor.codeRunTriggered = codeRunTriggered; - state.editor.senseHatAlwaysEnabled = senseHatAlwaysEnabled; - const store = mockStore(state); + act(() => { + store.dispatch( + setProject({ + ...initialState.editor.project, + components: [ + { + ...initialState.editor.project.components[0], + content: code, + }, + ...initialState.editor.project.components.slice(1), + ], + }), + ); + if (codeRunTriggered) { + store.dispatch(triggerCodeRun()); + } + store.dispatch(setSenseHatAlwaysEnabled(senseHatAlwaysEnabled)); + }); +}; + +beforeEach(() => { + window.crossOriginIsolated = true; render( , ); -}; - -beforeEach(() => { - window.crossOriginIsolated = true; + updateRunner({ code: "print('some loaded code')" }); }); test("Renders with Pyodide runner initially", () => { - renderRunnerWithCode({}); + updateRunner({}); expect( document.querySelector(".skulptrunner--active"), ).not.toBeInTheDocument(); @@ -56,7 +76,7 @@ test("Renders with Pyodide runner initially", () => { }); test("Uses pyodide when no skulpt-only modules are imported", () => { - renderRunnerWithCode({ code: "import math" }); + updateRunner({ code: "import math" }); expect( document.querySelector(".skulptrunner--active"), ).not.toBeInTheDocument(); @@ -64,7 +84,7 @@ test("Uses pyodide when no skulpt-only modules are imported", () => { }); test("Uses skulpt when skulpt-only modules are imported", () => { - renderRunnerWithCode({ code: "import p5" }); + updateRunner({ code: "import p5" }); expect( document.querySelector(".pyodiderunner--active"), ).not.toBeInTheDocument(); @@ -72,7 +92,7 @@ test("Uses skulpt when skulpt-only modules are imported", () => { }); test("Uses skulpt when senseHatAlwaysEnabled is true", () => { - renderRunnerWithCode({ code: "import math", senseHatAlwaysEnabled: true }); + updateRunner({ code: "import math", senseHatAlwaysEnabled: true }); expect( document.querySelector(".pyodiderunner--active"), ).not.toBeInTheDocument(); @@ -81,7 +101,7 @@ test("Uses skulpt when senseHatAlwaysEnabled is true", () => { test("Uses skulpt if not cross origin isolated", () => { window.crossOriginIsolated = false; - renderRunnerWithCode({ code: "import math" }); + updateRunner({ code: "import math" }); expect( document.querySelector(".pyodiderunner--active"), ).not.toBeInTheDocument(); @@ -89,7 +109,7 @@ test("Uses skulpt if not cross origin isolated", () => { }); test("Switches runners if the import has a from clause", () => { - renderRunnerWithCode({ code: "from p5 import *" }); + updateRunner({ code: "from p5 import *" }); expect(document.querySelector(".skulptrunner--active")).toBeInTheDocument(); expect( document.querySelector(".pyodiderunner--active"), @@ -97,7 +117,7 @@ test("Switches runners if the import has a from clause", () => { }); test("Switches runners if the import is indented", () => { - renderRunnerWithCode({ code: " import p5" }); + updateRunner({ code: " import p5" }); expect(document.querySelector(".skulptrunner--active")).toBeInTheDocument(); expect( document.querySelector(".pyodiderunner--active"), @@ -105,7 +125,7 @@ test("Switches runners if the import is indented", () => { }); test("Uses skulpt if the py5 magic comment is used", () => { - renderRunnerWithCode({ code: "# input.comment.py5" }); + updateRunner({ code: "# input.comment.py5" }); expect(document.querySelector(".skulptrunner--active")).toBeInTheDocument(); expect( document.querySelector(".pyodiderunner--active"), @@ -113,7 +133,7 @@ test("Uses skulpt if the py5 magic comment is used", () => { }); test("Does not switch runners while the code is running", () => { - renderRunnerWithCode({ code: "import p5", codeRunTriggered: true }); + updateRunner({ code: "import p5", codeRunTriggered: true }); expect(document.querySelector(".pyodiderunner--active")).toBeInTheDocument(); expect( document.querySelector(".skulptrunner--active"), @@ -121,7 +141,7 @@ test("Does not switch runners while the code is running", () => { }); test("Does not switch runners if the import is in a comment", () => { - renderRunnerWithCode({ code: "# import p5" }); + updateRunner({ code: "# import p5" }); expect(document.querySelector(".pyodiderunner--active")).toBeInTheDocument(); expect( document.querySelector(".skulptrunner--active"), @@ -129,7 +149,7 @@ test("Does not switch runners if the import is in a comment", () => { }); test("Does not switch runners if the import is in a string", () => { - renderRunnerWithCode({ code: 'print("import p5")' }); + updateRunner({ code: 'print("import p5")' }); expect(document.querySelector(".pyodiderunner--active")).toBeInTheDocument(); expect( document.querySelector(".skulptrunner--active"), @@ -137,7 +157,7 @@ test("Does not switch runners if the import is in a string", () => { }); test("Does not switch runners if the import is in a multiline string", () => { - renderRunnerWithCode({ code: '"""\nimport p5\n"""' }); + updateRunner({ code: '"""\nimport p5\n"""' }); expect(document.querySelector(".pyodiderunner--active")).toBeInTheDocument(); expect( document.querySelector(".skulptrunner--active"), diff --git a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx index 4e835f4bf..51eee57c9 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import Sk from "skulpt"; import { useMediaQuery } from "react-responsive"; +import classNames from "classnames"; import { setError, setErrorDetails, @@ -13,6 +14,7 @@ import { stopDraw, setSenseHatEnabled, triggerDraw, + setLoadedRunner, } from "../../../../../redux/EditorSlice"; import ErrorMessage from "../../../ErrorMessage/ErrorMessage"; import ApiCallHandler from "../../../../../utils/apiCallHandler"; @@ -22,7 +24,6 @@ import OutputViewToggle from "../OutputViewToggle"; import { SettingsContext } from "../../../../../utils/settings"; import RunnerControls from "../../../../RunButton/RunnerControls"; import { MOBILE_MEDIA_QUERY } from "../../../../../utils/mediaQueryBreakpoints"; -import classNames from "classnames"; const externalLibraries = { "./pygal/__init__.js": { @@ -55,6 +56,7 @@ const externalLibraries = { }; const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { + const loadedRunner = useSelector((state) => state.editor.loadedRunner); const projectCode = useSelector((state) => state.editor.project.components); const mainComponent = projectCode?.find( (component) => component.name === "main" && component.extension === "py", @@ -71,6 +73,9 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { ); const codeRunStopped = useSelector((state) => state.editor.codeRunStopped); const drawTriggered = useSelector((state) => state.editor.drawTriggered); + const senseHatAlwaysEnabled = useSelector( + (state) => state.editor.senseHatAlwaysEnabled, + ); const reactAppApiEndpoint = useSelector((s) => s.editor.reactAppApiEndpoint); const output = useRef(); const dispatch = useDispatch(); @@ -78,9 +83,10 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { const settings = useContext(SettingsContext); const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); - const [hasVisualOutput, setHasVisualOutput] = useState(true); - - const [showRunner, setShowRunner] = useState(active); + const [codeHasVisualOutput, setCodeHasVisualOutput] = useState( + senseHatAlwaysEnabled, + ); + const [showVisualOutput, setShowVisualOutput] = useState(true); const getInput = () => { const pageInput = document.getElementById("input"); @@ -91,17 +97,26 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { }; useEffect(() => { - if (codeRunTriggered && active && showRunner) { - console.log("running with skulpt"); - runCode(); + if (active && loadedRunner !== "skulpt") { + dispatch(setLoadedRunner("skulpt")); + } + }, [active]); + + useEffect(() => { + if (active) { + if (codeRunTriggered) { + runCode(); + } else if (!senseHatAlwaysEnabled) { + setCodeHasVisualOutput(false); + } } - }, [codeRunTriggered, showRunner]); + }, [codeRunTriggered, active]); useEffect(() => { - if (codeRunTriggered) { - setShowRunner(active); + if (codeRunTriggered && !senseHatAlwaysEnabled) { + setShowVisualOutput(!!codeHasVisualOutput); } - }, [codeRunTriggered]); + }, [codeRunTriggered, codeHasVisualOutput]); useEffect(() => { if (codeRunStopped && active && getInput()) { @@ -157,7 +172,7 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { // TODO: Handle pre-importing py5_imported when refactored py5 shim imported if (visualLibraries.includes(library)) { - setHasVisualOutput(true); + setCodeHasVisualOutput(true); } let localProjectFiles = projectCode @@ -388,6 +403,7 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { }) .finally(() => { dispatch(codeRunHandled()); + setCodeHasVisualOutput(false); }); myPromise.then(function (_mod) {}); }; @@ -414,8 +430,8 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { } const singleOutputPanel = outputPanels.length === 1; - const showVisualOutput = outputPanels.includes("visual"); - const showTextOutput = outputPanels.includes("text"); + const showVisualOutputPanel = outputPanels.includes("visual"); + const showTextOutputPanel = outputPanels.includes("text"); const outputPanelClasses = (panelType) => { return classNames("output-panel", `output-panel--${panelType}`, { @@ -425,14 +441,13 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { return (
{isSplitView || singleOutputPanel ? ( <> - {hasVisualOutput && showVisualOutput && ( + {showVisualOutput && showVisualOutputPanel && (
{ - {!isEmbedded && hasVisualOutput && } + {!isEmbedded && showVisualOutput && } {!isEmbedded && isMobile && }
@@ -456,7 +471,7 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => {
)} - {showTextOutput && ( + {showTextOutputPanel && (
{ - {!hasVisualOutput && !isEmbedded && isMobile && ( + {!showVisualOutput && !isEmbedded && isMobile && ( )}
@@ -488,10 +503,13 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { )} ) : ( - +
- {hasVisualOutput ? ( + {showVisualOutput ? ( {t("output.visualOutput")} @@ -504,11 +522,11 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { - {!isEmbedded && hasVisualOutput && } + {!isEmbedded && showVisualOutput && } {!isEmbedded && isMobile && }
{!isOutputOnly && } - {hasVisualOutput ? ( + {showVisualOutput ? ( diff --git a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.js b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.js index d3d76b1e5..293bc9925 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.js @@ -1180,10 +1180,9 @@ describe("When active and first loaded", () => { expect(screen.queryByText("output.textOutput")).toBeInTheDocument(); }); - test("it has style display: flex", () => { - expect(document.querySelector(".skulptrunner")).toHaveStyle( - "display: flex", - ); + test("it does have active styles", () => { + const element = document.querySelector(".skulptrunner"); + expect(element).toHaveClass("skulptrunner--active"); }); }); @@ -1215,10 +1214,9 @@ describe("When not active", () => { ); }); - test("it has style display: none", () => { - expect(document.querySelector(".skulptrunner")).toHaveStyle( - "display: none", - ); + test("it does not have active styles", () => { + const element = document.querySelector(".skulptrunner"); + expect(element).not.toHaveClass("skulptrunner--active"); }); }); diff --git a/src/components/Mobile/MobileProject/MobileProject.test.js b/src/components/Mobile/MobileProject/MobileProject.test.js index ddea521bf..7893a8ecf 100644 --- a/src/components/Mobile/MobileProject/MobileProject.test.js +++ b/src/components/Mobile/MobileProject/MobileProject.test.js @@ -99,6 +99,8 @@ describe("When withSidebar is true", () => { beforeEach(() => { const initialState = { editor: { + activeRunner: "skulpt", + loadedRunner: "skulpt", project: { components: [ { diff --git a/src/components/RunButton/RunButton.jsx b/src/components/RunButton/RunButton.jsx index 2b6717697..4034284f0 100644 --- a/src/components/RunButton/RunButton.jsx +++ b/src/components/RunButton/RunButton.jsx @@ -6,6 +6,8 @@ import { triggerCodeRun } from "../../redux/EditorSlice"; const RunButton = ({ embedded = false, className, ...props }) => { const codeRunLoading = useSelector((state) => state.editor.codeRunLoading); + const activeRunner = useSelector((state) => state.editor.activeRunner); + const loadedRunner = useSelector((state) => state.editor.loadedRunner); const dispatch = useDispatch(); const onClickRun = () => { @@ -17,7 +19,9 @@ const RunButton = ({ embedded = false, className, ...props }) => { return (