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 (