chore: complete jest to vitest migration (#24216)

This commit is contained in:
Kayla はな
2026-04-10 16:04:24 -04:00
committed by GitHub
parent 8dff1cbc57
commit b149433138
42 changed files with 348 additions and 2862 deletions
-6
View File
@@ -91,12 +91,6 @@ updates:
emotion:
patterns:
- "@emotion*"
exclude-patterns:
- "jest-runner-eslint"
jest:
patterns:
- "jest"
- "@types/jest"
vite:
patterns:
- "vite*"
+6 -9
View File
@@ -34,16 +34,14 @@ the most important.
- [React](https://reactjs.org/) for the UI framework
- [Typescript](https://www.typescriptlang.org/) to keep our sanity
- [Vite](https://vitejs.dev/) to build the project
- [Material V5](https://mui.com/material-ui/getting-started/) for UI components
- [react-router](https://reactrouter.com/en/main) for routing
- [TanStack Query v4](https://tanstack.com/query/v4/docs/react/overview) for
- [TanStack Query](https://tanstack.com/query/v4/docs/react/overview) for
fetching data
- [axios](https://github.com/axios/axios) as fetching lib
- [Vitest](https://vitest.dev/) for integration testing
- [Playwright](https://playwright.dev/) for end-to-end (E2E) testing
- [Jest](https://jestjs.io/) for integration testing
- [Storybook](https://storybook.js.org/) and
[Chromatic](https://www.chromatic.com/) for visual testing
- [PNPM](https://pnpm.io/) as the package manager
- [pnpm](https://pnpm.io/) as the package manager
## Structure
@@ -51,7 +49,6 @@ All UI-related code is in the `site` folder. Key directories include:
- **e2e** - End-to-end (E2E) tests
- **src** - Source code
- **mocks** - [Manual mocks](https://jestjs.io/docs/manual-mocks) used by Jest
- **@types** - Custom types for dependencies that don't have defined types
(largely code that has no server-side equivalent)
- **api** - API function calls and types
@@ -59,7 +56,7 @@ All UI-related code is in the `site` folder. Key directories include:
- **components** - Reusable UI components without Coder specific business
logic
- **hooks** - Custom React hooks
- **modules** - Coder-specific UI components
- **modules** - Coder specific logic and components related to multiple parts of the UI
- **pages** - Page-level components
- **testHelpers** - Helper functions for integration testing
- **theme** - theme configuration and color definitions
@@ -286,9 +283,9 @@ local machine and forward the necessary ports to your workspace. At the end of
the script, you will land _inside_ your workspace with environment variables set
so you can simply execute the test (`pnpm run playwright:test`).
### Integration/Unit Jest
### Integration/Unit
We use Jest mostly for testing code that does _not_ pertain to React. Functions and classes that contain notable app logic, and which are well abstracted from React should have accompanying tests. If the logic is tightly coupled to a React component, a Storybook test or an E2E test may be a better option depending on the scenario.
We use unit and integration tests mostly for testing code that does _not_ pertain to React. Functions and classes that contain notable app logic, and which are well abstracted from React should have accompanying tests. If the logic is tightly coupled to a React component, a Storybook test or an E2E test is usually a better option.
### Visual Testing Storybook
+7 -7
View File
@@ -1,17 +1,17 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["./src/index.tsx", "./src/serviceWorker.ts"],
"project": ["./src/**/*.ts", "./src/**/*.tsx", "./e2e/**/*.ts"],
"project": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./test/**/*.ts",
"./e2e/**/*.ts"
],
"ignore": ["**/*Generated.ts", "src/api/chatModelOptions.ts"],
"ignoreBinaries": ["protoc"],
"ignoreDependencies": [
"@babel/plugin-syntax-typescript",
"@types/react-virtualized-auto-sizer",
"babel-plugin-react-compiler",
"jest_workaround",
"ts-proto"
],
"jest": {
"entry": "./src/**/*.jest.{ts,tsx}"
}
]
}
-61
View File
@@ -1,61 +0,0 @@
module.exports = {
// Use a big timeout for CI.
testTimeout: 20_000,
maxWorkers: 8,
projects: [
{
displayName: "test",
roots: ["<rootDir>"],
setupFiles: ["./jest.polyfills.js"],
setupFilesAfterEnv: ["./jest.setup.ts"],
extensionsToTreatAsEsm: [".ts"],
transform: {
"^.+\\.(t|j)sx?$": [
"@swc/jest",
{
jsc: {
transform: {
react: {
runtime: "automatic",
importSource: "@emotion/react",
},
},
experimental: {
plugins: [["jest_workaround", {}]],
},
},
},
],
},
testEnvironment: "jest-fixed-jsdom",
testEnvironmentOptions: {
customExportConditions: [""],
},
testRegex: "(/__tests__/.*|(\\.|/)(jest))\\.tsx?$",
testPathIgnorePatterns: ["/node_modules/", "/e2e/"],
transformIgnorePatterns: [],
moduleDirectories: ["node_modules"],
moduleNameMapper: {
"\\.css$": "<rootDir>/src/testHelpers/styleMock.ts",
"^@fontsource": "<rootDir>/src/testHelpers/styleMock.ts",
"^@pierre/diffs/react$":
"<rootDir>/src/testHelpers/pierreDiffsReactMock.tsx",
},
},
],
collectCoverageFrom: [
// included files
"<rootDir>/**/*.ts",
"<rootDir>/**/*.tsx",
// excluded files
"!<rootDir>/**/*.stories.tsx",
"!<rootDir>/_jest/**/*.*",
"!<rootDir>/api.ts",
"!<rootDir>/coverage/**/*.*",
"!<rootDir>/e2e/**/*.*",
"!<rootDir>/jest-runner.eslint.config.js",
"!<rootDir>/jest.config.js",
"!<rootDir>/out/**/*.*",
"!<rootDir>/storybook-static/**/*.*",
],
};
-44
View File
@@ -1,44 +0,0 @@
/**
* Necessary for MSW
*
* @note The block below contains polyfills for Node.js globals
* required for Jest to function when running JSDOM tests.
* These HAVE to be require's and HAVE to be in this exact
* order, since "undici" depends on the "TextEncoder" global API.
*
* Consider migrating to a more modern test runner if
* you don't want to deal with this.
*/
const { TextDecoder, TextEncoder } = require("node:util");
const { ReadableStream } = require("node:stream/web");
Object.defineProperties(globalThis, {
TextDecoder: { value: TextDecoder },
TextEncoder: { value: TextEncoder },
ReadableStream: { value: ReadableStream },
});
const { Blob, File } = require("node:buffer");
const { fetch, Headers, FormData, Request, Response } = require("undici");
Object.defineProperties(globalThis, {
fetch: { value: fetch, writable: true },
Blob: { value: Blob },
File: { value: File },
Headers: { value: Headers },
FormData: { value: FormData },
Request: { value: Request },
Response: { value: Response },
matchMedia: {
value: (query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}),
},
});
-80
View File
@@ -1,80 +0,0 @@
import "@testing-library/jest-dom";
import "jest-location-mock";
import crypto from "node:crypto";
import { cleanup } from "@testing-library/react";
import { useMemo } from "react";
import type { Region } from "#/api/typesGenerated";
import type { ProxyLatencyReport } from "#/contexts/useProxyLatency";
import { server } from "#/testHelpers/server";
// useProxyLatency does some http requests to determine latency.
// This would fail unit testing, or at least make it very slow with
// actual network requests. So just globally mock this hook.
jest.mock("#/contexts/useProxyLatency", () => ({
useProxyLatency: (proxies?: Region[]) => {
// Must use `useMemo` here to avoid infinite loop.
// Mocking the hook with a hook.
const proxyLatencies = useMemo(() => {
if (!proxies) {
return {} as Record<string, ProxyLatencyReport>;
}
return proxies.reduce(
(acc, proxy) => {
acc[proxy.id] = {
accurate: true,
// Return a constant latency of 8ms.
// If you make this random it could break stories.
latencyMS: 8,
at: new Date(),
};
return acc;
},
{} as Record<string, ProxyLatencyReport>,
);
}, [proxies]);
return { proxyLatencies, refetch: jest.fn() };
},
}));
global.scrollTo = jest.fn();
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Polyfill pointer capture methods for JSDOM compatibility with Radix UI
window.HTMLElement.prototype.hasPointerCapture = jest
.fn()
.mockReturnValue(false);
window.HTMLElement.prototype.setPointerCapture = jest.fn();
window.HTMLElement.prototype.releasePointerCapture = jest.fn();
window.open = jest.fn();
navigator.sendBeacon = jest.fn();
global.ResizeObserver = require("resize-observer-polyfill");
// Polyfill the getRandomValues that is used on utils/random.ts
Object.defineProperty(global.self, "crypto", {
value: {
getRandomValues: crypto.randomFillSync,
},
});
// Establish API mocking before all tests through MSW.
beforeAll(() =>
server.listen({
onUnhandledRequest: "warn",
}),
);
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => {
cleanup();
server.resetHandlers();
jest.resetAllMocks();
});
// Clean up after the tests are finished.
afterAll(() => server.close());
// biome-ignore lint/complexity/noUselessEmptyExport: This is needed because we are compiling under `--isolatedModules`
export {};
+3 -14
View File
@@ -28,11 +28,10 @@
"storybook": "STORYBOOK=true storybook dev -p 6006",
"storybook:build": "storybook build",
"storybook:ci": "storybook build --test",
"test": "vitest run --project=unit && jest",
"test": "vitest run --project=unit",
"test:storybook": "vitest --project=storybook",
"test:ci": "vitest run --project=unit && jest --silent",
"test:ci": "vitest run --project=unit",
"test:watch": "vitest --project=unit",
"test:watch-jest": "jest --watch",
"stats": "STATS=true pnpm build && npx http-server ./stats -p 8081 -c-1",
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis && cp -f ./node_modules/emoji-datasource-apple/img/apple/sheets-256/64.png ./static/emojis/spritesheet.png"
},
@@ -109,7 +108,6 @@
"react-window": "1.8.11",
"recharts": "2.15.4",
"remark-gfm": "4.0.1",
"resize-observer-polyfill": "1.5.1",
"semver": "7.7.3",
"sonner": "2.0.7",
"streamdown": "2.5.0",
@@ -118,7 +116,6 @@
"tzdata": "1.0.46",
"ua-parser-js": "1.0.41",
"ufuzzy": "npm:@leeoniya/ufuzzy@1.0.10",
"undici": "6.22.0",
"unique-names-generator": "4.7.1",
"uuid": "9.0.1",
"websocket-ts": "2.2.1",
@@ -138,8 +135,6 @@
"@storybook/addon-themes": "10.3.3",
"@storybook/addon-vitest": "10.3.3",
"@storybook/react-vite": "10.3.3",
"@swc/core": "1.3.38",
"@swc/jest": "0.2.37",
"@tailwindcss/typography": "0.5.19",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "14.3.1",
@@ -149,7 +144,6 @@
"@types/express": "4.17.17",
"@types/file-saver": "2.0.7",
"@types/humanize-duration": "3.27.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.21",
"@types/node": "20.19.25",
"@types/novnc__novnc": "1.5.0",
@@ -170,18 +164,14 @@
"chromatic": "11.29.0",
"dpdm": "3.14.0",
"express": "4.21.2",
"jest": "29.7.0",
"jest-canvas-mock": "2.5.2",
"jest-environment-jsdom": "29.5.0",
"jest-fixed-jsdom": "0.0.11",
"jest-location-mock": "2.0.0",
"jest-websocket-mock": "2.5.0",
"jest_workaround": "0.1.14",
"jsdom": "27.2.0",
"knip": "5.71.0",
"msw": "2.4.8",
"postcss": "8.5.6",
"protobufjs": "7.5.4",
"resize-observer-polyfill": "1.5.1",
"rollup-plugin-visualizer": "7.0.1",
"rxjs": "7.8.2",
"ssh2": "1.17.0",
@@ -224,7 +214,6 @@
"storybook-addon-remix-react-router"
],
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"ssh2"
]
+3 -2316
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
export default jest.fn();
export default vi.fn();
+2 -2
View File
@@ -97,8 +97,8 @@ export const IconField: FC<IconFieldProps> = ({
Unfortunately, React doesn't provide an API to start warming a lazy component,
so we just have to sneak it into the DOM, which is kind of annoying, but means
that users shouldn't ever spend time waiting for it to load.
- Except we don't do it when running tests, because Jest doesn't define
`IntersectionObserver`, and it would make them slower anyway. */}
- Except we don't do it when running tests, because it would make them
slower anyway. */}
{process.env.NODE_ENV !== "test" && (
<div className="sr-only" aria-hidden="true">
<Suspense>
+1 -2
View File
@@ -49,8 +49,7 @@ export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
setPreferredColorScheme(event.matches ? "light" : "dark");
};
// `addEventListener` here is a recent API that only _very_ up-to-date
// browsers support, and that isn't mocked in Jest.
// `addEventListener` here is a recent API that isn't mocked in tests.
themeQuery.addEventListener?.("change", listener);
return () => {
themeQuery.removeEventListener?.("change", listener);
@@ -70,11 +70,11 @@ function setupMockClipboard(isSecure: boolean): SetupMockClipboardResult {
// Don't need these other methods for any of the tests; read and write are
// both synchronous and slower than the promise-based methods, so ideally
// we won't ever need to call them in the hook logic
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
read: jest.fn(),
write: jest.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
read: vi.fn(),
write: vi.fn(),
};
return {
@@ -145,19 +145,19 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
} = setupMockClipboard(isSecure);
beforeEach(() => {
jest.useFakeTimers();
vi.useFakeTimers();
// Can't use jest.spyOn here because there's no guarantee that the mock
// Can't use vi.spyOn here because there's no guarantee that the mock
// browser environment actually implements execCommand. Trying to spy on an
// undefined value will throw an error
global.document.execCommand = mockExecCommand;
jest.spyOn(window, "navigator", "get").mockImplementation(() => ({
vi.spyOn(window, "navigator", "get").mockImplementation(() => ({
...originalNavigator,
clipboard: mockClipboard,
}));
jest.spyOn(console, "error").mockImplementation((errorValue, ...rest) => {
vi.spyOn(console, "error").mockImplementation((errorValue, ...rest) => {
const canIgnore =
errorValue instanceof Error &&
errorValue.message === COPY_FAILED_MESSAGE;
@@ -169,9 +169,9 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
});
afterEach(() => {
jest.runAllTimers();
jest.useRealTimers();
jest.resetAllMocks();
vi.runAllTimers();
vi.useRealTimers();
vi.resetAllMocks();
global.document.execCommand = originalExecCommand;
// Still have to reset the mock clipboard state because the same mock values
@@ -193,7 +193,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
// tests more annoying. Getting around that by waiting for all timeouts to
// wrap up, but note that the value of showCopiedSuccess will become false
// after runAllTimersAsync finishes
await act(() => jest.runAllTimersAsync());
await act(() => vi.runAllTimersAsync());
const clipboardText = getClipboardText();
expect(clipboardText).toEqual(textToCopy);
@@ -214,7 +214,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
it("Should notify the user of an error using the provided callback", async () => {
const textToCopy = "birds";
const onError = jest.fn();
const onError = vi.fn();
const { result } = renderUseClipboard({ onError });
setSimulateFailure(true);
@@ -223,7 +223,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
});
it("Should dispatch a new toast message to the global snackbar when errors happen while no error callback is provided to the hook", async () => {
const toastErrorSpy = jest.spyOn(toast, "error");
const toastErrorSpy = vi.spyOn(toast, "error");
const textToCopy = "crow";
const { result } = renderUseClipboard();
@@ -239,7 +239,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
// Snackbar state transitions that you might get if the hook uses the
// default
const textToCopy = "hamster";
const { result } = renderUseClipboard({ onError: jest.fn() });
const { result } = renderUseClipboard({ onError: vi.fn() });
setSimulateFailure(true);
await act(() => result.current.copyToClipboard(textToCopy));
@@ -264,7 +264,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
// inside of useEffect calls without having to think about dependencies too
// much
it("Ensures that the copyToClipboard function always maintains a stable reference across all re-renders", async () => {
const initialOnError = jest.fn();
const initialOnError = vi.fn();
const { result, rerender } = renderUseClipboard({
onError: initialOnError,
clearErrorOnSuccess: true,
@@ -278,7 +278,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
// Re-render with new onError prop and then swap back to simplify
// testing
rerender({ onError: jest.fn() });
rerender({ onError: vi.fn() });
expect(result.current.copyToClipboard).toBe(initialCopy);
rerender({ onError: initialOnError });
@@ -310,13 +310,13 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
});
it("Always uses the most up-to-date onError prop", async () => {
const initialOnError = jest.fn();
const initialOnError = vi.fn();
const { result, rerender } = renderUseClipboard({
onError: initialOnError,
});
setSimulateFailure(true);
const secondOnError = jest.fn();
const secondOnError = vi.fn();
rerender({ onError: secondOnError });
await act(() => result.current.copyToClipboard("dummy-text"));
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type { Mock } from "vitest";
import { agentLogsKey } from "#/api/queries/workspaces";
import type { WorkspaceAgentLog } from "#/api/typesGenerated";
import { MockWorkspaceAgent } from "#/testHelpers/entities";
@@ -41,7 +42,7 @@ export const ClickOnDownload: Story = {
`${MockWorkspaceAgent.name}-logs.txt`,
),
);
const blob: Blob = (args.download as jest.Mock).mock.calls[0][0];
const blob: Blob = (args.download as Mock).mock.calls[0][0];
await expect(blob.type).toEqual("text/plain");
},
};
@@ -84,12 +84,12 @@ describe("useAgentContainers", () => {
});
it("handles parsing errors from WebSocket", async () => {
const toastErrorSpy = jest.spyOn(toast, "error");
const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers");
const toastErrorSpy = vi.spyOn(toast, "error");
const watchAgentContainersSpy = vi.spyOn(API, "watchAgentContainers");
const mockSocket = {
addEventListener: jest.fn(),
close: jest.fn(),
addEventListener: vi.fn(),
close: vi.fn(),
};
watchAgentContainersSpy.mockReturnValue(
mockSocket as unknown as OneWayWebSocket<WorkspaceAgentListContainersResponse>,
@@ -146,12 +146,12 @@ describe("useAgentContainers", () => {
});
it("handles WebSocket errors", async () => {
const toastErrorSpy = jest.spyOn(toast, "error");
const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers");
const toastErrorSpy = vi.spyOn(toast, "error");
const watchAgentContainersSpy = vi.spyOn(API, "watchAgentContainers");
const mockSocket = {
addEventListener: jest.fn(),
close: jest.fn(),
addEventListener: vi.fn(),
close: vi.fn(),
};
watchAgentContainersSpy.mockReturnValue(
mockSocket as unknown as OneWayWebSocket<WorkspaceAgentListContainersResponse>,
@@ -204,7 +204,7 @@ describe("useAgentContainers", () => {
});
it("does not establish WebSocket connection when agent is not connected", () => {
const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers");
const watchAgentContainersSpy = vi.spyOn(API, "watchAgentContainers");
const disconnectedAgent = {
...MockWorkspaceAgent,
@@ -222,7 +222,7 @@ describe("useAgentContainers", () => {
});
it("does not establish WebSocket connection when dev container feature is not enabled", async () => {
const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers");
const watchAgentContainersSpy = vi.spyOn(API, "watchAgentContainers");
server.use(
http.get(
@@ -1,6 +1,7 @@
import { renderHook, waitFor } from "@testing-library/react";
import { act } from "react";
import { toast } from "sonner";
import type { MockInstance } from "vitest";
import * as apiModule from "#/api/api";
import type { WorkspaceAgentLog } from "#/api/typesGenerated";
import { MockWorkspaceAgent } from "#/testHelpers/entities";
@@ -45,7 +46,7 @@ type MountHookOptions = Readonly<{
type MountHookResult = Readonly<{
serverResult: ServerResult;
rerender: (props: { agentId: string; enabled: boolean }) => void;
toastError: jest.SpyInstance;
toastError: MockInstance;
// Note: the `current` property is only "halfway" readonly; the value is
// readonly, but the key is still mutable
@@ -56,9 +57,8 @@ function mountHook(options: MountHookOptions): MountHookResult {
const { initialAgentId, enabled = true } = options;
const serverResult: ServerResult = { current: undefined };
jest
.spyOn(apiModule, "watchWorkspaceAgentLogs")
.mockImplementation((agentId, params) => {
vi.spyOn(apiModule, "watchWorkspaceAgentLogs").mockImplementation(
(agentId, params) => {
return new OneWayWebSocket({
apiRoute: `/api/v2/workspaceagents/${agentId}/logs`,
searchParams: new URLSearchParams({
@@ -71,10 +71,11 @@ function mountHook(options: MountHookOptions): MountHookResult {
return mockSocket;
},
});
});
},
);
void jest.spyOn(console, "error").mockImplementation(() => {});
const toastError = jest.spyOn(toast, "error");
void vi.spyOn(console, "error").mockImplementation(() => {});
const toastError = vi.spyOn(toast, "error");
const { result: hookResult, rerender } = renderHook(
(props) => useAgentLogs(props),
@@ -67,10 +67,10 @@ const mockRequiredParameter = createMockParameter({
});
describe("DynamicParameter", () => {
const mockOnChange = jest.fn();
const mockOnChange = vi.fn();
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
describe("Input Parameter", () => {
@@ -800,7 +800,7 @@ describe("DynamicParameter", () => {
});
it("calls onChange when numeric value changes (debounced)", () => {
jest.useFakeTimers();
vi.useFakeTimers();
render(
<DynamicParameter
parameter={mockNumberInputParameter}
@@ -813,11 +813,11 @@ describe("DynamicParameter", () => {
fireEvent.change(input, { target: { value: "7" } });
act(() => {
jest.runAllTimers();
vi.runAllTimers();
});
expect(mockOnChange).toHaveBeenCalledWith("7");
jest.useRealTimers();
vi.useRealTimers();
});
});
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type { Mock } from "vitest";
import { agentLogsKey, buildLogsKey } from "#/api/queries/workspaces";
import { MockWorkspace, MockWorkspaceAgent } from "#/testHelpers/entities";
import { withDesktopViewport } from "#/testHelpers/storybook";
@@ -61,7 +62,7 @@ export const DownloadLogs: Story = {
`${MockWorkspace.name}-logs.zip`,
),
);
const blob: Blob = (args.download as jest.Mock).mock.calls[0][0];
const blob: Blob = (args.download as Mock).mock.calls[0][0];
await expect(blob.type).toEqual("application/zip");
},
};
@@ -30,7 +30,7 @@ const renderPage = async (searchParams: URLSearchParams) => {
return view;
};
test("Create template from starter template", async () => {
test("Create template from starter template", { timeout: 20_000 }, async () => {
// Render page, fill the name and submit
const searchParams = new URLSearchParams({
exampleId: MockTemplateExample.id,
@@ -38,14 +38,14 @@ test("Create template from starter template", async () => {
const { router, container } = await renderPage(searchParams);
const form = container.querySelector("form") as HTMLFormElement;
jest.spyOn(API, "createTemplateVersion").mockResolvedValueOnce({
vi.spyOn(API, "createTemplateVersion").mockResolvedValueOnce({
...MockTemplateVersion,
job: {
...MockTemplateVersion.job,
status: "pending",
},
});
jest.spyOn(API, "getTemplateVersion").mockResolvedValue({
vi.spyOn(API, "getTemplateVersion").mockResolvedValue({
...MockTemplateVersion,
job: {
...MockTemplateVersion.job,
@@ -53,13 +53,11 @@ test("Create template from starter template", async () => {
error_code: "REQUIRED_TEMPLATE_VARIABLES",
},
});
jest
.spyOn(API, "getTemplateVersionVariables")
.mockResolvedValue([
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
MockTemplateVersionVariable3,
]);
vi.spyOn(API, "getTemplateVersionVariables").mockResolvedValue([
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
MockTemplateVersionVariable3,
]);
await userEvent.type(screen.getByLabelText(/Name/), "my-template");
await userEvent.click(within(form).getByRole("button", { name: /save/i }));
@@ -84,12 +82,10 @@ test("Create template from starter template", async () => {
// Select third variable on radio
await userEvent.click(screen.getByLabelText(/True/));
// Setup the mock for the second template version creation before submit the form
jest.clearAllMocks();
jest
.spyOn(API, "createTemplateVersion")
.mockResolvedValue(MockTemplateVersion);
jest.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
jest.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate);
vi.clearAllMocks();
vi.spyOn(API, "createTemplateVersion").mockResolvedValue(MockTemplateVersion);
vi.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
vi.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate);
await userEvent.click(within(form).getByRole("button", { name: /save/i }));
await waitFor(() => expect(API.createTemplate).toBeCalledTimes(1));
expect(router.state.location.pathname).toEqual(
@@ -109,11 +105,11 @@ test("Create template from starter template", async () => {
});
test("Create template from duplicating a template", async () => {
jest.spyOn(API, "getTemplateByName").mockResolvedValue(MockTemplate);
jest.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
jest
.spyOn(API, "getTemplateVersionVariables")
.mockResolvedValue([MockTemplateVersionVariable1]);
vi.spyOn(API, "getTemplateByName").mockResolvedValue(MockTemplate);
vi.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
vi.spyOn(API, "getTemplateVersionVariables").mockResolvedValue([
MockTemplateVersionVariable1,
]);
const searchParams = new URLSearchParams({
fromTemplate: MockTemplate.id,
@@ -133,11 +129,9 @@ test("Create template from duplicating a template", async () => {
}),
).toHaveValue(MockTemplateVersionVariable1.value);
// Create template
jest
.spyOn(API, "createTemplateVersion")
.mockResolvedValue(MockTemplateVersion);
jest.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
jest.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate);
vi.spyOn(API, "createTemplateVersion").mockResolvedValue(MockTemplateVersion);
vi.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
vi.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(router.state.location.pathname).toEqual(
@@ -148,7 +142,7 @@ test("Create template from duplicating a template", async () => {
test("The page displays an error if the upload fails", async () => {
const errMsg = "Unsupported content type header";
jest.spyOn(API, "uploadFile").mockRejectedValueOnce(
vi.spyOn(API, "uploadFile").mockRejectedValueOnce(
mockApiError({
message: errMsg,
}),
@@ -9,7 +9,7 @@ import CreateTokenPage from "./CreateTokenPage";
describe("TokenPage", () => {
it("shows the success modal", async () => {
jest.spyOn(API, "createToken").mockResolvedValueOnce({
vi.spyOn(API, "createToken").mockResolvedValueOnce({
key: "abcd",
});
@@ -40,17 +40,16 @@ describe("CreateWorkspacePage", () => {
};
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
jest.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]);
jest.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]);
jest.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace);
jest.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions);
vi.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]);
vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]);
vi.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace);
vi.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions);
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
vi.spyOn(API, "templateVersionDynamicParameters").mockImplementation(
(_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
@@ -73,11 +72,13 @@ describe("CreateWorkspacePage", () => {
);
return mockWebSocket;
});
},
);
});
afterEach(() => {
jest.restoreAllMocks();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("WebSocket Integration", () => {
@@ -107,9 +108,8 @@ describe("CreateWorkspacePage", () => {
it("sends parameter updates via WebSocket when form values change", async () => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
vi.spyOn(API, "templateVersionDynamicParameters").mockImplementation(
(_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
@@ -130,7 +130,8 @@ describe("CreateWorkspacePage", () => {
);
return mockWebSocket;
});
},
);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
@@ -144,7 +145,7 @@ describe("CreateWorkspacePage", () => {
within(instanceTypeField).getByRole("combobox");
expect(instanceTypeSelect).toBeInTheDocument();
jest.useFakeTimers();
vi.useFakeTimers({ shouldAdvanceTime: true });
await waitFor(async () => {
await userEvent.click(instanceTypeSelect);
@@ -160,29 +161,29 @@ describe("CreateWorkspacePage", () => {
await userEvent.click(mediumOption!);
});
act(() => {
jest.runAllTimers();
await act(async () => {
await vi.runAllTimersAsync();
});
expect(mockWebSocket.send).toHaveBeenCalledWith(
expect.stringContaining('"instance_type":"t3.medium"'),
);
jest.useRealTimers();
vi.useRealTimers();
});
it("handles WebSocket error gracefully", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
vi.spyOn(API, "templateVersionDynamicParameters").mockImplementation(
(_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("error", () => {
callbacks.onError(new Error("Connection failed"));
});
return mockWebSocket;
});
},
);
renderCreateWorkspacePage();
@@ -199,15 +200,15 @@ describe("CreateWorkspacePage", () => {
it("handles WebSocket close event", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
vi.spyOn(API, "templateVersionDynamicParameters").mockImplementation(
(_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
return mockWebSocket;
});
},
);
renderCreateWorkspacePage();
@@ -225,9 +226,8 @@ describe("CreateWorkspacePage", () => {
it("only parameters from latest response are displayed", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
vi.spyOn(API, "templateVersionDynamicParameters").mockImplementation(
(_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
@@ -244,7 +244,8 @@ describe("CreateWorkspacePage", () => {
);
return mockWebSocket;
});
},
);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
@@ -277,9 +278,8 @@ describe("CreateWorkspacePage", () => {
describe("Dynamic Parameter Types", () => {
it("displays parameter validation errors", async () => {
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
vi.spyOn(API, "templateVersionDynamicParameters").mockImplementation(
(_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
@@ -293,7 +293,8 @@ describe("CreateWorkspacePage", () => {
);
return mockWebSocket;
});
},
);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
@@ -337,9 +338,8 @@ describe("CreateWorkspacePage", () => {
diagnostics: [],
};
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
vi.spyOn(API, "templateVersionDynamicParameters").mockImplementation(
(_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
@@ -355,7 +355,7 @@ describe("CreateWorkspacePage", () => {
);
const originalSend = mockWebSocket.send;
mockWebSocket.send = jest.fn((data) => {
mockWebSocket.send = vi.fn((data) => {
originalSend.call(mockWebSocket, data);
if (typeof data === "string" && data.includes('"200"')) {
@@ -368,7 +368,8 @@ describe("CreateWorkspacePage", () => {
});
return mockWebSocket;
});
},
);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
@@ -414,9 +415,9 @@ describe("CreateWorkspacePage", () => {
describe("External Authentication", () => {
it("displays external auth providers", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithub,
]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
@@ -430,11 +431,9 @@ describe("CreateWorkspacePage", () => {
});
it("shows authenticated state for connected providers", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
@@ -446,9 +445,9 @@ describe("CreateWorkspacePage", () => {
});
it("prevents auto-creation when required external auth is missing", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithub,
]);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?mode=auto&version=${MockTemplate.id}`,
@@ -470,14 +469,12 @@ describe("CreateWorkspacePage", () => {
describe("Auto-creation Mode", () => {
it("falls back to form mode when auto-creation fails", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
jest
.spyOn(API, "createWorkspace")
.mockRejectedValue(new Error("Auto-creation failed"));
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
vi.spyOn(API, "createWorkspace").mockRejectedValue(
new Error("Auto-creation failed"),
);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?mode=auto`,
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type { Mock } from "vitest";
import { MockOrganizationSyncSettings } from "#/testHelpers/entities";
import { ExportPolicyButton } from "./ExportPolicyButton";
@@ -32,7 +33,7 @@ export const ClickExportPolicy: Story = {
"organizations_policy.json",
),
);
const blob: Blob = (args.download as jest.Mock).mock.lastCall[0];
const blob: Blob = (args.download as Mock).mock.lastCall![0];
await expect(blob.type).toEqual("application/json");
await expect(await blob.text()).toEqual(
JSON.stringify(MockOrganizationSyncSettings, null, 2),
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type { Mock } from "vitest";
import {
MockGroupSyncSettings,
MockOrganization,
@@ -40,7 +41,7 @@ export const ClickExportGroupPolicy: Story = {
`${MockOrganization.name}_groups-policy.json`,
),
);
const blob: Blob = (args.download as jest.Mock).mock.lastCall[0];
const blob: Blob = (args.download as Mock).mock.lastCall![0];
await expect(blob.type).toEqual("application/json");
await expect(await blob.text()).toEqual(
JSON.stringify(MockGroupSyncSettings, null, 2),
@@ -66,7 +67,7 @@ export const ClickExportRolePolicy: Story = {
`${MockOrganization.name}_roles-policy.json`,
),
);
const blob: Blob = (args.download as jest.Mock).mock.lastCall[0];
const blob: Blob = (args.download as Mock).mock.lastCall![0];
await expect(blob.type).toEqual("application/json");
await expect(await blob.text()).toEqual(
JSON.stringify(MockRoleSyncSettings, null, 2),
@@ -14,9 +14,10 @@ import {
import TemplateEmbedPage from "./TemplateEmbedPage";
test("Users can fill the parameters and copy the open in coder url", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValue([parameter1, parameter2]);
vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([
parameter1,
parameter2,
]);
renderWithAuth(
<TemplateLayout>
@@ -47,10 +48,10 @@ test("Users can fill the parameters and copy the open in coder url", async () =>
await user.clear(secondParameterField);
await user.type(secondParameterField, "123456");
jest.spyOn(window.navigator.clipboard, "writeText");
vi.spyOn(navigator.clipboard, "writeText");
const copyButton = screen.getByRole("button", { name: /copy/i });
await userEvent.click(copyButton);
expect(window.navigator.clipboard.writeText).toBeCalledWith(
`[![Open in Coder](http://localhost/open-in-coder.svg)](http://localhost/templates/${MockTemplate.organization_name}/${MockTemplate.name}/workspace?mode=manual&name=my-first-workspace&param.first_parameter=firstParameterValue&param.second_parameter=123456)`,
expect(navigator.clipboard.writeText).toBeCalledWith(
`[![Open in Coder](${location.origin}/open-in-coder.svg)](${location.origin}/templates/${MockTemplate.organization_name}/${MockTemplate.name}/workspace?mode=manual&name=my-first-workspace&param.first_parameter=firstParameterValue&param.second_parameter=123456)`,
);
});
@@ -50,7 +50,7 @@ interface TemplateEmbedPageViewProps {
templateParameters?: TemplateVersionParameter[];
}
const deploymentUrl = `${window.location.protocol}//${window.location.host}`;
const deploymentUrl = `${location.protocol}//${location.host}`;
function getClipboardCopyContent(
templateName: string,
@@ -10,7 +10,7 @@ import TemplateFilesPage from "./TemplateFilesPage";
// Occasionally, Jest encounters HTML5 canvas errors. As the SyntaxHighlight is
// not required for these tests, we can safely mock it.
jest.mock("#/components/SyntaxHighlighter/SyntaxHighlighter", () => ({
vi.mock("#/components/SyntaxHighlighter/SyntaxHighlighter", () => ({
SyntaxHighlighter: () => <div data-testid="syntax-highlighter" />,
}));
@@ -10,14 +10,14 @@ const wrapper = ({ children }: { children: React.ReactNode }) =>
test("delete dialog starts closed", () => {
const { result } = renderHook(
() => useDeletionDialogState(MockTemplate.id, jest.fn()),
() => useDeletionDialogState(MockTemplate.id, vi.fn()),
{ wrapper },
);
expect(result.current.isDeleteDialogOpen).toBeFalsy();
});
test("confirm template deletion", async () => {
const onDeleteTemplate = jest.fn();
const onDeleteTemplate = vi.fn();
const { result } = renderHook(
() => useDeletionDialogState(MockTemplate.id, onDeleteTemplate),
{ wrapper },
@@ -28,7 +28,7 @@ test("confirm template deletion", async () => {
});
expect(result.current.isDeleteDialogOpen).toBeTruthy();
jest.spyOn(API, "deleteTemplate");
vi.spyOn(API, "deleteTemplate");
await act(async () => result.current.confirmDelete());
await waitFor(() => expect(API.deleteTemplate).toBeCalledTimes(1));
await waitFor(() => expect(onDeleteTemplate).toBeCalledTimes(1));
@@ -36,7 +36,7 @@ test("confirm template deletion", async () => {
test("cancel template deletion", () => {
const { result } = renderHook(
() => useDeletionDialogState(MockTemplate.id, jest.fn()),
() => useDeletionDialogState(MockTemplate.id, vi.fn()),
{ wrapper },
);
@@ -105,10 +105,14 @@ const fillAndSubmitForm = async ({
await userEvent.click(submitButton);
};
describe("TemplateSettingsPage", () => {
describe("TemplateSettingsPage", { timeout: 20_000 }, () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("succeeds", async () => {
await renderTemplateSettingsPage();
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
vi.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
...MockTemplate,
...validFormValues,
});
@@ -118,7 +122,7 @@ describe("TemplateSettingsPage", () => {
it("displays an error if the name is taken", async () => {
await renderTemplateSettingsPage();
jest.spyOn(API, "updateTemplateMeta").mockRejectedValueOnce(
vi.spyOn(API, "updateTemplateMeta").mockRejectedValueOnce(
mockApiError({
message: `Template with name "test-template" already exists`,
validations: [
@@ -173,14 +177,16 @@ describe("TemplateSettingsPage", () => {
});
}),
);
const updateTemplateMetaSpy = jest.spyOn(API, "updateTemplateMeta");
const updateTemplateMetaSpy = vi.spyOn(API, "updateTemplateMeta");
const deprecationMessage = "This template is deprecated";
await renderTemplateSettingsPage();
await deprecateTemplate(deprecationMessage);
await waitFor(() =>
expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1),
);
const [templateId, data] = updateTemplateMetaSpy.mock.calls[0];
expect(templateId).toEqual(MockTemplate.id);
expect(data).toEqual(
expect.objectContaining({ deprecation_message: deprecationMessage }),
@@ -198,13 +204,15 @@ describe("TemplateSettingsPage", () => {
});
}),
);
const updateTemplateMetaSpy = jest.spyOn(API, "updateTemplateMeta");
const updateTemplateMetaSpy = vi.spyOn(API, "updateTemplateMeta");
await renderTemplateSettingsPage();
await deprecateTemplate("This template should not be able to deprecate");
await waitFor(() =>
expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1),
);
const [templateId, data] = updateTemplateMetaSpy.mock.calls[0];
expect(templateId).toEqual(MockTemplate.id);
expect(data).toEqual(
expect.objectContaining({ deprecation_message: "" }),
@@ -19,7 +19,7 @@ import TemplateVariablesPage from "./TemplateVariablesPage";
// a real `delay(1000)` call. Without this mock the 1 s wall-clock wait races
// against the default `waitFor` timeout (also 1 s), making the "submit"
// assertion flaky in CI.
jest.mock("#/utils/delay", () => ({
vi.mock("#/utils/delay", () => ({
delay: () => Promise.resolve(),
}));
@@ -39,16 +39,14 @@ const renderTemplateVariablesPage = async () => {
describe("TemplateVariablesPage", () => {
it("renders with variables", async () => {
jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate);
jest
.spyOn(API, "getTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion);
jest
.spyOn(API, "getTemplateVersionVariables")
.mockResolvedValueOnce([
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
]);
vi.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate);
vi.spyOn(API, "getTemplateVersion").mockResolvedValueOnce(
MockTemplateVersion,
);
vi.spyOn(API, "getTemplateVersionVariables").mockResolvedValueOnce([
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
]);
await renderTemplateVariablesPage();
@@ -64,20 +62,16 @@ describe("TemplateVariablesPage", () => {
});
it("user submits the form successfully", async () => {
jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate);
jest
.spyOn(API, "getTemplateVersion")
.mockResolvedValue(MockTemplateVersion);
jest
.spyOn(API, "getTemplateVersionVariables")
.mockResolvedValueOnce([
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
]);
jest
.spyOn(API, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion2);
jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({
vi.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate);
vi.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
vi.spyOn(API, "getTemplateVersionVariables").mockResolvedValueOnce([
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
]);
vi.spyOn(API, "createTemplateVersion").mockResolvedValueOnce(
MockTemplateVersion2,
);
vi.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({
message: "done",
});
@@ -107,7 +101,7 @@ describe("TemplateVariablesPage", () => {
await userEvent.type(secondVariableField, validFormValues.second_variable);
// Submit the form
const toastSuccessSpy = jest.spyOn(toast, "success");
const toastSuccessSpy = vi.spyOn(toast, "success");
const submitButton = await screen.findByText(/save/i);
await userEvent.click(submitButton);
@@ -40,8 +40,8 @@ vi.mock(
}),
);
// Occasionally, Jest encounters HTML5 canvas errors. As the MonacoEditor is not
// required for these tests, we can safely mock it.
// Monaco is a large and complicated codebase that slows tests down and we don't
// need to test.
vi.mock("#/pages/TemplateVersionEditorPage/MonacoEditor", () => ({
MonacoEditor: (props: MonacoEditorProps) => (
<textarea
@@ -17,13 +17,13 @@ const TEMPLATE_VERSION_FILES = {
};
const setup = async () => {
jest
.spyOn(templateVersionUtils, "getTemplateVersionFiles")
.mockResolvedValue(TEMPLATE_VERSION_FILES);
vi.spyOn(templateVersionUtils, "getTemplateVersionFiles").mockResolvedValue(
TEMPLATE_VERSION_FILES,
);
jest
.spyOn(CreateDayString, "createDayString")
.mockImplementation(() => "a minute ago");
vi.spyOn(CreateDayString, "createDayString").mockImplementation(
() => "a minute ago",
);
renderWithAuth(<TemplateVersionPage />, {
route: `/templates/${TEMPLATE_NAME}/versions/${VERSION_NAME}`,
@@ -36,16 +36,16 @@ const fillAndSubmitSecurityForm = () => {
};
beforeEach(() => {
jest.spyOn(API, "getAuthMethods").mockResolvedValue(MockAuthMethodsAll);
jest.spyOn(API, "getUserLoginType").mockResolvedValue({
vi.spyOn(API, "getAuthMethods").mockResolvedValue(MockAuthMethodsAll);
vi.spyOn(API, "getUserLoginType").mockResolvedValue({
login_type: "password",
});
});
test("update password successfully", async () => {
jest
.spyOn(API, "updateUserPassword")
.mockImplementationOnce((_userId, _data) => Promise.resolve(undefined));
vi.spyOn(API, "updateUserPassword").mockImplementationOnce((_userId, _data) =>
Promise.resolve(undefined),
);
const { user } = await renderPage();
fillAndSubmitSecurityForm();
@@ -54,11 +54,11 @@ test("update password successfully", async () => {
expect(API.updateUserPassword).toBeCalledTimes(1);
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues);
await waitFor(() => expect(window.location).toBeAt("/"));
await waitFor(() => expect(location.pathname).toBe("/"));
});
test("update password with incorrect old password", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
vi.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Incorrect password.",
validations: [{ detail: "Incorrect password.", field: "old_password" }],
@@ -76,7 +76,7 @@ test("update password with incorrect old password", async () => {
});
test("update password when submit returns an unknown error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
vi.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
data: "unknown error",
});
@@ -92,16 +92,14 @@ test("update password when submit returns an unknown error", async () => {
test("change login type to OIDC", async () => {
const user = userEvent.setup();
const { user: userData } = await renderPage();
const convertToOAUTHSpy = jest
.spyOn(API, "convertToOAUTH")
.mockResolvedValue({
state_string: "some-state-string",
expires_at: "2021-01-01T00:00:00Z",
to_type: "oidc",
user_id: userData.id,
} as OAuthConversionResponse);
const convertToOAUTHSpy = vi.spyOn(API, "convertToOAUTH").mockResolvedValue({
state_string: "some-state-string",
expires_at: "2021-01-01T00:00:00Z",
to_type: "oidc",
user_id: userData.id,
} as OAuthConversionResponse);
jest.spyOn(SSO, "redirectToOIDCAuth").mockImplementation(() => {
vi.spyOn(SSO, "redirectToOIDCAuth").mockImplementation(() => {
// Does a noop
return "";
});
@@ -24,7 +24,7 @@ afterEach(() => {
describe("WorkspaceBuildPage", () => {
test("gets the right workspace build", async () => {
const getWorkspaceBuildSpy = jest
const getWorkspaceBuildSpy = vi
.spyOn(API, "getWorkspaceBuildByNumber")
.mockResolvedValue(MockWorkspaceBuild);
renderWithAuth(<WorkspaceBuildPage />, {
@@ -54,8 +54,8 @@ describe("WorkspaceBuildPage", () => {
output: "",
};
client.send(JSON.stringify(log));
await expect(server).toReceiveMessage(log);
expect(server).toHaveReceivedMessages([log]);
expect(await server.nextMessage).toEqual(log);
expect(server.messages).toEqual([log]);
client.onmessage = async () => {
renderWithAuth(<WorkspaceBuildPage />, {
@@ -73,9 +73,8 @@ describe("WorkspaceBuildPage", () => {
test("shows selected agent logs", async () => {
let mockServer: MockWebSocketServer | undefined;
jest
.spyOn(apiModule, "watchWorkspaceAgentLogs")
.mockImplementation((agentId, params) => {
vi.spyOn(apiModule, "watchWorkspaceAgentLogs").mockImplementation(
(agentId, params) => {
return new OneWayWebSocket({
apiRoute: `/api/v2/workspaceagents/${agentId}/logs`,
searchParams: new URLSearchParams({
@@ -88,7 +87,8 @@ describe("WorkspaceBuildPage", () => {
return socket;
},
});
});
},
);
const user = userEvent.setup();
renderWithAuth(<WorkspaceBuildPage />, {
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import MockServerSocket from "jest-websocket-mock";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import type { MockInstance } from "vitest";
import * as apiModule from "#/api/api";
import type { TemplateVersionParameter, Workspace } from "#/api/typesGenerated";
import {
@@ -45,12 +46,12 @@ const renderWorkspacePage = async (
workspace: Workspace,
options: RenderWorkspacePageOptions = {},
) => {
jest.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(workspace);
jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate);
jest
.spyOn(API, "getDeploymentConfig")
.mockResolvedValueOnce(MockDeploymentConfig);
jest.spyOn(apiModule, "watchWorkspaceAgentLogs");
vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(workspace);
vi.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate);
vi.spyOn(API, "getDeploymentConfig").mockResolvedValueOnce(
MockDeploymentConfig,
);
vi.spyOn(apiModule, "watchWorkspaceAgentLogs");
const result = renderWithAuth(<WorkspacePage />, {
...options,
@@ -74,7 +75,7 @@ const renderWorkspacePage = async (
const testButton = async (
workspace: Workspace,
name: string | RegExp,
actionMock: jest.SpyInstance,
actionMock: MockInstance,
) => {
await renderWorkspacePage(workspace);
const workspaceActions = screen.getByTestId("workspace-actions");
@@ -92,7 +93,7 @@ afterEach(() => {
describe("WorkspacePage", () => {
it("requests a delete job when the user presses Delete and confirms", async () => {
const user = userEvent.setup({ delay: 0 });
const deleteWorkspaceMock = jest
const deleteWorkspaceMock = vi
.spyOn(API, "deleteWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
await renderWorkspacePage(MockWorkspace);
@@ -135,7 +136,7 @@ describe("WorkspacePage", () => {
}),
);
const deleteWorkspaceMock = jest
const deleteWorkspaceMock = vi
.spyOn(API, "deleteWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuildDelete);
await renderWorkspacePage(MockFailedWorkspace);
@@ -183,7 +184,7 @@ describe("WorkspacePage", () => {
}),
);
const startWorkspaceMock = jest
const startWorkspaceMock = vi
.spyOn(API, "startWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild));
@@ -191,7 +192,7 @@ describe("WorkspacePage", () => {
});
it("requests a stop job when the user presses Stop", async () => {
const stopWorkspaceMock = jest
const stopWorkspaceMock = vi
.spyOn(API, "stopWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
@@ -199,7 +200,7 @@ describe("WorkspacePage", () => {
});
it("requests a stop when the user presses Restart", async () => {
const stopWorkspaceMock = jest
const stopWorkspaceMock = vi
.spyOn(API, "stopWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
@@ -226,7 +227,7 @@ describe("WorkspacePage", () => {
);
const user = userEvent.setup({ delay: 0 });
const cancelWorkspaceMock = jest
const cancelWorkspaceMock = vi
.spyOn(API, "cancelWorkspaceBuild")
.mockImplementation(() => Promise.resolve({ message: "job canceled" }));
await renderWorkspacePage(MockStartingWorkspace);
@@ -257,7 +258,7 @@ describe("WorkspacePage", () => {
);
const user = userEvent.setup({ delay: 0 });
const cancelWorkspaceMock = jest
const cancelWorkspaceMock = vi
.spyOn(API, "cancelWorkspaceBuild")
.mockImplementation(() => Promise.resolve({ message: "job canceled" }));
await renderWorkspacePage(MockPendingWorkspace);
@@ -282,11 +283,11 @@ describe("WorkspacePage", () => {
it("requests an update when the user presses Update", async () => {
// Mocks
jest
.spyOn(API, "getWorkspaceByOwnerAndName")
.mockResolvedValueOnce(MockOutdatedWorkspace);
vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce(
MockOutdatedWorkspace,
);
const updateWorkspaceMock = jest
const updateWorkspaceMock = vi
.spyOn(API, "updateWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
@@ -310,10 +311,10 @@ describe("WorkspacePage", () => {
// in a few releases when dynamic parameters takes over the world.
it("updates the parameters when they are missing during update", async () => {
// Mocks
jest
.spyOn(API, "getWorkspaceByOwnerAndName")
.mockResolvedValueOnce(MockOutdatedWorkspace);
const updateWorkspaceSpy = jest
vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce(
MockOutdatedWorkspace,
);
const updateWorkspaceSpy = vi
.spyOn(API, "updateWorkspace")
.mockRejectedValueOnce(
new MissingBuildParameters(
@@ -379,7 +380,7 @@ describe("WorkspacePage", () => {
it("restart the workspace with one time parameters when having the confirmation dialog", async () => {
localStorage.removeItem(`${MockUserOwner.id}_ignoredWarnings`);
jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([
vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([
{
...MockTemplateVersionParameter1,
ephemeral: true,
@@ -388,10 +389,11 @@ describe("WorkspacePage", () => {
required: false,
},
]);
jest
.spyOn(API, "getWorkspaceBuildParameters")
.mockResolvedValue([{ name: "rebuild", value: "false" }]);
const restartWorkspaceSpy = jest.spyOn(API, "restartWorkspace");
vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([
{ name: "rebuild", value: "false" },
]);
const restartWorkspaceSpy = vi.spyOn(API, "restartWorkspace");
const user = userEvent.setup();
await renderWorkspacePage(MockWorkspace);
await user.click(screen.getByTestId("build-parameters-button"));
@@ -421,7 +423,7 @@ describe("WorkspacePage", () => {
const retryDebugButtonRe = /^Debug$/i;
describe("Retries a failed 'Start' transition", () => {
const mockRetry = jest
const mockRetry = vi
.spyOn(API, "retryWorkspace")
.mockResolvedValue(MockWorkspaceBuild);
const failedStart: Workspace = {
@@ -456,7 +458,7 @@ describe("WorkspacePage", () => {
});
describe("Retries a failed 'Stop' transition", () => {
const mockStop = jest.spyOn(API, "stopWorkspace");
const mockStop = vi.spyOn(API, "stopWorkspace");
const failedStop: Workspace = {
...MockFailedWorkspace,
latest_build: {
@@ -477,7 +479,7 @@ describe("WorkspacePage", () => {
});
describe("Retries a failed 'Delete' transition", () => {
const mockDelete = jest.spyOn(API, "deleteWorkspace");
const mockDelete = vi.spyOn(API, "deleteWorkspace");
const failedDelete: Workspace = {
...MockFailedWorkspace,
latest_build: {
@@ -524,7 +526,7 @@ describe("WorkspacePage", () => {
return HttpResponse.json([parameter]);
}),
);
const retryWorkspaceSpy = jest
const retryWorkspaceSpy = vi
.spyOn(API, "retryWorkspace")
.mockResolvedValue(MockWorkspaceBuild);
@@ -534,9 +536,13 @@ describe("WorkspacePage", () => {
});
await user.click(retryWithBuildParametersButton);
await screen.findByText("Build Options");
const parameterField = screen.getByLabelText(parameter.display_name, {
exact: false,
});
const parameterField = await screen.findByLabelText(
parameter.display_name,
{
exact: false,
},
);
await user.clear(parameterField);
await user.type(parameterField, "some-value");
const submitButton = screen.getByText("Build workspace");
@@ -572,7 +578,7 @@ describe("WorkspacePage", () => {
return HttpResponse.json([parameter]);
}),
);
const retryWorkspaceSpy = jest
const retryWorkspaceSpy = vi
.spyOn(API, "retryWorkspace")
.mockResolvedValue(MockWorkspaceBuild);
@@ -582,9 +588,13 @@ describe("WorkspacePage", () => {
});
await user.click(retryWithBuildParametersButton);
await screen.findByText("Build Options");
const parameterField = screen.getByLabelText(parameter.display_name, {
exact: false,
});
const parameterField = await screen.findByLabelText(
parameter.display_name,
{
exact: false,
},
);
await user.clear(parameterField);
await user.type(parameterField, "some-value");
const submitButton = screen.getByText("Build workspace");
@@ -602,7 +612,7 @@ describe("WorkspacePage", () => {
describe("Navigation to other pages", () => {
it("Shows a quota link when quota budget is greater than 0. Link navigates user to /workspaces route with the URL params populated with the corresponding organization", async () => {
jest.spyOn(API, "getWorkspaceQuota").mockResolvedValueOnce({
vi.spyOn(API, "getWorkspaceQuota").mockResolvedValueOnce({
budget: 25,
credits_consumed: 2,
});
@@ -1,25 +0,0 @@
import { createContext, type FC, type PropsWithChildren } from "react";
export type WorkerInitializationRenderOptions = {
theme?: {
light?: string;
dark?: string;
};
};
export type WorkerPoolOptions = {
poolSize?: number;
workerFactory?: () => Worker;
};
export type SupportedLanguages = string;
export const WorkerPoolContextProvider: FC<PropsWithChildren> = ({
children,
}) => <>{children}</>;
export const VirtualizerContext = createContext(undefined);
export const FileDiff: FC = () => null;
export const File: FC = () => null;
-1
View File
@@ -1 +0,0 @@
export default {};
@@ -16,10 +16,10 @@ describe(createMockWebSocket.name, () => {
it("Sends events from server to socket", () => {
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/shake");
const onOpen = jest.fn();
const onError = jest.fn();
const onMessage = jest.fn();
const onClose = jest.fn();
const onOpen = vi.fn();
const onError = vi.fn();
const onMessage = vi.fn();
const onClose = vi.fn();
socket.addEventListener("open", onOpen);
socket.addEventListener("error", onError);
@@ -51,7 +51,7 @@ describe(createMockWebSocket.name, () => {
it("Sends JSON data to the socket for message events", () => {
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/wag");
const onMessage = jest.fn();
const onMessage = vi.fn();
// Could type this as a special JSON type, but unknown is good enough,
// since any invalid values will throw in the test case
@@ -91,10 +91,10 @@ describe(createMockWebSocket.name, () => {
it("Only registers each socket event handler once", () => {
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/borf");
const onOpen = jest.fn();
const onError = jest.fn();
const onMessage = jest.fn();
const onClose = jest.fn();
const onOpen = vi.fn();
const onError = vi.fn();
const onMessage = vi.fn();
const onClose = vi.fn();
// Do it once
socket.addEventListener("open", onOpen);
@@ -122,10 +122,10 @@ describe(createMockWebSocket.name, () => {
it("Lets a socket unsubscribe to event types", () => {
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/zoomies");
const onOpen = jest.fn();
const onError = jest.fn();
const onMessage = jest.fn();
const onClose = jest.fn();
const onOpen = vi.fn();
const onError = vi.fn();
const onMessage = vi.fn();
const onClose = vi.fn();
socket.addEventListener("open", onOpen);
socket.addEventListener("error", onError);
@@ -152,7 +152,7 @@ describe(createMockWebSocket.name, () => {
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/woof");
expect(server.isConnectionOpen).toBe(true);
const onMessage = jest.fn();
const onMessage = vi.fn();
socket.addEventListener("message", onMessage);
socket.close();
+7 -6
View File
@@ -1,3 +1,4 @@
import type { Mock } from "vitest";
import type { WebSocketEventType } from "#/utils/OneWayWebSocket";
type SocketSendData = Parameters<WebSocket["send"]>[0];
@@ -19,15 +20,15 @@ type CallbackStore = {
type MockWebSocket = Omit<WebSocket, "send"> & {
/**
* A version of the WebSocket `send` method that has been pre-wrapped inside
* a Jest mock.
* a vitest mock.
*
* The Jest mock functionality should be used at a minimum. Basically:
* The vitest mock functionality should be used at a minimum. Basically:
* 1. If you want to check that the mock socket sent something to the mock
* server: call the `send` method as a function, and then check the
* `clientSentData` on `MockWebSocketServer` to see what data got
* received.
* 2. If you need to make sure that the client-side `send` method got called
* at all: you can use the Jest mock functionality, but you should
* at all: you can use the vitest mock functionality, but you should
* probably also be checking `clientSentData` still and making additional
* assertions with it.
*
@@ -35,7 +36,7 @@ type MockWebSocket = Omit<WebSocket, "send"> & {
* communication was successful, not whether the client-side method was
* called.
*/
send: jest.Mock<void, [SocketSendData], unknown>;
send: Mock<(data: SocketSendData) => void>;
};
export function createMockWebSocket(
@@ -76,9 +77,9 @@ export function createMockWebSocket(
onerror: null,
onmessage: null,
onopen: null,
dispatchEvent: jest.fn(),
dispatchEvent: vi.fn(),
send: jest.fn((data) => {
send: vi.fn((data) => {
if (!isOpen) {
return;
}
@@ -43,10 +43,10 @@ describe(OneWayWebSocket.name, () => {
},
});
const onOpen = jest.fn();
const onClose = jest.fn();
const onError = jest.fn();
const onMessage = jest.fn();
const onOpen = vi.fn();
const onClose = vi.fn();
const onError = vi.fn();
const onMessage = vi.fn();
oneWay.addEventListener("open", onOpen);
oneWay.addEventListener("close", onClose);
@@ -83,10 +83,10 @@ describe(OneWayWebSocket.name, () => {
},
});
const onOpen = jest.fn();
const onClose = jest.fn();
const onError = jest.fn();
const onMessage = jest.fn();
const onOpen = vi.fn();
const onClose = vi.fn();
const onError = vi.fn();
const onMessage = vi.fn();
oneWay.addEventListener("open", onOpen);
oneWay.addEventListener("close", onClose);
@@ -128,10 +128,10 @@ describe(OneWayWebSocket.name, () => {
},
});
const onOpen = jest.fn();
const onClose = jest.fn();
const onError = jest.fn();
const onMessage = jest.fn();
const onOpen = vi.fn();
const onClose = vi.fn();
const onError = vi.fn();
const onMessage = vi.fn();
for (let i = 0; i < 10; i++) {
oneWay.addEventListener("open", onOpen);
@@ -170,19 +170,19 @@ describe(OneWayWebSocket.name, () => {
},
});
const onOpen1 = jest.fn();
const onClose1 = jest.fn();
const onError1 = jest.fn();
const onMessage1 = jest.fn();
const onOpen1 = vi.fn();
const onClose1 = vi.fn();
const onError1 = vi.fn();
const onMessage1 = vi.fn();
oneWay.addEventListener("open", onOpen1);
oneWay.addEventListener("close", onClose1);
oneWay.addEventListener("error", onError1);
oneWay.addEventListener("message", onMessage1);
const onOpen2 = jest.fn();
const onClose2 = jest.fn();
const onError2 = jest.fn();
const onMessage2 = jest.fn();
const onOpen2 = vi.fn();
const onClose2 = vi.fn();
const onError2 = vi.fn();
const onMessage2 = vi.fn();
oneWay.addEventListener("open", onOpen2);
oneWay.addEventListener("close", onClose2);
oneWay.addEventListener("error", onError2);
@@ -251,7 +251,7 @@ describe(OneWayWebSocket.name, () => {
},
});
const onMessage = jest.fn();
const onMessage = vi.fn();
oneWay.addEventListener("message", onMessage);
const payload = {
@@ -281,7 +281,7 @@ describe(OneWayWebSocket.name, () => {
},
});
const onMessage = jest.fn();
const onMessage = vi.fn();
oneWay.addEventListener("message", onMessage);
const payload = "definitely not valid JSON";
@@ -290,7 +290,7 @@ describe(OneWayWebSocket.name, () => {
});
mockServer.publishMessage(event);
const arg: OneWayMessageEvent<never> = onMessage.mock.lastCall[0];
const arg: OneWayMessageEvent<never> = onMessage.mock.lastCall![0];
expect(arg.sourceEvent).toEqual(event);
expect(arg.parsedMessage).toEqual(undefined);
expect(arg.parseError).toBeInstanceOf(Error);
+6
View File
@@ -1,3 +1,9 @@
// Monaco editor calls document.queryCommandSupported at import time,
// which is absent from JSDOM.
if (typeof document.queryCommandSupported !== "function") {
document.queryCommandSupported = () => false;
}
// Pointer capture stubs required for Radix UI in JSDOM.
globalThis.HTMLElement.prototype.hasPointerCapture = vi
.fn()
+18 -1
View File
@@ -1,4 +1,5 @@
import { Blob as NativeBlob } from "node:buffer";
import ResizeObserver from "resize-observer-polyfill";
// JSDom `Blob` is missing important methods[1] that have been standardized for
// years. MDN categorizes this API as baseline[2].
@@ -9,4 +10,20 @@ import { Blob as NativeBlob } from "node:buffer";
// changes.
globalThis.Blob = NativeBlob;
globalThis.ResizeObserver = require("resize-observer-polyfill");
globalThis.ResizeObserver = ResizeObserver;
// JSDOM does not implement window.matchMedia. Monaco editor (and other
// libraries) rely on it for dark/light theme detection.
if (typeof globalThis.matchMedia !== "function") {
globalThis.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList;
}