diff --git a/cypress/e2e/spec-wc-pyodide.cy.js b/cypress/e2e/spec-wc-pyodide.cy.js index 371900470..ac45af0ec 100644 --- a/cypress/e2e/spec-wc-pyodide.cy.js +++ b/cypress/e2e/spec-wc-pyodide.cy.js @@ -1,6 +1,7 @@ import { getEditorShadow, getErrorMessage, + getFriendlyErrorMessage, getFileButtonByName, getProgramInput, getPyodideOutput, @@ -253,3 +254,41 @@ print(text_out) ); }); }); + +describe("When friendly errors enabled with pyodide", () => { + beforeEach(() => { + cy.intercept("GET", "**/python-error-copydecks/**", { + fixture: "copydeck.json", + }).as("copydeck"); + + const params = new URLSearchParams(); + params.set("friendly_errors_enabled", "true"); + + cy.visit({ + url: `${origin}?${params.toString()}`, + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + }); + cy.window().then((win) => { + Object.defineProperty(win, "crossOriginIsolated", { + value: true, + configurable: true, + }); + }); + cy.wait("@copydeck"); + }); + + it("shows a friendly error message when an error occurs", () => { + runCode("print(kitten)"); + getErrorMessage().should( + "contain", + "NameError: name 'kitten' is not defined on line 1 of main.py", + ); + getFriendlyErrorMessage().should( + "contain", + "This variable doesn't exist here", + ); + }); +}); diff --git a/cypress/e2e/spec-wc-skulpt.cy.js b/cypress/e2e/spec-wc-skulpt.cy.js index 4535072da..d2fe74f18 100644 --- a/cypress/e2e/spec-wc-skulpt.cy.js +++ b/cypress/e2e/spec-wc-skulpt.cy.js @@ -1,5 +1,6 @@ import { getErrorMessage, + getFriendlyErrorMessage, getP5Canvas, getPythonConsoleOutput, getSkulptRunner, @@ -149,3 +150,35 @@ describe("Running the code with skulpt", () => { ); }); }); + +describe("When friendly errors enabled with skulpt", () => { + beforeEach(() => { + cy.intercept("GET", "**/python-error-copydecks/**", { + fixture: "copydeck.json", + }).as("copydeck"); + + const params = new URLSearchParams(); + params.set("friendly_errors_enabled", "true"); + + cy.visit(`${origin}?${params.toString()}`); + cy.window().then((win) => { + Object.defineProperty(win, "crossOriginIsolated", { + value: false, + configurable: true, + }); + }); + cy.wait("@copydeck"); + }); + + it("shows a friendly error message when an error occurs", () => { + runCode("import turtle\nprint(kitten)"); + getErrorMessage().should( + "contain", + "NameError: name 'kitten' is not defined on line 2 of main.py", + ); + getFriendlyErrorMessage().should( + "contain", + "This variable doesn't exist here", + ); + }); +}); diff --git a/cypress/fixtures/copydeck.json b/cypress/fixtures/copydeck.json new file mode 100644 index 000000000..abe369568 --- /dev/null +++ b/cypress/fixtures/copydeck.json @@ -0,0 +1,41 @@ +{ + "errors": { + "NameError": { + "variants": [ + { + "if": { + "not_message": ["is not defined"] + }, + "title": "This variable doesn't exist yet", + "summary": "Your code uses the variable {{name}}, but it hasn't been created yet. Check {{loc}}. If you meant to print the text {{name}}, put it in double quotes.", + "why": "Without speech marks Python treats {{name}} as a variable, and this variable does not exist yet.", + "steps": [ + "If it is meant to be text, put speech marks around {{name}}.", + "If it is meant to be a variable, make it first (for example: {{name}} = 0).", + "Check spelling and capital letters." + ], + "_placeholders": { + "name": "The undefined variable name, e.g. kittens", + "loc": "Where it was used, e.g. line 2 in main.py" + } + }, + { + "if": { + "match_message": ["is not defined"] + }, + "title": "This variable doesn't exist here", + "summary": "{{name}} might be created somewhere else, but you're using it at {{loc}}. If you meant the text {{name}}, put it in double quotes.", + "why": "A variable created in another place might not be available here.", + "steps": [ + "Move the line that makes it to above where you use it.", + "Or set it here just before you use it." + ], + "_placeholders": { + "name": "The variable name used out of scope, e.g. total", + "loc": "Where it was referenced, e.g. line 8 in main.py" + } + } + ] + } + } +} \ No newline at end of file diff --git a/cypress/helpers/editor.js b/cypress/helpers/editor.js index eddc88914..432b935a7 100644 --- a/cypress/helpers/editor.js +++ b/cypress/helpers/editor.js @@ -61,7 +61,10 @@ export const getP5Canvas = () => getEditorShadow().find(".p5Canvas"); export const getTurtleOutput = () => getEditorShadow().find("#turtleOutput"); export const getErrorMessage = () => - getEditorShadow().find(".error-message__content"); + getEditorShadow().find(".error-message__python"); + +export const getFriendlyErrorMessage = () => + getEditorShadow().find(".friendly-error-message"); export const getTextOutputTab = () => getPyodideOutput().findByLabelText("Text output"); diff --git a/src/assets/icons/cancel_FILL.svg b/src/assets/icons/cancel_FILL.svg new file mode 100644 index 000000000..5ef0d2fec --- /dev/null +++ b/src/assets/icons/cancel_FILL.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/stylesheets/EditorPanel.scss b/src/assets/stylesheets/EditorPanel.scss index f29850738..2a147f49a 100644 --- a/src/assets/stylesheets/EditorPanel.scss +++ b/src/assets/stylesheets/EditorPanel.scss @@ -1,5 +1,6 @@ -@forward '@raspberrypifoundation/design-system-core/scss/properties/spacing'; -@use '@raspberrypifoundation/design-system-core/scss/mixins/typography' as typography; +@forward "@raspberrypifoundation/design-system-core/scss/properties/spacing"; +@use "@raspberrypifoundation/design-system-core/scss/mixins/typography" as + typography; // Scrollbar-width is needed from scrollbar to show in Chrome .editor-wrapper { @@ -31,7 +32,6 @@ overscroll-behavior-x: none; font-family: var(--wc-font-family-monospace); - .cm-content { flex: 1; padding-block-start: var(--space-0-5); @@ -60,5 +60,3 @@ display: none; } } - - diff --git a/src/assets/stylesheets/ErrorMessage.scss b/src/assets/stylesheets/ErrorMessage.scss index 33a3a0af7..ab09a1ef0 100644 --- a/src/assets/stylesheets/ErrorMessage.scss +++ b/src/assets/stylesheets/ErrorMessage.scss @@ -1,25 +1,35 @@ -@use "./rpf_design_system/font-size" as *; -@use "./rpf_design_system/spacing" as *; +@use "@raspberrypifoundation/design-system-core/scss/mixins/typography" as + typography; .error-message { - color: #7e0305; - background-color: #fde2e1; - padding: $space-0-75 $space-1-25; + color: var(--rpf-red-900); + background-color: var(--rpf-red-100); + border-block-start: 1px solid var(--editor-color-outline); + padding: var(--space-1); overflow-y: auto; + scrollbar-width: thin; + max-block-size: 50%; - &__content { + &__error { padding: 0; margin: 0; white-space: pre-wrap; overflow-wrap: break-word; + font-weight: bold; } - &--medium { - @include font-size-1-5(regular); - } + &__python { + display: flex; + gap: var(--space-1); + @include typography.style-1; - &--large { - @include font-size-2(regular); + &--medium { + @include typography.style-1; + } + + &--large { + @include typography.style-2; + } } // Only displaying title and summary of friendlyError diff --git a/src/assets/stylesheets/FriendlyErrorMessage.scss b/src/assets/stylesheets/FriendlyErrorMessage.scss new file mode 100644 index 000000000..09a943264 --- /dev/null +++ b/src/assets/stylesheets/FriendlyErrorMessage.scss @@ -0,0 +1,27 @@ +.friendly-error-message { + background-color: var(--rpf-white); + padding: var(--space-2); + color: var(--rpf-text-primary); + border-radius: var(--space-1); + margin-block-start: var(--space-1); + + .pfem { + &__title { + display: block; + font-weight: 600; + margin-block-end: var(--space-1); + } + + &__summary { + display: block; + } + + // Only displaying title and summary of friendlyError + &__why, + &__steps, + &__patch, + &__details { + display: none; + } + } +} diff --git a/src/assets/stylesheets/InternalStyles.scss b/src/assets/stylesheets/InternalStyles.scss index 01f136b70..9f36bf398 100644 --- a/src/assets/stylesheets/InternalStyles.scss +++ b/src/assets/stylesheets/InternalStyles.scss @@ -43,6 +43,7 @@ @use "./EditorFinalStep.scss" as *; @use "./Loader.scss" as *; @use "./LoadFailed.scss" as *; +@use "./FriendlyErrorMessage" as *; @use "./settings/fonts" as fonts; @@ -158,7 +159,9 @@ button:focus-visible { // Primary button --rpf-button-primary-background-color: var(--editor-color-theme); --rpf-button-primary-background-color-focus: var(--editor-color-theme); - --rpf-button-primary-background-color-hover: var(--editor-color-theme-secondary); + --rpf-button-primary-background-color-hover: var( + --editor-color-theme-secondary + ); --rpf-button-primary-background-color-active: var(--rpf-navy-600); --rpf-button-primary-background-color-disabled: var(--rpf-navy-200); --rpf-button-primary-color-disabled: var(--rpf-grey-600); @@ -167,10 +170,16 @@ button:focus-visible { // Secondary button --rpf-button-secondary-background-color: var(--editor-color-theme); --rpf-button-secondary-background-color-focus: var(--rpf-brand-raspberry); - --rpf-button-secondary-background-color-hover: var(--editor-color-theme-tertiary); + --rpf-button-secondary-background-color-hover: var( + --editor-color-theme-tertiary + ); --rpf-button-secondary-border-color: var(--editor-color-theme); - --rpf-button-secondary-border-color-hover: var(--editor-color-theme-secondary); - --rpf-button-secondary-background-color-active: var(--editor-color-theme-secondary); + --rpf-button-secondary-border-color-hover: var( + --editor-color-theme-secondary + ); + --rpf-button-secondary-background-color-active: var( + --editor-color-theme-secondary + ); --rpf-button-secondary-background-color-disabled: var(--rpf-grey-50); --rpf-button-secondary-text-color: var(--editor-color-theme); @@ -230,7 +239,9 @@ button:focus-visible { --rpf-button-secondary-text-color: var(--rpf-white); --rpf-button-secondary-background-color: var(--editor-color-layer-2); --rpf-button-secondary-background-color-active: var(--rpf-navy-200); - --rpf-button-secondary-color-disabled-background: var(--editor-color-layer-3); + --rpf-button-secondary-color-disabled-background: var( + --editor-color-layer-3 + ); --rpf-button-secondary-background-color-hover: var(--editor-color-outline); --rpf-button-secondary-border-color: var(--editor-color-theme); --rpf-button-secondary-border-color-hover: var(--editor-color-theme); @@ -240,9 +251,17 @@ button:focus-visible { // Tertiary button --rpf-button-tertiary-text-color-hover: var(--rpf-grey-200); --rpf-button-tertiary-danger-text-color: var(--rpf-red-600); - --rpf-button-tertiary-danger-background-color-hover: rgba(255, 255, 255, 0.1); - --rpf-button-tertiary-danger-background-color-active: rgba(255, 255, 255, 0.15); + --rpf-button-tertiary-danger-background-color-hover: rgba( + 255, + 255, + 255, + 0.1 + ); + --rpf-button-tertiary-danger-background-color-active: rgba( + 255, + 255, + 255, + 0.15 + ); } } - - diff --git a/src/components/Editor/EditorInput/EditorInput.jsx b/src/components/Editor/EditorInput/EditorInput.jsx index ece8db7ff..98de64507 100644 --- a/src/components/Editor/EditorInput/EditorInput.jsx +++ b/src/components/Editor/EditorInput/EditorInput.jsx @@ -15,6 +15,7 @@ import EditorPanel from "../EditorPanel/EditorPanel"; import DraggableTab from "../DraggableTabs/DraggableTab"; import DroppableTabList from "../DraggableTabs/DroppableTabList"; import RunBar from "../../RunButton/RunBar"; +import ErrorMessage from "../ErrorMessage/ErrorMessage"; import "../../../assets/stylesheets/EditorInput.scss"; import RunnerControls from "../../RunButton/RunnerControls"; @@ -186,6 +187,7 @@ const EditorInput = () => { /> ))} + {isMobile ? null : } ))} diff --git a/src/components/Editor/ErrorMessage/ErrorMessage.jsx b/src/components/Editor/ErrorMessage/ErrorMessage.jsx index fe1516cec..1030f6e86 100644 --- a/src/components/Editor/ErrorMessage/ErrorMessage.jsx +++ b/src/components/Editor/ErrorMessage/ErrorMessage.jsx @@ -3,30 +3,25 @@ import "../../../assets/stylesheets/ErrorMessage.scss"; import { useSelector } from "react-redux"; import DOMPurify from "dompurify"; import { SettingsContext } from "../../../utils/settings"; +import CancelFillIcon from "../../../assets/icons/cancel_FILL.svg"; +import FriendlyErrorMessage from "../FriendlyErrorMessage/FriendlyErrorMessage"; const ErrorMessage = () => { const error = useSelector((state) => state.editor.error); - const friendlyError = useSelector((state) => state.editor.friendlyError); const settings = useContext(SettingsContext); - const errorHtml = DOMPurify.sanitize(error); - - const friendlyErrorHtml = friendlyError?.html - ? DOMPurify.sanitize(friendlyError.html) - : null; - return error ? ( -
-
-      {friendlyErrorHtml && (
-        
+
+ +
-      )}
+      
+
) : null; }; diff --git a/src/components/Editor/ErrorMessage/ErrorMessage.test.js b/src/components/Editor/ErrorMessage/ErrorMessage.test.js index f9fe32a43..439b70155 100644 --- a/src/components/Editor/ErrorMessage/ErrorMessage.test.js +++ b/src/components/Editor/ErrorMessage/ErrorMessage.test.js @@ -33,7 +33,7 @@ describe("When error is set", () => { test("Font size class is set correctly", () => { const errorMessage = screen.queryByText("Oops").parentElement; - expect(errorMessage).toHaveClass("error-message--myFontSize"); + expect(errorMessage).toHaveClass("error-message__python--myFontSize"); }); test("Does not display friendly error elements", () => { diff --git a/src/components/Editor/FriendlyErrorMessage/FriendlyErrorMessage.jsx b/src/components/Editor/FriendlyErrorMessage/FriendlyErrorMessage.jsx new file mode 100644 index 000000000..3c074917b --- /dev/null +++ b/src/components/Editor/FriendlyErrorMessage/FriendlyErrorMessage.jsx @@ -0,0 +1,23 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import "../../../assets/stylesheets/FriendlyErrorMessage.scss"; +import DOMPurify from "dompurify"; + +const FriendlyErrorMessage = () => { + const friendlyError = useSelector((state) => state.editor.friendlyError); + + const friendlyErrorHtml = friendlyError?.html + ? DOMPurify.sanitize(friendlyError.html) + : null; + + return friendlyErrorHtml ? ( +
+
+
+ ) : null; +}; + +export default FriendlyErrorMessage; diff --git a/src/components/Editor/FriendlyErrorMessage/FriendlyErrorMessage.test.js b/src/components/Editor/FriendlyErrorMessage/FriendlyErrorMessage.test.js new file mode 100644 index 000000000..745e82374 --- /dev/null +++ b/src/components/Editor/FriendlyErrorMessage/FriendlyErrorMessage.test.js @@ -0,0 +1,85 @@ +import configureStore from "redux-mock-store"; +import { Provider } from "react-redux"; +import { render, screen } from "@testing-library/react"; +import FriendlyErrorMessage from "./FriendlyErrorMessage"; + +const middlewares = []; +const mockStore = configureStore(middlewares); + +describe("When friendlyError is set", () => { + beforeEach(() => { + const initialState = { + editor: { + error: "An error occurred", + friendlyError: { + html: '
Friendly Error Title
This is a more user-friendly explanation of the error.
A variable created in another place might not be available here.
  • Move the line that makes it to above where you use it.
  • Or set it here just before you use it.
kettle = 0
Original error
An error occurred
', + }, + }, + }; + const store = mockStore(initialState); + render( + + + , + ); + }); + + test("Friendly error title and summary display", () => { + expect(screen.getByText("Friendly Error Title")).toBeInTheDocument(); + expect( + screen.getByText( + "This is a more user-friendly explanation of the error.", + ), + ).toBeInTheDocument(); + }); + + it("strips unsafe scripts", () => { + const initialState = { + editor: { + error: "An error occurred", + friendlyError: { + html: ` +
Friendly Error Title
+ + + Bad link + `, + }, + }, + }; + const store = mockStore(initialState); + const { container } = render( + + + , + ); + + expect(container.querySelector("script")).not.toBeInTheDocument(); + expect(container.querySelector("[onerror]")).not.toBeInTheDocument(); + expect( + container.querySelector('a[href^="javascript:"]'), + ).not.toBeInTheDocument(); + }); +}); + +describe("When friendlyError is not set", () => { + beforeEach(() => { + const initialState = { + editor: { + error: "Oops", + }, + }; + const store = mockStore(initialState); + render( + + + , + ); + }); + + test("Does not display friendly error elements", () => { + expect( + document.querySelector(".friendly-error-message"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index ebdcf013c..0e84c3c41 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -21,7 +21,6 @@ import { import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import { useMediaQuery } from "react-responsive"; import { MOBILE_MEDIA_QUERY } from "../../../../../utils/mediaQueryBreakpoints"; -import ErrorMessage from "../../../ErrorMessage/ErrorMessage"; import ApiCallHandler from "../../../../../utils/apiCallHandler"; import VisualOutputPane from "./VisualOutputPane"; import OutputViewToggle from "../OutputViewToggle"; @@ -460,7 +459,6 @@ const PyodideRunner = ({ )}
-
}
             {!isEmbedded && isMobile && }
           
- {hasVisual && ( diff --git a/src/web-component.html b/src/web-component.html index 0611083eb..34998332a 100644 --- a/src/web-component.html +++ b/src/web-component.html @@ -1,4 +1,4 @@ - +