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..659f5d71 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -9,11 +9,18 @@ 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"; import type { CliCredentialManager } from "@/core/cliCredentialManager"; import type { Logger } from "@/logging/logger"; +import type { + EventHandler, + EventPayloadMap, + ParsedMessageEvent, + UnidirectionalStream, +} from "@/websocket/eventStreamConnection"; /** * Mock configuration provider that integrates with the vscode workspace configuration mock. @@ -834,3 +841,81 @@ export class MockCancellationToken implements vscode.CancellationToken { this._isCancellationRequested = false; } } + +/** + * Mock event stream that implements UnidirectionalStream directly. + * Use pushMessage/pushError for common cases, or emit() for any event type. + */ +export class MockEventStream implements UnidirectionalStream { + readonly url = "ws://test/mock-stream"; + readonly close = vi.fn(); + + private readonly handlers = new Map< + string, + Set<(...args: unknown[]) => void> + >(); + + 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 payload: ParsedMessageEvent = { + sourceEvent: { data: undefined }, + parsedMessage, + parseError: undefined, + }; + this.emit("message", payload); + } + + pushError(error: Error): void { + const payload: ParsedMessageEvent = { + sourceEvent: { data: undefined }, + parsedMessage: undefined, + parseError: error, + }; + this.emit("message", payload); + } +} + +/** + * 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/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/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.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 cfd99974..7618f9d5 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) }; }); @@ -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/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..701bedf6 --- /dev/null +++ b/test/unit/inbox.test.ts @@ -0,0 +1,117 @@ +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, + }; +} + +describe("Inbox", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + async function setup( + stream = new MockEventStream(), + ) { + 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 with the message title", async () => { + const { stream } = await setup(); + + stream.pushMessage(createNotification("Out of memory")); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Out of memory", + ); + }); + + 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); + }); + + it("logs parse errors without showing notifications", async () => { + const { stream } = await setup(); + + stream.pushError(new Error("bad json")); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it("closes the socket on dispose", async () => { + const stream = new MockEventStream(); + const { inbox } = await setup(stream); + + inbox.dispose(); + + expect(stream.close).toHaveBeenCalled(); + }); + }); + + describe("disableNotifications", () => { + it("suppresses notifications when enabled", async () => { + const { stream, config } = await setup(); + config.set("coder.disableNotifications", true); + + stream.pushMessage(createNotification("Out of memory")); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it("shows notifications after re-enabling", async () => { + const { stream, config } = await setup(); + config.set("coder.disableNotifications", true); + + 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", + ); + }); + }); +}); 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/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/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 new file mode 100644 index 00000000..6a6742b4 --- /dev/null +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -0,0 +1,310 @@ +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"; +import type { ContextManager } from "@/core/contextManager"; + +function workspaceEvent( + overrides?: Parameters[0], +): ServerSentEvent { + return { type: "data", data: createWorkspace(overrides) }; +} + +function minutesFromNow(n: number): string { + return new Date(Date.now() + n * 60_000).toISOString(); +} + +describe("WorkspaceMonitor", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + 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( + createWorkspace(), + client, + createMockLogger(), + contextManager as unknown as ContextManager, + ); + return { monitor, client, stream, config, statusBar, contextManager }; + } + + describe("websocket lifecycle", () => { + it("fires onChange when a workspace message arrives", async () => { + const { monitor, stream } = await setup(); + const changes: Workspace[] = []; + monitor.onChange.event((ws) => changes.push(ws)); + + stream.pushMessage(workspaceEvent({ outdated: true })); + + expect(changes).toHaveLength(1); + expect(changes[0].outdated).toBe(true); + }); + + it("logs parse errors without showing notifications", async () => { + const { stream } = await setup(); + + stream.pushError(new Error("bad json")); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it("closes the socket on dispose", async () => { + const stream = new MockEventStream(); + const { monitor } = await setup(stream); + + monitor.dispose(); + + expect(stream.close).toHaveBeenCalled(); + }); + }); + + describe("context and status bar", () => { + it("sets coder.workspace.updatable context when workspace is outdated", async () => { + const { stream, contextManager } = await setup(); + + stream.pushMessage(workspaceEvent({ outdated: true })); + + expect(contextManager.get("coder.workspace.updatable")).toBe(true); + }); + + it("shows status bar when outdated, hides when not", async () => { + const { stream, statusBar } = await setup(); + + stream.pushMessage(workspaceEvent({ outdated: true })); + expect(statusBar.show).toHaveBeenCalled(); + + stream.pushMessage(workspaceEvent({ outdated: false })); + expect(statusBar.hide).toHaveBeenCalled(); + }); + }); + + describe("notifications", () => { + it("shows autostop notification when deadline is impending", async () => { + const { stream } = await setup(); + + stream.pushMessage( + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), + ); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("scheduled to shut down"), + ); + }); + + it("shows deletion notification when deletion is impending", async () => { + const { monitor, stream } = await setup(); + monitor.markInitialSetupComplete(); + + stream.pushMessage( + workspaceEvent({ deleting_at: minutesFromNow(12 * 60) }), + ); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("scheduled for deletion"), + ); + }); + + it("shows not-running notification after initial setup", async () => { + const { monitor, stream } = await setup(); + monitor.markInitialSetupComplete(); + + stream.pushMessage( + workspaceEvent({ latest_build: { status: "stopped" } }), + ); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("no longer running"), + expect.anything(), + expect.anything(), + ); + }); + + it("does not show deletion or not-running notifications before initial setup", async () => { + const { stream } = await setup(); + + stream.pushMessage( + workspaceEvent({ deleting_at: minutesFromNow(12 * 60) }), + ); + stream.pushMessage( + workspaceEvent({ latest_build: { status: "stopped" } }), + ); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it("fetches template details for outdated notification", async () => { + const { stream } = await setup(); + + stream.pushMessage(workspaceEvent({ outdated: true })); + + await vi.waitFor(() => { + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("template v2"), + "Update", + ); + }); + }); + + it("only notifies once per event type", async () => { + const { stream } = await setup(); + + const event = workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }); + stream.pushMessage(event); + stream.pushMessage(event); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); + }); + }); + + describe("disableUpdateNotifications", () => { + it("suppresses outdated notification but allows other types", async () => { + const { stream, client, config } = await setup(); + config.set("coder.disableUpdateNotifications", true); + + stream.pushMessage(workspaceEvent({ outdated: true })); + expect(client.getTemplate).not.toHaveBeenCalled(); + + stream.pushMessage( + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("scheduled to shut down"), + ); + }); + + it("shows outdated notification after re-enabling", async () => { + const { stream, config } = await setup(); + config.set("coder.disableUpdateNotifications", true); + + stream.pushMessage(workspaceEvent({ outdated: true })); + + config.set("coder.disableUpdateNotifications", false); + + stream.pushMessage(workspaceEvent({ outdated: true })); + await vi.waitFor(() => { + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("template v2"), + "Update", + ); + }); + }); + }); + + describe("disableNotifications", () => { + it("suppresses all notification types", async () => { + const { monitor, stream, config } = await setup(); + config.set("coder.disableNotifications", true); + monitor.markInitialSetupComplete(); + + stream.pushMessage(workspaceEvent({ outdated: true })); + stream.pushMessage( + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), + ); + stream.pushMessage( + workspaceEvent({ deleting_at: minutesFromNow(12 * 60) }), + ); + stream.pushMessage( + workspaceEvent({ latest_build: { status: "stopped" } }), + ); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it("still updates context and status bar", async () => { + const { stream, config, contextManager, statusBar } = await setup(); + config.set("coder.disableNotifications", true); + + stream.pushMessage(workspaceEvent({ outdated: true })); + + expect(contextManager.get("coder.workspace.updatable")).toBe(true); + expect(statusBar.show).toHaveBeenCalled(); + }); + + it("still fires onChange events", async () => { + const { monitor, stream, config } = await setup(); + config.set("coder.disableNotifications", true); + const changes: Workspace[] = []; + monitor.onChange.event((ws) => changes.push(ws)); + + stream.pushMessage(workspaceEvent({ outdated: true })); + + expect(changes).toHaveLength(1); + }); + + it("shows notifications after re-enabling", async () => { + const { stream, config } = await setup(); + config.set("coder.disableNotifications", true); + + stream.pushMessage( + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), + ); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + + config.set("coder.disableNotifications", false); + + stream.pushMessage( + workspaceEvent({ + latest_build: { + status: "running", + deadline: minutesFromNow(15), + }, + }), + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); + }); + }); +});