From 42cc406f5c291ab68f311100c3a6799d33854a61 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 6 Oct 2026 10:17:58 +0000 Subject: [PATCH] perf(test): speed up unit tests by ~60% - Move createTestCommands out of testHelpers into test/mocks/commands.ts. testHelpers imported @/commands at runtime, so every extension test file loaded most of src/ (~450ms each). - Run the webview project on vmForks, ~70% faster for jsdom. The setup file's uncaughtException filter is removed per file, since vmForks shares process across files. The extension project stays on forks because vm contexts break instanceof Error for Node built-in values. - Replace `as unknown as CoderApi` casts with createTestCoderApi, a real CoderApi with typed overrides. --- test/mocks/coderApi.ts | 18 ++++++++ test/mocks/commands.ts | 37 ++++++++++++++++ test/mocks/testHelpers.ts | 31 ------------- .../command/updateWorkspace.telemetry.test.ts | 2 +- test/unit/commands.netcheck.test.ts | 16 +++---- test/unit/commands.openDevContainer.test.ts | 2 +- test/unit/commands.openSshConfig.test.ts | 7 +-- test/unit/commands.openWorkspace.test.ts | 2 +- test/unit/commands.supportBundle.test.ts | 9 ++-- test/unit/commands.telemetry.test.ts | 6 +-- .../webviews/tasks/tasksPanelProvider.test.ts | 43 +++++++++++-------- test/unit/workspace/workspaceMonitor.test.ts | 24 ++++++----- test/webview/setup.ts | 11 +++-- vitest.config.mts | 3 ++ 14 files changed, 120 insertions(+), 91 deletions(-) create mode 100644 test/mocks/coderApi.ts create mode 100644 test/mocks/commands.ts diff --git a/test/mocks/coderApi.ts b/test/mocks/coderApi.ts new file mode 100644 index 0000000000..75f92cf518 --- /dev/null +++ b/test/mocks/coderApi.ts @@ -0,0 +1,18 @@ +import { onTestFinished } from "vitest"; + +import { CoderApi } from "@/api/coderApi"; + +import { createMockLogger } from "./testHelpers"; + +/** Real `CoderApi` with `overrides` applied; unstubbed methods hit `baseUrl`. */ +export function createTestCoderApi>( + options: { baseUrl?: string; token?: string; overrides?: T } = {}, +): CoderApi & T { + const client = CoderApi.create( + options.baseUrl ?? "", + options.token, + createMockLogger(), + ); + onTestFinished(() => client.dispose()); + return Object.assign(client, options.overrides); +} diff --git a/test/mocks/commands.ts b/test/mocks/commands.ts new file mode 100644 index 0000000000..684f109e07 --- /dev/null +++ b/test/mocks/commands.ts @@ -0,0 +1,37 @@ +import { vi } from "vitest"; + +import { Commands } from "@/commands"; + +import { createTestCoderApi } from "./coderApi"; +import { createTestTelemetryService } from "./telemetry"; +import { createMockLogger } from "./testHelpers"; + +import type { CoderApi } from "@/api/coderApi"; +import type { ServiceContainer } from "@/core/container"; +import type { DeploymentManager } from "@/deployment/deploymentManager"; + +/** Build `Commands`; services left unnamed stand in as empty objects. */ +export function createTestCommands( + options: { + services?: Record; + baseUrl?: string; + client?: Partial; + } = {}, +): Commands { + const services: Record = { + getTelemetryService: createTestTelemetryService(), + getLogger: createMockLogger(), + getMementoManager: { setStartupMode: vi.fn() }, + getDuplicateWorkspaceIpc: { + sendPing: vi.fn().mockResolvedValue(undefined), + }, + ...options.services, + }; + return new Commands( + new Proxy({} as ServiceContainer, { + get: (_, name: string) => () => services[name] ?? {}, + }), + createTestCoderApi({ baseUrl: options.baseUrl, overrides: options.client }), + {} as DeploymentManager, + ); +} diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index cebc94b7ab..c061370675 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -9,7 +9,6 @@ import * as fs from "node:fs/promises"; import { onTestFinished, vi } from "vitest"; import * as vscode from "vscode"; -import { Commands } from "@/commands"; import { SessionStore, type SessionData, @@ -44,7 +43,6 @@ import type { ContextManager } from "@/core/contextManager"; import type { MementoManager } from "@/core/mementoManager"; import type { PathResolver } from "@/core/pathResolver"; import type { SecretsManager } from "@/core/secretsManager"; -import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { Deployment } from "@/deployment/types"; import type { ConnectionLogBuffer } from "@/logging/logBuffer"; import type { Logger } from "@/logging/logger"; @@ -664,35 +662,6 @@ export function createMockServiceContainer( } as ServiceContainer; } -/** Build `Commands`; services left unnamed stand in as empty objects. */ -export function createTestCommands( - options: { - services?: Record; - baseUrl?: string; - client?: Partial; - } = {}, -): Commands { - const services: Record = { - getTelemetryService: createTestTelemetryService(), - getLogger: createMockLogger(), - getMementoManager: { setStartupMode: vi.fn() }, - getDuplicateWorkspaceIpc: { - sendPing: vi.fn().mockResolvedValue(undefined), - }, - ...options.services, - }; - return new Commands( - new Proxy({} as ServiceContainer, { - get: (_, name: string) => () => services[name] ?? {}, - }), - { - getAxiosInstance: () => ({ defaults: { baseURL: options.baseUrl } }), - ...options.client, - } as unknown as CoderApi, - {} as DeploymentManager, - ); -} - /** Recently opened entries on one path; a multi-root file has no folder URI. */ export function mockRecentlyOpened( authorities: string[], diff --git a/test/unit/command/updateWorkspace.telemetry.test.ts b/test/unit/command/updateWorkspace.telemetry.test.ts index b80b8a2e52..d963604cb1 100644 --- a/test/unit/command/updateWorkspace.telemetry.test.ts +++ b/test/unit/command/updateWorkspace.telemetry.test.ts @@ -3,8 +3,8 @@ import * as vscode from "vscode"; import { workspace } from "@repo/mocks"; +import { createTestCommands } from "../../mocks/commands"; import { createTelemetryHarness } from "../../mocks/telemetry"; -import { createTestCommands } from "../../mocks/testHelpers"; import type { CoderApi } from "@/api/coderApi"; diff --git a/test/unit/commands.netcheck.test.ts b/test/unit/commands.netcheck.test.ts index 692d4172c7..793924879a 100644 --- a/test/unit/commands.netcheck.test.ts +++ b/test/unit/commands.netcheck.test.ts @@ -4,10 +4,10 @@ import * as vscode from "vscode"; import { Commands } from "@/commands"; import { toSafeHost } from "@/util/uri"; +import { createTestCoderApi } from "../mocks/coderApi"; import { createTelemetryHarness } from "../mocks/telemetry"; import { createMockLogger, MockUserInteraction } from "../mocks/testHelpers"; -import type { CoderApi } from "@/api/coderApi"; import type { ServiceContainer } from "@/core/container"; import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { NetcheckPanelFactory } from "@/webviews/netcheck/netcheckPanelFactory"; @@ -17,12 +17,6 @@ vi.mock("@/workspace/workspacesProvider", () => ({ WorkspaceTreeItem: class {}, })); -function clientWithBaseUrl(baseURL: string | undefined): CoderApi { - return { - getAxiosInstance: () => ({ defaults: { baseURL } }), - } as unknown as CoderApi; -} - function setup(options: { extensionBaseUrl?: string } = {}) { vi.clearAllMocks(); new MockUserInteraction(); @@ -44,7 +38,7 @@ function setup(options: { extensionBaseUrl?: string } = {}) { const commands = new Commands( serviceContainer, - clientWithBaseUrl(options.extensionBaseUrl), + createTestCoderApi({ baseUrl: options.extensionBaseUrl }), {} as DeploymentManager, ); @@ -87,9 +81,9 @@ describe("Commands.netcheck", () => { it("prefers the remote workspace client over the extension client", async () => { const { commands } = setup({ extensionBaseUrl: "https://ext.coder.test" }); - commands.remoteWorkspaceClient = clientWithBaseUrl( - "https://remote.coder.test", - ); + commands.remoteWorkspaceClient = createTestCoderApi({ + baseUrl: "https://remote.coder.test", + }); const title = captureProgressTitle(); await commands.netcheck(); diff --git a/test/unit/commands.openDevContainer.test.ts b/test/unit/commands.openDevContainer.test.ts index e2a30a7ab2..6cdc9365f3 100644 --- a/test/unit/commands.openDevContainer.test.ts +++ b/test/unit/commands.openDevContainer.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestCommands } from "../mocks/commands"; import { - createTestCommands, MockConfigurationProvider, mockRecentlyOpened, openedAuthority, diff --git a/test/unit/commands.openSshConfig.test.ts b/test/unit/commands.openSshConfig.test.ts index f029e0bc5e..a6d8d4ce0e 100644 --- a/test/unit/commands.openSshConfig.test.ts +++ b/test/unit/commands.openSshConfig.test.ts @@ -3,11 +3,8 @@ import * as vscode from "vscode"; import { PathResolver } from "@/core/pathResolver"; -import { - createTestCommands, - MockConfigurationProvider, - useEditor, -} from "../mocks/testHelpers"; +import { createTestCommands } from "../mocks/commands"; +import { MockConfigurationProvider, useEditor } from "../mocks/testHelpers"; const pathResolver = new PathResolver("/data", "/logs"); const configPath = (editorId: string) => diff --git a/test/unit/commands.openWorkspace.test.ts b/test/unit/commands.openWorkspace.test.ts index 2f7093d61f..85b5628b40 100644 --- a/test/unit/commands.openWorkspace.test.ts +++ b/test/unit/commands.openWorkspace.test.ts @@ -7,8 +7,8 @@ import { workspace as createWorkspace, } from "@repo/mocks"; +import { createTestCommands } from "../mocks/commands"; import { - createTestCommands, MockConfigurationProvider, mockRecentlyOpened, openedAuthority, diff --git a/test/unit/commands.supportBundle.test.ts b/test/unit/commands.supportBundle.test.ts index 4f5c3d776e..97dd1f1b6c 100644 --- a/test/unit/commands.supportBundle.test.ts +++ b/test/unit/commands.supportBundle.test.ts @@ -12,6 +12,7 @@ import { import { agent, resource, workspace } from "@repo/mocks"; +import { createTestCoderApi } from "../mocks/coderApi"; import { createTelemetryHarness } from "../mocks/telemetry"; import { config, @@ -102,10 +103,10 @@ function setup(options: { cliVersion?: string } = {}) { getNetcheckPanelFactory: () => ({}), } as unknown as ServiceContainer; - const client = { - getAxiosInstance: () => ({ defaults: { baseURL: "https://coder.test" } }), - getSessionToken: () => "token", - } as unknown as CoderApi; + const client = createTestCoderApi({ + baseUrl: "https://coder.test", + token: "token", + }); const commands = new Commands( serviceContainer, diff --git a/test/unit/commands.telemetry.test.ts b/test/unit/commands.telemetry.test.ts index 2e1c67c396..43921ee375 100644 --- a/test/unit/commands.telemetry.test.ts +++ b/test/unit/commands.telemetry.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { Commands } from "@/commands"; import { maybeAskUrl } from "@/promptUtils"; +import { createTestCoderApi } from "../mocks/coderApi"; import { createTelemetryHarness } from "../mocks/telemetry"; import { createMockLogger, @@ -10,7 +11,6 @@ import { MockUserInteraction, } from "../mocks/testHelpers"; -import type { CoderApi } from "@/api/coderApi"; import type { CliManager } from "@/core/cliManager"; import type { ServiceContainer } from "@/core/container"; import type { MementoManager } from "@/core/mementoManager"; @@ -127,9 +127,7 @@ function setup(options: SetupOptions = {}) { }), } as ServiceContainer; - const extensionClient = { - getAxiosInstance: () => ({ defaults: { baseURL: TEST_URL } }), - } as unknown as CoderApi; + const extensionClient = createTestCoderApi({ baseUrl: TEST_URL }); const commands = new Commands( serviceContainer, diff --git a/test/unit/webviews/tasks/tasksPanelProvider.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts index 1b59abe282..7e9cf2666f 100644 --- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -21,6 +21,7 @@ import { type TaskIdParams, } from "@repo/shared"; +import { createTestCoderApi } from "../../../mocks/coderApi"; import { createAxiosError, createMockLogger, @@ -85,23 +86,28 @@ type TasksPanelClient = Pick< type MockClient = { [K in keyof TasksPanelClient]: Mock }; -function createClient(baseUrl = "https://coder.example.com"): MockClient { - return { - getTasks: vi.fn().mockResolvedValue([]), - getTask: vi.fn(), - getTaskLogs: vi.fn().mockResolvedValue({ logs: [] }), - createTask: vi.fn(), - deleteTask: vi.fn().mockResolvedValue(undefined), - getTemplates: vi.fn().mockResolvedValue([]), - getTemplateVersionPresets: vi.fn().mockResolvedValue([]), - startWorkspace: vi.fn().mockResolvedValue(undefined), - stopWorkspace: vi.fn().mockResolvedValue(undefined), - pauseTask: vi.fn().mockResolvedValue(undefined), - resumeTask: vi.fn().mockResolvedValue(undefined), - sendTaskInput: vi.fn().mockResolvedValue(undefined), - getHost: vi.fn().mockReturnValue(baseUrl), - getWorkspace: vi.fn().mockResolvedValue(workspace()), - }; +function createClient( + baseUrl = "https://coder.example.com", +): CoderApi & MockClient { + return createTestCoderApi({ + baseUrl, + overrides: { + getTasks: vi.fn().mockResolvedValue([]), + getTask: vi.fn(), + getTaskLogs: vi.fn().mockResolvedValue({ logs: [] }), + createTask: vi.fn(), + deleteTask: vi.fn().mockResolvedValue(undefined), + getTemplates: vi.fn().mockResolvedValue([]), + getTemplateVersionPresets: vi.fn().mockResolvedValue([]), + startWorkspace: vi.fn().mockResolvedValue(undefined), + stopWorkspace: vi.fn().mockResolvedValue(undefined), + pauseTask: vi.fn().mockResolvedValue(undefined), + resumeTask: vi.fn().mockResolvedValue(undefined), + sendTaskInput: vi.fn().mockResolvedValue(undefined), + getHost: vi.fn().mockReturnValue(baseUrl), + getWorkspace: vi.fn().mockResolvedValue(workspace()), + }, + }); } interface Harness { @@ -126,8 +132,7 @@ function createHarness(): Harness { const client = createClient(); const panel = new TasksPanelProvider( vscode.Uri.file("/test/extension"), - // Cast needed: mock only implements the subset of CoderApi methods used by TasksPanelProvider - client as unknown as CoderApi, + client, createMockLogger(), ); diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts index a1b00c115b..3f182f4dfe 100644 --- a/test/unit/workspace/workspaceMonitor.test.ts +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -8,6 +8,7 @@ import { workspace as createWorkspace, } from "@repo/mocks"; +import { createTestCoderApi } from "../../mocks/coderApi"; import { createTestTelemetryService, enableLocalTelemetry, @@ -30,7 +31,6 @@ import type { WorkspaceAgentScript, } from "coder/site/src/api/typesGenerated"; -import type { CoderApi } from "@/api/coderApi"; import type { TelemetryService } from "@/telemetry/service"; function workspaceEvent( @@ -57,16 +57,18 @@ describe("WorkspaceMonitor", () => { const statusBar = new MockStatusBarItem(); const contextManager = new MockContextManager(); const logger = createMockLogger(); - const client = { - watchWorkspace: vi.fn().mockResolvedValue(stream), - getTemplate: vi.fn().mockResolvedValue({ - active_version_id: "version-2", - }), - getTemplateVersion: vi.fn().mockResolvedValue({ - message: "template v2", - }), - getWorkspaceAgentLogs: vi.fn(), - } as unknown as CoderApi; + const client = createTestCoderApi({ + overrides: { + watchWorkspace: vi.fn().mockResolvedValue(stream), + getTemplate: vi.fn().mockResolvedValue({ + active_version_id: "version-2", + }), + getTemplateVersion: vi.fn().mockResolvedValue({ + message: "template v2", + }), + getWorkspaceAgentLogs: vi.fn(), + }, + }); const monitor = await WorkspaceMonitor.create( initialWorkspace, client, diff --git a/test/webview/setup.ts b/test/webview/setup.ts index e224ca8d19..0bcedd11bd 100644 --- a/test/webview/setup.ts +++ b/test/webview/setup.ts @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { vi } from "vitest"; +import { afterAll, vi } from "vitest"; // Lit's dev build emits warnings we can't avoid (resolve.conditions is // additive so we can't force the production bundle). Filter them out. @@ -49,13 +49,18 @@ HTMLCanvasElement.prototype.getContext = ((type: string) => // VscodeSingleSelect fires internal slot-change events that read properties // jsdom doesn't support (e.g. textContent of slotted elements). Suppress -// these uncaught errors from the third-party web component. -process.on("uncaughtException", (err: Error) => { +// these uncaught errors from the third-party web component. Removed per file +// because vmForks shares `process` across files. +function ignoreWebComponentErrors(err: Error): void { const msg = err?.message ?? ""; if (msg.includes("reading 'trim'") || msg.includes("reading 'unobserve'")) { return; } throw err; +} +process.on("uncaughtException", ignoreWebComponentErrors); +afterAll(() => { + process.off("uncaughtException", ignoreWebComponentErrors); }); // Form controls call setFormValue/setValidity which jsdom doesn't provide. diff --git a/vitest.config.mts b/vitest.config.mts index 9f1cbdd8ba..a455a1848a 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -41,6 +41,9 @@ export default defineConfig({ include: ["test/webview/**/*.test.{ts,tsx}"], exclude: ["**/node_modules/**", "**/out/**", "**/*.d.ts"], environment: "jsdom", + // Much faster for jsdom. Not used for the extension project: Node + // built-ins return cross-realm values that break `instanceof Error`. + pool: "vmForks", globals: true, setupFiles: ["test/webview/setup.ts"], },