@@ -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 };