From bd2b782fbf357de4decaf403cb952f3a6cd8e415 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:56:47 +0100 Subject: [PATCH 01/12] Migrate AstroPiModel test files to Vitest These 5 files were the first candidates blocked by literal jest.fn/ jest.mock usage rather than the JSX-in-.test.js issue earlier migrations hit. Swapping jest.fn -> vi.fn and jest.mock -> vi.mock was enough for 4 of the 5, but MotionInput.test.jsx's assertions on call counts leaked across tests within the same describe block, because Vitest's mocks aren't reset between tests by default the way Jest's resetMocks: true config resets jest.fn(). Added the equivalent mockReset: true to vite.config.js's test block so Vitest mocks behave the same way. Confirmed via full Jest and Vitest runs: same 1006 tests pass before and after (832 Jest + 174 Vitest). --- .../AstroPiModel/AstroPiControls/MotionInput.test.jsx | 4 ++-- src/components/AstroPiModel/FlightCase.test.jsx | 2 +- .../AstroPiModel/OrientationPanel/OrientationPanel.test.jsx | 2 +- .../OrientationPanel/OrientationResetButton.test.jsx | 2 +- src/components/AstroPiModel/Simulator.test.jsx | 6 +++--- test-runner-migration.js | 5 ----- vite.config.js | 1 + 7 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/components/AstroPiModel/AstroPiControls/MotionInput.test.jsx b/src/components/AstroPiModel/AstroPiControls/MotionInput.test.jsx index 61f154f13..1bfd42f6e 100644 --- a/src/components/AstroPiModel/AstroPiControls/MotionInput.test.jsx +++ b/src/components/AstroPiModel/AstroPiControls/MotionInput.test.jsx @@ -7,8 +7,8 @@ import Sk from "skulpt"; let container; let store; -const start_motion_function = jest.fn(); -const stop_motion_function = jest.fn(); +const start_motion_function = vi.fn(); +const stop_motion_function = vi.fn(); describe("No motion and code running", () => { beforeEach(() => { diff --git a/src/components/AstroPiModel/FlightCase.test.jsx b/src/components/AstroPiModel/FlightCase.test.jsx index 1652605c2..ceb9e066d 100644 --- a/src/components/AstroPiModel/FlightCase.test.jsx +++ b/src/components/AstroPiModel/FlightCase.test.jsx @@ -13,7 +13,7 @@ import FlightCase from "./FlightCase"; // By mocking @react-three/drei to return a scene synchronously, we ensure that // the event handlers are properly registered and the tests can run as // expected. -jest.mock("@react-three/drei", () => ({ +vi.mock("@react-three/drei", () => ({ useGLTF: () => ({ scene: { getObjectByName: () => ({ material: null }), diff --git a/src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.jsx b/src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.jsx index 04f5e5d24..a850cfca5 100644 --- a/src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.jsx +++ b/src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.jsx @@ -3,7 +3,7 @@ import { render } from "@testing-library/react"; import OrientationPanel from "./OrientationPanel"; let panel; -const resetFunction = jest.fn(); +const resetFunction = vi.fn(); beforeAll(() => { panel = render( diff --git a/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.jsx b/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.jsx index 0f9c37c86..98037ac5b 100644 --- a/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.jsx +++ b/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.jsx @@ -3,7 +3,7 @@ import { render, fireEvent } from "@testing-library/react"; import OrientationResetButton from "./OrientationResetButton"; let resetButton; -const resetFunction = jest.fn(); +const resetFunction = vi.fn(); beforeEach(() => { resetButton = render( diff --git a/src/components/AstroPiModel/Simulator.test.jsx b/src/components/AstroPiModel/Simulator.test.jsx index 4cfb2212d..1ff8a88f8 100644 --- a/src/components/AstroPiModel/Simulator.test.jsx +++ b/src/components/AstroPiModel/Simulator.test.jsx @@ -16,7 +16,7 @@ test("Three canvas renders", () => { }); test("Moving pointer over model does not change orientation", () => { - const updateOrientation = jest.fn(); + const updateOrientation = vi.fn(); const simulator = render(); const canvas = simulator.container.querySelector("canvas"); fireEvent.pointerMove(canvas); @@ -24,7 +24,7 @@ test("Moving pointer over model does not change orientation", () => { }); test("Dragging model changes orientation", async () => { - const updateOrientation = jest.fn(); + const updateOrientation = vi.fn(); const simulator = render(); const canvas = simulator.container.querySelector("canvas"); fireEvent.pointerDown(canvas); @@ -33,7 +33,7 @@ test("Dragging model changes orientation", async () => { }); test("Dragging before model has loaded does not throw", () => { - const updateOrientation = jest.fn(); + const updateOrientation = vi.fn(); const loadedMod = window.mod; window.mod = undefined; diff --git a/test-runner-migration.js b/test-runner-migration.js index f53d49d09..f08d688ad 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -6,11 +6,6 @@ // Vitest - anything not listed here runs under Vitest by default, including // any new test file. const JEST_ONLY_TEST_FILES = [ - "src/components/AstroPiModel/AstroPiControls/MotionInput.test.jsx", - "src/components/AstroPiModel/FlightCase.test.jsx", - "src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.jsx", - "src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.jsx", - "src/components/AstroPiModel/Simulator.test.jsx", "src/components/DownloadButton/DownloadButton.test.jsx", "src/components/Editor/EditorInput/EditorInput.test.jsx", "src/components/Editor/EditorPanel/EditorPanel.test.jsx", diff --git a/vite.config.js b/vite.config.js index 8d151382b..369e45770 100644 --- a/vite.config.js +++ b/vite.config.js @@ -170,6 +170,7 @@ export default defineConfig(async ({ mode }) => { test: { environment: "jsdom", globals: true, + mockReset: true, setupFiles: [path.resolve(__dirname, "src/utils/setupTests.vitest.js")], include: [ "src/**/__tests__/**/*.{js,jsx,ts,tsx}", From 47b309d572dc78311fe2d1d06d90c48f8f4d0351 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:58:24 +0100 Subject: [PATCH 02/12] Migrate DownloadButton test file to Vitest Converting jest.mock/jest.fn to vi.mock/vi.fn was enough for the file-saver, jszip and scratchIframe mocks, but the jszip-utils factory mock needed an explicit default export. Jest's CJS interop treats a factory mock with no __esModule flag as the default export as a whole, so `getBinaryContent: jest.fn()` was reachable via the default import in the component. Vitest doesn't apply that interop to mock factories, so the component's `import JSZipUtils from "jszip-utils"` resolved to undefined until the factory returned an explicit `default` key. Confirmed via full Jest and Vitest runs: same 1006 tests pass before and after (822 Jest + 184 Vitest). --- src/components/DownloadButton/DownloadButton.test.jsx | 10 +++++----- test-runner-migration.js | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/DownloadButton/DownloadButton.test.jsx b/src/components/DownloadButton/DownloadButton.test.jsx index cefaee724..03e0e2635 100644 --- a/src/components/DownloadButton/DownloadButton.test.jsx +++ b/src/components/DownloadButton/DownloadButton.test.jsx @@ -8,12 +8,12 @@ import JSZip from "jszip"; import JSZipUtils from "jszip-utils"; import { postMessageToScratchIframe } from "../../utils/scratchIframe"; -jest.mock("file-saver"); -jest.mock("jszip"); -jest.mock("jszip-utils", () => ({ - getBinaryContent: jest.fn(), +vi.mock("file-saver"); +vi.mock("jszip"); +vi.mock("jszip-utils", () => ({ + default: { getBinaryContent: vi.fn() }, })); -jest.mock("../../utils/scratchIframe"); +vi.mock("../../utils/scratchIframe"); describe("Downloading project with name set", () => { let downloadButton; diff --git a/test-runner-migration.js b/test-runner-migration.js index f08d688ad..d56b7d38c 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -6,7 +6,6 @@ // Vitest - anything not listed here runs under Vitest by default, including // any new test file. const JEST_ONLY_TEST_FILES = [ - "src/components/DownloadButton/DownloadButton.test.jsx", "src/components/Editor/EditorInput/EditorInput.test.jsx", "src/components/Editor/EditorPanel/EditorPanel.test.jsx", "src/components/Editor/ErrorMessage/ErrorMessage.test.jsx", From d2e8a8102e3639c4b8e9481872a01892001bc157 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:19:48 +0100 Subject: [PATCH 03/12] Migrate 5 Editor test files to Vitest EditorPanel, ErrorMessage and Output passed unmodified. EditorInput and HtmlRenderer both mock react-responsive with a factory referencing jest.requireActual, ported to an async factory using vi.importActual. Getting EditorInput running under Vitest surfaced two setupTests.vitest.js gaps shared by the whole Editor tree: window.matchMedia isn't implemented by jsdom by default (src/utils/settings.js calls it at import time), and @raspberrypifoundation/python-friendly-error-messages's dist build doesn't resolve under Vitest's module runner the way Jest's CJS require() tolerates. Both are now mocked/polyfilled globally in setupTests.vitest.js, matching their equivalents in the Jest-only setupTests.js. Six more Editor/Runners files were tried and left on the Jest list: Project.test.jsx (module-level jest.useFakeTimers() combined with this setup produces an empty render under Vitest - needs deeper investigation), ScratchContainer.test.jsx and HtmlRunner.test.jsx (both call window.localStorage, which is undefined under this Vitest+jsdom+Node 22 combination - jsdom's own localStorage never gets bridged onto the global object), PyodideRunner.test.jsx and PythonRunner.test.jsx (both import app/store, which constructs an oidc-client UserManager that reads localStorage at import time, hitting the same gap), and PyodideWorker.test.js (dynamically re-imports the worker script per test via vi.resetModules()/import(), but the script's `new TextEncoder()` call fails post-reset in a way its Jest require()-based equivalent doesn't). Confirmed via full Jest and Vitest runs: same 1006 tests pass before and after (785 Jest + 221 Vitest). --- .../Editor/EditorInput/EditorInput.test.jsx | 6 +++--- .../Runners/HtmlRunner/HtmlRenderer.test.jsx | 4 ++-- src/utils/setupTests.vitest.js | 19 +++++++++++++++++++ test-runner-migration.js | 5 ----- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/components/Editor/EditorInput/EditorInput.test.jsx b/src/components/Editor/EditorInput/EditorInput.test.jsx index 84b2426aa..64f2e6a38 100644 --- a/src/components/Editor/EditorInput/EditorInput.test.jsx +++ b/src/components/Editor/EditorInput/EditorInput.test.jsx @@ -11,14 +11,14 @@ import { import { matchMedia, setMedia } from "mock-match-media"; import { MOBILE_BREAKPOINT } from "../../../utils/mediaQueryBreakpoints"; -window.HTMLElement.prototype.scrollIntoView = jest.fn(); +window.HTMLElement.prototype.scrollIntoView = vi.fn(); let mockMediaQuery = (query) => { return matchMedia(query).matches; }; -jest.mock("react-responsive", () => ({ - ...jest.requireActual("react-responsive"), +vi.mock("react-responsive", async () => ({ + ...(await vi.importActual("react-responsive")), useMediaQuery: ({ query }) => mockMediaQuery(query), })); diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx index 9a7f2ca3e..e3550fed9 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx @@ -11,8 +11,8 @@ let mockMediaQuery = (query) => { return matchMedia(query).matches; }; -jest.mock("react-responsive", () => ({ - ...jest.requireActual("react-responsive"), +vi.mock("react-responsive", async () => ({ + ...(await vi.importActual("react-responsive")), useMediaQuery: ({ query }) => mockMediaQuery(query), })); diff --git a/src/utils/setupTests.vitest.js b/src/utils/setupTests.vitest.js index b4716c0e1..1b8cbf5a9 100644 --- a/src/utils/setupTests.vitest.js +++ b/src/utils/setupTests.vitest.js @@ -1,3 +1,22 @@ // Vitest equivalent of setupTests.js, used by every file not listed in // JEST_ONLY_TEST_FILES (test-runner-migration.js). import "@testing-library/jest-dom"; +import { vi } from "vitest"; + +window.matchMedia = (query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), +}); + +vi.mock("@raspberrypifoundation/python-friendly-error-messages", () => ({ + loadCopydeckFor: vi.fn(), + registerAdapter: vi.fn(), + cpythonAdapter: {}, + friendlyExplain: vi.fn(), +})); diff --git a/test-runner-migration.js b/test-runner-migration.js index d56b7d38c..fd01a8359 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -6,13 +6,8 @@ // Vitest - anything not listed here runs under Vitest by default, including // any new test file. const JEST_ONLY_TEST_FILES = [ - "src/components/Editor/EditorInput/EditorInput.test.jsx", - "src/components/Editor/EditorPanel/EditorPanel.test.jsx", - "src/components/Editor/ErrorMessage/ErrorMessage.test.jsx", - "src/components/Editor/Output/Output.test.jsx", "src/components/Editor/Project/Project.test.jsx", "src/components/Editor/Project/ScratchContainer.test.jsx", - "src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx", "src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.jsx", "src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.jsx", "src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js", From 95c3ae7c1c9af1cc4b8eb4a176a8fb35cf21dfdc Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:43:12 +0100 Subject: [PATCH 04/12] Migrate Menus test files to Vitest Previously ContextMenu, FileMenu, and the ten Sidebar test files (DownloadPanel, FilePanel, InstructionsPanel, ProgressBar, ProjectsPanel, SettingsPanel, ThemeToggle, Sidebar, SidebarBar, SidebarBarOption) only ran under Jest. This change swaps jest.fn/jest.mock for vi.fn/vi.mock across the batch. DownloadPanel needed the jszip-utils factory mock to return an explicit default export, matching the fix already applied to DownloadButton, since Vitest does not apply Jest's CJS default-export interop to mock factories. InstructionsPanel's "adds the demo instructions" test asserted the demo markdown had been replaced by Jest's jest-transform-stub, which returns the filename for any non-JS asset. Vitest's `?raw` import loads the real file content instead, so the assertion now compares against populateMarkdownTemplate(demoInstructions, ...) to match the component's actual behaviour rather than a Jest-only stand-in. Confirmed via full Jest and Vitest runs: 616 Jest + 435 Vitest tests pass, the same total as before the move. --- .../Menus/ContextMenu/ContextMenu.test.jsx | 2 +- .../DownloadPanel/DownloadPanel.test.jsx | 12 +++++----- .../InstructionsPanel.test.jsx | 10 +++++---- .../ProgressBar/ProgressBar.test.jsx | 8 +++---- .../ProjectsPanel/ProjectsPanel.test.jsx | 2 +- .../ThemeToggle/ThemeToggle.test.jsx | 22 +++++++++---------- .../Menus/Sidebar/SidebarBar.test.jsx | 2 +- .../Menus/Sidebar/SidebarBarOption.test.jsx | 2 +- test-runner-migration.js | 12 ---------- 9 files changed, 31 insertions(+), 41 deletions(-) diff --git a/src/components/Menus/ContextMenu/ContextMenu.test.jsx b/src/components/Menus/ContextMenu/ContextMenu.test.jsx index f14830fe2..6b0f45635 100644 --- a/src/components/Menus/ContextMenu/ContextMenu.test.jsx +++ b/src/components/Menus/ContextMenu/ContextMenu.test.jsx @@ -4,7 +4,7 @@ import { axe, toHaveNoViolations } from "jest-axe"; import ContextMenu from "./ContextMenu"; expect.extend(toHaveNoViolations); -const action1 = jest.fn(); +const action1 = vi.fn(); describe("With file items", () => { beforeEach(() => { diff --git a/src/components/Menus/Sidebar/DownloadPanel/DownloadPanel.test.jsx b/src/components/Menus/Sidebar/DownloadPanel/DownloadPanel.test.jsx index c5fb0852e..8dae35e72 100644 --- a/src/components/Menus/Sidebar/DownloadPanel/DownloadPanel.test.jsx +++ b/src/components/Menus/Sidebar/DownloadPanel/DownloadPanel.test.jsx @@ -5,16 +5,16 @@ import { MemoryRouter } from "react-router"; import configureStore from "redux-mock-store"; import FileSaver from "file-saver"; -jest.mock("file-saver"); -jest.mock("jszip"); -jest.mock("jszip-utils", () => ({ - getBinaryContent: jest.fn(), +vi.mock("file-saver"); +vi.mock("jszip"); +vi.mock("jszip-utils", () => ({ + default: { getBinaryContent: vi.fn() }, })); let container; -const logInHandler = jest.fn(); -const signUpHandler = jest.fn(); +const logInHandler = vi.fn(); +const signUpHandler = vi.fn(); beforeAll(() => { document.addEventListener("editor-logIn", logInHandler); diff --git a/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.jsx b/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.jsx index bfa47f5e8..cd1d22e9c 100644 --- a/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.jsx +++ b/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.jsx @@ -6,11 +6,13 @@ import { act } from "react"; import Modal from "react-modal"; import { scratchblocksInit } from "../../../../utils/scratchblocks"; import { renderWithProviders } from "../../../../utils/renderWithProviders"; +import demoInstructions from "../../../../assets/markdown/demoInstructions.md?raw"; +import populateMarkdownTemplate from "../../../../utils/populateMarkdownTemplate"; -window.HTMLElement.prototype.scrollTo = jest.fn(); +window.HTMLElement.prototype.scrollTo = vi.fn(); -jest.mock("../../../../utils/scratchblocks", () => ({ - scratchblocksInit: jest.fn(), +vi.mock("../../../../utils/scratchblocks", () => ({ + scratchblocksInit: vi.fn(), })); // Stand-in for the real (jsdom-unfriendly) scratchblocks SVG rendering: swap @@ -305,7 +307,7 @@ describe("When instructionsEditable is true", () => { }); expect(store.getState().editor.project.instructions).toBe( - "demoInstructions.md", + populateMarkdownTemplate(demoInstructions, (str) => str), ); }); diff --git a/src/components/Menus/Sidebar/InstructionsPanel/ProgressBar/ProgressBar.test.jsx b/src/components/Menus/Sidebar/InstructionsPanel/ProgressBar/ProgressBar.test.jsx index 061b87e8e..2a7174441 100644 --- a/src/components/Menus/Sidebar/InstructionsPanel/ProgressBar/ProgressBar.test.jsx +++ b/src/components/Menus/Sidebar/InstructionsPanel/ProgressBar/ProgressBar.test.jsx @@ -76,7 +76,7 @@ describe("When on a middle step", () => { }); test("Clicking previous step button calls scrollTo when panelRef is provided", () => { - const mockScrollTo = jest.fn(); + const mockScrollTo = vi.fn(); const panelRef = { current: { scrollTo: mockScrollTo } }; renderProgressBarOnStep(1, 3, panelRef); @@ -93,7 +93,7 @@ describe("When on a middle step", () => { }); test("Clicking next step button calls scrollTo when panelRef is provided", () => { - const mockScrollTo = jest.fn(); + const mockScrollTo = vi.fn(); const panelRef = { current: { scrollTo: mockScrollTo } }; renderProgressBarOnStep(1, 3, panelRef); @@ -110,7 +110,7 @@ describe("When on a middle step", () => { }); test("Does not call scrollTo when panelRef is null", () => { - const mockScrollTo = jest.fn(); + const mockScrollTo = vi.fn(); renderProgressBarOnStep(1, 3, null); @@ -123,7 +123,7 @@ describe("When on a middle step", () => { }); test("Does not call scrollTo when panelRef.current is null", () => { - const mockScrollTo = jest.fn(); + const mockScrollTo = vi.fn(); const panelRef = { current: null }; renderProgressBarOnStep(1, 3, panelRef); diff --git a/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx b/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx index f759e4e3b..9ab640af8 100644 --- a/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx +++ b/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx @@ -6,7 +6,7 @@ import { MemoryRouter } from "react-router-dom"; import ProjectsPanel from "./ProjectsPanel"; -document.dispatchEvent = jest.fn(); +document.dispatchEvent = vi.fn(); const initialState = { editor: { diff --git a/src/components/Menus/Sidebar/SettingsPanel/ThemeToggle/ThemeToggle.test.jsx b/src/components/Menus/Sidebar/SettingsPanel/ThemeToggle/ThemeToggle.test.jsx index 8a78f9d27..0a037c045 100644 --- a/src/components/Menus/Sidebar/SettingsPanel/ThemeToggle/ThemeToggle.test.jsx +++ b/src/components/Menus/Sidebar/SettingsPanel/ThemeToggle/ThemeToggle.test.jsx @@ -3,7 +3,7 @@ import { act, render, fireEvent, screen } from "@testing-library/react"; import ThemeToggle from "./ThemeToggle"; import { Cookies, CookiesProvider } from "react-cookie"; -const themeUpdatedHandler = jest.fn(); +const themeUpdatedHandler = vi.fn(); beforeAll(() => { document.addEventListener("editor-themeUpdated", (e) => @@ -20,11 +20,11 @@ describe("When default theme is light mode and cookie unset", () => { matches: false, media: query, onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), }); cookies = new Cookies(); toggleContainer = render( @@ -68,11 +68,11 @@ describe("When default theme is dark mode and cookie unset", () => { matches: true, media: query, onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), }); cookies = new Cookies(); toggleContainer = render( diff --git a/src/components/Menus/Sidebar/SidebarBar.test.jsx b/src/components/Menus/Sidebar/SidebarBar.test.jsx index 9014b4fbb..cce2d8336 100644 --- a/src/components/Menus/Sidebar/SidebarBar.test.jsx +++ b/src/components/Menus/Sidebar/SidebarBar.test.jsx @@ -4,7 +4,7 @@ import configureStore from "redux-mock-store"; import { Provider } from "react-redux"; import SidebarBar from "./SidebarBar"; -const toggleOption = jest.fn(); +const toggleOption = vi.fn(); const mockStore = configureStore([]); const initialState = { diff --git a/src/components/Menus/Sidebar/SidebarBarOption.test.jsx b/src/components/Menus/Sidebar/SidebarBarOption.test.jsx index 98008b379..c1915dfad 100644 --- a/src/components/Menus/Sidebar/SidebarBarOption.test.jsx +++ b/src/components/Menus/Sidebar/SidebarBarOption.test.jsx @@ -2,7 +2,7 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import SidebarBarOption from "./SidebarBarOption"; -const toggleOption = jest.fn(); +const toggleOption = vi.fn(); beforeEach(() => { render( diff --git a/test-runner-migration.js b/test-runner-migration.js index fd01a8359..e6d26754b 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -14,18 +14,6 @@ const JEST_ONLY_TEST_FILES = [ "src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.test.jsx", "src/components/Editor/Runners/PythonRunner/PythonRunner.test.jsx", "src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.jsx", - "src/components/Menus/ContextMenu/ContextMenu.test.jsx", - "src/components/Menus/FileMenu/FileMenu.test.jsx", - "src/components/Menus/Sidebar/DownloadPanel/DownloadPanel.test.jsx", - "src/components/Menus/Sidebar/FilePanel/FilePanel.test.jsx", - "src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.jsx", - "src/components/Menus/Sidebar/InstructionsPanel/ProgressBar/ProgressBar.test.jsx", - "src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx", - "src/components/Menus/Sidebar/SettingsPanel/SettingsPanel.test.jsx", - "src/components/Menus/Sidebar/SettingsPanel/ThemeToggle/ThemeToggle.test.jsx", - "src/components/Menus/Sidebar/Sidebar.test.jsx", - "src/components/Menus/Sidebar/SidebarBar.test.jsx", - "src/components/Menus/Sidebar/SidebarBarOption.test.jsx", "src/components/Mobile/MobileProject/MobileProject.test.jsx", "src/components/Modals/ErrorModal.test.jsx", "src/components/Modals/GeneralModal.test.jsx", From f1e55c06ad940c094e6081a864051656601744b4 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:44:09 +0100 Subject: [PATCH 05/12] Migrate Mobile and Modals test files to Vitest Previously MobileProject, ErrorModal, and GeneralModal only ran under Jest. This change swaps jest.fn for vi.fn across all three; none used jest.mock or other Jest-only APIs, so no other changes were needed. Confirmed via full Jest and Vitest runs: 600 Jest + 451 Vitest tests pass, the same total as before the move. --- .../Mobile/MobileProject/MobileProject.test.jsx | 2 +- src/components/Modals/ErrorModal.test.jsx | 2 +- src/components/Modals/GeneralModal.test.jsx | 8 ++++---- test-runner-migration.js | 3 --- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/Mobile/MobileProject/MobileProject.test.jsx b/src/components/Mobile/MobileProject/MobileProject.test.jsx index 7893a8ecf..e34051738 100644 --- a/src/components/Mobile/MobileProject/MobileProject.test.jsx +++ b/src/components/Mobile/MobileProject/MobileProject.test.jsx @@ -5,7 +5,7 @@ import configureStore from "redux-mock-store"; import MobileProject from "./MobileProject"; import { showSidebar } from "../../../redux/EditorSlice"; -window.HTMLElement.prototype.scrollIntoView = jest.fn(); +window.HTMLElement.prototype.scrollIntoView = vi.fn(); const middlewares = []; const mockStore = configureStore(middlewares); diff --git a/src/components/Modals/ErrorModal.test.jsx b/src/components/Modals/ErrorModal.test.jsx index e9a9ac0fa..a668ac3fa 100644 --- a/src/components/Modals/ErrorModal.test.jsx +++ b/src/components/Modals/ErrorModal.test.jsx @@ -120,7 +120,7 @@ test("Additional closeModal function fired", () => { }, }; const store = mockStore(initialState); - const testOnClose = jest.fn(); + const testOnClose = vi.fn(); render( diff --git a/src/components/Modals/GeneralModal.test.jsx b/src/components/Modals/GeneralModal.test.jsx index 8bad7a18c..b6a8e810e 100644 --- a/src/components/Modals/GeneralModal.test.jsx +++ b/src/components/Modals/GeneralModal.test.jsx @@ -2,8 +2,8 @@ import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import GeneralModal from "./GeneralModal"; -const defaultCallback = jest.fn(); -const closeModal = jest.fn(); +const defaultCallback = vi.fn(); +const closeModal = vi.fn(); describe("With close button", () => { beforeEach(() => { @@ -16,7 +16,7 @@ describe("With close button", () => { defaultCallback={defaultCallback} heading="My modal heading" text={[{ content: "Paragraph1", type: "paragraph" }]} - buttons={[]} + buttons={[]} /> , ); @@ -52,7 +52,7 @@ describe("Without close button", () => { defaultCallback={defaultCallback} heading="My modal heading" text={[{ content: "Paragraph1", type: "paragraph" }]} - buttons={[]} + buttons={[]} /> , ); diff --git a/test-runner-migration.js b/test-runner-migration.js index e6d26754b..50ebf1dc5 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -14,9 +14,6 @@ const JEST_ONLY_TEST_FILES = [ "src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.test.jsx", "src/components/Editor/Runners/PythonRunner/PythonRunner.test.jsx", "src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.jsx", - "src/components/Mobile/MobileProject/MobileProject.test.jsx", - "src/components/Modals/ErrorModal.test.jsx", - "src/components/Modals/GeneralModal.test.jsx", "src/components/ProjectBar/ProjectBar.test.jsx", "src/components/ProjectBar/ScratchProjectBar.test.jsx", "src/components/ProjectName/ProjectName.test.jsx", From 232055b0f23ccc86572c77fcb4ea28c94a48c5bc Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:53:35 +0100 Subject: [PATCH 06/12] Migrate ProjectBar, ProjectName, SaveButton, UploadButton, SaveStatus tests Previously ProjectBar, ScratchProjectBar, ProjectName, SaveButton, SaveStatus, and UploadButton only ran under Jest. This change swaps jest.fn/jest.mock/jest.useFakeTimers/jest.spyOn for their vi equivalents, and jest.requireActual for the async vi.importActual pattern already used for react-responsive. Rendering SaveStatus (directly, and via ProjectBar/ScratchProjectBar) crashed under Vitest because react-i18nexts real useTranslation returns an uninitialised i18n instance - nothing calls i18n.init() in a component test, unlike the full app - so SaveStatus reading i18n.options.fallbackLng threw. setupTests.vitest.js now mocks react-i18next globally the same way setupTests.js already does for Jest, which fixes this for any test rendering SaveStatus, not just this batch. ProjectNames "Updates project name" tests asserted store.getActions()).toEqual([updateProjectName(project.name)]), which only ever passed under Jest because its useDispatch mock returns a dispatch thats disconnected from the mock store, so getActions() is always [], and Jests toEqual treats [] and [undefined] (the auto-mocked action creators return value) as equal - a quirk Vitest doesnt share. Replaced with the equivalent, more direct expect(updateProjectName).toHaveBeenCalledWith(...) assertion already used elsewhere in the same file. StopButton.test.jsx was tried and left on the Jest list: it imports app/store, hitting the same oidc-client/localStorage gap under Vitest+jsdom+Node 22 documented for the Editor/Runners files. Confirmed via full Jest and Vitest runs: 510 Jest + 541 Vitest tests pass, the same total as before the move. --- src/components/ProjectBar/ProjectBar.test.jsx | 10 +++---- .../ProjectBar/ScratchProjectBar.test.jsx | 26 +++++++++---------- .../ProjectName/ProjectName.test.jsx | 12 ++++----- src/components/SaveButton/SaveButton.test.jsx | 4 +-- .../UploadButton/UploadButton.test.jsx | 4 +-- src/utils/setupTests.vitest.js | 18 +++++++++++++ test-runner-migration.js | 6 ----- 7 files changed, 46 insertions(+), 34 deletions(-) diff --git a/src/components/ProjectBar/ProjectBar.test.jsx b/src/components/ProjectBar/ProjectBar.test.jsx index 46c7f0cfc..bd37f068f 100644 --- a/src/components/ProjectBar/ProjectBar.test.jsx +++ b/src/components/ProjectBar/ProjectBar.test.jsx @@ -6,12 +6,12 @@ import { MemoryRouter } from "react-router-dom"; import ProjectBar from "./ProjectBar"; import useIsOnline from "../../hooks/useIsOnline"; -jest.mock("axios"); -jest.mock("../../hooks/useIsOnline"); +vi.mock("axios"); +vi.mock("../../hooks/useIsOnline"); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), - useNavigate: () => jest.fn(), +vi.mock("react-router-dom", async () => ({ + ...(await vi.importActual("react-router-dom")), + useNavigate: () => vi.fn(), })); const project = { diff --git a/src/components/ProjectBar/ScratchProjectBar.test.jsx b/src/components/ProjectBar/ScratchProjectBar.test.jsx index f054cf26d..85840103e 100644 --- a/src/components/ProjectBar/ScratchProjectBar.test.jsx +++ b/src/components/ProjectBar/ScratchProjectBar.test.jsx @@ -7,18 +7,18 @@ import ScratchProjectBar from "./ScratchProjectBar"; import editorReducer, { editorInitialState } from "../../redux/EditorSlice"; import { postMessageToScratchIframe } from "../../utils/scratchIframe"; -jest.mock("axios"); -jest.mock("../../utils/scratchIframe", () => ({ - ...jest.requireActual("../../utils/scratchIframe"), - postMessageToScratchIframe: jest.fn(), +vi.mock("axios"); +vi.mock("../../utils/scratchIframe", async () => ({ + ...(await vi.importActual("../../utils/scratchIframe")), + postMessageToScratchIframe: vi.fn(), })); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), - useNavigate: () => jest.fn(), +vi.mock("react-router-dom", async () => ({ + ...(await vi.importActual("react-router-dom")), + useNavigate: () => vi.fn(), })); -jest.useFakeTimers(); +vi.useFakeTimers(); const scratchProject = { name: "Hello world", @@ -99,11 +99,11 @@ const dispatchScratchMessage = (type, origin = getScratchOrigin()) => { }; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); afterEach(() => { - jest.clearAllTimers(); + vi.clearAllTimers(); }); describe("When project is Scratch", () => { @@ -175,7 +175,7 @@ describe("When project is Scratch", () => { dispatchScratchMessage("scratch-gui-project-changed"); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(postMessageToScratchIframe).toHaveBeenCalledWith({ @@ -202,7 +202,7 @@ describe("When project is Scratch", () => { dispatchScratchMessage("scratch-gui-project-changed"); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(postMessageToScratchIframe).toHaveBeenCalledWith({ @@ -279,7 +279,7 @@ describe("Additional Scratch manual save states", () => { dispatchScratchMessage("scratch-gui-project-changed"); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(postMessageToScratchIframe).not.toHaveBeenCalled(); diff --git a/src/components/ProjectName/ProjectName.test.jsx b/src/components/ProjectName/ProjectName.test.jsx index 5f6c8e95b..e18954acc 100644 --- a/src/components/ProjectName/ProjectName.test.jsx +++ b/src/components/ProjectName/ProjectName.test.jsx @@ -17,10 +17,10 @@ let store; let editButton; let inputField; -jest.mock("../../redux/EditorSlice"); -jest.mock("react-redux", () => ({ - ...jest.requireActual("react-redux"), - useDispatch: () => jest.fn(), +vi.mock("../../redux/EditorSlice"); +vi.mock("react-redux", async () => ({ + ...(await vi.importActual("react-redux")), + useDispatch: () => vi.fn(), })); describe("With a label", () => { @@ -178,7 +178,7 @@ describe("With no label", () => { }); test("Updates project name", () => { - expect(store.getActions()).toEqual([updateProjectName(project.name)]); + expect(updateProjectName).toHaveBeenCalledWith(project.name); }); test("Disables input field", async () => { @@ -198,7 +198,7 @@ describe("With no label", () => { }); test("Updates project name", () => { - expect(store.getActions()).toEqual([updateProjectName(project.name)]); + expect(updateProjectName).toHaveBeenCalledWith(project.name); }); test("Disables input field", async () => { diff --git a/src/components/SaveButton/SaveButton.test.jsx b/src/components/SaveButton/SaveButton.test.jsx index fff7f01c1..ccad526cb 100644 --- a/src/components/SaveButton/SaveButton.test.jsx +++ b/src/components/SaveButton/SaveButton.test.jsx @@ -6,9 +6,9 @@ import { triggerSave } from "../../redux/EditorSlice"; import SaveButton from "./SaveButton"; import useIsOnline from "../../hooks/useIsOnline"; -jest.mock("../../hooks/useIsOnline"); +vi.mock("../../hooks/useIsOnline"); -const logInHandler = jest.fn(); +const logInHandler = vi.fn(); describe("When project is loaded", () => { beforeAll(() => { diff --git a/src/components/UploadButton/UploadButton.test.jsx b/src/components/UploadButton/UploadButton.test.jsx index 8e3052303..74b6ec36b 100644 --- a/src/components/UploadButton/UploadButton.test.jsx +++ b/src/components/UploadButton/UploadButton.test.jsx @@ -5,7 +5,7 @@ import { configureStore } from "@reduxjs/toolkit"; import UploadButton from "./UploadButton"; import { postMessageToScratchIframe } from "../../utils/scratchIframe"; -jest.mock("../../utils/scratchIframe"); +vi.mock("../../utils/scratchIframe"); const createStore = (project) => configureStore({ @@ -38,7 +38,7 @@ describe("UploadButton", () => { test("clicking the button triggers the file input", () => { const button = screen.getByRole("button", { name: "Upload" }); - const clickSpy = jest.spyOn(fileInput, "click"); + const clickSpy = vi.spyOn(fileInput, "click"); fireEvent.click(button); expect(clickSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/utils/setupTests.vitest.js b/src/utils/setupTests.vitest.js index 1b8cbf5a9..2a88fc4df 100644 --- a/src/utils/setupTests.vitest.js +++ b/src/utils/setupTests.vitest.js @@ -20,3 +20,21 @@ vi.mock("@raspberrypifoundation/python-friendly-error-messages", () => ({ cpythonAdapter: {}, friendlyExplain: vi.fn(), })); + +// react-i18next's real useTranslation returns an uninitialised i18n +// instance under Vitest (no app entrypoint runs first to call i18n.init()), +// so anything reading i18n.options - e.g. SaveStatus - crashes. Mocked +// globally to match setupTests.js's Jest equivalent. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (str) => (str.includes("null") ? null : str), + i18n: { + changeLanguage: () => new Promise(() => {}), + language: "ja-JP", + options: { + locales: ["en", "es-LA", "fr-FR", "ja-JP"], + }, + }, + }), + Trans: ({ children, i18nKey }) => children || i18nKey, +})); diff --git a/test-runner-migration.js b/test-runner-migration.js index 50ebf1dc5..12f1b4630 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -14,13 +14,7 @@ const JEST_ONLY_TEST_FILES = [ "src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.test.jsx", "src/components/Editor/Runners/PythonRunner/PythonRunner.test.jsx", "src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.jsx", - "src/components/ProjectBar/ProjectBar.test.jsx", - "src/components/ProjectBar/ScratchProjectBar.test.jsx", - "src/components/ProjectName/ProjectName.test.jsx", "src/components/RunButton/StopButton.test.jsx", - "src/components/SaveButton/SaveButton.test.jsx", - "src/components/SaveStatus/SaveStatus.test.jsx", - "src/components/UploadButton/UploadButton.test.jsx", "src/components/WebComponentProject/WebComponentProject.integration.test.jsx", "src/components/WebComponentProject/WebComponentProject.test.jsx", "src/components/WebComponentProject/runEventCodeSnapshot.test.js", From fd5bdb2b897abfb96d462036d0883455f177ced1 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:53:57 +0100 Subject: [PATCH 07/12] Drop redundant comment from setupTests.vitest.js The react-i18next mock added in the previous commit already has its rationale in that commits message; the inline comment restating it was redundant. --- src/utils/setupTests.vitest.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/utils/setupTests.vitest.js b/src/utils/setupTests.vitest.js index 2a88fc4df..1b60faaf4 100644 --- a/src/utils/setupTests.vitest.js +++ b/src/utils/setupTests.vitest.js @@ -21,10 +21,6 @@ vi.mock("@raspberrypifoundation/python-friendly-error-messages", () => ({ friendlyExplain: vi.fn(), })); -// react-i18next's real useTranslation returns an uninitialised i18n -// instance under Vitest (no app entrypoint runs first to call i18n.init()), -// so anything reading i18n.options - e.g. SaveStatus - crashes. Mocked -// globally to match setupTests.js's Jest equivalent. vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (str) => (str.includes("null") ? null : str), From 6f03f93d25473ccb7681221beb287cf349711dd4 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:57:00 +0100 Subject: [PATCH 08/12] Migrate WebComponentProject and runEventCodeSnapshot tests Previously WebComponentProject.test.jsx and runEventCodeSnapshot.test.js only ran under Jest. This change swaps jest.fn/jest.useFakeTimers/jest.advanceTimersByTime for their vi equivalents. Rendering either file crashed under Vitest because src/utils/i18n.js imports initReactI18next from react-i18next, which the react-i18next mock added in the previous commit did not export - it only replaced useTranslation and Trans. setupTests.vitest.js now also mocks ./i18n itself the same way setupTests.js does for Jest, so the real i18n.js (and its initReactI18next usage) never runs in a component test. WebComponentProject.integration.test.jsx and src/containers/WebComponentLoader.test.jsx were tried and left on the Jest list: both hit the same window.localStorage-is-undefined gap under Vitest+jsdom+Node 22 already documented for the Editor/Runners files, this time via ScratchContainer and direct localStorage calls respectively. Confirmed via full Jest and Vitest runs: 474 Jest + 577 Vitest tests pass, the same total as before the move. --- .../WebComponentProject.test.jsx | 14 +++++----- .../runEventCodeSnapshot.test.js | 28 +++++++++---------- src/utils/setupTests.vitest.js | 5 ++++ test-runner-migration.js | 2 -- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/components/WebComponentProject/WebComponentProject.test.jsx b/src/components/WebComponentProject/WebComponentProject.test.jsx index 9f2aa64fb..d7df95a85 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.jsx +++ b/src/components/WebComponentProject/WebComponentProject.test.jsx @@ -7,10 +7,10 @@ import WebComponentProject, { } from "./WebComponentProject"; import { RUN_EVENT_DEBOUNCE_MS } from "./runEventCodeSnapshot"; -const codeChangedHandler = jest.fn(); -const runStartedHandler = jest.fn(); -const runCompletedHandler = jest.fn(); -const stepChangedHandler = jest.fn(); +const codeChangedHandler = vi.fn(); +const runStartedHandler = vi.fn(); +const runCompletedHandler = vi.fn(); +const stepChangedHandler = vi.fn(); beforeAll(() => { document.addEventListener("editor-codeChanged", codeChangedHandler); @@ -19,11 +19,11 @@ beforeAll(() => { document.addEventListener("editor-stepChanged", stepChangedHandler); }); -jest.useFakeTimers(); +vi.useFakeTimers(); const flushRunEventDebounce = () => { act(() => { - jest.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); + vi.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); }); }; @@ -96,7 +96,7 @@ describe("When state set", () => { test("Triggers codeChanged event", () => { act(() => { - jest.runAllTimers(); + vi.runAllTimers(); }); expect(codeChangedHandler).toHaveBeenCalled(); expect(codeChangedHandler.mock.lastCall[0].detail).toHaveProperty("step"); diff --git a/src/components/WebComponentProject/runEventCodeSnapshot.test.js b/src/components/WebComponentProject/runEventCodeSnapshot.test.js index 3a6fb1e11..b92bf2749 100644 --- a/src/components/WebComponentProject/runEventCodeSnapshot.test.js +++ b/src/components/WebComponentProject/runEventCodeSnapshot.test.js @@ -13,21 +13,21 @@ const components = [ ]; const flushDebounce = () => { - jest.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); + vi.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); }; describe("runEventCodeSnapshot", () => { beforeEach(() => { - jest.useFakeTimers(); + vi.useFakeTimers(); resetRunEventCodeSnapshot(); }); afterEach(() => { - jest.useRealTimers(); + vi.useRealTimers(); }); test("allows the first run for a project after debounce", () => { - const onRunStarted = jest.fn(); + const onRunStarted = vi.fn(); scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); flushDebounce(); @@ -37,7 +37,7 @@ describe("runEventCodeSnapshot", () => { }); test("suppresses repeated runs with unchanged code", () => { - const onRunStarted = jest.fn(); + const onRunStarted = vi.fn(); scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); flushDebounce(); @@ -51,7 +51,7 @@ describe("runEventCodeSnapshot", () => { }); test("allows a run after code changes", () => { - const onRunStarted = jest.fn(); + const onRunStarted = vi.fn(); scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); flushDebounce(); @@ -69,8 +69,8 @@ describe("runEventCodeSnapshot", () => { }); test("collapses a rapid burst into one run event", () => { - const onRunStarted = jest.fn(); - const onRunCompletedIfRunAlreadyEnded = jest.fn(); + const onRunStarted = vi.fn(); + const onRunCompletedIfRunAlreadyEnded = vi.fn(); scheduleRunEventCycle( "project-a", @@ -95,8 +95,8 @@ describe("runEventCodeSnapshot", () => { }); test("emits run completed on run end after debounced start", () => { - const onRunStarted = jest.fn(); - const onRunCompleted = jest.fn(); + const onRunStarted = vi.fn(); + const onRunCompleted = vi.fn(); scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); flushDebounce(); @@ -108,7 +108,7 @@ describe("runEventCodeSnapshot", () => { }); test("resets snapshot when the project identifier changes", () => { - const onRunStarted = jest.fn(); + const onRunStarted = vi.fn(); scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); flushDebounce(); @@ -122,7 +122,7 @@ describe("runEventCodeSnapshot", () => { }); test("allows separate bursts after debounce quiet period", () => { - const onRunStarted = jest.fn(); + const onRunStarted = vi.fn(); scheduleRunEventCycle( "project-a", @@ -133,7 +133,7 @@ describe("runEventCodeSnapshot", () => { flushDebounce(); endRunEventCycle(); - jest.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); + vi.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); scheduleRunEventCycle( "project-a", @@ -148,7 +148,7 @@ describe("runEventCodeSnapshot", () => { }); test("does not fire pending callbacks after debounce is cancelled", () => { - const onRunStarted = jest.fn(); + const onRunStarted = vi.fn(); scheduleRunEventCycle( "project-a", diff --git a/src/utils/setupTests.vitest.js b/src/utils/setupTests.vitest.js index 1b60faaf4..60ac57915 100644 --- a/src/utils/setupTests.vitest.js +++ b/src/utils/setupTests.vitest.js @@ -33,4 +33,9 @@ vi.mock("react-i18next", () => ({ }, }), Trans: ({ children, i18nKey }) => children || i18nKey, + initReactI18next: {}, +})); + +vi.mock("./i18n", () => ({ + t: (string) => string, })); diff --git a/test-runner-migration.js b/test-runner-migration.js index 12f1b4630..ce104a2d5 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -16,8 +16,6 @@ const JEST_ONLY_TEST_FILES = [ "src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.jsx", "src/components/RunButton/StopButton.test.jsx", "src/components/WebComponentProject/WebComponentProject.integration.test.jsx", - "src/components/WebComponentProject/WebComponentProject.test.jsx", - "src/components/WebComponentProject/runEventCodeSnapshot.test.js", "src/containers/WebComponentLoader.test.jsx", "src/hooks/useAutoSave/useAutoSave.test.js", "src/hooks/useContainerMinWidth.test.js", From fcf1ecf5f979dea007484c932d807f581430c34f Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:07:11 +0100 Subject: [PATCH 09/12] Migrate hooks test files to Vitest Previously useAutoSave, useContainerMinWidth, useIsOnline, useProject, useProjectPersistence, and the two useScratchSave test files only ran under Jest. This change swaps jest.fn/jest.mock/jest.useFakeTimers/ jest.advanceTimersByTime/jest.runAllTimers/jest.clearAllMocks/ jest.clearAllTimers for their vi equivalents, jest.requireActual for the async vi.importActual pattern, and, in useProject.test.jsx, wraps the apiCallHandler factory mocks return value in an explicit default key - the same jszip-utils-style fix already applied elsewhere, since Vitest does not apply Jests CJS default-export interop to mock factories. Rendering useProject and useProjectPersistence crashed under Vitest because window.localStorage is undefined in this environment: Node 22 defines its own global localStorage accessor, which takes precedence over jsdoms and returns undefined unless --localstorage-file is passed. setupTests.vitest.js now replaces it with a minimal in-memory Storage, which fixes this for any test that reads or writes localStorage, not just this batch. Making that work surfaced a second gap: defaultProjects.js and Notifications.jsx import the i18n instance from ./i18n as a default export, but setupTests.vitest.jss existing ./i18n mock only exported a bare t function with no default key, so Vitest could not resolve the import. Fixed by nesting it under default, matching the shape setupTests.js already mocks for Jest. Confirmed via full Jest and Vitest runs: 323 Jest + 728 Vitest tests pass, the same total as before the move. --- src/hooks/useAutoSave/useAutoSave.test.js | 22 ++--- src/hooks/useContainerMinWidth.test.js | 6 +- src/hooks/useIsOnline.test.js | 16 ++-- src/hooks/useProject.test.jsx | 52 ++++++------ src/hooks/useProjectPersistence.test.js | 80 +++++++++---------- .../scratchSaveLifecycle.test.js | 30 +++---- .../useScratchSaveState.test.jsx | 52 ++++++------ src/utils/setupTests.vitest.js | 35 +++++++- test-runner-migration.js | 7 -- 9 files changed, 164 insertions(+), 136 deletions(-) diff --git a/src/hooks/useAutoSave/useAutoSave.test.js b/src/hooks/useAutoSave/useAutoSave.test.js index 5a370e313..6d67bce44 100644 --- a/src/hooks/useAutoSave/useAutoSave.test.js +++ b/src/hooks/useAutoSave/useAutoSave.test.js @@ -11,8 +11,8 @@ let mockDispatch; let resolveSave; let rejectSave; -jest.mock("react-redux", () => ({ - ...jest.requireActual("react-redux"), +vi.mock("react-redux", async () => ({ + ...(await vi.importActual("react-redux")), useDispatch: () => mockDispatch, useSelector: (selector) => selector({ @@ -26,12 +26,12 @@ jest.mock("react-redux", () => ({ }), })); -jest.mock("../../redux/EditorSlice", () => ({ - ...jest.requireActual("../../redux/EditorSlice"), - syncProject: jest.fn((_) => jest.fn()), +vi.mock("../../redux/EditorSlice", async () => ({ + ...(await vi.importActual("../../redux/EditorSlice")), + syncProject: vi.fn((_) => vi.fn()), })); -jest.useFakeTimers(); +vi.useFakeTimers(); const user1 = { access_token: "myAccessToken1", @@ -71,10 +71,10 @@ const editedProject = { }; const saveAction = { type: "SAVE_PROJECT" }; -const saveProject = jest.fn(() => saveAction); +const saveProject = vi.fn(() => saveAction); const createAsyncThunkDispatchMock = () => - jest.fn(() => { + vi.fn(() => { const thunkPromise = new Promise((resolve) => { resolveSave = resolve; rejectSave = (error) => @@ -99,7 +99,7 @@ beforeEach(() => { mockInitialProjectInstructions = project.instructions ?? null; mockSaving = "idle"; mockCodeRunInProgress = false; - syncProject.mockImplementation(jest.fn((_) => saveProject)); + syncProject.mockImplementation(vi.fn((_) => saveProject)); mockDispatch = createAsyncThunkDispatchMock(); }); @@ -182,7 +182,7 @@ describe("useAutoSave", () => { }); act(() => { - jest.advanceTimersByTime(10000); + vi.advanceTimersByTime(10000); }); expect(mockDispatch).toHaveBeenCalledTimes(2); @@ -319,7 +319,7 @@ describe("useAutoSave", () => { expect(mockDispatch).toHaveBeenCalledTimes(1); act(() => { - jest.advanceTimersByTime(10000); + vi.advanceTimersByTime(10000); }); expect(mockDispatch).toHaveBeenCalledTimes(2); diff --git a/src/hooks/useContainerMinWidth.test.js b/src/hooks/useContainerMinWidth.test.js index e74743819..1ed9de359 100644 --- a/src/hooks/useContainerMinWidth.test.js +++ b/src/hooks/useContainerMinWidth.test.js @@ -8,14 +8,14 @@ let observers; class MockResizeObserver { constructor(callback) { this.callback = callback; - this.observe = jest.fn(); - this.disconnect = jest.fn(); + this.observe = vi.fn(); + this.disconnect = vi.fn(); observers.push(this); } } const makeElement = (width) => ({ - getBoundingClientRect: jest.fn(() => ({ width })), + getBoundingClientRect: vi.fn(() => ({ width })), }); const renderObservedHook = (width) => { diff --git a/src/hooks/useIsOnline.test.js b/src/hooks/useIsOnline.test.js index d8406b664..0c38c4d1a 100644 --- a/src/hooks/useIsOnline.test.js +++ b/src/hooks/useIsOnline.test.js @@ -17,7 +17,7 @@ describe("useIsOnline", () => { get: () => true, }); - mockPostMessage = jest.fn(); + mockPostMessage = vi.fn(); swEventTarget = new EventTarget(); swEventTarget.controller = { postMessage: mockPostMessage }; @@ -82,17 +82,17 @@ describe("useIsOnline", () => { describe("CHECK_ONLINE polling", () => { beforeEach(() => { - jest.useFakeTimers(); + vi.useFakeTimers(); }); afterEach(() => { - jest.useRealTimers(); + vi.useRealTimers(); }); test("does not poll while online", async () => { renderHook(() => useIsOnline()); await act(async () => { - jest.advanceTimersByTime(6000); + vi.advanceTimersByTime(6000); }); expect(mockPostMessage).not.toHaveBeenCalled(); }); @@ -103,13 +103,13 @@ describe("useIsOnline", () => { expect(result.current).toBe(false); await act(async () => { - jest.advanceTimersByTime(3000); + vi.advanceTimersByTime(3000); }); expect(mockPostMessage).toHaveBeenCalledTimes(1); expect(mockPostMessage).toHaveBeenCalledWith({ type: "CHECK_ONLINE" }); await act(async () => { - jest.advanceTimersByTime(3000); + vi.advanceTimersByTime(3000); }); expect(mockPostMessage).toHaveBeenCalledTimes(2); }); @@ -121,7 +121,7 @@ describe("useIsOnline", () => { expect(result.current).toBe(true); await act(async () => { - jest.advanceTimersByTime(6000); + vi.advanceTimersByTime(6000); }); expect(mockPostMessage).not.toHaveBeenCalled(); }); @@ -134,7 +134,7 @@ describe("useIsOnline", () => { unmount(); await act(async () => { - jest.advanceTimersByTime(6000); + vi.advanceTimersByTime(6000); }); expect(mockPostMessage).not.toHaveBeenCalled(); }); diff --git a/src/hooks/useProject.test.jsx b/src/hooks/useProject.test.jsx index d2805d7af..f13ff2b3f 100644 --- a/src/hooks/useProject.test.jsx +++ b/src/hooks/useProject.test.jsx @@ -6,21 +6,23 @@ import { useProject } from "./useProject"; import { syncProject, setProject } from "../redux/EditorSlice"; import { defaultPythonProject } from "../utils/defaultProjects"; -jest.mock("react-redux", () => ({ - ...jest.requireActual("react-redux"), - useDispatch: () => jest.fn(), +vi.mock("react-redux", async () => ({ + ...(await vi.importActual("react-redux")), + useDispatch: () => vi.fn(), })); -const loadProject = jest.fn(); +const loadProject = vi.fn(); const reactAppApiEndpoint = "localhost"; -jest.mock("../redux/EditorSlice"); +vi.mock("../redux/EditorSlice"); -jest.mock("../utils/apiCallHandler", () => () => ({ - readProject: async (identifier, projectType) => - Promise.resolve({ - data: { identifier: identifier, project_type: projectType }, - }), +vi.mock("../utils/apiCallHandler", () => ({ + default: () => ({ + readProject: async (identifier, projectType) => + Promise.resolve({ + data: { identifier: identifier, project_type: projectType }, + }), + }), })); const cachedProject = { @@ -136,7 +138,7 @@ describe("When not embedded", () => { }); test("If embedded prop is true before embedded state is set, loads from server instead of cache", () => { - syncProject.mockImplementation(jest.fn((_) => jest.fn())); + syncProject.mockImplementation(vi.fn((_) => vi.fn())); localStorage.setItem( cachedProject.identifier, JSON.stringify(cachedProject), @@ -157,7 +159,7 @@ describe("When not embedded", () => { }); test("If cached project does not match identifier, does not use cached project", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => jest.fn())); + syncProject.mockImplementationOnce(vi.fn((_) => vi.fn())); localStorage.setItem("project", JSON.stringify(cachedProject)); renderHook( () => useProject({ projectIdentifier: "my-favourite-project" }), @@ -172,7 +174,7 @@ describe("When not embedded", () => { }); test("If cached project does not match locale, does not use cached project", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => jest.fn())); + syncProject.mockImplementationOnce(vi.fn((_) => vi.fn())); localStorage.setItem("project", JSON.stringify(cachedProject)); renderHook( () => @@ -190,7 +192,7 @@ describe("When not embedded", () => { test("If current project has changed and locale changes, keeps current project", async () => { setCurrentProjectWithEdits(); - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); renderHook( () => @@ -233,7 +235,7 @@ describe("When not embedded", () => { }); test("If cached project does not match locale and browserPreview query is used outside embedded viewer, does not use cached project", () => { - syncProject.mockImplementation(jest.fn((_) => jest.fn())); + syncProject.mockImplementation(vi.fn((_) => vi.fn())); window.history.pushState( {}, "", @@ -258,7 +260,7 @@ describe("When not embedded", () => { }); test("If cached project does not match identifier and locale, loads correct uncached project", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); localStorage.setItem("project", JSON.stringify(cachedProject)); renderHook( () => @@ -282,7 +284,7 @@ describe("When not embedded", () => { }); test("If loadCache is set to false, loads correct uncached project", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); localStorage.setItem("project", JSON.stringify(cachedProject)); renderHook( () => @@ -306,7 +308,7 @@ describe("When not embedded", () => { }); test("If no cached project, loads uncached project", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); renderHook( () => useProject({ @@ -328,7 +330,7 @@ describe("When not embedded", () => { }); test("If requested locale does not match the set language, does not set project", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => jest.fn())); + syncProject.mockImplementationOnce(vi.fn((_) => vi.fn())); renderHook( () => useProject({ projectIdentifier: "my-favourite-project" }), { @@ -374,7 +376,7 @@ describe("When not embedded", () => { }); test("If loadRemix of a project is requested and remixLoadFailed is false", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); renderHook( () => useProject({ @@ -396,7 +398,7 @@ describe("When not embedded", () => { }); test("If loadRemix of a project is requested and remixLoadFailed is true", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); renderHook( () => useProject({ @@ -420,7 +422,7 @@ describe("When not embedded", () => { }); test("it calls loadProject when access token becomes available", async () => { - syncProject.mockImplementation(jest.fn((_) => loadProject)); + syncProject.mockImplementation(vi.fn((_) => loadProject)); const { rerender } = renderHook((props) => useProject(props), { initialProps: { projectIdentifier: project1.identifier, @@ -444,7 +446,7 @@ describe("When not embedded", () => { }); test("it does not call loadProject when access token changes", async () => { - syncProject.mockImplementation(jest.fn((_) => loadProject)); + syncProject.mockImplementation(vi.fn((_) => loadProject)); const { rerender } = renderHook((props) => useProject(props), { initialProps: { projectIdentifier: project1.identifier, @@ -468,7 +470,7 @@ describe("When not embedded", () => { }); test("If assetsIdentifer is set then set assetsOnly to true when loading project", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); renderHook( () => useProject({ @@ -723,7 +725,7 @@ describe("When embedded", () => { }); test("If embedded and cached project, loads from server", async () => { - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); localStorage.setItem("hello-world-project", JSON.stringify(cachedProject)); renderHook( () => diff --git a/src/hooks/useProjectPersistence.test.js b/src/hooks/useProjectPersistence.test.js index 601121a97..d3c72ae09 100644 --- a/src/hooks/useProjectPersistence.test.js +++ b/src/hooks/useProjectPersistence.test.js @@ -14,8 +14,8 @@ let mockSaving = "idle"; let mockCodeRunInProgress = false; let mockDispatch; -jest.mock("react-redux", () => ({ - ...jest.requireActual("react-redux"), +vi.mock("react-redux", async () => ({ + ...(await vi.importActual("react-redux")), useDispatch: () => mockDispatch, useSelector: (selector) => selector({ @@ -29,22 +29,22 @@ jest.mock("react-redux", () => ({ }), })); -jest.mock("../redux/EditorSlice", () => ({ - ...jest.requireActual("../redux/EditorSlice"), - syncProject: jest.fn((_) => jest.fn()), - expireJustLoaded: jest.fn(), - setHasShownSavePrompt: jest.fn(), +vi.mock("../redux/EditorSlice", async () => ({ + ...(await vi.importActual("../redux/EditorSlice")), + syncProject: vi.fn((_) => vi.fn()), + expireJustLoaded: vi.fn(), + setHasShownSavePrompt: vi.fn(), })); -jest.mock("../utils/Notifications"); +vi.mock("../utils/Notifications"); const remixAction = { type: "REMIX_PROJECT" }; -const remixProject = jest.fn(() => remixAction); +const remixProject = vi.fn(() => remixAction); const saveAction = { type: "SAVE_PROJECT" }; -const saveProject = jest.fn(() => saveAction); -const loadProject = jest.fn(); +const saveProject = vi.fn(() => saveAction); +const loadProject = vi.fn(); -jest.useFakeTimers(); +vi.useFakeTimers(); const user1 = { access_token: "myAccessToken1", @@ -89,7 +89,7 @@ const editedProject = { }; const createAsyncThunkDispatchMock = (resolveImmediately = saveAction) => - jest.fn(() => { + vi.fn(() => { const thunkPromise = typeof resolveImmediately === "function" ? new Promise((resolve) => { @@ -135,7 +135,7 @@ describe("When not logged in", () => { justLoaded: true, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Expires justLoaded", () => { @@ -156,7 +156,7 @@ describe("When not logged in", () => { justLoaded: false, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Login prompt shown", () => { @@ -184,7 +184,7 @@ describe("When not logged in", () => { hasShownSavePrompt: true, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Login prompt shown", () => { @@ -210,7 +210,7 @@ describe("When logged in", () => { justLoaded: true, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Expires justLoaded", () => { @@ -231,7 +231,7 @@ describe("When logged in", () => { justLoaded: true, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Expires justLoaded", () => { @@ -254,7 +254,7 @@ describe("When logged in", () => { justLoaded: false, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Save prompt shown", () => { @@ -282,7 +282,7 @@ describe("When logged in", () => { hasShownSavePrompt: true, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Save prompt not shown again", () => { @@ -298,8 +298,8 @@ describe("When logged in", () => { describe("When project has identifier and save triggered", () => { beforeEach(() => { - syncProject.mockImplementationOnce(jest.fn((_) => remixProject)); - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => remixProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); renderHook(() => useProjectPersistence({ @@ -308,7 +308,7 @@ describe("When logged in", () => { saveTriggered: true, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Clicking save dispatches remixProject with correct parameters", async () => { @@ -329,8 +329,8 @@ describe("When logged in", () => { describe("When project has identifier and save triggered without loadRemix", () => { beforeEach(() => { - syncProject.mockImplementationOnce(jest.fn((_) => remixProject)); - syncProject.mockImplementationOnce(jest.fn((_) => loadProject)); + syncProject.mockImplementationOnce(vi.fn((_) => remixProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); renderHook(() => useProjectPersistence({ @@ -340,7 +340,7 @@ describe("When logged in", () => { loadRemix: false, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Clicking save dispatches remixProject with correct parameters", async () => { @@ -358,7 +358,7 @@ describe("When logged in", () => { describe("When project has no identifier and save triggered", () => { beforeEach(() => { - syncProject.mockImplementationOnce(jest.fn((_) => saveProject)); + syncProject.mockImplementationOnce(vi.fn((_) => saveProject)); renderHook(() => useProjectPersistence({ user: user2, @@ -366,7 +366,7 @@ describe("When logged in", () => { saveTriggered: true, }), ); - jest.runAllTimers(); + vi.runAllTimers(); }); test("Save project is called with the correct parameters", async () => { @@ -381,7 +381,7 @@ describe("When logged in", () => { describe("When user can autosave to the API", () => { beforeEach(() => { - syncProject.mockImplementation(jest.fn((_) => saveProject)); + syncProject.mockImplementation(vi.fn((_) => saveProject)); }); test("Does not autosave unchanged project to database", () => { @@ -392,7 +392,7 @@ describe("When logged in", () => { saveTriggered: false, }), ); - jest.runAllTimers(); + vi.runAllTimers(); expect(saveProject).not.toHaveBeenCalled(); }); @@ -404,7 +404,7 @@ describe("When logged in", () => { saveTriggered: false, }), ); - jest.runAllTimers(); + vi.runAllTimers(); expect(saveProject).toHaveBeenCalledWith({ project: editedProject, accessToken: user1.access_token, @@ -439,7 +439,7 @@ describe("When logged in", () => { ); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(saveProject).toHaveBeenCalledTimes(1); @@ -447,7 +447,7 @@ describe("When logged in", () => { rerender({ project: furtherEditedProject }); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(saveProject).toHaveBeenCalledTimes(1); @@ -458,7 +458,7 @@ describe("When logged in", () => { }); act(() => { - jest.advanceTimersByTime(10000); + vi.advanceTimersByTime(10000); }); expect(saveProject).toHaveBeenCalledTimes(2); @@ -478,7 +478,7 @@ describe("When logged in", () => { saveTriggered: false, }), ); - jest.runAllTimers(); + vi.runAllTimers(); expect(saveProject).not.toHaveBeenCalled(); expect(expireJustLoaded).toHaveBeenCalled(); }); @@ -492,7 +492,7 @@ describe("When logged in", () => { saveTriggered: false, }), ); - jest.runAllTimers(); + vi.runAllTimers(); expect(saveProject).toHaveBeenCalledWith({ project: editedProject, accessToken: user1.access_token, @@ -519,9 +519,9 @@ describe("When logged in", () => { saveTriggered: false, }), ); - jest.advanceTimersByTime(2500); + vi.advanceTimersByTime(2500); expect(saveProject).not.toHaveBeenCalled(); - jest.runAllTimers(); + vi.runAllTimers(); expect(saveProject).toHaveBeenCalledWith({ project: largeProject, accessToken: user1.access_token, @@ -537,7 +537,7 @@ describe("When logged in", () => { saveTriggered: true, }), ); - jest.runAllTimers(); + vi.runAllTimers(); expect(saveProject).toHaveBeenCalledWith({ project, accessToken: user1.access_token, @@ -555,7 +555,7 @@ describe("When logged in", () => { saveTriggered: false, }), ); - jest.runAllTimers(); + vi.runAllTimers(); expect(saveProject).toHaveBeenCalledWith({ project, accessToken: user1.access_token, diff --git a/src/hooks/useScratchSave/scratchSaveLifecycle.test.js b/src/hooks/useScratchSave/scratchSaveLifecycle.test.js index dc7bff4e5..1b6a3fea0 100644 --- a/src/hooks/useScratchSave/scratchSaveLifecycle.test.js +++ b/src/hooks/useScratchSave/scratchSaveLifecycle.test.js @@ -1,6 +1,6 @@ import { createScratchSaveLifecycle } from "./scratchSaveLifecycle"; -jest.useFakeTimers(); +vi.useFakeTimers(); const createLifecycle = ({ context = {}, @@ -16,7 +16,7 @@ const createLifecycle = ({ let dirty = context.dirty ?? true; let projectChangedAt = context.projectChangedAt ?? Date.now(); - const postSave = jest.fn(); + const postSave = vi.fn(); const baseContext = { canAutoSave: () => context.canAutoSave ?? true, @@ -68,7 +68,7 @@ describe("scratchSaveLifecycle", () => { expect(postSave).not.toHaveBeenCalled(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledWith({ autosave: true }); }); @@ -78,16 +78,16 @@ describe("scratchSaveLifecycle", () => { createLifecycle(); triggerProjectChanged(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(1); triggerProjectChanged(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(1); resolveInFlight(); lifecycle.markSaveSucceeded(false); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(2); }); @@ -97,12 +97,12 @@ describe("scratchSaveLifecycle", () => { createLifecycle(); triggerProjectChanged(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); resolveInFlight(); lifecycle.markSaveSucceeded(true); triggerProjectChanged(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(1); const flushPromise = lifecycle.flushPendingAutoSave(); @@ -141,18 +141,18 @@ describe("scratchSaveLifecycle", () => { createLifecycle(); triggerProjectChanged(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(1); lifecycle.markAutosaveFailed(true); lifecycle.flushQueuedSave(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(2); expect(schedulerState.autosaveRetryUsed).toBe(true); lifecycle.markAutosaveFailed(true); lifecycle.flushQueuedSave(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(2); expect(schedulerState.queued).toBe(false); }); @@ -161,17 +161,17 @@ describe("scratchSaveLifecycle", () => { const { postSave, triggerProjectChanged, lifecycle } = createLifecycle(); triggerProjectChanged(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); lifecycle.markAutosaveFailed(true); lifecycle.flushQueuedSave(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); lifecycle.markAutosaveFailed(true); lifecycle.flushQueuedSave(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(2); triggerProjectChanged(); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); expect(postSave).toHaveBeenCalledTimes(3); }); }); diff --git a/src/hooks/useScratchSave/useScratchSaveState.test.jsx b/src/hooks/useScratchSave/useScratchSaveState.test.jsx index d971e2656..f011ee83c 100644 --- a/src/hooks/useScratchSave/useScratchSaveState.test.jsx +++ b/src/hooks/useScratchSave/useScratchSaveState.test.jsx @@ -14,12 +14,12 @@ import { getAutoSaveHostApi, } from "../../utils/save/autoSaveHostApi"; -jest.mock("../../utils/scratchIframe", () => ({ - getScratchAllowedOrigin: jest.fn(), - postMessageToScratchIframe: jest.fn(), +vi.mock("../../utils/scratchIframe", () => ({ + getScratchAllowedOrigin: vi.fn(), + postMessageToScratchIframe: vi.fn(), })); -jest.useFakeTimers(); +vi.useFakeTimers(); const scratchOrigin = "https://scratch-frame.example.com"; const scratchProject = { @@ -85,7 +85,7 @@ describe("useScratchSaveState", () => { const originalScratchFrameUrl = process.env.REACT_APP_SCRATCH_FRAME_URL; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); process.env.REACT_APP_SCRATCH_FRAME_URL = scratchOrigin; getScratchAllowedOrigin.mockReturnValue(scratchOrigin); }); @@ -94,7 +94,7 @@ describe("useScratchSaveState", () => { scratchSaveUnmount?.(); scratchSaveUnmount = null; clearScratchAutoSaveHostApi(); - jest.clearAllTimers(); + vi.clearAllTimers(); process.env.REACT_APP_SCRATCH_FRAME_URL = originalScratchFrameUrl; }); @@ -128,7 +128,7 @@ describe("useScratchSaveState", () => { dispatchScratchMessage("scratch-gui-project-changed"); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(postMessageToScratchIframe).not.toHaveBeenCalled(); @@ -141,7 +141,7 @@ describe("useScratchSaveState", () => { dispatchScratchMessage("scratch-gui-ready"); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(postMessageToScratchIframe).toHaveBeenCalledWith({ @@ -155,13 +155,13 @@ describe("useScratchSaveState", () => { dispatchScratchUserEdit(); act(() => { - jest.advanceTimersByTime(1999); + vi.advanceTimersByTime(1999); }); expect(postMessageToScratchIframe).not.toHaveBeenCalled(); act(() => { - jest.advanceTimersByTime(1); + vi.advanceTimersByTime(1); }); expect(postMessageToScratchIframe).toHaveBeenCalledWith({ @@ -175,19 +175,19 @@ describe("useScratchSaveState", () => { dispatchScratchUserEdit(); act(() => { - jest.advanceTimersByTime(1000); + vi.advanceTimersByTime(1000); }); dispatchScratchMessage("scratch-gui-project-changed"); act(() => { - jest.advanceTimersByTime(999); + vi.advanceTimersByTime(999); }); expect(postMessageToScratchIframe).not.toHaveBeenCalled(); act(() => { - jest.advanceTimersByTime(1); + vi.advanceTimersByTime(1); }); expect(postMessageToScratchIframe).toHaveBeenCalledTimes(1); @@ -203,19 +203,19 @@ describe("useScratchSaveState", () => { dispatchScratchUserEdit(); act(() => { - jest.advanceTimersByTime(1000); + vi.advanceTimersByTime(1000); }); dispatchScratchMessage("scratch-gui-saving-succeeded"); act(() => { - jest.advanceTimersByTime(999); + vi.advanceTimersByTime(999); }); expect(postMessageToScratchIframe).not.toHaveBeenCalled(); act(() => { - jest.advanceTimersByTime(1); + vi.advanceTimersByTime(1); }); expect(postMessageToScratchIframe).toHaveBeenCalledWith({ @@ -232,7 +232,7 @@ describe("useScratchSaveState", () => { dispatchScratchUserEdit(); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); dispatchScratchMessage("scratch-gui-saving-started"); @@ -291,19 +291,19 @@ describe("useScratchSaveState", () => { dispatchScratchUserEdit(); act(() => { - jest.advanceTimersByTime(1000); + vi.advanceTimersByTime(1000); }); dispatchScratchMessage("scratch-gui-saving-failed"); act(() => { - jest.advanceTimersByTime(999); + vi.advanceTimersByTime(999); }); expect(postMessageToScratchIframe).not.toHaveBeenCalled(); act(() => { - jest.advanceTimersByTime(1); + vi.advanceTimersByTime(1); }); expect(postMessageToScratchIframe).toHaveBeenCalledWith({ @@ -339,7 +339,7 @@ describe("useScratchSaveState", () => { dispatchScratchUserEdit(); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); dispatchScratchMessage("scratch-gui-saving-started"); @@ -348,13 +348,13 @@ describe("useScratchSaveState", () => { dispatchScratchMessage("scratch-gui-project-changed"); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(postMessageToScratchIframe).toHaveBeenCalledTimes(1); act(() => { - jest.advanceTimersByTime(10000); + vi.advanceTimersByTime(10000); }); expect(postMessageToScratchIframe).toHaveBeenCalledTimes(2); @@ -366,7 +366,7 @@ describe("useScratchSaveState", () => { dispatchScratchUserEdit(); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); dispatchScratchMessage("scratch-gui-saving-started"); @@ -375,7 +375,7 @@ describe("useScratchSaveState", () => { dispatchScratchMessage("scratch-gui-project-changed"); act(() => { - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); }); expect(postMessageToScratchIframe).toHaveBeenCalledTimes(1); @@ -414,7 +414,7 @@ describe("useScratchSaveState", () => { dispatchScratchUserEdit(); act(() => { - jest.advanceTimersByTime(1000); + vi.advanceTimersByTime(1000); }); expect(postMessageToScratchIframe).not.toHaveBeenCalled(); diff --git a/src/utils/setupTests.vitest.js b/src/utils/setupTests.vitest.js index 60ac57915..7f1497d1d 100644 --- a/src/utils/setupTests.vitest.js +++ b/src/utils/setupTests.vitest.js @@ -3,6 +3,39 @@ import "@testing-library/jest-dom"; import { vi } from "vitest"; +class MemoryStorage { + #store = new Map(); + + getItem(key) { + return this.#store.has(key) ? this.#store.get(key) : null; + } + + setItem(key, value) { + this.#store.set(key, String(value)); + } + + removeItem(key) { + this.#store.delete(key); + } + + clear() { + this.#store.clear(); + } + + get length() { + return this.#store.size; + } + + key(index) { + return Array.from(this.#store.keys())[index] ?? null; + } +} + +Object.defineProperty(window, "localStorage", { + value: new MemoryStorage(), + configurable: true, +}); + window.matchMedia = (query) => ({ matches: false, media: query, @@ -37,5 +70,5 @@ vi.mock("react-i18next", () => ({ })); vi.mock("./i18n", () => ({ - t: (string) => string, + default: { t: (string) => string }, })); diff --git a/test-runner-migration.js b/test-runner-migration.js index ce104a2d5..6a63962d1 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -17,13 +17,6 @@ const JEST_ONLY_TEST_FILES = [ "src/components/RunButton/StopButton.test.jsx", "src/components/WebComponentProject/WebComponentProject.integration.test.jsx", "src/containers/WebComponentLoader.test.jsx", - "src/hooks/useAutoSave/useAutoSave.test.js", - "src/hooks/useContainerMinWidth.test.js", - "src/hooks/useIsOnline.test.js", - "src/hooks/useProject.test.jsx", - "src/hooks/useProjectPersistence.test.js", - "src/hooks/useScratchSave/scratchSaveLifecycle.test.js", - "src/hooks/useScratchSave/useScratchSaveState.test.jsx", "src/redux/EditorSlice.test.js", "src/redux/reducers/loadProjectReducers.test.js", "src/utils/Notifications.test.js", From c136fe1de0feea3739d84a0bc1766ff2d386efe9 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:07:39 +0100 Subject: [PATCH 10/12] Migrate StopButton, WebComponentProject.integration, WebComponentLoader These three were previously left on the Jest list because they all hit the window.localStorage gap fixed for the hooks batch in the previous commit - StopButton and WebComponentProject.integration via app/store/ScratchContainer constructing an oidc-client UserManager that reads localStorage at import time, WebComponentLoader via its own direct localStorage calls. With that gap fixed they now pass, so this change swaps their jest.fn/jest.mock/jest.useFakeTimers/ jest.useRealTimers/jest.advanceTimersByTime for vi equivalents. Two WebComponentLoader assertions still failed after that: rendering the same #wc-carrying markup twice within one test (once in a nested beforeEach, once in the test body, both left mounted) left two elements with id="wc" in the document, and Vitests jsdom/nwsapi resolves a scoped container.querySelector("#wc") via a document-wide getElementById fast path that returns null when the first document-wide match with that id isnt inside the given container - unlike Jests jsdom, which falls back to a proper scoped search. Switched both assertions to the equivalent attribute selector container.querySelector("[id='wc']"), which resolves correctly under both. Confirmed via full Jest and Vitest runs: 323 Jest + 728 Vitest tests pass, the same total as before the move. --- src/components/RunButton/StopButton.test.jsx | 6 ++-- .../WebComponentProject.integration.test.jsx | 2 +- src/containers/WebComponentLoader.test.jsx | 28 +++++++++---------- test-runner-migration.js | 3 -- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/components/RunButton/StopButton.test.jsx b/src/components/RunButton/StopButton.test.jsx index 8cc4b0c0b..a1f968a9a 100644 --- a/src/components/RunButton/StopButton.test.jsx +++ b/src/components/RunButton/StopButton.test.jsx @@ -6,11 +6,11 @@ import store from "../../app/store"; import { codeRunHandled, triggerCodeRun } from "../../redux/EditorSlice"; beforeEach(() => { - jest.useFakeTimers(); + vi.useFakeTimers(); }); afterEach(() => { - jest.useRealTimers(); + vi.useRealTimers(); }); test("Clicking stop button sets codeRunStopped to true", () => { @@ -45,7 +45,7 @@ test("Clicking stop button changes it to 'Stopping...' after a time out", () => expect(stopButton.textContent).toEqual("Stop Code"); act(() => { - jest.runAllTimers(); + vi.runAllTimers(); }); expect(stopButton.textContent).toEqual("runButton.stopping"); }); diff --git a/src/components/WebComponentProject/WebComponentProject.integration.test.jsx b/src/components/WebComponentProject/WebComponentProject.integration.test.jsx index 89abb59b1..ba1db1b8b 100644 --- a/src/components/WebComponentProject/WebComponentProject.integration.test.jsx +++ b/src/components/WebComponentProject/WebComponentProject.integration.test.jsx @@ -12,7 +12,7 @@ import { } from "../../redux/EditorSlice"; import { projectIdentifierChangedEvent } from "../../events/WebComponentCustomEvents"; -const projectIdentifierChangedHandler = jest.fn(); +const projectIdentifierChangedHandler = vi.fn(); beforeAll(() => { document.addEventListener( diff --git a/src/containers/WebComponentLoader.test.jsx b/src/containers/WebComponentLoader.test.jsx index 9ded0f337..2564b066f 100644 --- a/src/containers/WebComponentLoader.test.jsx +++ b/src/containers/WebComponentLoader.test.jsx @@ -20,17 +20,17 @@ import { useProject } from "../hooks/useProject"; import { useProjectPersistence } from "../hooks/useProjectPersistence"; import { Cookies, CookiesProvider } from "react-cookie"; -jest.mock("../hooks/useProject", () => ({ - useProject: jest.fn(), +vi.mock("../hooks/useProject", () => ({ + useProject: vi.fn(), })); -jest.mock("../hooks/useProjectPersistence", () => ({ - useProjectPersistence: jest.fn(), +vi.mock("../hooks/useProjectPersistence", () => ({ + useProjectPersistence: vi.fn(), })); -const mockedChangeLanguage = jest.fn(() => Promise.resolve()); +const mockedChangeLanguage = vi.fn(() => Promise.resolve()); -jest.mock("react-i18next", () => ({ +vi.mock("react-i18next", () => ({ useTranslation: () => { return { i18n: { @@ -56,7 +56,7 @@ const user = { access_token: "my_token" }; describe("When initially rendered", () => { beforeEach(() => { - document.dispatchEvent = jest.fn(); + document.dispatchEvent = vi.fn(); const mockStore = configureStore([]); const initialState = { editor: { @@ -362,7 +362,7 @@ describe("When no user is in state", () => { , ); - expect(container.querySelector("#wc")).toHaveClass( + expect(container.querySelector("[id='wc']")).toHaveClass( "--light", "--use-editor-styles", ); @@ -385,8 +385,8 @@ describe("When no user is in state", () => { , ); - expect(container.querySelector("#wc")).toHaveClass("--dark"); - expect(container.querySelector("#wc")).not.toHaveClass( + expect(container.querySelector("[id='wc']")).toHaveClass("--dark"); + expect(container.querySelector("[id='wc']")).not.toHaveClass( "--use-editor-styles", ); }); @@ -483,7 +483,7 @@ describe("When user is in state", () => { describe("when user data is set", () => { beforeEach(() => { - jest.useFakeTimers(); + vi.useFakeTimers(); render( @@ -494,12 +494,12 @@ describe("When user is in state", () => { }); afterEach(() => { - jest.useRealTimers(); + vi.useRealTimers(); }); test("removes if user data is removed from local storage", () => { localStorage.removeItem(authKey); - jest.advanceTimersByTime(50000); + vi.advanceTimersByTime(50000); expect(store.getActions()).toEqual( expect.arrayContaining([setUser(null)]), ); @@ -513,7 +513,7 @@ describe("When user is in state", () => { other_user_data: "data", }), ); - jest.advanceTimersByTime(50000); + vi.advanceTimersByTime(50000); expect(store.getActions()).toEqual( expect.arrayContaining([ setUser({ access_token: "new_token", other_user_data: "data" }), diff --git a/test-runner-migration.js b/test-runner-migration.js index 6a63962d1..71be098b5 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -14,9 +14,6 @@ const JEST_ONLY_TEST_FILES = [ "src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.test.jsx", "src/components/Editor/Runners/PythonRunner/PythonRunner.test.jsx", "src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.jsx", - "src/components/RunButton/StopButton.test.jsx", - "src/components/WebComponentProject/WebComponentProject.integration.test.jsx", - "src/containers/WebComponentLoader.test.jsx", "src/redux/EditorSlice.test.js", "src/redux/reducers/loadProjectReducers.test.js", "src/utils/Notifications.test.js", From 00e0600dc6128aa04bd47745c569c46c62dd07aa Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:15:22 +0100 Subject: [PATCH 11/12] Migrate redux test files to Vitest Previously EditorSlice.test.js and loadProjectReducers.test.js only ran under Jest. This change swaps jest.fn for vi.fn, and wraps the apiCallHandler factory mocks in both files in an explicit default key, the same fix already applied elsewhere for Vitests stricter mock factory interop. "When project has an identifier > The saveProject/fulfilled action sets saving to success" failed under Vitest because Date.now was never restored after the earlier "When project has no identifier" describe reassigned it to a fixed mock - a real leak, previously masked under Jest only because resetMocks happened to wipe that leaked mocks return value back to undefined before this describe ran, and Jests toEqual ignores undefined-valued properties, hiding the resulting lastSavedTime: undefined. Fixed the leak with a file-level afterEach restoring the original Date.now, then updated this describes own beforeEach to mock Date.now deterministically (as its sibling describe already does) and added the resulting lastSavedTime to the expected state, since the reducer sets it on every successful save. Confirmed via full Jest and Vitest runs: 238 Jest + 813 Vitest tests pass, the same total as before the move. --- src/redux/EditorSlice.test.js | 46 +++++++++++-------- .../reducers/loadProjectReducers.test.js | 10 ++-- test-runner-migration.js | 2 - 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js index 3237afccb..ec785f862 100644 --- a/src/redux/EditorSlice.test.js +++ b/src/redux/EditorSlice.test.js @@ -25,20 +25,28 @@ import reducer, { setFriendlyError, } from "./EditorSlice"; -const mockCreateRemix = jest.fn(); -const mockDeleteProject = jest.fn(); -const mockLoadAssets = jest.fn(); -const mockReadProject = jest.fn(); -const mockCreateOrUpdateProject = jest.fn(); - -jest.mock("../utils/apiCallHandler", () => () => ({ - createRemix: jest.fn(mockCreateRemix), - deleteProject: jest.fn(mockDeleteProject), - loadAssets: jest.fn(mockLoadAssets), - readProject: jest.fn(mockReadProject), - createOrUpdateProject: jest.fn(mockCreateOrUpdateProject), +const mockCreateRemix = vi.fn(); +const mockDeleteProject = vi.fn(); +const mockLoadAssets = vi.fn(); +const mockReadProject = vi.fn(); +const mockCreateOrUpdateProject = vi.fn(); + +vi.mock("../utils/apiCallHandler", () => ({ + default: () => ({ + createRemix: vi.fn(mockCreateRemix), + deleteProject: vi.fn(mockDeleteProject), + loadAssets: vi.fn(mockLoadAssets), + readProject: vi.fn(mockReadProject), + createOrUpdateProject: vi.fn(mockCreateOrUpdateProject), + }), })); +const originalDateNow = Date.now; + +afterEach(() => { + Date.now = originalDateNow; +}); + const friendlyErrorHtml = '
Friendly error title
' + '
A friendly summary of the error
'; @@ -301,7 +309,7 @@ test("Action setFriendlyError sets friendlyError", () => { }); describe("When project has no identifier", () => { - const dispatch = jest.fn(); + const dispatch = vi.fn(); const project = { name: "hello world", project_type: "python", @@ -329,7 +337,7 @@ describe("When project has no identifier", () => { let saveAction; beforeEach(() => { - Date.now = jest.fn(() => 1669808953); + Date.now = vi.fn(() => 1669808953); saveThunk = syncProject("save"); saveAction = saveThunk({ project, @@ -387,7 +395,7 @@ describe("When project has no identifier", () => { }); describe("When project has an identifier", () => { - const dispatch = jest.fn(); + const dispatch = vi.fn(); const project = { name: "hello world", project_type: "python", @@ -420,6 +428,7 @@ describe("When project has an identifier", () => { beforeEach(() => { localStorage.clear(); + Date.now = vi.fn(() => 1669808953); saveThunk = syncProject("save"); saveAction = saveThunk({ @@ -463,6 +472,7 @@ describe("When project has an identifier", () => { const expectedState = { project: project, saving: "success", + lastSavedTime: 1669808953, initialComponents: [ { name: "main", @@ -540,7 +550,7 @@ describe("When project has an identifier", () => { }); describe("When deleting a project", () => { - const dispatch = jest.fn(); + const dispatch = vi.fn(); let project = { identifier: "my-amazing-project", name: "hello world" }; const access_token = "myToken"; const initialState = { @@ -730,7 +740,7 @@ describe("Updating file name", () => { }); describe("Loading a project", () => { - const dispatch = jest.fn(); + const dispatch = vi.fn(); const identifier = "my-project-identifier"; const accessToken = "myToken"; const locale = "es-LA"; @@ -989,7 +999,7 @@ describe("initialComponents snapshot", () => { }); test("Scratch save lifecycle actions update editor save state", () => { - Date.now = jest.fn(() => 1669808953); + Date.now = vi.fn(() => 1669808953); const initialState = reducer( undefined, setProject({ diff --git a/src/redux/reducers/loadProjectReducers.test.js b/src/redux/reducers/loadProjectReducers.test.js index 95c50c2c4..07ddde7d4 100644 --- a/src/redux/reducers/loadProjectReducers.test.js +++ b/src/redux/reducers/loadProjectReducers.test.js @@ -3,13 +3,15 @@ import produce from "immer"; import reducer, { syncProject } from "../../redux/EditorSlice"; import { loadProjectRejected } from "./loadProjectReducers"; -const mockReadProject = jest.fn(); -jest.mock("../../utils/apiCallHandler", () => () => ({ - readProject: jest.fn(mockReadProject), +const mockReadProject = vi.fn(); +vi.mock("../../utils/apiCallHandler", () => ({ + default: () => ({ + readProject: vi.fn(mockReadProject), + }), })); const requestingAProject = function (project, projectFile) { - const dispatch = jest.fn(); + const dispatch = vi.fn(); const initialState = { editor: { project: {}, diff --git a/test-runner-migration.js b/test-runner-migration.js index 71be098b5..453ddb46d 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -14,8 +14,6 @@ const JEST_ONLY_TEST_FILES = [ "src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.test.jsx", "src/components/Editor/Runners/PythonRunner/PythonRunner.test.jsx", "src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.jsx", - "src/redux/EditorSlice.test.js", - "src/redux/reducers/loadProjectReducers.test.js", "src/utils/Notifications.test.js", "src/utils/ResizableWithHandle.test.jsx", "src/utils/SelectButtons.test.jsx", From b7d7bf1040530655b6fa2a39684727bfaddd16f6 Mon Sep 17 00:00:00 2001 From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:17:08 +0100 Subject: [PATCH 12/12] Migrate remaining utils test files to Vitest Previously Notifications, ResizableWithHandle, SelectButtons, ToastCloseButton, apiCallHandler, autoSaveHostApi, autoSaveLifecycle, and scratchIframe only ran under Jest - the last non-Editor files on the list. This change swaps jest.fn/jest.mock/jest.useFakeTimers/ jest.advanceTimersByTime for vi equivalents and jest.requireActual for the async vi.importActual pattern. Notifications.test.js also mocked ./i18n with a bare { t } object with no default key, the same default-export gap fixed elsewhere, so it is now wrapped as { default: { t } }. Only the src/components/Editor test files remain on the Jest list. Confirmed via full Jest and Vitest runs: 193 Jest + 858 Vitest tests pass, the same total as before the move. --- src/utils/Notifications.test.js | 6 +++--- src/utils/ResizableWithHandle.test.jsx | 2 +- src/utils/SelectButtons.test.jsx | 2 +- src/utils/ToastCloseButton.test.jsx | 2 +- src/utils/apiCallHandler.test.js | 2 +- src/utils/save/autoSaveHostApi.test.js | 4 ++-- src/utils/save/autoSaveLifecycle.test.js | 16 ++++++++-------- src/utils/scratchIframe.test.js | 12 ++++++------ test-runner-migration.js | 8 -------- 9 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/utils/Notifications.test.js b/src/utils/Notifications.test.js index 4548661f1..6e921e91a 100644 --- a/src/utils/Notifications.test.js +++ b/src/utils/Notifications.test.js @@ -5,10 +5,10 @@ import { showSavePrompt, } from "./Notifications"; -jest.mock("./i18n", () => ({ - t: (string) => string, +vi.mock("./i18n", () => ({ + default: { t: (string) => string }, })); -jest.mock("react-toastify"); +vi.mock("react-toastify"); test("Calling showSavedMessage calls toast with correct string", () => { showSavedMessage(); diff --git a/src/utils/ResizableWithHandle.test.jsx b/src/utils/ResizableWithHandle.test.jsx index 27a4593a9..ca1aa10d0 100644 --- a/src/utils/ResizableWithHandle.test.jsx +++ b/src/utils/ResizableWithHandle.test.jsx @@ -4,7 +4,7 @@ import ResizableWithHandle from "./ResizableWithHandle"; let mockResizeStop; -jest.mock("re-resizable", () => ({ +vi.mock("re-resizable", () => ({ Resizable: ({ children, handleComponent, diff --git a/src/utils/SelectButtons.test.jsx b/src/utils/SelectButtons.test.jsx index 8fc6a5c91..1dbbc7c3c 100644 --- a/src/utils/SelectButtons.test.jsx +++ b/src/utils/SelectButtons.test.jsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import SelectButtons from "./SelectButtons"; -const setValue = jest.fn(); +const setValue = vi.fn(); beforeEach(() => { render( diff --git a/src/utils/ToastCloseButton.test.jsx b/src/utils/ToastCloseButton.test.jsx index 88fd9fd48..044b82b11 100644 --- a/src/utils/ToastCloseButton.test.jsx +++ b/src/utils/ToastCloseButton.test.jsx @@ -2,7 +2,7 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import ToastCloseButton from "./ToastCloseButton"; -const closeToast = jest.fn(); +const closeToast = vi.fn(); beforeEach(() => { render(); diff --git a/src/utils/apiCallHandler.test.js b/src/utils/apiCallHandler.test.js index d478e73fa..e5fb96d14 100644 --- a/src/utils/apiCallHandler.test.js +++ b/src/utils/apiCallHandler.test.js @@ -2,7 +2,7 @@ import axios from "axios"; import ApiCallHandler from "./apiCallHandler"; -jest.mock("axios"); +vi.mock("axios"); const host = "http://localhost:3009"; const defaultHeaders = { headers: { Accept: "application/json" } }; const accessToken = "39a09671-be55-4847-baf5-8919a0c24a25"; diff --git a/src/utils/save/autoSaveHostApi.test.js b/src/utils/save/autoSaveHostApi.test.js index f68fc20ca..8146994d5 100644 --- a/src/utils/save/autoSaveHostApi.test.js +++ b/src/utils/save/autoSaveHostApi.test.js @@ -24,8 +24,8 @@ describe("autoSaveHostApi", () => { }); test("flushes project and scratch saves in sequence", async () => { - const projectFlush = jest.fn(() => Promise.resolve()); - const scratchFlush = jest.fn(() => Promise.resolve()); + const projectFlush = vi.fn(() => Promise.resolve()); + const scratchFlush = vi.fn(() => Promise.resolve()); registerAutoSaveHostApi({ flushPendingAutoSave: projectFlush, diff --git a/src/utils/save/autoSaveLifecycle.test.js b/src/utils/save/autoSaveLifecycle.test.js index 195b68e38..c2620172b 100644 --- a/src/utils/save/autoSaveLifecycle.test.js +++ b/src/utils/save/autoSaveLifecycle.test.js @@ -1,12 +1,12 @@ import { syncProject } from "../../redux/EditorSlice"; import { createAutoSaveLifecycle } from "./autoSaveLifecycle"; -jest.mock("../../redux/EditorSlice", () => ({ - ...jest.requireActual("../../redux/EditorSlice"), - syncProject: jest.fn((_) => jest.fn()), +vi.mock("../../redux/EditorSlice", async () => ({ + ...(await vi.importActual("../../redux/EditorSlice")), + syncProject: vi.fn((_) => vi.fn()), })); -jest.useFakeTimers(); +vi.useFakeTimers(); const user1 = { access_token: "myAccessToken1", @@ -30,7 +30,7 @@ const editedProject = { }; const saveAction = { type: "SAVE_PROJECT" }; -const saveProject = jest.fn(() => saveAction); +const saveProject = vi.fn(() => saveAction); /** flushPendingAutoSave is async; yield so it reaches its next await. */ const awaitAsyncFlush = () => Promise.resolve(); @@ -56,7 +56,7 @@ const createLifecycle = ({ let resolveSave; let rejectSave; - const dispatch = jest.fn(() => { + const dispatch = vi.fn(() => { const thunkPromise = new Promise((resolve) => { resolveSave = resolve; rejectSave = (error) => @@ -110,7 +110,7 @@ const createLifecycle = ({ }; beforeEach(() => { - syncProject.mockImplementation(jest.fn((_) => saveProject)); + syncProject.mockImplementation(vi.fn((_) => saveProject)); }); describe("autoSaveLifecycle", () => { @@ -162,7 +162,7 @@ describe("autoSaveLifecycle", () => { lifecycle.requestAutoSave(); expect(dispatch).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(10000); + vi.advanceTimersByTime(10000); expect(inFlightSavePromiseRef.current).not.toBeNull(); await completeInFlightSave({ inFlightSavePromiseRef, resolveSave }); diff --git a/src/utils/scratchIframe.test.js b/src/utils/scratchIframe.test.js index e373a56b4..a523ab205 100644 --- a/src/utils/scratchIframe.test.js +++ b/src/utils/scratchIframe.test.js @@ -13,13 +13,13 @@ describe("scratchIframe", () => { let originalQuerySelector; beforeEach(() => { - mockPostMessage = jest.fn(); + mockPostMessage = vi.fn(); mockContentWindow = { postMessage: mockPostMessage }; - mockShadowQuerySelector = jest.fn(() => ({ + mockShadowQuerySelector = vi.fn(() => ({ contentWindow: mockContentWindow, })); originalQuerySelector = document.querySelector; - document.querySelector = jest.fn(() => ({ + document.querySelector = vi.fn(() => ({ shadowRoot: { querySelector: mockShadowQuerySelector, }, @@ -91,7 +91,7 @@ describe("scratchIframe", () => { }); it("calls the handler with the updated project id", () => { - const handler = jest.fn(); + const handler = vi.fn(); const unsubscribe = subscribeToScratchProjectIdentifierUpdates(handler); window.dispatchEvent( @@ -111,7 +111,7 @@ describe("scratchIframe", () => { it("accepts updates when REACT_APP_SCRATCH_FRAME_URL contains a path", () => { process.env.REACT_APP_SCRATCH_FRAME_URL = "https://scratch-frame.example.com/branches/main"; - const handler = jest.fn(); + const handler = vi.fn(); const unsubscribe = subscribeToScratchProjectIdentifierUpdates(handler); window.dispatchEvent( @@ -129,7 +129,7 @@ describe("scratchIframe", () => { }); it("ignores unrelated messages", () => { - const handler = jest.fn(); + const handler = vi.fn(); const unsubscribe = subscribeToScratchProjectIdentifierUpdates(handler); window.dispatchEvent( diff --git a/test-runner-migration.js b/test-runner-migration.js index 453ddb46d..198ab5ffd 100644 --- a/test-runner-migration.js +++ b/test-runner-migration.js @@ -14,14 +14,6 @@ const JEST_ONLY_TEST_FILES = [ "src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.test.jsx", "src/components/Editor/Runners/PythonRunner/PythonRunner.test.jsx", "src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.test.jsx", - "src/utils/Notifications.test.js", - "src/utils/ResizableWithHandle.test.jsx", - "src/utils/SelectButtons.test.jsx", - "src/utils/ToastCloseButton.test.jsx", - "src/utils/apiCallHandler.test.js", - "src/utils/save/autoSaveHostApi.test.js", - "src/utils/save/autoSaveLifecycle.test.js", - "src/utils/scratchIframe.test.js", ]; module.exports = { JEST_ONLY_TEST_FILES };