diff --git a/.gitignore b/.gitignore index 75f80c5f..faf84830 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /coverage/ *.vsix yarn-error.log +.DS_Store \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 04c75edc..7acd9bb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,3 +25,54 @@ - Error handling: wrap and type errors appropriately - Use async/await for promises, avoid explicit Promise construction where possible - Test files must be named `*.test.ts` and use Vitest + +## Development Workflow: Spec → Code + +THESE INSTRUCTIONS ARE CRITICAL! + +They dramatically improve the quality of the work you create. + +### Phase 1: Requirements First + +When asked to implement any feature or make changes, ALWAYS start by asking: +"Should I create a Spec for this task first?" + +IFF user agrees: + +- Create a markdown file in `.claude/scopes/FeatureName.md` +- Interview the user to clarify: +- Purpose & user problem +- Success criteria +- Scope & constraints +- Technical considerations +- Out of scope items + +### Phase 2: Review & Refine + +After drafting the Spec: + +- Present it to the user +- Ask: "Does this capture your intent? Any changes needed?" +- Iterate until user approves +- End with: "Spec looks good? Type 'GO!' when ready to implement" + +### Phase 3: Implementation + +ONLY after user types "GO!" or explicitly approves: + +- Begin coding based on the Spec +- Reference the Spec for decisions +- Update Spec if scope changes, but ask user first. + +### File Organization + +``` + +.claude/ +├── scopes/ +│ ├── FeatureName.md # Shared/committed Specs +│ └── OtherFeature.md # Other Specs, for future or past work + +``` + +**Remember: Think first, ask clarifying questions, _then_ code. The Spec is your north star.** diff --git a/src/api.ts b/src/api.ts index 22de2618..43548a3b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -17,6 +17,11 @@ import { getProxyForUrl } from "./proxy"; import { Storage } from "./storage"; import { expandPath } from "./util"; +// TODO: Add WebSocket connection logging +// TODO: Add HTTP API call logging +// TODO: Add certificate validation logging +// TODO: Add token refresh logging + export const coderSessionTokenHeader = "Coder-Session-Token"; /** @@ -105,7 +110,7 @@ export function makeCoderSdk( restClient.getAxiosInstance().interceptors.response.use( (r) => r, async (err) => { - throw await CertificateError.maybeWrap(err, baseUrl, storage); + throw await CertificateError.maybeWrap(err, baseUrl); }, ); diff --git a/src/commands.ts b/src/commands.ts index d6734376..0f89de1a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -10,6 +10,7 @@ import * as vscode from "vscode"; import { makeCoderSdk, needToken } from "./api"; import { extractAgents } from "./api-helper"; import { CertificateError } from "./error"; +import { logger } from "./logger"; import { Storage } from "./storage"; import { toRemoteAuthority, toSafeHost } from "./util"; import { OpenableTreeItem } from "./workspacesProvider"; @@ -245,9 +246,7 @@ export class Commands { } catch (err) { const message = getErrorMessage(err, "no response from the server"); if (isAutologin) { - this.storage.writeToCoderOutputChannel( - `Failed to log in to Coder server: ${message}`, - ); + logger.info(`Failed to log in to Coder server: ${message}`); } else { this.vscodeProposed.window.showErrorMessage( "Failed to log in to Coder server", diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 00000000..19faa06c --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1 @@ +export { VSCodeConfigProvider } from "./vscodeConfigProvider"; diff --git a/src/config/vscodeConfigProvider.ts b/src/config/vscodeConfigProvider.ts new file mode 100644 index 00000000..e0bdd7c0 --- /dev/null +++ b/src/config/vscodeConfigProvider.ts @@ -0,0 +1,18 @@ +import * as vscode from "vscode"; +import { ConfigProvider } from "../logger"; + +export class VSCodeConfigProvider implements ConfigProvider { + getVerbose(): boolean { + const config = vscode.workspace.getConfiguration("coder"); + return config.get("verbose", false); + } + + onVerboseChange(callback: () => void): { dispose: () => void } { + const disposable = vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("coder.verbose")) { + callback(); + } + }); + return disposable; + } +} diff --git a/src/error.test.ts b/src/error.test.ts index 3c4a50c3..95ce8b79 100644 --- a/src/error.test.ts +++ b/src/error.test.ts @@ -19,16 +19,19 @@ const isElectron = // TODO: Remove the vscode mock once we revert the testing framework. beforeAll(() => { vi.mock("vscode", () => { - return {}; + return { + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(false), + }), + onDidChangeConfiguration: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + }, + }; }); }); -const logger = { - writeToCoderOutputChannel(message: string) { - throw new Error(message); - }, -}; - const disposers: (() => void)[] = []; afterAll(() => { disposers.forEach((d) => d()); @@ -89,7 +92,7 @@ it("detects partial chains", async () => { try { await request; } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, address, logger); + const wrapped = await CertificateError.maybeWrap(error, address); expect(wrapped instanceof CertificateError).toBeTruthy(); expect((wrapped as CertificateError).x509Err).toBe(X509_ERR.PARTIAL_CHAIN); } @@ -126,7 +129,7 @@ it("detects self-signed certificates without signing capability", async () => { try { await request; } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, address, logger); + const wrapped = await CertificateError.maybeWrap(error, address); expect(wrapped instanceof CertificateError).toBeTruthy(); expect((wrapped as CertificateError).x509Err).toBe(X509_ERR.NON_SIGNING); } @@ -157,7 +160,7 @@ it("detects self-signed certificates", async () => { try { await request; } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, address, logger); + const wrapped = await CertificateError.maybeWrap(error, address); expect(wrapped instanceof CertificateError).toBeTruthy(); expect((wrapped as CertificateError).x509Err).toBe(X509_ERR.UNTRUSTED_LEAF); } @@ -200,7 +203,7 @@ it("detects an untrusted chain", async () => { try { await request; } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, address, logger); + const wrapped = await CertificateError.maybeWrap(error, address); expect(wrapped instanceof CertificateError).toBeTruthy(); expect((wrapped as CertificateError).x509Err).toBe( X509_ERR.UNTRUSTED_CHAIN, @@ -243,11 +246,11 @@ it("falls back with different error", async () => { servername: "localhost", }), }); - await expect(request).rejects.toMatch(/failed with status code 500/); + await expect(request).rejects.toThrow(/failed with status code 500/); try { await request; } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, "1", logger); + const wrapped = await CertificateError.maybeWrap(error, "1"); expect(wrapped instanceof CertificateError).toBeFalsy(); expect((wrapped as Error).message).toMatch(/failed with status code 500/); } diff --git a/src/error.ts b/src/error.ts index 53cc3389..e2ef93d4 100644 --- a/src/error.ts +++ b/src/error.ts @@ -3,6 +3,7 @@ import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; import * as forge from "node-forge"; import * as tls from "tls"; import * as vscode from "vscode"; +import { logger } from "./logger"; // X509_ERR_CODE represents error codes as returned from BoringSSL/OpenSSL. export enum X509_ERR_CODE { @@ -21,10 +22,6 @@ export enum X509_ERR { UNTRUSTED_CHAIN = "Your Coder deployment's certificate chain does not appear to be trusted by this system. The root of the certificate chain must be added to this system's trust store. ", } -export interface Logger { - writeToCoderOutputChannel(message: string): void; -} - interface KeyUsage { keyCertSign: boolean; } @@ -47,7 +44,6 @@ export class CertificateError extends Error { static async maybeWrap( err: T, address: string, - logger: Logger, ): Promise { if (isAxiosError(err)) { switch (err.code) { @@ -59,7 +55,7 @@ export class CertificateError extends Error { await CertificateError.determineVerifyErrorCause(address); return new CertificateError(err.message, cause); } catch (error) { - logger.writeToCoderOutputChannel( + logger.info( `Failed to parse certificate from ${address}: ${error}`, ); break; diff --git a/src/extension.ts b/src/extension.ts index 05eb7319..8fe00130 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,7 +6,10 @@ import * as vscode from "vscode"; import { makeCoderSdk, needToken } from "./api"; import { errToStr } from "./api-helper"; import { Commands } from "./commands"; +import { VSCodeConfigProvider } from "./config"; import { CertificateError, getErrorDetail } from "./error"; +import { logger } from "./logger"; +import { OutputChannelAdapter } from "./logging"; import { Remote } from "./remote"; import { Storage } from "./storage"; import { toSafeHost } from "./util"; @@ -48,6 +51,60 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } const output = vscode.window.createOutputChannel("Coder"); + + // Initialize logger with the output channel and config provider + logger.initialize(new OutputChannelAdapter(output)); + logger.setConfigProvider(new VSCodeConfigProvider()); + + // Set up error handlers for uncaught exceptions and unhandled rejections + process.on("uncaughtException", (error) => { + logger.debug(`[process#global] error: Uncaught exception - ${error.stack}`); + // Don't crash the extension - let VS Code handle it + }); + + process.on("unhandledRejection", (reason, promise) => { + logger.debug( + `[process#global] error: Unhandled rejection at ${promise} - reason: ${reason}`, + ); + }); + + // Set up process signal handlers + const signals: NodeJS.Signals[] = ["SIGTERM", "SIGINT", "SIGHUP"]; + signals.forEach((signal) => { + process.on(signal, () => { + logger.debug(`[process#global] disconnect: Received signal ${signal}`); + }); + }); + + // Set up memory pressure monitoring + let memoryCheckInterval: NodeJS.Timeout | undefined; + const checkMemoryPressure = () => { + const usage = process.memoryUsage(); + const heapUsedPercent = (usage.heapUsed / usage.heapTotal) * 100; + if (heapUsedPercent > 90) { + logger.debug( + `[process#global] error: High memory usage detected - heap used: ${heapUsedPercent.toFixed( + 1, + )}% (${Math.round(usage.heapUsed / 1024 / 1024)}MB / ${Math.round( + usage.heapTotal / 1024 / 1024, + )}MB)`, + ); + } + }; + + // Check memory every 30 seconds when verbose logging is enabled + const configProvider = new VSCodeConfigProvider(); + if (configProvider.getVerbose()) { + memoryCheckInterval = setInterval(checkMemoryPressure, 30000); + ctx.subscriptions.push({ + dispose: () => { + if (memoryCheckInterval) { + clearInterval(memoryCheckInterval); + } + }, + }); + } + const storage = new Storage( output, ctx.globalState, @@ -317,7 +374,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } catch (ex) { if (ex instanceof CertificateError) { - storage.writeToCoderOutputChannel(ex.x509Err || ex.message); + logger.info(ex.x509Err || ex.message); await ex.showModal("Failed to open workspace"); } else if (isAxiosError(ex)) { const msg = getErrorMessage(ex, "None"); @@ -326,7 +383,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const method = ex.config?.method?.toUpperCase() || "request"; const status = ex.response?.status || "None"; const message = `API ${method} to '${urlString}' failed.\nStatus code: ${status}\nMessage: ${msg}\nDetail: ${detail}`; - storage.writeToCoderOutputChannel(message); + logger.info(message); await vscodeProposed.window.showErrorMessage( "Failed to open workspace", { @@ -337,7 +394,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } else { const message = errToStr(ex, "No error message was provided"); - storage.writeToCoderOutputChannel(message); + logger.info(message); await vscodeProposed.window.showErrorMessage( "Failed to open workspace", { @@ -356,14 +413,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // See if the plugin client is authenticated. const baseUrl = restClient.getAxiosInstance().defaults.baseURL; if (baseUrl) { - storage.writeToCoderOutputChannel( - `Logged in to ${baseUrl}; checking credentials`, - ); + logger.info(`Logged in to ${baseUrl}; checking credentials`); restClient .getAuthenticatedUser() .then(async (user) => { if (user && user.roles) { - storage.writeToCoderOutputChannel("Credentials are valid"); + logger.info("Credentials are valid"); vscode.commands.executeCommand( "setContext", "coder.authenticated", @@ -381,17 +436,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { myWorkspacesProvider.fetchAndRefresh(); allWorkspacesProvider.fetchAndRefresh(); } else { - storage.writeToCoderOutputChannel( - `No error, but got unexpected response: ${user}`, - ); + logger.info(`No error, but got unexpected response: ${user}`); } }) .catch((error) => { // This should be a failure to make the request, like the header command // errored. - storage.writeToCoderOutputChannel( - `Failed to check user authentication: ${error.message}`, - ); + logger.info(`Failed to check user authentication: ${error.message}`); vscode.window.showErrorMessage( `Failed to check user authentication: ${error.message}`, ); @@ -400,7 +451,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.executeCommand("setContext", "coder.loaded", true); }); } else { - storage.writeToCoderOutputChannel("Not currently logged in"); + logger.info("Not currently logged in"); vscode.commands.executeCommand("setContext", "coder.loaded", true); // Handle autologin, if not already logged in. diff --git a/src/headers.test.ts b/src/headers.test.ts index 5cf333f5..db137809 100644 --- a/src/headers.test.ts +++ b/src/headers.test.ts @@ -1,87 +1,95 @@ import * as os from "os"; -import { it, expect, describe, beforeEach, afterEach, vi } from "vitest"; +import { + it, + expect, + describe, + beforeEach, + afterEach, + vi, + beforeAll, +} from "vitest"; import { WorkspaceConfiguration } from "vscode"; import { getHeaderCommand, getHeaders } from "./headers"; -const logger = { - writeToCoderOutputChannel() { - // no-op - }, -}; +// Mock vscode module before importing anything that uses logger +beforeAll(() => { + vi.mock("vscode", () => { + return { + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(false), + }), + onDidChangeConfiguration: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + }, + }; + }); +}); it("should return no headers", async () => { - await expect(getHeaders(undefined, undefined, logger)).resolves.toStrictEqual( - {}, - ); - await expect( - getHeaders("localhost", undefined, logger), - ).resolves.toStrictEqual({}); - await expect(getHeaders(undefined, "command", logger)).resolves.toStrictEqual( - {}, - ); - await expect(getHeaders("localhost", "", logger)).resolves.toStrictEqual({}); - await expect(getHeaders("", "command", logger)).resolves.toStrictEqual({}); - await expect(getHeaders("localhost", " ", logger)).resolves.toStrictEqual( - {}, - ); - await expect(getHeaders(" ", "command", logger)).resolves.toStrictEqual({}); - await expect( - getHeaders("localhost", "printf ''", logger), - ).resolves.toStrictEqual({}); + await expect(getHeaders(undefined, undefined)).resolves.toStrictEqual({}); + await expect(getHeaders("localhost", undefined)).resolves.toStrictEqual({}); + await expect(getHeaders(undefined, "command")).resolves.toStrictEqual({}); + await expect(getHeaders("localhost", "")).resolves.toStrictEqual({}); + await expect(getHeaders("", "command")).resolves.toStrictEqual({}); + await expect(getHeaders("localhost", " ")).resolves.toStrictEqual({}); + await expect(getHeaders(" ", "command")).resolves.toStrictEqual({}); + await expect(getHeaders("localhost", "printf ''")).resolves.toStrictEqual({}); }); it("should return headers", async () => { await expect( - getHeaders("localhost", "printf 'foo=bar\\nbaz=qux'", logger), + getHeaders("localhost", "printf 'foo=bar\\nbaz=qux'"), ).resolves.toStrictEqual({ foo: "bar", baz: "qux", }); await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\nbaz=qux'", logger), + getHeaders("localhost", "printf 'foo=bar\\r\\nbaz=qux'"), ).resolves.toStrictEqual({ foo: "bar", baz: "qux", }); await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\n'", logger), + getHeaders("localhost", "printf 'foo=bar\\r\\n'"), ).resolves.toStrictEqual({ foo: "bar" }); await expect( - getHeaders("localhost", "printf 'foo=bar'", logger), + getHeaders("localhost", "printf 'foo=bar'"), ).resolves.toStrictEqual({ foo: "bar" }); await expect( - getHeaders("localhost", "printf 'foo=bar='", logger), + getHeaders("localhost", "printf 'foo=bar='"), ).resolves.toStrictEqual({ foo: "bar=" }); await expect( - getHeaders("localhost", "printf 'foo=bar=baz'", logger), + getHeaders("localhost", "printf 'foo=bar=baz'"), ).resolves.toStrictEqual({ foo: "bar=baz" }); - await expect( - getHeaders("localhost", "printf 'foo='", logger), - ).resolves.toStrictEqual({ foo: "" }); + await expect(getHeaders("localhost", "printf 'foo='")).resolves.toStrictEqual( + { foo: "" }, + ); }); it("should error on malformed or empty lines", async () => { await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\n\\r\\n'", logger), - ).rejects.toMatch(/Malformed/); + getHeaders("localhost", "printf 'foo=bar\\r\\n\\r\\n'"), + ).rejects.toThrow(/Malformed/); await expect( - getHeaders("localhost", "printf '\\r\\nfoo=bar'", logger), - ).rejects.toMatch(/Malformed/); - await expect( - getHeaders("localhost", "printf '=foo'", logger), - ).rejects.toMatch(/Malformed/); - await expect(getHeaders("localhost", "printf 'foo'", logger)).rejects.toMatch( + getHeaders("localhost", "printf '\\r\\nfoo=bar'"), + ).rejects.toThrow(/Malformed/); + await expect(getHeaders("localhost", "printf '=foo'")).rejects.toThrow( + /Malformed/, + ); + await expect(getHeaders("localhost", "printf 'foo'")).rejects.toThrow( + /Malformed/, + ); + await expect(getHeaders("localhost", "printf ' =foo'")).rejects.toThrow( + /Malformed/, + ); + await expect(getHeaders("localhost", "printf 'foo =bar'")).rejects.toThrow( /Malformed/, ); await expect( - getHeaders("localhost", "printf ' =foo'", logger), - ).rejects.toMatch(/Malformed/); - await expect( - getHeaders("localhost", "printf 'foo =bar'", logger), - ).rejects.toMatch(/Malformed/); - await expect( - getHeaders("localhost", "printf 'foo foo=bar'", logger), - ).rejects.toMatch(/Malformed/); + getHeaders("localhost", "printf 'foo foo=bar'"), + ).rejects.toThrow(/Malformed/); }); it("should have access to environment variables", async () => { @@ -92,13 +100,12 @@ it("should have access to environment variables", async () => { os.platform() === "win32" ? "printf url=%CODER_URL%" : "printf url=$CODER_URL", - logger, ), ).resolves.toStrictEqual({ url: coderUrl }); }); it("should error on non-zero exit", async () => { - await expect(getHeaders("localhost", "exit 10", logger)).rejects.toMatch( + await expect(getHeaders("localhost", "exit 10")).rejects.toThrow( /exited unexpectedly with code 10/, ); }); diff --git a/src/headers.ts b/src/headers.ts index 4d4b5f44..aa549f1b 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -2,12 +2,9 @@ import * as cp from "child_process"; import * as os from "os"; import * as util from "util"; import type { WorkspaceConfiguration } from "vscode"; +import { logger } from "./logger"; import { escapeCommandArg } from "./util"; -export interface Logger { - writeToCoderOutputChannel(message: string): void; -} - interface ExecException { code?: number; stderr?: string; @@ -59,7 +56,6 @@ export function getHeaderArgs(config: WorkspaceConfiguration): string[] { export async function getHeaders( url: string | undefined, command: string | undefined, - logger: Logger, ): Promise> { const headers: Record = {}; if ( @@ -78,11 +74,11 @@ export async function getHeaders( }); } catch (error) { if (isExecException(error)) { - logger.writeToCoderOutputChannel( + logger.info( `Header command exited unexpectedly with code ${error.code}`, ); - logger.writeToCoderOutputChannel(`stdout: ${error.stdout}`); - logger.writeToCoderOutputChannel(`stderr: ${error.stderr}`); + logger.info(`stdout: ${error.stdout}`); + logger.info(`stderr: ${error.stderr}`); throw new Error( `Header command exited unexpectedly with code ${error.code}`, ); diff --git a/src/inbox.ts b/src/inbox.ts index 709dfbd8..a36ccc81 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -8,6 +8,7 @@ import * as vscode from "vscode"; import { WebSocket } from "ws"; import { coderSessionTokenHeader } from "./api"; import { errToStr } from "./api-helper"; +import { logger } from "./logger"; import { type Storage } from "./storage"; // These are the template IDs of our notifications. @@ -63,7 +64,7 @@ export class Inbox implements vscode.Disposable { }); this.#socket.on("open", () => { - this.#storage.writeToCoderOutputChannel("Listening to Coder Inbox"); + logger.info("Listening to Coder Inbox"); }); this.#socket.on("error", (error) => { @@ -86,9 +87,7 @@ export class Inbox implements vscode.Disposable { dispose() { if (!this.#disposed) { - this.#storage.writeToCoderOutputChannel( - "No longer listening to Coder Inbox", - ); + logger.info("No longer listening to Coder Inbox"); this.#socket.close(); this.#disposed = true; } @@ -99,6 +98,6 @@ export class Inbox implements vscode.Disposable { error, "Got empty error while monitoring Coder Inbox", ); - this.#storage.writeToCoderOutputChannel(message); + logger.info(message); } } diff --git a/src/logger.test.ts b/src/logger.test.ts new file mode 100644 index 00000000..06ff3f0d --- /dev/null +++ b/src/logger.test.ts @@ -0,0 +1,473 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock vscode module before importing logger +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(false), + }), + onDidChangeConfiguration: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + }, + Disposable: class { + dispose = vi.fn(); + }, +})); + +import * as vscode from "vscode"; +import { ArrayAdapter, LogLevel, NoOpAdapter, logger } from "./logger"; +import { OutputChannelAdapter } from "./logging"; +import { TestConfigProvider } from "./test"; + +describe("Logger", () => { + beforeEach(() => { + process.env.NODE_ENV = "test"; + logger.reset(); + }); + + afterEach(() => { + logger.reset(); + vi.clearAllMocks(); + }); + + describe("ArrayAdapter", () => { + it("should store messages in array", () => { + const adapter = new ArrayAdapter(); + adapter.write("test message 1"); + adapter.write("test message 2"); + + const snapshot = adapter.getSnapshot(); + expect(snapshot).toEqual(["test message 1", "test message 2"]); + }); + + it("should clear messages", () => { + const adapter = new ArrayAdapter(); + adapter.write("test message"); + adapter.clear(); + + const snapshot = adapter.getSnapshot(); + expect(snapshot).toEqual([]); + }); + + it("should return immutable snapshot", () => { + const adapter = new ArrayAdapter(); + adapter.write("test message"); + + const snapshot1 = adapter.getSnapshot(); + const snapshot2 = adapter.getSnapshot(); + + expect(snapshot1).not.toBe(snapshot2); + expect(snapshot1).toEqual(snapshot2); + }); + }); + + describe("OutputChannelAdapter", () => { + it("should write to output channel", () => { + const mockChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + } as unknown as vscode.OutputChannel; + + const adapter = new OutputChannelAdapter(mockChannel); + adapter.write("test message"); + + expect(mockChannel.appendLine).toHaveBeenCalledWith("test message"); + }); + + it("should clear output channel", () => { + const mockChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + } as unknown as vscode.OutputChannel; + + const adapter = new OutputChannelAdapter(mockChannel); + adapter.clear(); + + expect(mockChannel.clear).toHaveBeenCalled(); + }); + + it("should not throw if output channel is disposed", () => { + const mockChannel = { + appendLine: vi.fn().mockImplementation(() => { + throw new Error("Channel disposed"); + }), + clear: vi.fn().mockImplementation(() => { + throw new Error("Channel disposed"); + }), + } as unknown as vscode.OutputChannel; + + const adapter = new OutputChannelAdapter(mockChannel); + + expect(() => adapter.write("test")).not.toThrow(); + expect(() => adapter.clear()).not.toThrow(); + }); + }); + + describe("NoOpAdapter", () => { + it("should do nothing", () => { + const adapter = new NoOpAdapter(); + expect(() => adapter.write("test")).not.toThrow(); + expect(() => adapter.clear()).not.toThrow(); + }); + }); + + describe("Logger core functionality", () => { + it("should format info messages correctly", () => { + const adapter = new ArrayAdapter(); + logger.setAdapter(adapter); + + const beforeTime = new Date().toISOString(); + logger.info("Test info message"); + const afterTime = new Date().toISOString(); + + const logs = adapter.getSnapshot(); + expect(logs).toHaveLength(1); + + const logMatch = logs[0].match(/\[info\] (\S+) Test info message/); + expect(logMatch).toBeTruthy(); + + const timestamp = logMatch![1]; + expect(timestamp >= beforeTime).toBe(true); + expect(timestamp <= afterTime).toBe(true); + }); + + it("should format debug messages correctly", () => { + const adapter = new ArrayAdapter(); + logger.setAdapter(adapter); + logger.setLevel(LogLevel.DEBUG); + + logger.debug("Test debug message"); + + const logs = adapter.getSnapshot(); + expect(logs).toHaveLength(1); + expect(logs[0]).toMatch(/\[debug\] \S+ Test debug message/); + }); + + it("should include source location in debug messages when verbose", () => { + const adapter = new ArrayAdapter(); + logger.setAdapter(adapter); + logger.setLevel(LogLevel.DEBUG); + + logger.debug("Test debug with location"); + + const logs = adapter.getSnapshot(); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("[debug]"); + expect(logs[0]).toContain("Test debug with location"); + // Should contain source location - may be in either format + expect(logs[0]).toMatch(/\n\s+at .+:\d+|\n\s+at .+ \(.+:\d+\)/); + }); + + it("should respect log levels", () => { + const adapter = new ArrayAdapter(); + logger.setAdapter(adapter); + logger.setLevel(LogLevel.INFO); + + logger.debug("Debug message"); + logger.info("Info message"); + + const logs = adapter.getSnapshot(); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Info message"); + }); + + it("should handle NONE log level", () => { + const adapter = new ArrayAdapter(); + logger.setAdapter(adapter); + logger.setLevel(LogLevel.NONE); + + logger.debug("Debug message"); + logger.info("Info message"); + + const logs = adapter.getSnapshot(); + expect(logs).toHaveLength(0); + }); + }); + + describe("Configuration", () => { + it("should read verbose setting on initialization", () => { + const adapter = new ArrayAdapter(); + const configProvider = new TestConfigProvider(); + + logger.setAdapter(adapter); + logger.setConfigProvider(configProvider); + + // Test with verbose = false + configProvider.setVerbose(false); + logger.debug("Debug message"); + logger.info("Info message"); + + let logs = adapter.getSnapshot(); + expect(logs.length).toBe(1); // Only info should be logged + expect(logs[0]).toContain("Info message"); + + // Clear and test with verbose = true + adapter.clear(); + configProvider.setVerbose(true); + logger.debug("Debug message 2"); + logger.info("Info message 2"); + + logs = adapter.getSnapshot(); + expect(logs.length).toBe(2); // Both should be logged + expect(logs[0]).toContain("Debug message 2"); + expect(logs[1]).toContain("Info message 2"); + }); + + it("should update log level when configuration changes", () => { + const adapter = new ArrayAdapter(); + const configProvider = new TestConfigProvider(); + + logger.setAdapter(adapter); + logger.setConfigProvider(configProvider); + + // Start with verbose = false + configProvider.setVerbose(false); + logger.debug("Debug 1"); + logger.info("Info 1"); + + let logs = adapter.getSnapshot(); + expect(logs.length).toBe(1); // Only info + + // Change to verbose = true + adapter.clear(); + configProvider.setVerbose(true); + logger.debug("Debug 2"); + logger.info("Info 2"); + + logs = adapter.getSnapshot(); + expect(logs.length).toBe(2); // Both logged + }); + }); + + describe("Adapter management", () => { + it("should throw when setAdapter called in non-test environment", () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + expect(() => logger.setAdapter(new ArrayAdapter())).toThrow( + "setAdapter can only be called in test environment", + ); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it("should throw when reset called in non-test environment", () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + expect(() => logger.reset()).toThrow( + "reset can only be called in test environment", + ); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it("should throw when adapter already set", () => { + logger.setAdapter(new ArrayAdapter()); + expect(() => logger.setAdapter(new ArrayAdapter())).toThrow( + "Adapter already set. Use reset() first or withAdapter() for temporary changes", + ); + }); + + it("should allow temporary adapter changes with withAdapter", () => { + const adapter1 = new ArrayAdapter(); + const adapter2 = new ArrayAdapter(); + + logger.setAdapter(adapter1); + logger.info("Message 1"); + + const result = logger.withAdapter(adapter2, () => { + logger.info("Message 2"); + return "test result"; + }); + + logger.info("Message 3"); + + expect(result).toBe("test result"); + expect(adapter1.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.stringContaining("Message 1"), + expect.stringContaining("Message 3"), + ]), + ); + expect(adapter2.getSnapshot()).toEqual( + expect.arrayContaining([expect.stringContaining("Message 2")]), + ); + }); + + it("should restore adapter even if function throws", () => { + const adapter1 = new ArrayAdapter(); + const adapter2 = new ArrayAdapter(); + + logger.setAdapter(adapter1); + + expect(() => + logger.withAdapter(adapter2, () => { + throw new Error("Test error"); + }), + ).toThrow("Test error"); + + logger.info("After error"); + expect(adapter1.getSnapshot()).toEqual( + expect.arrayContaining([expect.stringContaining("After error")]), + ); + expect(adapter2.getSnapshot()).toHaveLength(0); + }); + + it("should dispose configuration listener on reset", () => { + const adapter = new ArrayAdapter(); + const configProvider = new TestConfigProvider(); + + logger.setAdapter(adapter); + logger.setConfigProvider(configProvider); + + // Track if dispose was called + let disposed = false; + const originalOnVerboseChange = + configProvider.onVerboseChange.bind(configProvider); + configProvider.onVerboseChange = (callback: () => void) => { + const result = originalOnVerboseChange(callback); + return { + dispose: () => { + disposed = true; + result.dispose(); + }, + }; + }; + + // Re-set config provider to register the wrapped listener + logger.setConfigProvider(configProvider); + + // Reset should dispose the listener + logger.reset(); + + expect(disposed).toBe(true); + }); + }); + + describe("Initialize", () => { + it("should initialize with OutputChannel", () => { + const mockChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + } as unknown as vscode.OutputChannel; + + const adapter = new OutputChannelAdapter(mockChannel); + logger.initialize(adapter); + + // Set up config provider for test + const configProvider = new TestConfigProvider(); + logger.setConfigProvider(configProvider); + + // Verify we can log after initialization + logger.info("Test message"); + expect(mockChannel.appendLine).toHaveBeenCalled(); + }); + + it("should throw if already initialized", () => { + const mockChannel = {} as vscode.OutputChannel; + const adapter = new OutputChannelAdapter(mockChannel); + logger.initialize(adapter); + + expect(() => logger.initialize(adapter)).toThrow( + "Logger already initialized", + ); + }); + }); + + describe("Performance", () => { + it("should have minimal overhead for disabled debug calls", () => { + const noOpAdapter = new NoOpAdapter(); + const arrayAdapter = new ArrayAdapter(); + + // Measure NoOp baseline + logger.setAdapter(noOpAdapter); + logger.setLevel(LogLevel.INFO); // Debug disabled + + const noOpStart = performance.now(); + for (let i = 0; i < 10000; i++) { + logger.debug(`Debug message ${i}`); + } + const noOpTime = performance.now() - noOpStart; + + // Measure with ArrayAdapter + logger.reset(); + logger.setAdapter(arrayAdapter); + logger.setLevel(LogLevel.INFO); // Debug disabled + + const arrayStart = performance.now(); + for (let i = 0; i < 10000; i++) { + logger.debug(`Debug message ${i}`); + } + const arrayTime = performance.now() - arrayStart; + + // Should have less than 10% overhead when disabled + const overhead = (arrayTime - noOpTime) / noOpTime; + expect(overhead).toBeLessThan(0.1); + expect(arrayAdapter.getSnapshot()).toHaveLength(0); // No messages logged + }); + + it("should have acceptable overhead for enabled debug calls", () => { + const noOpAdapter = new NoOpAdapter(); + const arrayAdapter = new ArrayAdapter(); + + // Measure NoOp baseline + logger.setAdapter(noOpAdapter); + logger.setLevel(LogLevel.DEBUG); // Debug enabled + + const noOpStart = performance.now(); + for (let i = 0; i < 1000; i++) { + logger.debug(`Debug message ${i}`); + } + const noOpTime = performance.now() - noOpStart; + + // Measure with ArrayAdapter + logger.reset(); + logger.setAdapter(arrayAdapter); + logger.setLevel(LogLevel.DEBUG); // Debug enabled + + const arrayStart = performance.now(); + for (let i = 0; i < 1000; i++) { + logger.debug(`Debug message ${i}`); + } + const arrayTime = performance.now() - arrayStart; + + // Should have less than 10x overhead when enabled + const overhead = arrayTime / noOpTime; + expect(overhead).toBeLessThan(10); + expect(arrayAdapter.getSnapshot()).toHaveLength(1000); // All messages logged + }); + + it("should have acceptable overhead for info calls", () => { + const noOpAdapter = new NoOpAdapter(); + const arrayAdapter = new ArrayAdapter(); + + // Measure NoOp baseline + logger.setAdapter(noOpAdapter); + + const noOpStart = performance.now(); + for (let i = 0; i < 1000; i++) { + logger.info(`Info message ${i}`); + } + const noOpTime = performance.now() - noOpStart; + + // Measure with ArrayAdapter + logger.reset(); + logger.setAdapter(arrayAdapter); + + const arrayStart = performance.now(); + for (let i = 0; i < 1000; i++) { + logger.info(`Info message ${i}`); + } + const arrayTime = performance.now() - arrayStart; + + // Should have less than 5x overhead for info + const overhead = arrayTime / noOpTime; + expect(overhead).toBeLessThan(5); + expect(arrayAdapter.getSnapshot()).toHaveLength(1000); // All messages logged + }); + }); +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..4d73f9ed --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,186 @@ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + NONE = 2, +} + +export interface LogAdapter { + write(message: string): void; + clear(): void; +} + +export interface ConfigProvider { + getVerbose(): boolean; + onVerboseChange(callback: () => void): { dispose: () => void }; +} + +export class ArrayAdapter implements LogAdapter { + private logs: string[] = []; + + write(message: string): void { + this.logs.push(message); + } + + clear(): void { + this.logs = []; + } + + getSnapshot(): readonly string[] { + return [...this.logs]; + } +} + +export class NoOpAdapter implements LogAdapter { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + write(_message: string): void { + // Intentionally empty - baseline for performance tests + } + + clear(): void { + // Intentionally empty - baseline for performance tests + } +} + +class LoggerImpl { + private adapter: LogAdapter | null = null; + private level: LogLevel = LogLevel.INFO; + private configProvider: ConfigProvider | null = null; + private configListener: { dispose: () => void } | null = null; + + constructor() { + // Level will be set when configProvider is set + } + + setConfigProvider(provider: ConfigProvider): void { + this.configProvider = provider; + this.updateLogLevel(); + this.setupConfigListener(); + } + + private setupConfigListener(): void { + if (!this.configProvider) { + return; + } + + // Dispose previous listener if exists + if (this.configListener) { + this.configListener.dispose(); + } + + this.configListener = this.configProvider.onVerboseChange(() => { + this.updateLogLevel(); + }); + } + + private updateLogLevel(): void { + if (!this.configProvider) { + this.level = LogLevel.INFO; + return; + } + const verbose = this.configProvider.getVerbose(); + this.level = verbose ? LogLevel.DEBUG : LogLevel.INFO; + } + + private formatMessage(message: string, level: LogLevel): string { + const levelStr = LogLevel[level].toLowerCase(); + const timestamp = new Date().toISOString(); + let formatted = `[${levelStr}] ${timestamp} ${message}`; + + // Add source location for debug messages when verbose is enabled + if (level === LogLevel.DEBUG && this.level === LogLevel.DEBUG) { + const stack = new Error().stack; + if (stack) { + const lines = stack.split("\n"); + // Find the first line that's not from the logger itself + for (let i = 2; i < lines.length; i++) { + const line = lines[i]; + if (!line.includes("logger.ts") && !line.includes("Logger.")) { + const match = + line.match(/at\s+(.+)\s+\((.+):(\d+):(\d+)\)/) || + line.match(/at\s+(.+):(\d+):(\d+)/); + if (match) { + const location = + match.length === 5 + ? `${match[1]} (${match[2]}:${match[3]})` + : `${match[1]}:${match[2]}`; + formatted += `\n at ${location}`; + } + break; + } + } + } + } + + return formatted; + } + + log(message: string, severity: LogLevel = LogLevel.INFO): void { + if (!this.adapter || severity < this.level) { + return; + } + + const formatted = this.formatMessage(message, severity); + this.adapter.write(formatted); + } + + debug(message: string): void { + this.log(message, LogLevel.DEBUG); + } + + info(message: string): void { + this.log(message, LogLevel.INFO); + } + + setLevel(level: LogLevel): void { + this.level = level; + } + + setAdapter(adapter: LogAdapter): void { + if (process.env.NODE_ENV !== "test") { + throw new Error("setAdapter can only be called in test environment"); + } + if (this.adapter !== null) { + throw new Error( + "Adapter already set. Use reset() first or withAdapter() for temporary changes", + ); + } + this.adapter = adapter; + } + + withAdapter(adapter: LogAdapter, fn: () => T): T { + const previous = this.adapter; + this.adapter = adapter; + try { + return fn(); + } finally { + this.adapter = previous; + } + } + + reset(): void { + if (process.env.NODE_ENV !== "test") { + throw new Error("reset can only be called in test environment"); + } + this.adapter = null; + this.level = LogLevel.INFO; + if (this.configListener) { + this.configListener.dispose(); + this.configListener = null; + } + this.configProvider = null; + } + + // Initialize for production use - adapter must be set externally + initialize(adapter: LogAdapter): void { + if (this.adapter !== null) { + throw new Error("Logger already initialized"); + } + this.adapter = adapter; + } +} + +// Export singleton instance +export const logger = new LoggerImpl(); + +// Export types for testing +export type Logger = typeof logger; diff --git a/src/logging/index.ts b/src/logging/index.ts new file mode 100644 index 00000000..d8f1b847 --- /dev/null +++ b/src/logging/index.ts @@ -0,0 +1,2 @@ +export { OutputChannelAdapter } from "./outputChannelAdapter"; +export { maskSensitiveData, truncateLargeData } from "./masking"; diff --git a/src/logging/masking.test.ts b/src/logging/masking.test.ts new file mode 100644 index 00000000..ce280229 --- /dev/null +++ b/src/logging/masking.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { maskSensitiveData, truncateLargeData } from "./masking"; + +describe("masking", () => { + describe("maskSensitiveData", () => { + it("should mask SSH private keys", () => { + const input = `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1234567890abcdef +-----END RSA PRIVATE KEY-----`; + expect(maskSensitiveData(input)).toBe("[REDACTED KEY]"); + }); + + it("should mask passwords in URLs", () => { + const input = "https://user:mypassword@example.com/path"; + expect(maskSensitiveData(input)).toBe( + "https://user:[REDACTED]@example.com/path", + ); + }); + + it("should mask AWS access keys", () => { + const input = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"; + expect(maskSensitiveData(input)).toBe( + "AWS_ACCESS_KEY_ID=[REDACTED AWS KEY]", + ); + }); + + it("should mask bearer tokens", () => { + const input = "Authorization: Bearer abc123def456"; + expect(maskSensitiveData(input)).toBe("Authorization: Bearer [REDACTED]"); + }); + + it("should mask password patterns", () => { + const input1 = "password: mysecret123"; + const input2 = "passwd=anothersecret"; + const input3 = 'pwd: "yetanothersecret"'; + + expect(maskSensitiveData(input1)).toBe("password: [REDACTED]"); + expect(maskSensitiveData(input2)).toBe("passwd: [REDACTED]"); + expect(maskSensitiveData(input3)).toBe("pwd: [REDACTED]"); + }); + + it("should mask token patterns", () => { + const input1 = "token: abc123xyz"; + const input2 = "api_key=secretkey123"; + + expect(maskSensitiveData(input1)).toBe("token: [REDACTED]"); + expect(maskSensitiveData(input2)).toBe("api_key: [REDACTED]"); + }); + + it("should handle multiple sensitive items", () => { + const input = `Config: + url: https://admin:password123@coder.example.com + token: mysecrettoken + AWS_KEY: AKIAIOSFODNN7EXAMPLE`; + + const expected = `Config: + url: https://admin:[REDACTED]@coder.example.com + token: [REDACTED] + AWS_KEY: [REDACTED AWS KEY]`; + + expect(maskSensitiveData(input)).toBe(expected); + }); + }); + + describe("truncateLargeData", () => { + it("should not truncate small data", () => { + const input = "Small data"; + expect(truncateLargeData(input)).toBe(input); + }); + + it("should truncate large data", () => { + const input = "x".repeat(11000); + const result = truncateLargeData(input); + expect(result.length).toBe(10240 + "[TRUNCATED after 10KB]".length + 1); // +1 for newline + expect(result).toContain("[TRUNCATED after 10KB]"); + }); + + it("should respect custom max length", () => { + const input = "x".repeat(100); + const result = truncateLargeData(input, 50); + expect(result).toBe("x".repeat(50) + "\n[TRUNCATED after 10KB]"); + }); + }); +}); diff --git a/src/logging/masking.ts b/src/logging/masking.ts new file mode 100644 index 00000000..340d57ee --- /dev/null +++ b/src/logging/masking.ts @@ -0,0 +1,54 @@ +/** + * Utility functions for masking sensitive data in logs + */ + +/** + * Masks sensitive information in log messages + * @param message The message to mask + * @returns The masked message + */ +export function maskSensitiveData(message: string): string { + let masked = message; + + // Mask SSH private keys + masked = masked.replace( + /-----BEGIN[^-]+-----[\s\S]*?-----END[^-]+-----/g, + "[REDACTED KEY]", + ); + + // Mask passwords in URLs + masked = masked.replace(/:\/\/([^:]+):([^@]+)@/g, "://$1:[REDACTED]@"); + + // Mask AWS access keys + masked = masked.replace(/AKIA[0-9A-Z]{16}/g, "[REDACTED AWS KEY]"); + + // Mask bearer tokens + masked = masked.replace(/Bearer\s+[^\s]+/gi, "Bearer [REDACTED]"); + + // Mask common password patterns in config + masked = masked.replace( + /(password|passwd|pwd)\s*[:=]\s*["']?[^\s"']+["']?/gi, + "$1: [REDACTED]", + ); + + // Mask token patterns + masked = masked.replace( + /(token|api_key|apikey)\s*[:=]\s*["']?[^\s"']+["']?/gi, + "$1: [REDACTED]", + ); + + return masked; +} + +/** + * Truncates large data with a message + * @param data The data to potentially truncate + * @param maxLength Maximum length in characters (default 10KB) + * @returns The potentially truncated data + */ +export function truncateLargeData(data: string, maxLength = 10240): string { + if (data.length <= maxLength) { + return data; + } + return data.substring(0, maxLength) + "\n[TRUNCATED after 10KB]"; +} diff --git a/src/logging/outputChannelAdapter.ts b/src/logging/outputChannelAdapter.ts new file mode 100644 index 00000000..7a76a9af --- /dev/null +++ b/src/logging/outputChannelAdapter.ts @@ -0,0 +1,22 @@ +import * as vscode from "vscode"; +import { LogAdapter } from "../logger"; + +export class OutputChannelAdapter implements LogAdapter { + constructor(private outputChannel: vscode.OutputChannel) {} + + write(message: string): void { + try { + this.outputChannel.appendLine(message); + } catch { + // Silently ignore - channel may be disposed + } + } + + clear(): void { + try { + this.outputChannel.clear(); + } catch { + // Silently ignore - channel may be disposed + } + } +} diff --git a/src/remote.ts b/src/remote.ts index 4a13ae56..14c79abd 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -22,6 +22,7 @@ import { Commands } from "./commands"; import { featureSetForVersion, FeatureSet } from "./featureSet"; import { getHeaderArgs } from "./headers"; import { Inbox } from "./inbox"; +import { logger } from "./logger"; import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig"; import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; import { Storage } from "./storage"; @@ -70,12 +71,17 @@ export class Remote { binPath: string, ): Promise { const workspaceName = `${workspace.owner_name}/${workspace.name}`; + const retryId = Math.random().toString(36).substring(7); // A terminal will be used to stream the build, if one is necessary. let writeEmitter: undefined | vscode.EventEmitter; let terminal: undefined | vscode.Terminal; let attempts = 0; + logger.debug( + `[retry#${retryId}] init: Starting workspace wait loop for ${workspaceName}, current status: ${workspace.latest_build.status}`, + ); + function initWriteEmitterAndTerminal(): vscode.EventEmitter { if (!writeEmitter) { writeEmitter = new vscode.EventEmitter(); @@ -112,27 +118,40 @@ export class Remote { ); while (workspace.latest_build.status !== "running") { ++attempts; + logger.debug( + `[retry#${retryId}] retry: Attempt ${attempts} - workspace status: ${workspace.latest_build.status}`, + ); + const startTime = Date.now(); + switch (workspace.latest_build.status) { case "pending": case "starting": case "stopping": writeEmitter = initWriteEmitterAndTerminal(); - this.storage.writeToCoderOutputChannel( - `Waiting for ${workspaceName}...`, + logger.info(`Waiting for ${workspaceName}...`); + logger.debug( + `[retry#${retryId}] retry: Workspace is ${workspace.latest_build.status}, waiting for build completion`, ); workspace = await waitForBuild( restClient, writeEmitter, workspace, ); + logger.debug( + `[retry#${retryId}] retry: Build wait completed after ${Date.now() - startTime}ms`, + ); break; case "stopped": if (!(await this.confirmStart(workspaceName))) { + logger.debug( + `[retry#${retryId}] disconnect: User declined to start stopped workspace`, + ); return undefined; } writeEmitter = initWriteEmitterAndTerminal(); - this.storage.writeToCoderOutputChannel( - `Starting ${workspaceName}...`, + logger.info(`Starting ${workspaceName}...`); + logger.debug( + `[retry#${retryId}] retry: Starting stopped workspace`, ); workspace = await startWorkspaceIfStoppedOrFailed( restClient, @@ -141,17 +160,24 @@ export class Remote { workspace, writeEmitter, ); + logger.debug( + `[retry#${retryId}] retry: Workspace start initiated after ${Date.now() - startTime}ms`, + ); break; case "failed": // On a first attempt, we will try starting a failed workspace // (for example canceling a start seems to cause this state). if (attempts === 1) { if (!(await this.confirmStart(workspaceName))) { + logger.debug( + `[retry#${retryId}] disconnect: User declined to start failed workspace`, + ); return undefined; } writeEmitter = initWriteEmitterAndTerminal(); - this.storage.writeToCoderOutputChannel( - `Starting ${workspaceName}...`, + logger.info(`Starting ${workspaceName}...`); + logger.debug( + `[retry#${retryId}] retry: Attempting to start failed workspace (first attempt)`, ); workspace = await startWorkspaceIfStoppedOrFailed( restClient, @@ -160,6 +186,9 @@ export class Remote { workspace, writeEmitter, ); + logger.debug( + `[retry#${retryId}] retry: Failed workspace restart initiated after ${Date.now() - startTime}ms`, + ); break; } // Otherwise fall through and error. @@ -175,10 +204,13 @@ export class Remote { ); } } - this.storage.writeToCoderOutputChannel( + logger.info( `${workspaceName} status is now ${workspace.latest_build.status}`, ); } + logger.debug( + `[retry#${retryId}] connect: Workspace reached running state after ${attempts} attempt(s)`, + ); return workspace; }, ); @@ -200,13 +232,26 @@ export class Remote { public async setup( remoteAuthority: string, ): Promise { + const connectionId = Math.random().toString(36).substring(7); + logger.debug( + `[remote#${connectionId}] init: Starting connection setup for ${remoteAuthority}`, + ); + const parts = parseRemoteAuthority(remoteAuthority); if (!parts) { // Not a Coder host. + logger.debug( + `[remote#${connectionId}] init: Not a Coder host, skipping setup`, + ); return; } const workspaceName = `${parts.username}/${parts.workspace}`; + logger.debug( + `[remote#${connectionId}] init: Connecting to workspace ${workspaceName} with label: ${ + parts.label || "default" + }`, + ); // Migrate "session_token" file to "session", if needed. await this.storage.migrateSessionToken(parts.label); @@ -243,12 +288,8 @@ export class Remote { return; } - this.storage.writeToCoderOutputChannel( - `Using deployment URL: ${baseUrlRaw}`, - ); - this.storage.writeToCoderOutputChannel( - `Using deployment label: ${parts.label || "n/a"}`, - ); + logger.info(`Using deployment URL: ${baseUrlRaw}`); + logger.info(`Using deployment label: ${parts.label || "n/a"}`); // We could use the plugin client, but it is possible for the user to log // out or log into a different deployment while still connected, which would @@ -314,16 +355,20 @@ export class Remote { // Next is to find the workspace from the URI scheme provided. let workspace: Workspace; try { - this.storage.writeToCoderOutputChannel( - `Looking for workspace ${workspaceName}...`, + logger.info(`Looking for workspace ${workspaceName}...`); + logger.debug( + `[remote#${connectionId}] connect: Fetching workspace details from API`, ); workspace = await workspaceRestClient.getWorkspaceByOwnerAndName( parts.username, parts.workspace, ); - this.storage.writeToCoderOutputChannel( + logger.info( `Found workspace ${workspaceName} with status ${workspace.latest_build.status}`, ); + logger.debug( + `[remote#${connectionId}] connect: Workspace ID: ${workspace.id}, Template: ${workspace.template_display_name}, Last transition: ${workspace.latest_build.transition}`, + ); this.commands.workspace = workspace; } catch (error) { if (!isAxiosError(error)) { @@ -388,6 +433,9 @@ export class Remote { // If the workspace is not in a running state, try to get it running. if (workspace.latest_build.status !== "running") { + logger.debug( + `[remote#${connectionId}] connect: Workspace not running, current status: ${workspace.latest_build.status}, attempting to start`, + ); const updatedWorkspace = await this.maybeWaitForRunning( workspaceRestClient, workspace, @@ -395,6 +443,9 @@ export class Remote { binaryPath, ); if (!updatedWorkspace) { + logger.debug( + `[remote#${connectionId}] disconnect: User cancelled workspace start`, + ); // User declined to start the workspace. await this.closeRemote(); return; @@ -404,22 +455,29 @@ export class Remote { this.commands.workspace = workspace; // Pick an agent. - this.storage.writeToCoderOutputChannel( - `Finding agent for ${workspaceName}...`, + logger.info(`Finding agent for ${workspaceName}...`); + logger.debug( + `[remote#${connectionId}] connect: Selecting agent, requested: ${ + parts.agent || "auto" + }`, ); const gotAgent = await this.commands.maybeAskAgent(workspace, parts.agent); if (!gotAgent) { // User declined to pick an agent. + logger.debug( + `[remote#${connectionId}] disconnect: User declined to pick an agent`, + ); await this.closeRemote(); return; } let agent = gotAgent; // Reassign so it cannot be undefined in callbacks. - this.storage.writeToCoderOutputChannel( - `Found agent ${agent.name} with status ${agent.status}`, + logger.info(`Found agent ${agent.name} with status ${agent.status}`); + logger.debug( + `[remote#${connectionId}] connect: Agent ID: ${agent.id}, Version: ${agent.version}, Architecture: ${agent.architecture}`, ); // Do some janky setting manipulation. - this.storage.writeToCoderOutputChannel("Modifying settings..."); + logger.info("Modifying settings..."); const remotePlatforms = this.vscodeProposed.workspace .getConfiguration() .get>("remote.SSH.remotePlatform", {}); @@ -491,9 +549,7 @@ export class Remote { // write here is not necessarily catastrophic since the user will be // asked for the platform and the default timeout might be sufficient. mungedPlatforms = mungedConnTimeout = false; - this.storage.writeToCoderOutputChannel( - `Failed to configure settings: ${ex}`, - ); + logger.info(`Failed to configure settings: ${ex}`); } } @@ -521,9 +577,7 @@ export class Remote { // Wait for the agent to connect. if (agent.status === "connecting") { - this.storage.writeToCoderOutputChannel( - `Waiting for ${workspaceName}/${agent.name}...`, - ); + logger.info(`Waiting for ${workspaceName}/${agent.name}...`); await vscode.window.withProgress( { title: "Waiting for the agent to connect...", @@ -552,9 +606,7 @@ export class Remote { }); }, ); - this.storage.writeToCoderOutputChannel( - `Agent ${agent.name} status is now ${agent.status}`, - ); + logger.info(`Agent ${agent.name} status is now ${agent.status}`); } // Make sure the agent is connected. @@ -584,7 +636,10 @@ export class Remote { // If we didn't write to the SSH config file, connecting would fail with // "Host not found". try { - this.storage.writeToCoderOutputChannel("Updating SSH config..."); + logger.info("Updating SSH config..."); + logger.debug( + `[remote#${connectionId}] connect: Updating SSH config for host ${parts.host}`, + ); await this.updateSSHConfig( workspaceRestClient, parts.label, @@ -593,9 +648,13 @@ export class Remote { logDir, featureSet, ); + logger.debug( + `[remote#${connectionId}] connect: SSH config updated successfully`, + ); } catch (error) { - this.storage.writeToCoderOutputChannel( - `Failed to configure SSH: ${error}`, + logger.info(`Failed to configure SSH: ${error}`); + logger.debug( + `[remote#${connectionId}] error: SSH config update failed: ${error}`, ); throw error; } @@ -604,8 +663,14 @@ export class Remote { this.findSSHProcessID().then(async (pid) => { if (!pid) { // TODO: Show an error here! + logger.debug( + `[remote#${connectionId}] error: Failed to find SSH process ID`, + ); return; } + logger.debug( + `[remote#${connectionId}] connect: Found SSH process with PID ${pid}`, + ); disposables.push(this.showNetworkUpdates(pid)); if (logDir) { const logFiles = await fs.readdir(logDir); @@ -633,7 +698,10 @@ export class Remote { }), ); - this.storage.writeToCoderOutputChannel("Remote setup complete"); + logger.info("Remote setup complete"); + logger.debug( + `[remote#${connectionId}] connect: Connection established successfully to ${workspaceName}`, + ); // Returning the URL and token allows the plugin to authenticate its own // client, for example to display the list of workspaces belonging to this @@ -643,6 +711,9 @@ export class Remote { url: baseUrlRaw, token, dispose: () => { + logger.debug( + `[remote#${connectionId}] disconnect: Disposing remote connection resources`, + ); disposables.forEach((d) => d.dispose()); }, }; @@ -674,9 +745,7 @@ export class Remote { return ""; } await fs.mkdir(logDir, { recursive: true }); - this.storage.writeToCoderOutputChannel( - `SSH proxy diagnostics are being written to ${logDir}`, - ); + logger.info(`SSH proxy diagnostics are being written to ${logDir}`); return ` --log-dir ${escapeCommandArg(logDir)}`; } diff --git a/src/sshConfig.test.ts b/src/sshConfig.test.ts index 1e4cb785..0b2a2893 100644 --- a/src/sshConfig.test.ts +++ b/src/sshConfig.test.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { it, afterEach, vi, expect } from "vitest"; +import { it, afterEach, vi, expect, beforeEach } from "vitest"; +import { logger, ArrayAdapter } from "./logger"; import { SSHConfig } from "./sshConfig"; +import { TestConfigProvider } from "./test"; // This is not the usual path to ~/.ssh/config, but // setting it to a different path makes it easier to test @@ -16,8 +18,22 @@ const mockFileSystem = { writeFile: vi.fn(), }; +let logAdapter: ArrayAdapter; +let configProvider: TestConfigProvider; + +beforeEach(() => { + // Set up logger for tests + logAdapter = new ArrayAdapter(); + configProvider = new TestConfigProvider(); + configProvider.setVerbose(true); // Enable debug logging for tests + logger.reset(); + logger.setAdapter(logAdapter); + logger.setConfigProvider(configProvider); +}); + afterEach(() => { vi.clearAllMocks(); + logger.reset(); }); it("creates a new file and adds config with empty label", async () => { @@ -60,6 +76,27 @@ Host coder-vscode--* expect.stringMatching(sshTempFilePathExpr), sshFilePath, ); + + // Verify logging occurred + const logs = logAdapter.getSnapshot(); + expect( + logs.some((log) => log.includes("[ssh#config] init: Loading SSH config")), + ).toBe(true); + expect( + logs.some((log) => + log.includes("[ssh#config] init: SSH config file not found"), + ), + ).toBe(true); + expect( + logs.some((log) => + log.includes("[ssh#config] connect: Updating SSH config block"), + ), + ).toBe(true); + expect( + logs.some((log) => + log.includes("[ssh#config] connect: SSH config updated successfully"), + ), + ).toBe(true); }); it("creates a new file and adds the config", async () => { diff --git a/src/sshConfig.ts b/src/sshConfig.ts index 4b184921..ac79d987 100644 --- a/src/sshConfig.ts +++ b/src/sshConfig.ts @@ -1,5 +1,7 @@ import { mkdir, readFile, rename, stat, writeFile } from "fs/promises"; import path from "path"; +import { logger } from "./logger"; +import { maskSensitiveData, truncateLargeData } from "./logging"; import { countSubstring } from "./util"; class SSHConfigBadFormat extends Error {} @@ -105,10 +107,20 @@ export class SSHConfig { } async load() { + logger.debug(`[ssh#config] init: Loading SSH config from ${this.filePath}`); try { this.raw = await this.fileSystem.readFile(this.filePath, "utf-8"); + logger.debug( + `[ssh#config] init: Successfully loaded SSH config (${this.raw.length} bytes)`, + ); + // Log a masked version of the config for debugging + const masked = maskSensitiveData(truncateLargeData(this.raw)); + logger.debug(`[ssh#config] init: Config content:\n${masked}`); } catch (ex) { // Probably just doesn't exist! + logger.debug( + `[ssh#config] init: SSH config file not found or unreadable: ${ex}`, + ); this.raw = ""; } } @@ -121,14 +133,37 @@ export class SSHConfig { values: SSHValues, overrides?: Record, ) { + logger.debug( + `[ssh#config] connect: Updating SSH config block for ${label}`, + ); + logger.debug( + `[ssh#config] connect: Host: ${values.Host}, ProxyCommand: ${maskSensitiveData( + values.ProxyCommand, + )}`, + ); + if (overrides && Object.keys(overrides).length > 0) { + logger.debug( + `[ssh#config] connect: Applying overrides: ${maskSensitiveData( + JSON.stringify(overrides), + )}`, + ); + } + const block = this.getBlock(label); const newBlock = this.buildBlock(label, values, overrides); + if (block) { + logger.debug( + `[ssh#config] connect: Replacing existing block for ${label}`, + ); this.replaceBlock(block, newBlock); } else { + logger.debug(`[ssh#config] connect: Appending new block for ${label}`); this.appendBlock(newBlock); } + await this.save(); + logger.debug(`[ssh#config] connect: SSH config updated successfully`); } /** @@ -216,9 +251,18 @@ export class SSHConfig { }); lines.push(this.endBlockComment(label)); - return { + const block = { raw: lines.join("\n"), }; + + // Log the generated block (masked) + logger.debug( + `[ssh#config] connect: Generated SSH config block:\n${maskSensitiveData( + block.raw, + )}`, + ); + + return block; } private replaceBlock(oldBlock: Block, newBlock: Block) { @@ -240,30 +284,41 @@ export class SSHConfig { } private async save() { + logger.debug(`[ssh#config] connect: Saving SSH config to ${this.filePath}`); + // We want to preserve the original file mode. const existingMode = await this.fileSystem .stat(this.filePath) .then((stat) => stat.mode) .catch((ex) => { if (ex.code && ex.code === "ENOENT") { + logger.debug( + `[ssh#config] connect: File doesn't exist, will create with mode 0600`, + ); return 0o600; // default to 0600 if file does not exist } throw ex; // Any other error is unexpected }); + await this.fileSystem.mkdir(path.dirname(this.filePath), { mode: 0o700, // only owner has rwx permission, not group or everyone. recursive: true, }); + const randSuffix = Math.random().toString(36).substring(8); const fileName = path.basename(this.filePath); const dirName = path.dirname(this.filePath); const tempFilePath = `${dirName}/.${fileName}.vscode-coder-tmp.${randSuffix}`; + + logger.debug(`[ssh#config] connect: Writing to temp file ${tempFilePath}`); + try { await this.fileSystem.writeFile(tempFilePath, this.getRaw(), { mode: existingMode, encoding: "utf-8", }); } catch (err) { + logger.debug(`[ssh#config] error: Failed to write temp file: ${err}`); throw new Error( `Failed to write temporary SSH config file at ${tempFilePath}: ${err instanceof Error ? err.message : String(err)}. ` + `Please check your disk space, permissions, and that the directory exists.`, @@ -272,7 +327,11 @@ export class SSHConfig { try { await this.fileSystem.rename(tempFilePath, this.filePath); + logger.debug( + `[ssh#config] connect: Successfully saved SSH config to ${this.filePath}`, + ); } catch (err) { + logger.debug(`[ssh#config] error: Failed to rename temp file: ${err}`); throw new Error( `Failed to rename temporary SSH config file at ${tempFilePath} to ${this.filePath}: ${ err instanceof Error ? err.message : String(err) diff --git a/src/sshSupport.test.ts b/src/sshSupport.test.ts index 050b7bb2..c09109a6 100644 --- a/src/sshSupport.test.ts +++ b/src/sshSupport.test.ts @@ -1,9 +1,28 @@ -import { it, expect } from "vitest"; +import { it, expect, beforeEach, afterEach } from "vitest"; +import { logger, ArrayAdapter } from "./logger"; import { computeSSHProperties, sshSupportsSetEnv, sshVersionSupportsSetEnv, } from "./sshSupport"; +import { TestConfigProvider } from "./test"; + +let logAdapter: ArrayAdapter; +let configProvider: TestConfigProvider; + +beforeEach(() => { + // Set up logger for tests + logAdapter = new ArrayAdapter(); + configProvider = new TestConfigProvider(); + configProvider.setVerbose(true); // Enable debug logging for tests + logger.reset(); + logger.setAdapter(logAdapter); + logger.setConfigProvider(configProvider); +}); + +afterEach(() => { + logger.reset(); +}); const supports = { "OpenSSH_8.9p1 Ubuntu-3ubuntu0.1, OpenSSL 3.0.2 15 Mar 2022": true, @@ -43,6 +62,21 @@ Host coder-vscode--* StrictHostKeyChecking: "yes", ProxyCommand: '/tmp/coder --header="X-FOO=bar" coder.dev', }); + + // Verify logging occurred + const logs = logAdapter.getSnapshot(); + expect( + logs.some((log) => + log.includes( + "[ssh#properties] init: Computing SSH properties for host: coder-vscode--testing", + ), + ), + ).toBe(true); + expect( + logs.some((log) => + log.includes("[ssh#properties] init: Computed properties"), + ), + ).toBe(true); }); it("handles ? wildcards", () => { diff --git a/src/sshSupport.ts b/src/sshSupport.ts index 8abcdd24..002ee5c4 100644 --- a/src/sshSupport.ts +++ b/src/sshSupport.ts @@ -1,4 +1,6 @@ import * as childProcess from "child_process"; +import { logger } from "./logger"; +import { maskSensitiveData } from "./logging"; export function sshSupportsSetEnv(): boolean { try { @@ -43,6 +45,10 @@ export function computeSSHProperties( host: string, config: string, ): Record { + logger.debug( + `[ssh#properties] init: Computing SSH properties for host: ${host}`, + ); + let currentConfig: | { Host: string; @@ -103,5 +109,18 @@ export function computeSSHProperties( } Object.assign(merged, config.properties); }); + + if (Object.keys(merged).length > 0) { + logger.debug( + `[ssh#properties] init: Computed properties for ${host}: ${maskSensitiveData( + JSON.stringify(merged), + )}`, + ); + } else { + logger.debug( + `[ssh#properties] init: No matching SSH properties found for ${host}`, + ); + } + return merged; } diff --git a/src/storage.ts b/src/storage.ts index 8453bc5d..8c981169 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -8,6 +8,7 @@ import * as vscode from "vscode"; import { errToStr } from "./api-helper"; import * as cli from "./cliManager"; import { getHeaderCommand, getHeaders } from "./headers"; +import { logger } from "./logger"; // Maximium number of recent URLs to store. const MAX_URLS = 10; @@ -507,12 +508,12 @@ export class Storage { : path.join(this.globalStorageUri.fsPath, "url"); } - public writeToCoderOutputChannel(message: string) { - this.output.appendLine(`[${new Date().toISOString()}] ${message}`); - // We don't want to focus on the output here, because the - // Coder server is designed to restart gracefully for users - // because of P2P connections, and we don't want to draw - // attention to it. + /** + * Compatibility method for Logger interface used by headers.ts and error.ts. + * Delegates to the logger module. + */ + public writeToCoderOutputChannel(message: string): void { + logger.info(message); } /** @@ -614,7 +615,6 @@ export class Storage { return getHeaders( url, getHeaderCommand(vscode.workspace.getConfiguration()), - this, ); } } diff --git a/src/test/index.ts b/src/test/index.ts new file mode 100644 index 00000000..aebf428e --- /dev/null +++ b/src/test/index.ts @@ -0,0 +1 @@ +export { TestConfigProvider } from "./testConfigProvider"; diff --git a/src/test/testConfigProvider.ts b/src/test/testConfigProvider.ts new file mode 100644 index 00000000..361c4da5 --- /dev/null +++ b/src/test/testConfigProvider.ts @@ -0,0 +1,29 @@ +import { ConfigProvider } from "../logger"; + +export class TestConfigProvider implements ConfigProvider { + private verbose = false; + private callbacks: Array<() => void> = []; + + setVerbose(verbose: boolean): void { + if (this.verbose !== verbose) { + this.verbose = verbose; + this.callbacks.forEach((cb) => cb()); + } + } + + getVerbose(): boolean { + return this.verbose; + } + + onVerboseChange(callback: () => void): { dispose: () => void } { + this.callbacks.push(callback); + return { + dispose: () => { + const index = this.callbacks.indexOf(callback); + if (index >= 0) { + this.callbacks.splice(index, 1); + } + }, + }; + } +} diff --git a/src/workspaceMonitor.ts b/src/workspaceMonitor.ts index 18df50b2..0c082635 100644 --- a/src/workspaceMonitor.ts +++ b/src/workspaceMonitor.ts @@ -5,6 +5,7 @@ import { EventSource } from "eventsource"; import * as vscode from "vscode"; import { createStreamingFetchAdapter } from "./api"; import { errToStr } from "./api-helper"; +import { logger } from "./logger"; import { Storage } from "./storage"; /** @@ -42,7 +43,10 @@ export class WorkspaceMonitor implements vscode.Disposable { this.name = `${workspace.owner_name}/${workspace.name}`; const url = this.restClient.getAxiosInstance().defaults.baseURL; const watchUrl = new URL(`${url}/api/v2/workspaces/${workspace.id}/watch`); - this.storage.writeToCoderOutputChannel(`Monitoring ${this.name}...`); + logger.info(`Monitoring ${this.name}...`); + logger.debug( + `[monitor#${workspace.id}] init: Starting workspace monitor via SSE at ${watchUrl}`, + ); const eventSource = new EventSource(watchUrl.toString(), { fetch: createStreamingFetchAdapter(this.restClient.getAxiosInstance()), @@ -51,15 +55,24 @@ export class WorkspaceMonitor implements vscode.Disposable { eventSource.addEventListener("data", (event) => { try { const newWorkspaceData = JSON.parse(event.data) as Workspace; + logger.debug( + `[monitor#${workspace.id}] connect: Received workspace update - Status: ${newWorkspaceData.latest_build.status}, Transition: ${newWorkspaceData.latest_build.transition}`, + ); this.update(newWorkspaceData); this.maybeNotify(newWorkspaceData); this.onChange.fire(newWorkspaceData); } catch (error) { + logger.debug( + `[monitor#${workspace.id}] error: Failed to parse workspace data: ${error}`, + ); this.notifyError(error); } }); eventSource.addEventListener("error", (event) => { + logger.debug( + `[monitor#${workspace.id}] error: SSE connection error: ${JSON.stringify(event)}`, + ); this.notifyError(event); }); @@ -85,7 +98,10 @@ export class WorkspaceMonitor implements vscode.Disposable { */ dispose() { if (!this.disposed) { - this.storage.writeToCoderOutputChannel(`Unmonitoring ${this.name}...`); + logger.info(`Unmonitoring ${this.name}...`); + logger.debug( + `[monitor#${this.name}] disconnect: Closing SSE connection and disposing resources`, + ); this.statusBarItem.dispose(); this.eventSource.close(); this.disposed = true; @@ -143,6 +159,9 @@ export class WorkspaceMonitor implements vscode.Disposable { workspace.latest_build.status !== "running" ) { this.notifiedNotRunning = true; + logger.debug( + `[monitor#${workspace.id}] disconnect: Workspace stopped running - Status: ${workspace.latest_build.status}, Transition: ${workspace.latest_build.transition}`, + ); this.vscodeProposed.window .showInformationMessage( `${this.name} is no longer running!`, @@ -202,7 +221,7 @@ export class WorkspaceMonitor implements vscode.Disposable { error, "Got empty error while monitoring workspace", ); - this.storage.writeToCoderOutputChannel(message); + logger.info(message); } private updateContext(workspace: Workspace) { diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index a77b31ad..08eb64ad 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -15,6 +15,7 @@ import { extractAgents, errToStr, } from "./api-helper"; +import { logger } from "./logger"; import { Storage } from "./storage"; export enum WorkspaceQuery { @@ -96,7 +97,7 @@ export class WorkspaceProvider */ private async fetch(): Promise { if (vscode.env.logLevel <= vscode.LogLevel.Debug) { - this.storage.writeToCoderOutputChannel( + logger.info( `Fetching workspaces: ${this.getWorkspacesQuery || "no filter"}...`, ); } diff --git a/vitest.config.ts b/vitest.config.ts index 2007fb45..11fbfa44 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,5 +13,8 @@ export default defineConfig({ "./src/test/**", ], environment: "node", + env: { + NODE_ENV: "test", + }, }, });