From f976283010665d7abffccf8a1d36d560d1dd1acf Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 23 Mar 2026 12:31:24 +0300 Subject: [PATCH 1/3] feat: add option to disable all deployment notifications (#852) Add `coder.disableNotifications` setting that suppresses all notification prompts from the Coder deployment (workspace updates, autostop warnings, deletion warnings, inbox alerts). WebSocket connections remain active so context updates, status bar, and onChange events continue to work. Also introduces a `src/settings/` folder to organize settings accessor utilities: - `settings/notifications.ts` - notification toggle checks - `settings/headers.ts` - header command settings (extracted from headers.ts) - `settings/cli.ts` - CLI flag settings (moved from cliConfig.ts) Adds comprehensive tests for WorkspaceMonitor, Inbox, and notification settings, along with shared MockEventStream and MockContextManager test helpers. Closes #852 --- package.json | 5 + src/api/coderApi.ts | 3 +- src/api/workspace.ts | 2 +- src/commands.ts | 2 +- src/core/cliCredentialManager.ts | 4 +- src/core/cliManager.ts | 2 +- src/headers.ts | 42 +-- src/inbox.ts | 6 +- src/login/loginCoordinator.ts | 2 +- src/remote/remote.ts | 16 +- src/remote/workspaceStateMachine.ts | 2 +- src/{cliConfig.ts => settings/cli.ts} | 7 +- src/settings/headers.ts | 36 ++ src/settings/notifications.ts | 18 + src/workspace/workspaceMonitor.ts | 30 +- test/mocks/testHelpers.ts | 70 ++++ test/unit/cliConfig.test.ts | 4 +- test/unit/core/cliCredentialManager.test.ts | 4 +- test/unit/core/cliManager.test.ts | 6 +- .../unit/deployment/deploymentManager.test.ts | 17 +- test/unit/headers.test.ts | 51 +-- test/unit/inbox.test.ts | 127 +++++++ test/unit/settings/headers.test.ts | 51 +++ test/unit/settings/notifications.test.ts | 48 +++ test/unit/workspace/workspaceMonitor.test.ts | 345 ++++++++++++++++++ 25 files changed, 758 insertions(+), 142 deletions(-) rename src/{cliConfig.ts => settings/cli.ts} (94%) create mode 100644 src/settings/headers.ts create mode 100644 src/settings/notifications.ts create mode 100644 test/unit/inbox.test.ts create mode 100644 test/unit/settings/headers.test.ts create mode 100644 test/unit/settings/notifications.test.ts create mode 100644 test/unit/workspace/workspaceMonitor.test.ts diff --git a/package.json b/package.json index 12daeed0..2fd18419 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,11 @@ "type": "boolean", "default": false }, + "coder.disableNotifications": { + "markdownDescription": "Disable all notification prompts from the Coder deployment (workspace updates, scheduling reminders, resource alerts, etc.). Notifications are delivered by your Coder server and displayed by this extension.", + "type": "boolean", + "default": false + }, "coder.disableUpdateNotifications": { "markdownDescription": "Disable notifications when workspace template updates are available.", "type": "boolean", diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 9cc33cd8..57e27117 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -21,7 +21,7 @@ import { watchConfigurationChanges } from "../configWatcher"; import { ClientCertificateError } from "../error/clientCertificateError"; import { toError } from "../error/errorUtils"; import { ServerCertificateError } from "../error/serverCertificateError"; -import { getHeaderCommand, getHeaders } from "../headers"; +import { getHeaders } from "../headers"; import { EventStreamLogger } from "../logging/eventStreamLogger"; import { createRequestMeta, @@ -35,6 +35,7 @@ import { HttpClientLogLevel, } from "../logging/types"; import { sizeOf } from "../logging/utils"; +import { getHeaderCommand } from "../settings/headers"; import { HttpStatusCode, WebSocketCloseCode } from "../websocket/codes"; import { type UnidirectionalStream, diff --git a/src/api/workspace.ts b/src/api/workspace.ts index e7d38327..95122cb9 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -7,8 +7,8 @@ import { import { spawn } from "node:child_process"; import * as vscode from "vscode"; -import { type CliAuth, getGlobalFlags } from "../cliConfig"; import { type FeatureSet } from "../featureSet"; +import { type CliAuth, getGlobalFlags } from "../settings/cli"; import { escapeCommandArg } from "../util"; import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; diff --git a/src/commands.ts b/src/commands.ts index 84227f10..bddd564f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,7 +9,6 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { type CoderApi } from "./api/coderApi"; -import { getGlobalFlags, resolveCliAuth } from "./cliConfig"; import { type CliManager } from "./core/cliManager"; import * as cliUtils from "./core/cliUtils"; import { type ServiceContainer } from "./core/container"; @@ -28,6 +27,7 @@ import { RECOMMENDED_SSH_SETTINGS, applySettingOverrides, } from "./remote/userSettings"; +import { getGlobalFlags, resolveCliAuth } from "./settings/cli"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; import { diff --git a/src/core/cliCredentialManager.ts b/src/core/cliCredentialManager.ts index 5debc588..d8a3e9a6 100644 --- a/src/core/cliCredentialManager.ts +++ b/src/core/cliCredentialManager.ts @@ -5,10 +5,10 @@ import path from "node:path"; import { promisify } from "node:util"; import * as semver from "semver"; -import { isKeyringEnabled } from "../cliConfig"; import { isAbortError } from "../error/errorUtils"; import { featureSetForVersion } from "../featureSet"; -import { getHeaderArgs } from "../headers"; +import { isKeyringEnabled } from "../settings/cli"; +import { getHeaderArgs } from "../settings/headers"; import { renameWithRetry, tempFilePath, toSafeHost } from "../util"; import * as cliUtils from "./cliUtils"; diff --git a/src/core/cliManager.ts b/src/core/cliManager.ts index 7f054d98..e1996c8b 100644 --- a/src/core/cliManager.ts +++ b/src/core/cliManager.ts @@ -10,9 +10,9 @@ import * as semver from "semver"; import * as vscode from "vscode"; import { errToStr } from "../api/api-helper"; -import { isKeyringEnabled } from "../cliConfig"; import * as pgp from "../pgp"; import { withCancellableProgress, withOptionalProgress } from "../progress"; +import { isKeyringEnabled } from "../settings/cli"; import { tempFilePath, toSafeHost } from "../util"; import { vscodeProposed } from "../vscodeProposed"; diff --git a/src/headers.ts b/src/headers.ts index d7685b07..ac46c95c 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -1,46 +1,10 @@ -import * as os from "node:os"; - import { execCommand } from "./command/exec"; import { type Logger } from "./logging/logger"; -import { escapeCommandArg } from "./util"; - -import type { WorkspaceConfiguration } from "vscode"; - -export function getHeaderCommand( - config: Pick, -): string | undefined { - const cmd = - config.get("coder.headerCommand")?.trim() || - process.env.CODER_HEADER_COMMAND?.trim(); - - return cmd || undefined; -} - -export function getHeaderArgs( - config: Pick, -): string[] { - // Escape a command line to be executed by the Coder binary, so ssh doesn't substitute variables. - const escapeSubcommand: (str: string) => string = - os.platform() === "win32" - ? // On Windows variables are %VAR%, and we need to use double quotes. - (str) => escapeCommandArg(str).replace(/%/g, "%%") - : // On *nix we can use single quotes to escape $VARS. - // Note single quotes cannot be escaped inside single quotes. - (str) => `'${str.replace(/'/g, "'\\''")}'`; - - const command = getHeaderCommand(config); - if (!command) { - return []; - } - return ["--header-command", escapeSubcommand(command)]; -} /** - * getHeaders executes the header command and parses the headers from stdout. - * Both stdout and stderr are logged on error but stderr is otherwise ignored. - * Throws an error if the process exits with non-zero or the JSON is invalid. - * Returns undefined if there is no header command set. No effort is made to - * validate the JSON other than making sure it can be parsed. + * Executes the header command and parses headers from stdout. + * Throws on non-zero exit or malformed output. Returns empty headers if no + * command is set. */ export async function getHeaders( url: string | undefined, diff --git a/src/inbox.ts b/src/inbox.ts index ed5956c8..789ac8ac 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; +import { areNotificationsDisabled } from "./settings/notifications"; + import type { Workspace, GetInboxNotificationResponse, @@ -53,7 +55,9 @@ export class Inbox implements vscode.Disposable { socket.addEventListener("message", (data) => { if (data.parseError) { logger.error("Failed to parse inbox message", data.parseError); - } else { + } else if ( + !areNotificationsDisabled(vscode.workspace.getConfiguration()) + ) { vscode.window.showInformationMessage( data.parsedMessage.notification.title, ); diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index f3b2740e..0835330b 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -4,12 +4,12 @@ import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; -import { isKeyringEnabled } from "../cliConfig"; import { CertificateError } from "../error/certificateError"; import { OAuthAuthorizer } from "../oauth/authorizer"; import { buildOAuthTokenData } from "../oauth/utils"; import { withOptionalProgress } from "../progress"; import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils"; +import { isKeyringEnabled } from "../settings/cli"; import { vscodeProposed } from "../vscodeProposed"; import type { User } from "coder/site/src/api/typesGenerated"; diff --git a/src/remote/remote.ts b/src/remote/remote.ts index d4ba7fbb..526579a8 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -20,13 +20,6 @@ import { extractAgents } from "../api/api-helper"; import { AuthInterceptor } from "../api/authInterceptor"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; -import { - type CliAuth, - getGlobalFlags, - getGlobalFlagsRaw, - getSshFlags, - resolveCliAuth, -} from "../cliConfig"; import { type Commands } from "../commands"; import { watchConfigurationChanges } from "../configWatcher"; import { type CliManager } from "../core/cliManager"; @@ -37,11 +30,18 @@ import { type PathResolver } from "../core/pathResolver"; import { type SecretsManager } from "../core/secretsManager"; import { toError } from "../error/errorUtils"; import { featureSetForVersion, type FeatureSet } from "../featureSet"; -import { getHeaderCommand } from "../headers"; import { Inbox } from "../inbox"; import { type Logger } from "../logging/logger"; import { type LoginCoordinator } from "../login/loginCoordinator"; import { OAuthSessionManager } from "../oauth/sessionManager"; +import { + type CliAuth, + getGlobalFlags, + getGlobalFlagsRaw, + getSshFlags, + resolveCliAuth, +} from "../settings/cli"; +import { getHeaderCommand } from "../settings/headers"; import { AuthorityPrefix, escapeCommandArg, diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index 26b8ffba..b874b969 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -19,9 +19,9 @@ import type { import type * as vscode from "vscode"; import type { CoderApi } from "../api/coderApi"; -import type { CliAuth } from "../cliConfig"; import type { FeatureSet } from "../featureSet"; import type { Logger } from "../logging/logger"; +import type { CliAuth } from "../settings/cli"; /** * Manages workspace and agent state transitions until ready for SSH connection. diff --git a/src/cliConfig.ts b/src/settings/cli.ts similarity index 94% rename from src/cliConfig.ts rename to src/settings/cli.ts index be1326c1..2dd8c275 100644 --- a/src/cliConfig.ts +++ b/src/settings/cli.ts @@ -1,10 +1,11 @@ -import { isKeyringSupported } from "./core/cliCredentialManager"; +import { isKeyringSupported } from "../core/cliCredentialManager"; +import { escapeCommandArg } from "../util"; + import { getHeaderArgs } from "./headers"; -import { escapeCommandArg } from "./util"; import type { WorkspaceConfiguration } from "vscode"; -import type { FeatureSet } from "./featureSet"; +import type { FeatureSet } from "../featureSet"; export type CliAuth = | { mode: "global-config"; configDir: string } diff --git a/src/settings/headers.ts b/src/settings/headers.ts new file mode 100644 index 00000000..bb1c4594 --- /dev/null +++ b/src/settings/headers.ts @@ -0,0 +1,36 @@ +import * as os from "node:os"; + +import { escapeCommandArg } from "../util"; + +import type { WorkspaceConfiguration } from "vscode"; + +/** Returns the header command from settings or the CODER_HEADER_COMMAND env var. */ +export function getHeaderCommand( + config: Pick, +): string | undefined { + const cmd = + config.get("coder.headerCommand")?.trim() || + process.env.CODER_HEADER_COMMAND?.trim(); + + return cmd || undefined; +} + +/** Returns `--header-command` CLI args, escaped for the current platform. */ +export function getHeaderArgs( + config: Pick, +): string[] { + // Escape a command line to be executed by the Coder binary, so ssh doesn't substitute variables. + const escapeSubcommand: (str: string) => string = + os.platform() === "win32" + ? // On Windows variables are %VAR%, and we need to use double quotes. + (str) => escapeCommandArg(str).replace(/%/g, "%%") + : // On *nix we can use single quotes to escape $VARS. + // Note single quotes cannot be escaped inside single quotes. + (str) => `'${str.replace(/'/g, "'\\''")}'`; + + const command = getHeaderCommand(config); + if (!command) { + return []; + } + return ["--header-command", escapeSubcommand(command)]; +} diff --git a/src/settings/notifications.ts b/src/settings/notifications.ts new file mode 100644 index 00000000..5f107ce4 --- /dev/null +++ b/src/settings/notifications.ts @@ -0,0 +1,18 @@ +import type { WorkspaceConfiguration } from "vscode"; + +/** Whether all deployment notifications are disabled. */ +export function areNotificationsDisabled( + cfg: Pick, +): boolean { + return cfg.get("coder.disableNotifications", false); +} + +/** Whether workspace update notifications are disabled (blanket or update-specific). */ +export function areUpdateNotificationsDisabled( + cfg: Pick, +): boolean { + return ( + areNotificationsDisabled(cfg) || + cfg.get("coder.disableUpdateNotifications", false) + ); +} diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 2c3d3cd9..e353e1f8 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -6,11 +6,16 @@ import { formatDistanceToNowStrict } from "date-fns"; import * as vscode from "vscode"; import { createWorkspaceIdentifier, errToStr } from "../api/api-helper"; -import { type CoderApi } from "../api/coderApi"; -import { type ContextManager } from "../core/contextManager"; -import { type Logger } from "../logging/logger"; +import { + areNotificationsDisabled, + areUpdateNotificationsDisabled, +} from "../settings/notifications"; import { vscodeProposed } from "../vscodeProposed"; -import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; + +import type { CoderApi } from "../api/coderApi"; +import type { ContextManager } from "../core/contextManager"; +import type { Logger } from "../logging/logger"; +import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; /** * Monitor a single workspace using a WebSocket for events like shutdown and deletion. @@ -130,7 +135,11 @@ export class WorkspaceMonitor implements vscode.Disposable { } private maybeNotify(workspace: Workspace) { - this.maybeNotifyOutdated(workspace); + const cfg = vscode.workspace.getConfiguration(); + if (areNotificationsDisabled(cfg)) { + return; + } + this.maybeNotifyOutdated(workspace, cfg); this.maybeNotifyAutostop(workspace); if (this.completedInitialSetup) { // This instance might be created before the workspace is running @@ -204,13 +213,12 @@ export class WorkspaceMonitor implements vscode.Disposable { return timeLeft >= 0 && timeLeft <= notifyTime; } - private maybeNotifyOutdated(workspace: Workspace) { + private maybeNotifyOutdated( + workspace: Workspace, + cfg: Pick, + ) { if (!this.notifiedOutdated && workspace.outdated) { - // Check if update notifications are disabled - const disableNotifications = vscode.workspace - .getConfiguration("coder") - .get("disableUpdateNotifications", false); - if (disableNotifications) { + if (areUpdateNotificationsDisabled(cfg)) { return; } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 5e4487af..13dfcc86 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -14,6 +14,11 @@ import type { IncomingMessage } from "node:http"; import type { CoderApi } from "@/api/coderApi"; import type { CliCredentialManager } from "@/core/cliCredentialManager"; import type { Logger } from "@/logging/logger"; +import type { + EventHandler, + ParsedMessageEvent, + UnidirectionalStream, +} from "@/websocket/eventStreamConnection"; /** * Mock configuration provider that integrates with the vscode workspace configuration mock. @@ -834,3 +839,68 @@ export class MockCancellationToken implements vscode.CancellationToken { this._isCancellationRequested = false; } } + +/** + * Mock event stream for testing UnidirectionalStream consumers. + */ +export class MockEventStream { + private readonly handlers = new Map< + string, + Array<(...args: unknown[]) => void> + >(); + + readonly stream: UnidirectionalStream = { + url: "ws://test/mock-stream", + addEventListener: vi.fn( + (event: string, callback: (...args: unknown[]) => void) => { + if (!this.handlers.has(event)) { + this.handlers.set(event, []); + } + this.handlers.get(event)!.push(callback); + }, + ), + removeEventListener: vi.fn(), + close: vi.fn(), + }; + + pushMessage(parsedMessage: T): void { + const event: ParsedMessageEvent = { + sourceEvent: { data: undefined }, + parsedMessage, + parseError: undefined, + }; + this.fire("message", event); + } + + pushError(error: Error): void { + const event: ParsedMessageEvent = { + sourceEvent: { data: undefined }, + parsedMessage: undefined, + parseError: error, + }; + this.fire("message", event); + } + + private fire(event: string, payload: unknown): void { + for (const handler of this.handlers.get(event) ?? []) { + (handler as EventHandler)(payload as ParsedMessageEvent); + } + } +} + +/** + * Mock ContextManager that stores values and tracks `set` calls. + */ +export class MockContextManager { + private readonly contexts = new Map(); + + readonly set = vi.fn((key: string, value: boolean) => { + this.contexts.set(key, value); + }); + + get(key: string): boolean { + return this.contexts.get(key) ?? false; + } + + readonly dispose = vi.fn(); +} diff --git a/test/unit/cliConfig.test.ts b/test/unit/cliConfig.test.ts index 14a02697..c8eded86 100644 --- a/test/unit/cliConfig.test.ts +++ b/test/unit/cliConfig.test.ts @@ -2,6 +2,7 @@ import * as os from "node:os"; import * as semver from "semver"; import { it, expect, describe, vi } from "vitest"; +import { featureSetForVersion } from "@/featureSet"; import { type CliAuth, getGlobalFlags, @@ -9,8 +10,7 @@ import { getSshFlags, isKeyringEnabled, resolveCliAuth, -} from "@/cliConfig"; -import { featureSetForVersion } from "@/featureSet"; +} from "@/settings/cli"; import { MockConfigurationProvider } from "../mocks/testHelpers"; import { isWindows } from "../utils/platform"; diff --git a/test/unit/core/cliCredentialManager.test.ts b/test/unit/core/cliCredentialManager.test.ts index 13a2a279..fdd3be85 100644 --- a/test/unit/core/cliCredentialManager.test.ts +++ b/test/unit/core/cliCredentialManager.test.ts @@ -3,7 +3,6 @@ import { execFile } from "node:child_process"; import * as os from "node:os"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { isKeyringEnabled } from "@/cliConfig"; import { CliCredentialManager, isKeyringSupported, @@ -11,6 +10,7 @@ import { } from "@/core/cliCredentialManager"; import * as cliUtils from "@/core/cliUtils"; import { PathResolver } from "@/core/pathResolver"; +import { isKeyringEnabled } from "@/settings/cli"; import { createMockLogger } from "../../mocks/testHelpers"; @@ -22,7 +22,7 @@ vi.mock("node:child_process", () => ({ vi.mock("node:os"); -vi.mock("@/cliConfig", () => ({ +vi.mock("@/settings/cli", () => ({ isKeyringEnabled: vi.fn().mockReturnValue(false), })); diff --git a/test/unit/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts index cfd99974..fb6d0441 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -9,11 +9,11 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { isKeyringEnabled } from "@/cliConfig"; import { CliManager } from "@/core/cliManager"; import * as cliUtils from "@/core/cliUtils"; import { PathResolver } from "@/core/pathResolver"; import * as pgp from "@/pgp"; +import { isKeyringEnabled } from "@/settings/cli"; import { createMockCliCredentialManager, @@ -29,9 +29,9 @@ import type { CliCredentialManager } from "@/core/cliCredentialManager"; vi.mock("os"); vi.mock("axios"); -vi.mock("@/cliConfig", async () => { +vi.mock("@/settings/cli", async () => { const actual = - await vi.importActual("@/cliConfig"); + await vi.importActual("@/settings/cli"); return { ...actual, isKeyringEnabled: vi.fn().mockReturnValue(false) }; }); diff --git a/test/unit/deployment/deploymentManager.test.ts b/test/unit/deployment/deploymentManager.test.ts index fce4652f..2756b647 100644 --- a/test/unit/deployment/deploymentManager.test.ts +++ b/test/unit/deployment/deploymentManager.test.ts @@ -11,6 +11,7 @@ import { InMemoryMemento, InMemorySecretStorage, MockCoderApi, + MockContextManager, MockOAuthSessionManager, } from "../../mocks/testHelpers"; @@ -31,22 +32,6 @@ vi.mock("@/api/coderApi", async (importOriginal) => { }; }); -/** - * Mock ContextManager for deployment tests. - * Mimics real ContextManager which defaults to false for boolean contexts. - */ -class MockContextManager { - private readonly contexts = new Map(); - - readonly set = vi.fn((key: string, value: boolean) => { - this.contexts.set(key, value); - }); - - get(key: string): boolean { - return this.contexts.get(key) ?? false; - } -} - /** * Mock WorkspaceProvider for deployment tests. */ diff --git a/test/unit/headers.test.ts b/test/unit/headers.test.ts index 15b09117..bb35350b 100644 --- a/test/unit/headers.test.ts +++ b/test/unit/headers.test.ts @@ -1,7 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type WorkspaceConfiguration } from "vscode"; +import { describe, expect, it } from "vitest"; -import { getHeaderCommand, getHeaders } from "@/headers"; +import { getHeaders } from "@/headers"; import { createMockLogger } from "../mocks/testHelpers"; import { printCommand, exitCommand, printEnvCommand } from "../utils/platform"; @@ -100,50 +99,4 @@ describe("Headers", () => { getHeaders("localhost", exitCommand(10), logger), ).rejects.toThrow(/exited unexpectedly with code 10/); }); - - describe("getHeaderCommand", () => { - beforeEach(() => { - vi.stubEnv("CODER_HEADER_COMMAND", ""); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("should return undefined if coder.headerCommand is not set in config", () => { - const config = { - get: () => undefined, - } as unknown as WorkspaceConfiguration; - - expect(getHeaderCommand(config)).toBeUndefined(); - }); - - it("should return undefined if coder.headerCommand is a blank string", () => { - const config = { - get: () => " ", - } as unknown as WorkspaceConfiguration; - - expect(getHeaderCommand(config)).toBeUndefined(); - }); - - it("should return coder.headerCommand if set in config", () => { - vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); - - const config = { - get: () => "printf 'foo=bar'", - } as unknown as WorkspaceConfiguration; - - expect(getHeaderCommand(config)).toBe("printf 'foo=bar'"); - }); - - it("should return CODER_HEADER_COMMAND if coder.headerCommand is not set in config and CODER_HEADER_COMMAND is set in environment", () => { - vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); - - const config = { - get: () => undefined, - } as unknown as WorkspaceConfiguration; - - expect(getHeaderCommand(config)).toBe("printf 'x=y'"); - }); - }); }); diff --git a/test/unit/inbox.test.ts b/test/unit/inbox.test.ts new file mode 100644 index 00000000..89b558e3 --- /dev/null +++ b/test/unit/inbox.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { Inbox } from "@/inbox"; + +import { + MockConfigurationProvider, + MockEventStream, + createMockLogger, +} from "../mocks/testHelpers"; +import { workspace as createWorkspace } from "../mocks/workspace"; + +import type { GetInboxNotificationResponse } from "coder/site/src/api/typesGenerated"; + +import type { CoderApi } from "@/api/coderApi"; + +function createNotification(title: string): GetInboxNotificationResponse { + return { + notification: { + id: "notif-1", + user_id: "user-1", + template_id: "template-1", + targets: ["workspace-1"], + title, + content: "", + icon: "", + actions: [], + read_at: null, + created_at: new Date().toISOString(), + }, + unread_count: 1, + }; +} + +function createMockClient( + stream: MockEventStream, +) { + return { + watchInboxNotifications: vi.fn().mockResolvedValue(stream.stream), + } as unknown as CoderApi; +} + +describe("Inbox", () => { + let config: MockConfigurationProvider; + + beforeEach(() => { + vi.resetAllMocks(); + config = new MockConfigurationProvider(); + }); + + async function createInbox( + stream = new MockEventStream(), + ) { + const ws = createWorkspace(); + const client = createMockClient(stream); + const inbox = await Inbox.create(ws, client, createMockLogger()); + return { inbox, stream }; + } + + describe("message handling", () => { + it("shows notification when a message arrives", async () => { + const { inbox, stream } = await createInbox(); + + stream.pushMessage(createNotification("Out of memory")); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Out of memory", + ); + inbox.dispose(); + }); + + it("shows multiple notifications for successive messages", async () => { + const { inbox, stream } = await createInbox(); + + stream.pushMessage(createNotification("First alert")); + stream.pushMessage(createNotification("Second alert")); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(2); + inbox.dispose(); + }); + + it("logs parse errors without showing notifications", async () => { + const { inbox, stream } = await createInbox(); + + stream.pushError(new Error("bad json")); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + inbox.dispose(); + }); + + it("closes the socket on dispose", async () => { + const stream = new MockEventStream(); + const { inbox } = await createInbox(stream); + inbox.dispose(); + + expect(stream.stream.close).toHaveBeenCalled(); + }); + }); + + describe("disableNotifications", () => { + it("suppresses notifications when enabled", async () => { + config.set("coder.disableNotifications", true); + const { inbox, stream } = await createInbox(); + + stream.pushMessage(createNotification("Out of memory")); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + inbox.dispose(); + }); + + it("shows notifications after re-enabling", async () => { + config.set("coder.disableNotifications", true); + const { inbox, stream } = await createInbox(); + + stream.pushMessage(createNotification("suppressed")); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + + config.set("coder.disableNotifications", false); + + stream.pushMessage(createNotification("visible")); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "visible", + ); + inbox.dispose(); + }); + }); +}); diff --git a/test/unit/settings/headers.test.ts b/test/unit/settings/headers.test.ts new file mode 100644 index 00000000..877ec15b --- /dev/null +++ b/test/unit/settings/headers.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getHeaderCommand } from "@/settings/headers"; + +import type { WorkspaceConfiguration } from "vscode"; + +describe("getHeaderCommand", () => { + beforeEach(() => { + vi.stubEnv("CODER_HEADER_COMMAND", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("should return undefined if coder.headerCommand is not set in config", () => { + const config = { + get: () => undefined, + } as unknown as WorkspaceConfiguration; + + expect(getHeaderCommand(config)).toBeUndefined(); + }); + + it("should return undefined if coder.headerCommand is a blank string", () => { + const config = { + get: () => " ", + } as unknown as WorkspaceConfiguration; + + expect(getHeaderCommand(config)).toBeUndefined(); + }); + + it("should return coder.headerCommand if set in config", () => { + vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); + + const config = { + get: () => "printf 'foo=bar'", + } as unknown as WorkspaceConfiguration; + + expect(getHeaderCommand(config)).toBe("printf 'foo=bar'"); + }); + + it("should return CODER_HEADER_COMMAND if coder.headerCommand is not set in config and CODER_HEADER_COMMAND is set in environment", () => { + vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); + + const config = { + get: () => undefined, + } as unknown as WorkspaceConfiguration; + + expect(getHeaderCommand(config)).toBe("printf 'x=y'"); + }); +}); diff --git a/test/unit/settings/notifications.test.ts b/test/unit/settings/notifications.test.ts new file mode 100644 index 00000000..a8d5628e --- /dev/null +++ b/test/unit/settings/notifications.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + areNotificationsDisabled, + areUpdateNotificationsDisabled, +} from "@/settings/notifications"; + +import { MockConfigurationProvider } from "../../mocks/testHelpers"; + +describe("notification settings", () => { + let config: MockConfigurationProvider; + + beforeEach(() => { + config = new MockConfigurationProvider(); + }); + + describe("areNotificationsDisabled", () => { + it.each([ + [undefined, false], + [false, false], + [true, true], + ])( + "when coder.disableNotifications is %s, returns %s", + (value, expected) => { + if (value !== undefined) { + config.set("coder.disableNotifications", value); + } + expect(areNotificationsDisabled(config)).toBe(expected); + }, + ); + }); + + describe("areUpdateNotificationsDisabled", () => { + it.each([ + [false, false, false], + [true, false, true], + [false, true, true], + [true, true, true], + ])( + "when disableNotifications=%s and disableUpdateNotifications=%s, returns %s", + (disableAll, disableUpdate, expected) => { + config.set("coder.disableNotifications", disableAll); + config.set("coder.disableUpdateNotifications", disableUpdate); + expect(areUpdateNotificationsDisabled(config)).toBe(expected); + }, + ); + }); +}); diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts new file mode 100644 index 00000000..6834079e --- /dev/null +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { WorkspaceMonitor } from "@/workspace/workspaceMonitor"; + +import { + MockConfigurationProvider, + MockContextManager, + MockEventStream, + MockStatusBar, + createMockLogger, +} from "../../mocks/testHelpers"; +import { workspace as createWorkspace } from "../../mocks/workspace"; + +import type { + ServerSentEvent, + Workspace, +} from "coder/site/src/api/typesGenerated"; + +import type { CoderApi } from "@/api/coderApi"; + +function createMockClient(stream: MockEventStream) { + return { + watchWorkspace: vi.fn().mockResolvedValue(stream.stream), + getTemplate: vi.fn().mockResolvedValue({ + active_version_id: "version-2", + }), + getTemplateVersion: vi.fn().mockResolvedValue({ + message: "template v2", + }), + } as unknown as CoderApi; +} + +function workspaceEvent(ws: Workspace): ServerSentEvent { + return { type: "data", data: ws }; +} + +describe("WorkspaceMonitor", () => { + let config: MockConfigurationProvider; + let statusBar: MockStatusBar; + let contextManager: MockContextManager; + + beforeEach(() => { + vi.resetAllMocks(); + config = new MockConfigurationProvider(); + statusBar = new MockStatusBar(); + contextManager = new MockContextManager(); + }); + + async function createMonitor( + ws: Workspace = createWorkspace(), + stream = new MockEventStream(), + ) { + const client = createMockClient(stream); + const monitor = await WorkspaceMonitor.create( + ws, + client, + createMockLogger(), + contextManager as unknown as import("@/core/contextManager").ContextManager, + ); + return { monitor, client, stream }; + } + + describe("websocket lifecycle", () => { + it("fires onChange when a workspace message arrives", async () => { + const { monitor, stream } = await createMonitor(); + const changes: Workspace[] = []; + monitor.onChange.event((ws) => changes.push(ws)); + + const updated = createWorkspace({ outdated: true }); + stream.pushMessage(workspaceEvent(updated)); + + expect(changes).toHaveLength(1); + expect(changes[0].outdated).toBe(true); + monitor.dispose(); + }); + + it("logs parse errors without showing notifications", async () => { + const { monitor, stream } = await createMonitor(); + stream.pushError(new Error("bad json")); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + monitor.dispose(); + }); + + it("closes the socket on dispose", async () => { + const stream = new MockEventStream(); + const { monitor } = await createMonitor(createWorkspace(), stream); + monitor.dispose(); + + expect(stream.stream.close).toHaveBeenCalled(); + }); + }); + + describe("context and status bar", () => { + it("sets coder.workspace.updatable context when workspace is outdated", async () => { + const { monitor, stream } = await createMonitor(); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + + expect(contextManager.set).toHaveBeenCalledWith( + "coder.workspace.updatable", + true, + ); + monitor.dispose(); + }); + + it("shows status bar when outdated, hides when not", async () => { + const { monitor, stream } = await createMonitor(); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + expect(statusBar.show).toHaveBeenCalled(); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: false }))); + expect(statusBar.hide).toHaveBeenCalled(); + + monitor.dispose(); + }); + }); + + describe("notifications when enabled", () => { + it("shows autostop notification when deadline is impending", async () => { + const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); + const { monitor, stream } = await createMonitor(); + monitor.markInitialSetupComplete(); + + stream.pushMessage( + workspaceEvent( + createWorkspace({ + latest_build: { status: "running", deadline }, + }), + ), + ); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("scheduled to shut down"), + ); + monitor.dispose(); + }); + + it("shows deletion notification when deletion is impending", async () => { + const deletingAt = new Date( + Date.now() + 1000 * 60 * 60 * 12, + ).toISOString(); + const { monitor, stream } = await createMonitor(); + monitor.markInitialSetupComplete(); + + stream.pushMessage( + workspaceEvent(createWorkspace({ deleting_at: deletingAt })), + ); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("scheduled for deletion"), + ); + monitor.dispose(); + }); + + it("shows not-running notification after initial setup", async () => { + const { monitor, stream } = await createMonitor(); + monitor.markInitialSetupComplete(); + + stream.pushMessage( + workspaceEvent( + createWorkspace({ latest_build: { status: "stopped" } }), + ), + ); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("no longer running"), + expect.anything(), + expect.anything(), + ); + monitor.dispose(); + }); + + it("does not show not-running notification before initial setup", async () => { + const { monitor, stream } = await createMonitor(); + + stream.pushMessage( + workspaceEvent( + createWorkspace({ latest_build: { status: "stopped" } }), + ), + ); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + monitor.dispose(); + }); + + it("does not show deletion notification before initial setup", async () => { + const deletingAt = new Date( + Date.now() + 1000 * 60 * 60 * 12, + ).toISOString(); + const { monitor, stream } = await createMonitor(); + + stream.pushMessage( + workspaceEvent(createWorkspace({ deleting_at: deletingAt })), + ); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + monitor.dispose(); + }); + + it("shows outdated notification and fetches template details", async () => { + const { monitor, stream, client } = await createMonitor(); + monitor.markInitialSetupComplete(); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + + await vi.waitFor(() => { + expect(client.getTemplate).toHaveBeenCalledWith("template-1"); + expect(client.getTemplateVersion).toHaveBeenCalledWith("version-2"); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("template v2"), + "Update", + ); + }); + monitor.dispose(); + }); + + it("only notifies once per event type", async () => { + const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); + const { monitor, stream } = await createMonitor(); + monitor.markInitialSetupComplete(); + + const ws = createWorkspace({ + latest_build: { status: "running", deadline }, + }); + stream.pushMessage(workspaceEvent(ws)); + stream.pushMessage(workspaceEvent(ws)); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); + monitor.dispose(); + }); + }); + + describe("disableUpdateNotifications", () => { + it("suppresses outdated notification but allows other notifications", async () => { + config.set("coder.disableUpdateNotifications", true); + const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); + const { monitor, stream, client } = await createMonitor(); + monitor.markInitialSetupComplete(); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + expect(client.getTemplate).not.toHaveBeenCalled(); + + stream.pushMessage( + workspaceEvent( + createWorkspace({ + latest_build: { status: "running", deadline }, + }), + ), + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("scheduled to shut down"), + ); + monitor.dispose(); + }); + }); + + describe("disableNotifications", () => { + beforeEach(() => { + config.set("coder.disableNotifications", true); + }); + + it("suppresses all notification types", async () => { + const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); + const deletingAt = new Date( + Date.now() + 1000 * 60 * 60 * 12, + ).toISOString(); + const { monitor, stream } = await createMonitor(); + monitor.markInitialSetupComplete(); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + stream.pushMessage( + workspaceEvent( + createWorkspace({ + latest_build: { status: "running", deadline }, + }), + ), + ); + stream.pushMessage( + workspaceEvent(createWorkspace({ deleting_at: deletingAt })), + ); + stream.pushMessage( + workspaceEvent( + createWorkspace({ latest_build: { status: "stopped" } }), + ), + ); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + monitor.dispose(); + }); + + it("still updates context and status bar", async () => { + const { monitor, stream } = await createMonitor(); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + + expect(contextManager.set).toHaveBeenCalledWith( + "coder.workspace.updatable", + true, + ); + expect(statusBar.show).toHaveBeenCalled(); + monitor.dispose(); + }); + + it("still fires onChange events", async () => { + const { monitor, stream } = await createMonitor(); + const changes: Workspace[] = []; + monitor.onChange.event((ws) => changes.push(ws)); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + + expect(changes).toHaveLength(1); + monitor.dispose(); + }); + + it("shows notifications after re-enabling", async () => { + const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); + const { monitor, stream } = await createMonitor(); + monitor.markInitialSetupComplete(); + + stream.pushMessage( + workspaceEvent( + createWorkspace({ + latest_build: { status: "running", deadline }, + }), + ), + ); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + + config.set("coder.disableNotifications", false); + + stream.pushMessage( + workspaceEvent( + createWorkspace({ + latest_build: { status: "running", deadline }, + }), + ), + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); + monitor.dispose(); + }); + }); +}); From d70f829134a5881f8e081fbddb38b6fca6c79e11 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 23 Mar 2026 12:50:24 +0300 Subject: [PATCH 2/3] chore: remove unnecessary vscodeProposed mocks from tests The vscodeProposed proxy already falls back to the vscode module (which is aliased to the test mock via vitest.config.ts), so explicitly mocking it is redundant. Also adds disableUpdateNotifications re-enable test. --- test/unit/core/binaryLock.test.ts | 5 ----- test/unit/core/cliManager.concurrent.test.ts | 5 ----- test/unit/core/cliManager.test.ts | 4 ---- test/unit/login/loginCoordinator.test.ts | 4 ---- test/unit/uri/uriHandler.test.ts | 4 ---- test/unit/workspace/workspaceMonitor.test.ts | 18 ++++++++++++++++++ 6 files changed, 18 insertions(+), 22 deletions(-) diff --git a/test/unit/core/binaryLock.test.ts b/test/unit/core/binaryLock.test.ts index 84558953..b0452d5a 100644 --- a/test/unit/core/binaryLock.test.ts +++ b/test/unit/core/binaryLock.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as vscode from "vscode"; import { BinaryLock } from "@/core/binaryLock"; import * as downloadProgress from "@/core/downloadProgress"; @@ -11,10 +10,6 @@ import { vi.mock("vscode"); -vi.mock("@/vscodeProposed", () => ({ - vscodeProposed: vscode, -})); - // Mock proper-lockfile vi.mock("proper-lockfile", () => ({ lock: vi.fn(), diff --git a/test/unit/core/cliManager.concurrent.test.ts b/test/unit/core/cliManager.concurrent.test.ts index fe0b0fae..4c0c83ff 100644 --- a/test/unit/core/cliManager.concurrent.test.ts +++ b/test/unit/core/cliManager.concurrent.test.ts @@ -10,7 +10,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as vscode from "vscode"; import { CliManager } from "@/core/cliManager"; import * as cliUtils from "@/core/cliUtils"; @@ -27,10 +26,6 @@ import { vi.mock("@/pgp"); -vi.mock("@/vscodeProposed", () => ({ - vscodeProposed: vscode, -})); - vi.mock("@/core/cliUtils", async () => { const actual = await vi.importActual("@/core/cliUtils"); return { diff --git a/test/unit/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts index fb6d0441..7618f9d5 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -59,10 +59,6 @@ vi.mock("proper-lockfile", () => ({ vi.mock("@/pgp"); -vi.mock("@/vscodeProposed", () => ({ - vscodeProposed: vscode, -})); - vi.mock("@/core/cliUtils", async () => { const actual = await vi.importActual("@/core/cliUtils"); diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index 3ee05ad3..be9013f3 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -67,10 +67,6 @@ vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn(), })); -vi.mock("@/vscodeProposed", () => ({ - vscodeProposed: vscode, -})); - // Mock CoderApi to control getAuthenticatedUser behavior const mockGetAuthenticatedUser = vi.hoisted(() => vi.fn()); vi.mock("@/api/coderApi", async (importOriginal) => { diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index b8f0c3e5..b5e64925 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -21,10 +21,6 @@ import type { LoginCoordinator, LoginOptions } from "@/login/loginCoordinator"; vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn() })); -vi.mock("@/vscodeProposed", () => ({ - vscodeProposed: vscode, -})); - const TEST_URL = "https://coder.example.com"; const TEST_HOSTNAME = "coder.example.com"; diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts index 6834079e..b4a2aa8f 100644 --- a/test/unit/workspace/workspaceMonitor.test.ts +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -255,6 +255,24 @@ describe("WorkspaceMonitor", () => { ); monitor.dispose(); }); + + it("shows outdated notification after re-enabling", async () => { + config.set("coder.disableUpdateNotifications", true); + const { monitor, stream, client } = await createMonitor(); + monitor.markInitialSetupComplete(); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + expect(client.getTemplate).not.toHaveBeenCalled(); + + config.set("coder.disableUpdateNotifications", false); + + stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + + await vi.waitFor(() => { + expect(client.getTemplate).toHaveBeenCalled(); + }); + monitor.dispose(); + }); }); describe("disableNotifications", () => { From 7f07877580677c9af80659c10a272910915e721b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 23 Mar 2026 13:17:43 +0300 Subject: [PATCH 3/3] chore: clean up WorkspaceMonitor and Inbox tests - Remove unnecessary dispose() calls from tests that don't test dispose - Combine two "before initial setup" tests into one - Import ContextManager type properly instead of inline import() - Improve test names to describe behavior more precisely --- test/mocks/testHelpers.ts | 67 +++-- test/unit/inbox.test.ts | 50 ++-- test/unit/workspace/workspaceMonitor.test.ts | 275 ++++++++----------- 3 files changed, 172 insertions(+), 220 deletions(-) diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 13dfcc86..659f5d71 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -9,6 +9,7 @@ import { vi } from "vitest"; import * as vscode from "vscode"; import type { Experiment, User } from "coder/site/src/api/typesGenerated"; +import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; import type { IncomingMessage } from "node:http"; import type { CoderApi } from "@/api/coderApi"; @@ -16,6 +17,7 @@ import type { CliCredentialManager } from "@/core/cliCredentialManager"; import type { Logger } from "@/logging/logger"; import type { EventHandler, + EventPayloadMap, ParsedMessageEvent, UnidirectionalStream, } from "@/websocket/eventStreamConnection"; @@ -841,50 +843,63 @@ export class MockCancellationToken implements vscode.CancellationToken { } /** - * Mock event stream for testing UnidirectionalStream consumers. + * Mock event stream that implements UnidirectionalStream directly. + * Use pushMessage/pushError for common cases, or emit() for any event type. */ -export class MockEventStream { +export class MockEventStream implements UnidirectionalStream { + readonly url = "ws://test/mock-stream"; + readonly close = vi.fn(); + private readonly handlers = new Map< string, - Array<(...args: unknown[]) => void> + Set<(...args: unknown[]) => void> >(); - readonly stream: UnidirectionalStream = { - url: "ws://test/mock-stream", - addEventListener: vi.fn( - (event: string, callback: (...args: unknown[]) => void) => { - if (!this.handlers.has(event)) { - this.handlers.set(event, []); - } - this.handlers.get(event)!.push(callback); - }, - ), - removeEventListener: vi.fn(), - close: vi.fn(), - }; + addEventListener( + event: E, + callback: EventHandler, + ): void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()); + } + this.handlers.get(event)!.add(callback as (...args: unknown[]) => void); + } + + removeEventListener( + event: E, + callback: EventHandler, + ): void { + this.handlers.get(event)?.delete(callback as (...args: unknown[]) => void); + } + + emit( + event: E, + payload: EventPayloadMap[E], + ): void { + const handlers = this.handlers.get(event); + if (handlers) { + for (const handler of handlers) { + handler(payload); + } + } + } pushMessage(parsedMessage: T): void { - const event: ParsedMessageEvent = { + const payload: ParsedMessageEvent = { sourceEvent: { data: undefined }, parsedMessage, parseError: undefined, }; - this.fire("message", event); + this.emit("message", payload); } pushError(error: Error): void { - const event: ParsedMessageEvent = { + const payload: ParsedMessageEvent = { sourceEvent: { data: undefined }, parsedMessage: undefined, parseError: error, }; - this.fire("message", event); - } - - private fire(event: string, payload: unknown): void { - for (const handler of this.handlers.get(event) ?? []) { - (handler as EventHandler)(payload as ParsedMessageEvent); - } + this.emit("message", payload); } } diff --git a/test/unit/inbox.test.ts b/test/unit/inbox.test.ts index 89b558e3..701bedf6 100644 --- a/test/unit/inbox.test.ts +++ b/test/unit/inbox.test.ts @@ -32,85 +32,76 @@ function createNotification(title: string): GetInboxNotificationResponse { }; } -function createMockClient( - stream: MockEventStream, -) { - return { - watchInboxNotifications: vi.fn().mockResolvedValue(stream.stream), - } as unknown as CoderApi; -} - describe("Inbox", () => { - let config: MockConfigurationProvider; - beforeEach(() => { vi.resetAllMocks(); - config = new MockConfigurationProvider(); }); - async function createInbox( + async function setup( stream = new MockEventStream(), ) { - const ws = createWorkspace(); - const client = createMockClient(stream); - const inbox = await Inbox.create(ws, client, createMockLogger()); - return { inbox, stream }; + const config = new MockConfigurationProvider(); + const inbox = await Inbox.create( + createWorkspace(), + { + watchInboxNotifications: () => Promise.resolve(stream), + } as unknown as CoderApi, + createMockLogger(), + ); + return { inbox, stream, config }; } describe("message handling", () => { - it("shows notification when a message arrives", async () => { - const { inbox, stream } = await createInbox(); + it("shows notification with the message title", async () => { + const { stream } = await setup(); stream.pushMessage(createNotification("Out of memory")); expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( "Out of memory", ); - inbox.dispose(); }); - it("shows multiple notifications for successive messages", async () => { - const { inbox, stream } = await createInbox(); + it("shows each notification independently (no dedup)", async () => { + const { stream } = await setup(); stream.pushMessage(createNotification("First alert")); stream.pushMessage(createNotification("Second alert")); expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(2); - inbox.dispose(); }); it("logs parse errors without showing notifications", async () => { - const { inbox, stream } = await createInbox(); + const { stream } = await setup(); stream.pushError(new Error("bad json")); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - inbox.dispose(); }); it("closes the socket on dispose", async () => { const stream = new MockEventStream(); - const { inbox } = await createInbox(stream); + const { inbox } = await setup(stream); + inbox.dispose(); - expect(stream.stream.close).toHaveBeenCalled(); + expect(stream.close).toHaveBeenCalled(); }); }); describe("disableNotifications", () => { it("suppresses notifications when enabled", async () => { + const { stream, config } = await setup(); config.set("coder.disableNotifications", true); - const { inbox, stream } = await createInbox(); stream.pushMessage(createNotification("Out of memory")); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - inbox.dispose(); }); it("shows notifications after re-enabling", async () => { + const { stream, config } = await setup(); config.set("coder.disableNotifications", true); - const { inbox, stream } = await createInbox(); stream.pushMessage(createNotification("suppressed")); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); @@ -121,7 +112,6 @@ describe("Inbox", () => { expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( "visible", ); - inbox.dispose(); }); }); }); diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts index b4a2aa8f..6a6742b4 100644 --- a/test/unit/workspace/workspaceMonitor.test.ts +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -18,151 +18,132 @@ import type { } from "coder/site/src/api/typesGenerated"; import type { CoderApi } from "@/api/coderApi"; +import type { ContextManager } from "@/core/contextManager"; -function createMockClient(stream: MockEventStream) { - return { - watchWorkspace: vi.fn().mockResolvedValue(stream.stream), - getTemplate: vi.fn().mockResolvedValue({ - active_version_id: "version-2", - }), - getTemplateVersion: vi.fn().mockResolvedValue({ - message: "template v2", - }), - } as unknown as CoderApi; +function workspaceEvent( + overrides?: Parameters[0], +): ServerSentEvent { + return { type: "data", data: createWorkspace(overrides) }; } -function workspaceEvent(ws: Workspace): ServerSentEvent { - return { type: "data", data: ws }; +function minutesFromNow(n: number): string { + return new Date(Date.now() + n * 60_000).toISOString(); } describe("WorkspaceMonitor", () => { - let config: MockConfigurationProvider; - let statusBar: MockStatusBar; - let contextManager: MockContextManager; - beforeEach(() => { vi.resetAllMocks(); - config = new MockConfigurationProvider(); - statusBar = new MockStatusBar(); - contextManager = new MockContextManager(); }); - async function createMonitor( - ws: Workspace = createWorkspace(), - stream = new MockEventStream(), - ) { - const client = createMockClient(stream); + async function setup(stream = new MockEventStream()) { + const config = new MockConfigurationProvider(); + const statusBar = new MockStatusBar(); + const contextManager = new MockContextManager(); + const client = { + watchWorkspace: vi.fn().mockResolvedValue(stream), + getTemplate: vi.fn().mockResolvedValue({ + active_version_id: "version-2", + }), + getTemplateVersion: vi.fn().mockResolvedValue({ + message: "template v2", + }), + } as unknown as CoderApi; const monitor = await WorkspaceMonitor.create( - ws, + createWorkspace(), client, createMockLogger(), - contextManager as unknown as import("@/core/contextManager").ContextManager, + contextManager as unknown as ContextManager, ); - return { monitor, client, stream }; + return { monitor, client, stream, config, statusBar, contextManager }; } describe("websocket lifecycle", () => { it("fires onChange when a workspace message arrives", async () => { - const { monitor, stream } = await createMonitor(); + const { monitor, stream } = await setup(); const changes: Workspace[] = []; monitor.onChange.event((ws) => changes.push(ws)); - const updated = createWorkspace({ outdated: true }); - stream.pushMessage(workspaceEvent(updated)); + stream.pushMessage(workspaceEvent({ outdated: true })); expect(changes).toHaveLength(1); expect(changes[0].outdated).toBe(true); - monitor.dispose(); }); it("logs parse errors without showing notifications", async () => { - const { monitor, stream } = await createMonitor(); + const { stream } = await setup(); + stream.pushError(new Error("bad json")); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - monitor.dispose(); }); it("closes the socket on dispose", async () => { const stream = new MockEventStream(); - const { monitor } = await createMonitor(createWorkspace(), stream); + const { monitor } = await setup(stream); + monitor.dispose(); - expect(stream.stream.close).toHaveBeenCalled(); + expect(stream.close).toHaveBeenCalled(); }); }); describe("context and status bar", () => { it("sets coder.workspace.updatable context when workspace is outdated", async () => { - const { monitor, stream } = await createMonitor(); + const { stream, contextManager } = await setup(); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + stream.pushMessage(workspaceEvent({ outdated: true })); - expect(contextManager.set).toHaveBeenCalledWith( - "coder.workspace.updatable", - true, - ); - monitor.dispose(); + expect(contextManager.get("coder.workspace.updatable")).toBe(true); }); it("shows status bar when outdated, hides when not", async () => { - const { monitor, stream } = await createMonitor(); + const { stream, statusBar } = await setup(); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + stream.pushMessage(workspaceEvent({ outdated: true })); expect(statusBar.show).toHaveBeenCalled(); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: false }))); + stream.pushMessage(workspaceEvent({ outdated: false })); expect(statusBar.hide).toHaveBeenCalled(); - - monitor.dispose(); }); }); - describe("notifications when enabled", () => { + describe("notifications", () => { it("shows autostop notification when deadline is impending", async () => { - const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); - const { monitor, stream } = await createMonitor(); - monitor.markInitialSetupComplete(); + const { stream } = await setup(); stream.pushMessage( - workspaceEvent( - createWorkspace({ - latest_build: { status: "running", deadline }, - }), - ), + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), ); expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( expect.stringContaining("scheduled to shut down"), ); - monitor.dispose(); }); it("shows deletion notification when deletion is impending", async () => { - const deletingAt = new Date( - Date.now() + 1000 * 60 * 60 * 12, - ).toISOString(); - const { monitor, stream } = await createMonitor(); + const { monitor, stream } = await setup(); monitor.markInitialSetupComplete(); stream.pushMessage( - workspaceEvent(createWorkspace({ deleting_at: deletingAt })), + workspaceEvent({ deleting_at: minutesFromNow(12 * 60) }), ); expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( expect.stringContaining("scheduled for deletion"), ); - monitor.dispose(); }); it("shows not-running notification after initial setup", async () => { - const { monitor, stream } = await createMonitor(); + const { monitor, stream } = await setup(); monitor.markInitialSetupComplete(); stream.pushMessage( - workspaceEvent( - createWorkspace({ latest_build: { status: "stopped" } }), - ), + workspaceEvent({ latest_build: { status: "stopped" } }), ); expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( @@ -170,194 +151,160 @@ describe("WorkspaceMonitor", () => { expect.anything(), expect.anything(), ); - monitor.dispose(); }); - it("does not show not-running notification before initial setup", async () => { - const { monitor, stream } = await createMonitor(); + it("does not show deletion or not-running notifications before initial setup", async () => { + const { stream } = await setup(); stream.pushMessage( - workspaceEvent( - createWorkspace({ latest_build: { status: "stopped" } }), - ), + workspaceEvent({ deleting_at: minutesFromNow(12 * 60) }), ); - - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - monitor.dispose(); - }); - - it("does not show deletion notification before initial setup", async () => { - const deletingAt = new Date( - Date.now() + 1000 * 60 * 60 * 12, - ).toISOString(); - const { monitor, stream } = await createMonitor(); - stream.pushMessage( - workspaceEvent(createWorkspace({ deleting_at: deletingAt })), + workspaceEvent({ latest_build: { status: "stopped" } }), ); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - monitor.dispose(); }); - it("shows outdated notification and fetches template details", async () => { - const { monitor, stream, client } = await createMonitor(); - monitor.markInitialSetupComplete(); + it("fetches template details for outdated notification", async () => { + const { stream } = await setup(); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + stream.pushMessage(workspaceEvent({ outdated: true })); await vi.waitFor(() => { - expect(client.getTemplate).toHaveBeenCalledWith("template-1"); - expect(client.getTemplateVersion).toHaveBeenCalledWith("version-2"); expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( expect.stringContaining("template v2"), "Update", ); }); - monitor.dispose(); }); it("only notifies once per event type", async () => { - const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); - const { monitor, stream } = await createMonitor(); - monitor.markInitialSetupComplete(); + const { stream } = await setup(); - const ws = createWorkspace({ - latest_build: { status: "running", deadline }, + const event = workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, }); - stream.pushMessage(workspaceEvent(ws)); - stream.pushMessage(workspaceEvent(ws)); + stream.pushMessage(event); + stream.pushMessage(event); expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); - monitor.dispose(); }); }); describe("disableUpdateNotifications", () => { - it("suppresses outdated notification but allows other notifications", async () => { + it("suppresses outdated notification but allows other types", async () => { + const { stream, client, config } = await setup(); config.set("coder.disableUpdateNotifications", true); - const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); - const { monitor, stream, client } = await createMonitor(); - monitor.markInitialSetupComplete(); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + stream.pushMessage(workspaceEvent({ outdated: true })); expect(client.getTemplate).not.toHaveBeenCalled(); stream.pushMessage( - workspaceEvent( - createWorkspace({ - latest_build: { status: "running", deadline }, - }), - ), + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), ); expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( expect.stringContaining("scheduled to shut down"), ); - monitor.dispose(); }); it("shows outdated notification after re-enabling", async () => { + const { stream, config } = await setup(); config.set("coder.disableUpdateNotifications", true); - const { monitor, stream, client } = await createMonitor(); - monitor.markInitialSetupComplete(); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); - expect(client.getTemplate).not.toHaveBeenCalled(); + stream.pushMessage(workspaceEvent({ outdated: true })); config.set("coder.disableUpdateNotifications", false); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); - + stream.pushMessage(workspaceEvent({ outdated: true })); await vi.waitFor(() => { - expect(client.getTemplate).toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("template v2"), + "Update", + ); }); - monitor.dispose(); }); }); describe("disableNotifications", () => { - beforeEach(() => { - config.set("coder.disableNotifications", true); - }); - it("suppresses all notification types", async () => { - const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); - const deletingAt = new Date( - Date.now() + 1000 * 60 * 60 * 12, - ).toISOString(); - const { monitor, stream } = await createMonitor(); + const { monitor, stream, config } = await setup(); + config.set("coder.disableNotifications", true); monitor.markInitialSetupComplete(); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + stream.pushMessage(workspaceEvent({ outdated: true })); stream.pushMessage( - workspaceEvent( - createWorkspace({ - latest_build: { status: "running", deadline }, - }), - ), + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), ); stream.pushMessage( - workspaceEvent(createWorkspace({ deleting_at: deletingAt })), + workspaceEvent({ deleting_at: minutesFromNow(12 * 60) }), ); stream.pushMessage( - workspaceEvent( - createWorkspace({ latest_build: { status: "stopped" } }), - ), + workspaceEvent({ latest_build: { status: "stopped" } }), ); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - monitor.dispose(); }); it("still updates context and status bar", async () => { - const { monitor, stream } = await createMonitor(); + const { stream, config, contextManager, statusBar } = await setup(); + config.set("coder.disableNotifications", true); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + stream.pushMessage(workspaceEvent({ outdated: true })); - expect(contextManager.set).toHaveBeenCalledWith( - "coder.workspace.updatable", - true, - ); + expect(contextManager.get("coder.workspace.updatable")).toBe(true); expect(statusBar.show).toHaveBeenCalled(); - monitor.dispose(); }); it("still fires onChange events", async () => { - const { monitor, stream } = await createMonitor(); + const { monitor, stream, config } = await setup(); + config.set("coder.disableNotifications", true); const changes: Workspace[] = []; monitor.onChange.event((ws) => changes.push(ws)); - stream.pushMessage(workspaceEvent(createWorkspace({ outdated: true }))); + stream.pushMessage(workspaceEvent({ outdated: true })); expect(changes).toHaveLength(1); - monitor.dispose(); }); it("shows notifications after re-enabling", async () => { - const deadline = new Date(Date.now() + 1000 * 60 * 15).toISOString(); - const { monitor, stream } = await createMonitor(); - monitor.markInitialSetupComplete(); + const { stream, config } = await setup(); + config.set("coder.disableNotifications", true); stream.pushMessage( - workspaceEvent( - createWorkspace({ - latest_build: { status: "running", deadline }, - }), - ), + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), ); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); config.set("coder.disableNotifications", false); stream.pushMessage( - workspaceEvent( - createWorkspace({ - latest_build: { status: "running", deadline }, - }), - ), + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), ); expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); - monitor.dispose(); }); }); });