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"], },