diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..274e227f --- /dev/null +++ b/TODO.md @@ -0,0 +1,204 @@ +# Testing Improvement TODO + +This document outlines the comprehensive testing improvements needed for the VSCode Coder extension, focusing on achieving better test coverage and code quality. + +## Current Testing Status + +✅ **Files with existing tests (7 files):** +- `src/util.test.ts` (8 tests) +- `src/featureSet.test.ts` (2 tests) +- `src/sshSupport.test.ts` (9 tests) +- `src/sshConfig.test.ts` (14 tests) +- `src/headers.test.ts` (9 tests) +- `src/error.test.ts` (11 tests) +- `src/cliManager.test.ts` (6 tests) + +**Total: 59 tests passing** + +## Priority 1: Core API Module Testing + +### 🎯 `src/api.ts` - Complete Test Suite (FOCUS) + +**Functions needing comprehensive tests:** + +1. **`needToken()`** - Configuration-based token requirement logic + - Test with mTLS enabled (cert + key files present) + - Test with mTLS disabled (no cert/key files) + - Test with partial mTLS config (cert only, key only) + - Test with empty/whitespace config values + +2. **`createHttpAgent()`** - HTTP agent configuration + - Test proxy configuration with different proxy settings + - Test TLS certificate loading (cert, key, CA files) + - Test insecure mode vs secure mode + - Test file reading errors and fallbacks + - Test alternative hostname configuration + - Mock file system operations + +3. **`makeCoderSdk()`** - SDK instance creation and configuration + - Test with valid token authentication + - Test without token (mTLS authentication) + - Test header injection from storage + - Test request interceptor functionality + - Test response interceptor and error wrapping + - Mock external dependencies (Api, Storage) + +4. **`createStreamingFetchAdapter()`** - Streaming fetch adapter + - Test successful stream creation and data flow + - Test error handling during streaming + - Test stream cancellation + - Test different response status codes + - Test header extraction + - Mock AxiosInstance responses + +5. **`startWorkspaceIfStoppedOrFailed()`** - Workspace lifecycle management + - Test with already running workspace (early return) + - Test successful workspace start process + - Test workspace start failure scenarios + - Test stdout/stderr handling and output formatting + - Test process exit codes and error messages + - Mock child process spawning + +6. **`waitForBuild()`** - Build monitoring and log streaming + - Test initial log fetching + - Test WebSocket connection for follow logs + - Test log streaming and output formatting + - Test WebSocket error handling + - Test build completion detection + - Mock WebSocket and API responses + +**Test Infrastructure Needs:** +- Mock VSCode workspace configuration +- Mock file system operations (fs/promises) +- Mock child process spawning +- Mock WebSocket connections +- Mock Axios instances and responses +- Mock Storage interface + +## Priority 2: Missing Test Files + +### 🔴 `src/api-helper.ts` - Error handling utilities +- Test `errToStr()` function with various error types +- Test error message formatting and sanitization + +### 🔴 `src/commands.ts` - VSCode command implementations +- Test all command handlers +- Test command registration and lifecycle +- Mock VSCode command API + +### 🔴 `src/extension.ts` - Extension entry point +- Test extension activation/deactivation +- Test command registration +- Test provider registration +- Mock VSCode extension API + +### 🔴 `src/inbox.ts` - Message handling +- Test message queuing and processing +- Test different message types + +### 🔴 `src/proxy.ts` - Proxy configuration +- Test proxy URL resolution +- Test bypass logic +- Test different proxy configurations + +### 🔴 `src/remote.ts` - Remote connection handling +- Test remote authority resolution +- Test connection establishment +- Test error scenarios + +### 🔴 `src/storage.ts` - Data persistence +- Test header storage and retrieval +- Test configuration persistence +- Mock file system operations + +### 🔴 `src/workspaceMonitor.ts` - Workspace monitoring +- Test workspace state tracking +- Test change detection and notifications + +### 🔴 `src/workspacesProvider.ts` - VSCode tree view provider +- Test workspace tree construction +- Test refresh logic +- Test user interactions +- Mock VSCode tree view API + +## Priority 3: Test Quality Improvements + +### 🔧 Existing Test Enhancements + +1. **Increase coverage in existing test files:** + - Add edge cases and error scenarios + - Test async/await error handling + - Add integration test scenarios + +2. **Improve test structure:** + - Group related tests using `describe()` blocks + - Add setup/teardown with `beforeEach()`/`afterEach()` + - Consistent test naming conventions + +3. **Add performance tests:** + - Test timeout handling + - Test concurrent operations + - Memory usage validation + +## Priority 4: Test Infrastructure + +### 🛠 Testing Utilities + +1. **Create test helpers:** + - Mock factory functions for common objects + - Shared test fixtures and data + - Custom matchers for VSCode-specific assertions + +2. **Add test configuration:** + - Test environment setup + - Coverage reporting configuration + - CI/CD integration improvements + +3. **Mock improvements:** + - Better VSCode API mocking + - File system operation mocking + - Network request mocking + +## Implementation Strategy + +### Phase 1: `src/api.ts` Complete Coverage (Week 1) +- Create `src/api.test.ts` with comprehensive test suite +- Focus on the 6 main functions with all edge cases +- Set up necessary mocks and test infrastructure + +### Phase 2: Core Extension Files (Week 2) +- `src/extension.ts` - Entry point testing +- `src/commands.ts` - Command handler testing +- `src/storage.ts` - Persistence testing + +### Phase 3: Remaining Modules (Week 3) +- All remaining untested files +- Integration between modules +- End-to-end workflow testing + +### Phase 4: Quality & Coverage (Week 4) +- Achieve >90% code coverage +- Performance and reliability testing +- Documentation of testing patterns + +## Testing Standards + +- Use Vitest framework (already configured) +- Follow existing patterns from current test files +- Mock external dependencies (VSCode API, file system, network) +- Test both success and failure scenarios +- Include async/await error handling tests +- Use descriptive test names and organize with `describe()` blocks +- Maintain fast test execution (all tests should run in <5 seconds) + +## Success Metrics + +- [ ] All 17 source files have corresponding test files +- [ ] `src/api.ts` achieves >95% code coverage +- [ ] All tests pass in CI mode (`yarn test:ci`) +- [ ] Test execution time remains under 5 seconds +- [ ] Zero flaky tests (consistent pass/fail results) + +--- + +**Next Action:** Start with `src/api.test.ts` implementation focusing on the `needToken()` and `createHttpAgent()` functions first. \ No newline at end of file diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 00000000..17b65966 --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,621 @@ +import { describe, it, expect, vi, beforeEach, MockedFunction } from "vitest" +import * as vscode from "vscode" +import fs from "fs/promises" +import { ProxyAgent } from "proxy-agent" +import { spawn } from "child_process" +import { needToken, createHttpAgent, startWorkspaceIfStoppedOrFailed } from "./api" +import * as proxyModule from "./proxy" +import * as headersModule from "./headers" +import { Api } from "coder/site/src/api/api" +import { Workspace } from "coder/site/src/api/typesGenerated" + +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + }, + EventEmitter: vi.fn().mockImplementation(() => ({ + fire: vi.fn(), + })), +})) + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn(), + }, +})) + +vi.mock("proxy-agent", () => ({ + ProxyAgent: vi.fn(), +})) + +vi.mock("./proxy", () => ({ + getProxyForUrl: vi.fn(), +})) + +vi.mock("./headers", () => ({ + getHeaderArgs: vi.fn().mockReturnValue([]), +})) + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})) + +describe("needToken", () => { + let mockGet: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockGet = vi.fn() + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: mockGet, + } as any) + }) + + it("should return true when no TLS files are configured", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return "" + if (key === "coder.tlsKeyFile") return "" + return undefined + }) + + expect(needToken()).toBe(true) + }) + + it("should return true when TLS config values are null", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return null + if (key === "coder.tlsKeyFile") return null + return undefined + }) + + expect(needToken()).toBe(true) + }) + + it("should return true when TLS config values are undefined", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return undefined + if (key === "coder.tlsKeyFile") return undefined + return undefined + }) + + expect(needToken()).toBe(true) + }) + + it("should return true when TLS config values are whitespace only", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return " " + if (key === "coder.tlsKeyFile") return "\t\n" + return undefined + }) + + expect(needToken()).toBe(true) + }) + + it("should return false when only cert file is configured", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return "/path/to/cert.pem" + if (key === "coder.tlsKeyFile") return "" + return undefined + }) + + expect(needToken()).toBe(false) + }) + + it("should return false when only key file is configured", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return "" + if (key === "coder.tlsKeyFile") return "/path/to/key.pem" + return undefined + }) + + expect(needToken()).toBe(false) + }) + + it("should return false when both cert and key files are configured", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return "/path/to/cert.pem" + if (key === "coder.tlsKeyFile") return "/path/to/key.pem" + return undefined + }) + + expect(needToken()).toBe(false) + }) + + it("should handle paths with ${userHome} placeholder", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return "${userHome}/.coder/cert.pem" + if (key === "coder.tlsKeyFile") return "" + return undefined + }) + + expect(needToken()).toBe(false) + }) + + it("should handle mixed empty and configured values", () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.tlsCertFile") return " " + if (key === "coder.tlsKeyFile") return "/valid/path/key.pem" + return undefined + }) + + expect(needToken()).toBe(false) + }) +}) + +describe("createHttpAgent", () => { + let mockGet: ReturnType + let mockProxyAgentConstructor: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockGet = vi.fn() + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: mockGet, + } as any) + + mockProxyAgentConstructor = vi.mocked(ProxyAgent) + mockProxyAgentConstructor.mockImplementation((options) => { + return { options } as any + }) + }) + + it("should create agent with no TLS configuration", async () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.insecure") return false + if (key === "coder.tlsCertFile") return "" + if (key === "coder.tlsKeyFile") return "" + if (key === "coder.tlsCaFile") return "" + if (key === "coder.tlsAltHost") return "" + return undefined + }) + + const agent = await createHttpAgent() + + expect(mockProxyAgentConstructor).toHaveBeenCalledWith({ + getProxyForUrl: expect.any(Function), + cert: undefined, + key: undefined, + ca: undefined, + servername: undefined, + rejectUnauthorized: true, + }) + expect(vi.mocked(fs.readFile)).not.toHaveBeenCalled() + }) + + it("should create agent with insecure mode enabled", async () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.insecure") return true + if (key === "coder.tlsCertFile") return "" + if (key === "coder.tlsKeyFile") return "" + if (key === "coder.tlsCaFile") return "" + if (key === "coder.tlsAltHost") return "" + return undefined + }) + + const agent = await createHttpAgent() + + expect(mockProxyAgentConstructor).toHaveBeenCalledWith({ + getProxyForUrl: expect.any(Function), + cert: undefined, + key: undefined, + ca: undefined, + servername: undefined, + rejectUnauthorized: false, + }) + }) + + it("should load certificate files when configured", async () => { + const certContent = Buffer.from("cert-content") + const keyContent = Buffer.from("key-content") + const caContent = Buffer.from("ca-content") + + mockGet.mockImplementation((key: string) => { + if (key === "coder.insecure") return false + if (key === "coder.tlsCertFile") return "/path/to/cert.pem" + if (key === "coder.tlsKeyFile") return "/path/to/key.pem" + if (key === "coder.tlsCaFile") return "/path/to/ca.pem" + if (key === "coder.tlsAltHost") return "" + return undefined + }) + + vi.mocked(fs.readFile).mockImplementation((path: string) => { + if (path === "/path/to/cert.pem") return Promise.resolve(certContent) + if (path === "/path/to/key.pem") return Promise.resolve(keyContent) + if (path === "/path/to/ca.pem") return Promise.resolve(caContent) + return Promise.reject(new Error("Unknown file")) + }) + + const agent = await createHttpAgent() + + expect(vi.mocked(fs.readFile)).toHaveBeenCalledWith("/path/to/cert.pem") + expect(vi.mocked(fs.readFile)).toHaveBeenCalledWith("/path/to/key.pem") + expect(vi.mocked(fs.readFile)).toHaveBeenCalledWith("/path/to/ca.pem") + + expect(mockProxyAgentConstructor).toHaveBeenCalledWith({ + getProxyForUrl: expect.any(Function), + cert: certContent, + key: keyContent, + ca: caContent, + servername: undefined, + rejectUnauthorized: true, + }) + }) + + it("should handle alternate hostname configuration", async () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.insecure") return false + if (key === "coder.tlsCertFile") return "" + if (key === "coder.tlsKeyFile") return "" + if (key === "coder.tlsCaFile") return "" + if (key === "coder.tlsAltHost") return "alternative.hostname.com" + return undefined + }) + + const agent = await createHttpAgent() + + expect(mockProxyAgentConstructor).toHaveBeenCalledWith({ + getProxyForUrl: expect.any(Function), + cert: undefined, + key: undefined, + ca: undefined, + servername: "alternative.hostname.com", + rejectUnauthorized: true, + }) + }) + + it("should handle partial TLS configuration", async () => { + const certContent = Buffer.from("cert-content") + + mockGet.mockImplementation((key: string) => { + if (key === "coder.insecure") return false + if (key === "coder.tlsCertFile") return "/path/to/cert.pem" + if (key === "coder.tlsKeyFile") return "" + if (key === "coder.tlsCaFile") return "" + if (key === "coder.tlsAltHost") return "" + return undefined + }) + + vi.mocked(fs.readFile).mockResolvedValue(certContent) + + const agent = await createHttpAgent() + + expect(vi.mocked(fs.readFile)).toHaveBeenCalledTimes(1) + expect(vi.mocked(fs.readFile)).toHaveBeenCalledWith("/path/to/cert.pem") + + expect(mockProxyAgentConstructor).toHaveBeenCalledWith({ + getProxyForUrl: expect.any(Function), + cert: certContent, + key: undefined, + ca: undefined, + servername: undefined, + rejectUnauthorized: true, + }) + }) + + it("should pass proxy configuration to getProxyForUrl", async () => { + mockGet.mockImplementation((key: string) => { + if (key === "coder.insecure") return false + if (key === "coder.tlsCertFile") return "" + if (key === "coder.tlsKeyFile") return "" + if (key === "coder.tlsCaFile") return "" + if (key === "coder.tlsAltHost") return "" + if (key === "http.proxy") return "http://proxy.example.com:8080" + if (key === "coder.proxyBypass") return "localhost,127.0.0.1" + return undefined + }) + + vi.mocked(proxyModule.getProxyForUrl).mockReturnValue("http://proxy.example.com:8080") + + const agent = await createHttpAgent() + const options = (agent as any).options + + // Test the getProxyForUrl function + const proxyUrl = options.getProxyForUrl("https://example.com") + + expect(vi.mocked(proxyModule.getProxyForUrl)).toHaveBeenCalledWith( + "https://example.com", + "http://proxy.example.com:8080", + "localhost,127.0.0.1" + ) + expect(proxyUrl).toBe("http://proxy.example.com:8080") + }) + + it("should handle paths with ${userHome} in TLS files", async () => { + const certContent = Buffer.from("cert-content") + + mockGet.mockImplementation((key: string) => { + if (key === "coder.insecure") return false + if (key === "coder.tlsCertFile") return "${userHome}/.coder/cert.pem" + if (key === "coder.tlsKeyFile") return "" + if (key === "coder.tlsCaFile") return "" + if (key === "coder.tlsAltHost") return "" + return undefined + }) + + vi.mocked(fs.readFile).mockResolvedValue(certContent) + + const agent = await createHttpAgent() + + // The actual path will be expanded by expandPath + expect(vi.mocked(fs.readFile)).toHaveBeenCalled() + const calledPath = vi.mocked(fs.readFile).mock.calls[0][0] + expect(calledPath).toMatch(/\/.*\/.coder\/cert.pem/) + expect(calledPath).not.toContain("${userHome}") + }) +}) + +describe("startWorkspaceIfStoppedOrFailed", () => { + let mockRestClient: Partial + let mockWorkspace: Workspace + let mockWriteEmitter: vscode.EventEmitter + let mockSpawn: MockedFunction + let mockProcess: any + + beforeEach(() => { + vi.clearAllMocks() + + mockWorkspace = { + id: "workspace-123", + owner_name: "testuser", + name: "testworkspace", + latest_build: { + status: "stopped", + }, + } as Workspace + + mockRestClient = { + getWorkspace: vi.fn(), + } + + mockWriteEmitter = new (vi.mocked(vscode.EventEmitter))() + + mockProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + } + + mockSpawn = vi.mocked(spawn) + mockSpawn.mockReturnValue(mockProcess as any) + }) + + it("should return workspace immediately if already running", async () => { + const runningWorkspace = { + ...mockWorkspace, + latest_build: { status: "running" }, + } as Workspace + + vi.mocked(mockRestClient.getWorkspace).mockResolvedValue(runningWorkspace) + + const result = await startWorkspaceIfStoppedOrFailed( + mockRestClient as Api, + "/config/dir", + "/bin/coder", + mockWorkspace, + mockWriteEmitter, + ) + + expect(result).toBe(runningWorkspace) + expect(mockRestClient.getWorkspace).toHaveBeenCalledWith("workspace-123") + expect(mockSpawn).not.toHaveBeenCalled() + }) + + it("should start workspace when stopped", async () => { + const stoppedWorkspace = { + ...mockWorkspace, + latest_build: { status: "stopped" }, + } as Workspace + + const startedWorkspace = { + ...mockWorkspace, + latest_build: { status: "running" }, + } as Workspace + + vi.mocked(mockRestClient.getWorkspace) + .mockResolvedValueOnce(stoppedWorkspace) + .mockResolvedValueOnce(startedWorkspace) + + vi.mocked(headersModule.getHeaderArgs).mockReturnValue(["--header", "Custom: Value"]) + + // Simulate successful process execution + mockProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === "close") { + setTimeout(() => callback(0), 10) + } + }) + + const result = await startWorkspaceIfStoppedOrFailed( + mockRestClient as Api, + "/config/dir", + "/bin/coder", + mockWorkspace, + mockWriteEmitter, + ) + + expect(mockSpawn).toHaveBeenCalledWith("/bin/coder", [ + "--global-config", + "/config/dir", + "--header", + "Custom: Value", + "start", + "--yes", + "testuser/testworkspace", + ]) + + expect(result).toBe(startedWorkspace) + expect(mockRestClient.getWorkspace).toHaveBeenCalledTimes(2) + }) + + it("should start workspace when failed", async () => { + const failedWorkspace = { + ...mockWorkspace, + latest_build: { status: "failed" }, + } as Workspace + + const startedWorkspace = { + ...mockWorkspace, + latest_build: { status: "running" }, + } as Workspace + + vi.mocked(mockRestClient.getWorkspace) + .mockResolvedValueOnce(failedWorkspace) + .mockResolvedValueOnce(startedWorkspace) + + mockProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === "close") { + setTimeout(() => callback(0), 10) + } + }) + + const result = await startWorkspaceIfStoppedOrFailed( + mockRestClient as Api, + "/config/dir", + "/bin/coder", + mockWorkspace, + mockWriteEmitter, + ) + + expect(mockSpawn).toHaveBeenCalled() + expect(result).toBe(startedWorkspace) + }) + + it("should handle stdout data and fire events", async () => { + const stoppedWorkspace = { + ...mockWorkspace, + latest_build: { status: "stopped" }, + } as Workspace + + vi.mocked(mockRestClient.getWorkspace).mockResolvedValueOnce(stoppedWorkspace) + + let stdoutCallback: Function + mockProcess.stdout.on.mockImplementation((event: string, callback: Function) => { + if (event === "data") { + stdoutCallback = callback + } + }) + + mockProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === "close") { + setTimeout(() => { + // Simulate stdout data before close + stdoutCallback(Buffer.from("Starting workspace...\nWorkspace started!\n")) + callback(0) + }, 10) + } + }) + + await startWorkspaceIfStoppedOrFailed( + mockRestClient as Api, + "/config/dir", + "/bin/coder", + mockWorkspace, + mockWriteEmitter, + ) + + expect(mockWriteEmitter.fire).toHaveBeenCalledWith("Starting workspace...\r\n") + expect(mockWriteEmitter.fire).toHaveBeenCalledWith("Workspace started!\r\n") + }) + + it("should handle stderr data and capture for error message", async () => { + const stoppedWorkspace = { + ...mockWorkspace, + latest_build: { status: "stopped" }, + } as Workspace + + vi.mocked(mockRestClient.getWorkspace).mockResolvedValueOnce(stoppedWorkspace) + + let stderrCallback: Function + mockProcess.stderr.on.mockImplementation((event: string, callback: Function) => { + if (event === "data") { + stderrCallback = callback + } + }) + + mockProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === "close") { + setTimeout(() => { + // Simulate stderr data before close + stderrCallback(Buffer.from("Error: Failed to start\nPermission denied\n")) + callback(1) // Exit with error + }, 10) + } + }) + + await expect( + startWorkspaceIfStoppedOrFailed( + mockRestClient as Api, + "/config/dir", + "/bin/coder", + mockWorkspace, + mockWriteEmitter, + ) + ).rejects.toThrow('exited with code 1: Error: Failed to start\nPermission denied') + + expect(mockWriteEmitter.fire).toHaveBeenCalledWith("Error: Failed to start\r\n") + expect(mockWriteEmitter.fire).toHaveBeenCalledWith("Permission denied\r\n") + }) + + it("should handle process failure without stderr", async () => { + const stoppedWorkspace = { + ...mockWorkspace, + latest_build: { status: "stopped" }, + } as Workspace + + vi.mocked(mockRestClient.getWorkspace).mockResolvedValueOnce(stoppedWorkspace) + + mockProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === "close") { + setTimeout(() => callback(127), 10) // Command not found + } + }) + + await expect( + startWorkspaceIfStoppedOrFailed( + mockRestClient as Api, + "/config/dir", + "/bin/coder", + mockWorkspace, + mockWriteEmitter, + ) + ).rejects.toThrow('exited with code 127') + }) + + it("should handle empty lines in stdout/stderr", async () => { + const stoppedWorkspace = { + ...mockWorkspace, + latest_build: { status: "stopped" }, + } as Workspace + + vi.mocked(mockRestClient.getWorkspace).mockResolvedValueOnce(stoppedWorkspace) + + let stdoutCallback: Function + mockProcess.stdout.on.mockImplementation((event: string, callback: Function) => { + if (event === "data") { + stdoutCallback = callback + } + }) + + mockProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === "close") { + setTimeout(() => { + // Simulate data with empty lines + stdoutCallback(Buffer.from("Line 1\n\nLine 2\n\n\n")) + callback(0) + }, 10) + } + }) + + await startWorkspaceIfStoppedOrFailed( + mockRestClient as Api, + "/config/dir", + "/bin/coder", + mockWorkspace, + mockWriteEmitter, + ) + + // Empty lines should not fire events + expect(mockWriteEmitter.fire).toHaveBeenCalledTimes(2) + expect(mockWriteEmitter.fire).toHaveBeenCalledWith("Line 1\r\n") + expect(mockWriteEmitter.fire).toHaveBeenCalledWith("Line 2\r\n") + }) +}) \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index db58c478..ad6a95c3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -19,6 +19,21 @@ import { expandPath } from "./util"; export const coderSessionTokenHeader = "Coder-Session-Token"; +/** + * Get a string configuration value, with consistent handling of null/undefined. + */ +function getConfigString(cfg: vscode.WorkspaceConfiguration, key: string): string { + return String(cfg.get(key) ?? "").trim(); +} + +/** + * Get a configuration path value, with expansion and consistent handling. + */ +function getConfigPath(cfg: vscode.WorkspaceConfiguration, key: string): string { + const value = getConfigString(cfg, key); + return value ? expandPath(value) : ""; +} + /** * Return whether the API will need a token for authorization. * If mTLS is in use (as specified by the cert or key files being set) then @@ -26,10 +41,8 @@ export const coderSessionTokenHeader = "Coder-Session-Token"; */ export function needToken(): boolean { const cfg = vscode.workspace.getConfiguration(); - const certFile = expandPath( - String(cfg.get("coder.tlsCertFile") ?? "").trim(), - ); - const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()); + const certFile = getConfigPath(cfg, "coder.tlsCertFile"); + const keyFile = getConfigPath(cfg, "coder.tlsKeyFile"); return !certFile && !keyFile; } @@ -39,12 +52,10 @@ export function needToken(): boolean { export async function createHttpAgent(): Promise { const cfg = vscode.workspace.getConfiguration(); const insecure = Boolean(cfg.get("coder.insecure")); - const certFile = expandPath( - String(cfg.get("coder.tlsCertFile") ?? "").trim(), - ); - const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()); - const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()); - const altHost = expandPath(String(cfg.get("coder.tlsAltHost") ?? "").trim()); + const certFile = getConfigPath(cfg, "coder.tlsCertFile"); + const keyFile = getConfigPath(cfg, "coder.tlsKeyFile"); + const caFile = getConfigPath(cfg, "coder.tlsCaFile"); + const altHost = getConfigString(cfg, "coder.tlsAltHost"); return new ProxyAgent({ // Called each time a request is made.