diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 5c70a8ad1..94f329074 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -114,6 +114,7 @@ jobs: PUBLIC_URL: "http://localhost:3011" ASSETS_URL: "http://localhost:3011" REACT_APP_PLAUSIBLE_SOURCE: "" + REACT_APP_ALLOWED_IFRAME_ORIGINS: "http://localhost:3011" - name: Archive cypress artifacts uses: actions/upload-artifact@v4.6.0 diff --git a/.gitignore b/.gitignore index efe2593f4..56ae9ffea 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules # testing /coverage +/cypress/downloads /cypress/screenshots /cypress/videos diff --git a/cypress.config.mjs b/cypress.config.mjs index bbbcf5b57..f554a5669 100644 --- a/cypress.config.mjs +++ b/cypress.config.mjs @@ -1,5 +1,8 @@ import { defineConfig } from "cypress"; import dotenv from "dotenv"; +import fs from "fs"; +import path from "path"; +import JSZip from "jszip"; dotenv.config(); @@ -10,13 +13,82 @@ export default defineConfig({ video: false, defaultBrowser: "chrome", testIsolation: true, + downloadsFolder: "cypress/downloads", setupNodeEvents(on, config) { + const projectRoot = config.projectRoot ?? process.cwd(); + const downloadsFolder = path.resolve( + projectRoot, + config.downloadsFolder || "cypress/downloads", + ); on("task", { log(message) { console.log(message); return null; }, + resetDownloads() { + if (fs.existsSync(downloadsFolder)) { + const files = fs.readdirSync(downloadsFolder); + for (const file of files) { + fs.unlinkSync(path.join(downloadsFolder, file)); + } + } else { + fs.mkdirSync(downloadsFolder, { recursive: true }); + } + + return null; + }, + async getNewestDownload(extension) { + if (!extension || !extension.startsWith(".")) { + throw new Error( + "getNewestDownload requires a file extension starting with . (e.g. .sb3)", + ); + } + const ext = extension; + const pollMs = 100; + const timeoutMs = 1500; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (!fs.existsSync(downloadsFolder)) { + await new Promise((r) => setTimeout(r, pollMs)); + continue; + } + const files = fs.readdirSync(downloadsFolder); + const matching = files + .filter((f) => f.endsWith(ext)) + .map((f) => ({ + name: f, + path: path.join(downloadsFolder, f), + mtime: fs.statSync(path.join(downloadsFolder, f)).mtimeMs, + })) + .sort((a, b) => b.mtime - a.mtime); + + if (matching.length > 0) { + return matching[0].path; + } + await new Promise((r) => setTimeout(r, pollMs)); + } + + if (!fs.existsSync(downloadsFolder)) { + throw new Error("Downloads folder not found"); + } + throw new Error(`No ${ext} file found in downloads folder`); + }, + async readSb3(filePath) { + const buf = fs.readFileSync(filePath); + const zip = await JSZip.loadAsync(buf); + const fileNames = Object.keys(zip.files); + const projectJsonFile = zip.file("project.json"); + + if (!projectJsonFile) { + throw new Error("Invalid .sb3 file: missing project.json"); + } + + const projectJson = JSON.parse(await projectJsonFile.async("string")); + + return { fileNames, projectJson }; + }, }); on("before:browser:launch", (browser = {}, launchOptions) => { if (browser.name === "chrome") { @@ -24,6 +96,10 @@ export default defineConfig({ launchOptions.args.push("--enable-features=SharedArrayBuffer"); launchOptions.args.push("--disable-site-isolation-trials"); launchOptions.args.push("--enable-unsafe-swiftshader"); + launchOptions.preferences = { + ...launchOptions.preferences, + "download.default_directory": downloadsFolder, + }; } return launchOptions; }); diff --git a/cypress/e2e/spec-scratch.cy.js b/cypress/e2e/spec-scratch.cy.js index e17a8714c..4a6933c40 100644 --- a/cypress/e2e/spec-scratch.cy.js +++ b/cypress/e2e/spec-scratch.cy.js @@ -1,3 +1,12 @@ +import { + getEditorShadow, + openSaveAndDownloadPanel, +} from "../helpers/editor.js"; +import { + assertScratchIsRendered, + getScratchIframeBody, +} from "../helpers/scratch.js"; + const origin = "http://localhost:3011/web-component.html"; beforeEach(() => { @@ -8,33 +17,61 @@ beforeEach(() => { cy.viewport(1400, 800); }); -const getIframeBody = () => { - return cy - .get("editor-wc") - .shadow() - .findByTitle("Scratch") - .its("0.contentDocument.body") - .should("not.be.empty") - .then(cy.wrap); -}; - describe("Scratch", () => { beforeEach(() => { cy.visit(origin); cy.findByText("cool-scratch").click(); }); - it("loads Scratch in an iframe", () => { - getIframeBody().find("button [title='Go']").should("be.visible"); - }); - it("hides text size in settings for Scratch", () => { - getIframeBody().find("button [title='Go']").should("be.visible"); - cy.get("editor-wc").shadow().find("[title='Settings']").first().click(); - cy.get("editor-wc") - .shadow() + assertScratchIsRendered(); + + getEditorShadow() + .findByRole("button", { name: "Settings" }) + .first() + .click(); + getEditorShadow() .find(".settings-panel__text-size") .should("exist") .and("not.be.visible"); }); + + it("can perform uploads and downloads of Scratch projects via the save and download panel", () => { + assertScratchIsRendered(); + + // confirm set up is different to loaded project and does not contain a sprite with this name + getScratchIframeBody() + .findByRole("button", { name: "test sprite" }) + .should("not.exist"); + + const saveAndDownloadPanel = openSaveAndDownloadPanel(); + saveAndDownloadPanel.uploadProject( + "cypress/fixtures/upload-test-project.sb3", + ); + + // confirm project has been uploaded + getScratchIframeBody() + .findByRole("button", { name: "test sprite" }) + .should("be.visible"); + + cy.task("resetDownloads"); + + saveAndDownloadPanel.downloadProject(); + + // assert on the file + cy.task("getNewestDownload", ".sb3").then((filePath) => { + expect(filePath).to.be.a("string"); + expect(filePath).to.match(/\.sb3$/); + + cy.task("readSb3", filePath).then(({ fileNames, projectJson }) => { + expect(fileNames).to.include("project.json"); + + const spriteNames = projectJson.targets + .filter((t) => t.isStage === false) + .map((t) => t.name); + + expect(spriteNames).to.include("test sprite"); + }); + }); + }); }); diff --git a/cypress/fixtures/upload-test-project.sb3 b/cypress/fixtures/upload-test-project.sb3 new file mode 100644 index 000000000..5d59a7295 Binary files /dev/null and b/cypress/fixtures/upload-test-project.sb3 differ diff --git a/cypress/helpers/editor.js b/cypress/helpers/editor.js new file mode 100644 index 000000000..7a872d611 --- /dev/null +++ b/cypress/helpers/editor.js @@ -0,0 +1,26 @@ +export const getEditorShadow = () => cy.get("editor-wc").shadow(); + +export const openSaveAndDownloadPanel = () => { + getEditorShadow().findByRole("button", { name: "Download project" }).click(); + getEditorShadow() + .findByRole("heading", { name: "Save & download" }) + .should("be.visible"); + + return { + uploadProject: (fixturePath) => { + getEditorShadow() + .find(".download-panel__download-section") + .findByRole("button", { name: "Upload project" }) + .should("be.visible"); + getEditorShadow() + .findByTestId("upload-file-input") + .selectFile(fixturePath, { force: true }); + }, + downloadProject: () => { + getEditorShadow() + .find(".download-panel__download-section") + .findByRole("button", { name: "Download project" }) + .click(); + }, + }; +}; diff --git a/cypress/helpers/scratch.js b/cypress/helpers/scratch.js new file mode 100644 index 000000000..a1e33c736 --- /dev/null +++ b/cypress/helpers/scratch.js @@ -0,0 +1,16 @@ +import { getEditorShadow } from "./editor.js"; + +export const getScratchIframeBody = () => { + return getEditorShadow() + .findByTitle("Scratch") + .its("0.contentDocument.body") + .should("not.be.empty") + .then(cy.wrap); +}; + +export const getScratchGoButton = () => + getScratchIframeBody().find("button [title='Go']"); + +export const assertScratchIsRendered = () => { + getScratchGoButton().should("be.visible"); +};