diff --git a/package.json b/package.json index 03cf037b0f..e98f549b12 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,12 @@ "default": "", "scope": "application" }, + "coder.alternativeWebUrl": { + "markdownDescription": "Alternative URL for opening Coder pages in the browser (dashboard, workspace, and login pages). API, SSH, and CLI always use the connection URL; when this is empty, so does the browser. Useful when the API runs on a browser-restricted port (e.g. 7004) but the web UI is served on a standard port (e.g. 443).", + "type": "string", + "default": "", + "scope": "application" + }, "coder.autologin": { "markdownDescription": "Automatically log into the default URL when the extension is activated. coder.defaultUrl is preferred, otherwise the CODER_URL environment variable will be used. This setting has no effect if neither is set.", "type": "boolean", diff --git a/src/commands.ts b/src/commands.ts index 6dc95290d6..1a219148f7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -27,7 +27,7 @@ import { import { resolveCliAuth } from "./settings/cli"; import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; import { runExportTelemetryCommand } from "./telemetry/export/command"; -import { toRemoteAuthority, toSafeHost } from "./util"; +import { openInBrowser, toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; import { parseSpeedtestResult } from "./webviews/speedtest/types"; import { @@ -122,6 +122,17 @@ export class Commands { return url; } + /** + * Get the remote workspace deployment URL, throwing if not connected. + */ + private requireRemoteBaseUrl(): string { + const url = this.remoteWorkspaceClient?.getAxiosInstance().defaults.baseURL; + if (!url) { + throw new Error("No remote workspace connection"); + } + return url; + } + /** * Log into a deployment. If already authenticated, this is a no-op. * If no URL is provided, shows a menu of recent URLs plus defaults. @@ -587,9 +598,7 @@ export class Commands { * Must only be called if currently logged in. */ public async createWorkspace(): Promise { - const baseUrl = this.requireExtensionBaseUrl(); - const uri = baseUrl + "/templates"; - await vscode.commands.executeCommand("vscode.open", uri); + await openInBrowser(this.requireExtensionBaseUrl(), "/templates"); } /** @@ -602,15 +611,13 @@ export class Commands { */ public async navigateToWorkspace(item?: OpenableTreeItem) { if (item) { - const baseUrl = this.requireExtensionBaseUrl(); const workspaceId = createWorkspaceIdentifier(item.workspace); - const uri = baseUrl + `/@${workspaceId}`; - await vscode.commands.executeCommand("vscode.open", uri); + await openInBrowser(this.requireExtensionBaseUrl(), `/@${workspaceId}`); } else if (this.workspace && this.remoteWorkspaceClient) { - const baseUrl = - this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL; - const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}`; - await vscode.commands.executeCommand("vscode.open", uri); + await openInBrowser( + this.requireRemoteBaseUrl(), + `/@${createWorkspaceIdentifier(this.workspace)}`, + ); } else { vscode.window.showInformationMessage("No workspace found."); } @@ -626,15 +633,16 @@ export class Commands { */ public async navigateToWorkspaceSettings(item?: OpenableTreeItem) { if (item) { - const baseUrl = this.requireExtensionBaseUrl(); const workspaceId = createWorkspaceIdentifier(item.workspace); - const uri = baseUrl + `/@${workspaceId}/settings`; - await vscode.commands.executeCommand("vscode.open", uri); + await openInBrowser( + this.requireExtensionBaseUrl(), + `/@${workspaceId}/settings`, + ); } else if (this.workspace && this.remoteWorkspaceClient) { - const baseUrl = - this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL; - const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}/settings`; - await vscode.commands.executeCommand("vscode.open", uri); + await openInBrowser( + this.requireRemoteBaseUrl(), + `/@${createWorkspaceIdentifier(this.workspace)}/settings`, + ); } else { vscode.window.showInformationMessage("No workspace found."); } diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index e70da211a5..baac3f28d9 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -10,6 +10,7 @@ import { buildOAuthTokenData } from "../oauth/utils"; import { withOptionalProgress } from "../progress"; import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils"; import { isKeyringEnabled } from "../settings/cli"; +import { openInBrowser } from "../util"; import { vscodeProposed } from "../vscodeProposed"; import type { User } from "coder/site/src/api/typesGenerated"; @@ -398,7 +399,7 @@ export class LoginCoordinator implements vscode.Disposable { } // This prompt is for convenience; do not error if they close it since // they may already have a token or already have the page opened. - await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)); + await openInBrowser(url, "/cli-auth"); // For token auth, start with the existing token in the prompt or the last // used token. Once submitted, if there is a failure we will keep asking diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index 3d45bd61d9..809ccfaed5 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; +import { resolveUiUrl } from "../util"; import { AUTH_GRANT_TYPE, @@ -98,6 +99,7 @@ export class OAuthAuthorizer implements vscode.Disposable { reportProgress("waiting for authorization...", 30); const { code, verifier } = await this.startAuthorization( + deployment.url, metadata, registration, cancellationToken, @@ -187,6 +189,7 @@ export class OAuthAuthorizer implements vscode.Disposable { * Build authorization URL with all required OAuth 2.1 parameters. */ private buildAuthorizationUrl( + connectionUrl: string, metadata: OAuth2AuthorizationServerMetadata, clientId: string, state: string, @@ -215,7 +218,13 @@ export class OAuthAuthorizer implements vscode.Disposable { code_challenge_method: PKCE_CHALLENGE_METHOD, }); - const url = `${metadata.authorization_endpoint}?${params.toString()}`; + const endpoint = toBrowserAuthorizationUrl( + metadata.authorization_endpoint, + connectionUrl, + ); + for (const [key, value] of params) { + endpoint.searchParams.set(key, value); + } this.logger.debug("Built OAuth authorization URL:", { client_id: clientId, @@ -223,7 +232,7 @@ export class OAuthAuthorizer implements vscode.Disposable { scope: DEFAULT_OAUTH_SCOPES, }); - return url; + return endpoint.toString(); } /** @@ -232,6 +241,7 @@ export class OAuthAuthorizer implements vscode.Disposable { * Returns authorization code and PKCE verifier on success. */ private async startAuthorization( + connectionUrl: string, metadata: OAuth2AuthorizationServerMetadata, registration: OAuth2ClientRegistrationResponse, cancellationToken: vscode.CancellationToken, @@ -240,6 +250,7 @@ export class OAuthAuthorizer implements vscode.Disposable { const { verifier, challenge } = generatePKCE(); const authUrl = this.buildAuthorizationUrl( + connectionUrl, metadata, registration.client_id, state, @@ -359,3 +370,31 @@ export class OAuthAuthorizer implements vscode.Disposable { } } } + +/** + * Swap the server's authorization endpoint onto the browser-facing URL + * (`alternativeWebUrl`) when it lives under the connection URL, preserving any + * sub-path prefix. Endpoints on a different host are left untouched. + */ +function toBrowserAuthorizationUrl( + authorizationEndpoint: string, + connectionUrl: string, +): URL { + const endpoint = new URL(authorizationEndpoint); + const connectionBase = new URL(connectionUrl); + const browserBase = new URL(resolveUiUrl(connectionUrl)); + const connectionPrefix = connectionBase.pathname.replace(/\/$/, ""); + const browserPrefix = browserBase.pathname.replace(/\/$/, ""); + const underConnection = + endpoint.origin === connectionBase.origin && + (connectionPrefix === "" || + endpoint.pathname === connectionPrefix || + endpoint.pathname.startsWith(`${connectionPrefix}/`)); + if (underConnection) { + endpoint.protocol = browserBase.protocol; + endpoint.hostname = browserBase.hostname; + endpoint.port = browserBase.port; + endpoint.pathname = `${browserPrefix}${endpoint.pathname.slice(connectionPrefix.length)}`; + } + return endpoint; +} diff --git a/src/util.ts b/src/util.ts index 405d4f1769..375d6a29c2 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,6 @@ import os from "node:os"; import url from "node:url"; +import * as vscode from "vscode"; export interface AuthorityParts { agent: string | undefined; @@ -195,3 +196,29 @@ export function escapeShellArg(arg: string): string { } return `'${arg.replace(/'/g, "'\\''")}'`; } + +/** + * Return the URL for opening Coder pages in the browser. Uses the + * `coder.alternativeWebUrl` setting when configured, otherwise returns + * the connection URL unchanged. + */ +export function resolveUiUrl(connectionUrl: string): string { + const alt = vscode.workspace + .getConfiguration("coder") + .get("alternativeWebUrl") + ?.trim() + .replace(/\/+$/, ""); + return alt || connectionUrl; +} + +/** + * Open a path on the Coder deployment in the user's browser, applying + * `coder.alternativeWebUrl` when configured. + */ +export function openInBrowser( + connectionUrl: string, + path: string, +): Thenable { + const base = vscode.Uri.parse(resolveUiUrl(connectionUrl)); + return vscode.env.openExternal(vscode.Uri.joinPath(base, path)); +} diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts index 46614665f6..7aad4ef364 100644 --- a/src/webviews/tasks/tasksPanelProvider.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -25,6 +25,7 @@ import { streamBuildLogs, } from "../../api/workspace"; import { type Logger } from "../../logging/logger"; +import { openInBrowser } from "../../util"; import { vscodeProposed } from "../../vscodeProposed"; import { dispatchCommand, @@ -43,12 +44,12 @@ import type { WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; -/** Build URL to view task build logs in Coder dashboard */ -function getTaskBuildUrl(baseUrl: string, task: Task): string { +/** Build the dashboard path for a task's build logs. */ +function getTaskBuildPath(task: Task): string { if (task.workspace_name && task.workspace_build_number) { - return `${baseUrl}/@${task.owner_name}/${task.workspace_name}/builds/${task.workspace_build_number}`; + return `/@${task.owner_name}/${task.workspace_name}/builds/${task.workspace_build_number}`; } - return `${baseUrl}/tasks/${task.owner_name}/${task.id}`; + return `/tasks/${task.owner_name}/${task.id}`; } export class TasksPanelProvider @@ -308,21 +309,19 @@ export class TasksPanelProvider } private async handleViewInCoder(taskId: string): Promise { - const baseUrl = this.client.getHost(); - if (!baseUrl) return; + const connUrl = this.client.getHost(); + if (!connUrl) return; const task = await this.client.getTask("me", taskId); - vscode.env.openExternal( - vscode.Uri.parse(`${baseUrl}/tasks/${task.owner_name}/${task.id}`), - ); + await openInBrowser(connUrl, `/tasks/${task.owner_name}/${task.id}`); } private async handleViewLogs(taskId: string): Promise { - const baseUrl = this.client.getHost(); - if (!baseUrl) return; + const connUrl = this.client.getHost(); + if (!connUrl) return; const task = await this.client.getTask("me", taskId); - vscode.env.openExternal(vscode.Uri.parse(getTaskBuildUrl(baseUrl, task))); + await openInBrowser(connUrl, getTaskBuildPath(task)); } private async handleDownloadLogs(taskId: string): Promise { diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 42415bcb24..a111a5c42c 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -89,8 +89,14 @@ export class Uri { : `${this.scheme}:${this.path}`; } static joinPath(base: Uri, ...paths: string[]) { - const sep = base.path.endsWith("/") ? "" : "/"; - return new Uri(base.scheme, base.path + sep + paths.join("/")); + // Mirror vscode-uri: collapse slashes at the seams while preserving the + // leading "//" that separates the authority from the path. + const head = base.path.replace(/\/+$/, ""); + const tail = paths + .map((p) => p.replace(/^\/+|\/+$/g, "")) + .filter(Boolean) + .join("/"); + return new Uri(base.scheme, tail ? `${head}/${tail}` : head); } } diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 55560d84e9..976a8d4069 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -22,6 +22,8 @@ import { import type { CreateAxiosDefaults } from "axios"; +import type { Deployment } from "@/deployment/types"; + vi.mock("axios", async () => { const actual = await vi.importActual("axios"); const mockAdapter = vi.fn(); @@ -70,11 +72,12 @@ function createTestContext() { const startLogin = async (options?: { progress?: MockProgress; token?: MockCancellationToken; + deployment?: Deployment; }) => { const progress = options?.progress ?? new MockProgress(); const token = options?.token ?? new MockCancellationToken(); const loginPromise = authorizer.login( - createTestDeployment(), + options?.deployment ?? createTestDeployment(), progress, token, ); @@ -260,6 +263,123 @@ describe("OAuthAuthorizer", () => { "fetching user...", ]); }); + + it("rewrites the endpoint origin when alternativeWebUrl is set", async () => { + const { + configurationProvider, + setupOAuthRoutes, + startLogin, + completeLogin, + } = createTestContext(); + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com", + ); + setupOAuthRoutes( + createMockOAuthMetadata("https://coder.example.com:7004"), + ); + + const { loginPromise, authUrl, state } = await startLogin({ + deployment: { + url: "https://coder.example.com:7004", + safeHostname: "coder.example.com", + }, + }); + expect(authUrl.origin).toBe("https://coder.example.com"); + expect(authUrl.pathname).toBe("/oauth2/authorize"); + + await completeLogin(state); + await loginPromise; + }); + + it("preserves a path prefix on alternativeWebUrl", async () => { + const { + configurationProvider, + setupOAuthRoutes, + startLogin, + completeLogin, + } = createTestContext(); + configurationProvider.set( + "coder.alternativeWebUrl", + "https://proxy.example.com/coder", + ); + setupOAuthRoutes( + createMockOAuthMetadata("https://coder.example.com:7004"), + ); + + const { loginPromise, authUrl, state } = await startLogin({ + deployment: { + url: "https://coder.example.com:7004", + safeHostname: "coder.example.com", + }, + }); + expect(authUrl.origin).toBe("https://proxy.example.com"); + expect(authUrl.pathname).toBe("/coder/oauth2/authorize"); + + await completeLogin(state); + await loginPromise; + }); + + it("does not double the path prefix on sub-path deployments", async () => { + const { setupOAuthRoutes, startLogin, completeLogin } = + createTestContext(); + setupOAuthRoutes(createMockOAuthMetadata("https://example.com/coder")); + + const { loginPromise, authUrl, state } = await startLogin({ + deployment: { + url: "https://example.com/coder", + safeHostname: "example.com", + }, + }); + expect(authUrl.origin).toBe("https://example.com"); + expect(authUrl.pathname).toBe("/coder/oauth2/authorize"); + + await completeLogin(state); + await loginPromise; + }); + + it("swaps the sub-path prefix for the alternativeWebUrl prefix", async () => { + const { + configurationProvider, + setupOAuthRoutes, + startLogin, + completeLogin, + } = createTestContext(); + configurationProvider.set( + "coder.alternativeWebUrl", + "https://proxy.example.com/proxy", + ); + setupOAuthRoutes(createMockOAuthMetadata("https://example.com/coder")); + + const { loginPromise, authUrl, state } = await startLogin({ + deployment: { + url: "https://example.com/coder", + safeHostname: "example.com", + }, + }); + expect(authUrl.origin).toBe("https://proxy.example.com"); + expect(authUrl.pathname).toBe("/proxy/oauth2/authorize"); + + await completeLogin(state); + await loginPromise; + }); + + it("preserves query params already on the authorization endpoint", async () => { + const { setupOAuthRoutes, startLogin, completeLogin } = + createTestContext(); + setupOAuthRoutes( + createMockOAuthMetadata(TEST_URL, { + authorization_endpoint: `${TEST_URL}/oauth2/authorize?audience=workspace`, + }), + ); + + const { loginPromise, authUrl, state } = await startLogin(); + expect(authUrl.searchParams.get("audience")).toBe("workspace"); + expect(authUrl.searchParams.get("client_id")).toBeTruthy(); + + await completeLogin(state); + await loginPromise; + }); }); describe("callback handling", () => { diff --git a/test/unit/oauth/testUtils.ts b/test/unit/oauth/testUtils.ts index 714c581197..58e210fb54 100644 --- a/test/unit/oauth/testUtils.ts +++ b/test/unit/oauth/testUtils.ts @@ -123,7 +123,7 @@ export function createBaseTestContext() { vi.mocked(getHeaders).mockResolvedValue({}); // Constructor sets up vscode.workspace mock - const _configurationProvider = new MockConfigurationProvider(); + const configurationProvider = new MockConfigurationProvider(); const secretStorage = new InMemorySecretStorage(); const memento = new InMemoryMemento(); @@ -131,11 +131,14 @@ export function createBaseTestContext() { const secretsManager = new SecretsManager(secretStorage, memento, logger); const oauthCallback = new OAuthCallback(secretStorage, logger); - /** Sets up default OAuth routes - use explicit routes when asserting on values */ - const setupOAuthRoutes = () => { + /** Sets up OAuth routes, defaulting to metadata for TEST_URL. */ + const setupOAuthRoutes = ( + metadata: OAuth2AuthorizationServerMetadata = createMockOAuthMetadata( + TEST_URL, + ), + ) => { setupAxiosMockRoutes(mockAdapter, { - "/.well-known/oauth-authorization-server": - createMockOAuthMetadata(TEST_URL), + "/.well-known/oauth-authorization-server": metadata, "/oauth2/register": createMockClientRegistration(), "/oauth2/token": createMockTokenResponse(), "/api/v2/users/me": { username: "test-user" }, @@ -148,5 +151,6 @@ export function createBaseTestContext() { oauthCallback, logger, setupOAuthRoutes, + configurationProvider, }; } diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index 02212df16a..39daa59dc2 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -1,5 +1,6 @@ import os from "node:os"; import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import * as vscode from "vscode"; import { type AuthorityParts, @@ -8,10 +9,14 @@ import { escapeShellArg, expandPath, findPort, + openInBrowser, parseRemoteAuthority, + resolveUiUrl, toSafeHost, } from "@/util"; +import { MockConfigurationProvider } from "../mocks/testHelpers"; + describe("parseRemoteAuthority", () => { const remoteAuthority = (sshHost: string) => `vscode://ssh-remote+${sshHost}`; @@ -397,3 +402,93 @@ describe("findPort", () => { expect(findPort(log)).toBe(3333); }); }); + +describe("resolveUiUrl", () => { + let configurationProvider: MockConfigurationProvider; + + beforeEach(() => { + configurationProvider = new MockConfigurationProvider(); + }); + + it("returns the connection URL when no alternative is configured", () => { + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }); + + it.each([ + { name: "empty", value: "" }, + { name: "whitespace", value: " " }, + ])( + "returns the connection URL when the alternative is $name", + ({ value }) => { + configurationProvider.set("coder.alternativeWebUrl", value); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }, + ); + + it.each([ + { + name: "uses the alternative URL when configured", + value: "https://coder.example.com", + }, + { name: "strips trailing slashes", value: "https://coder.example.com/" }, + { + name: "strips multiple trailing slashes", + value: "https://coder.example.com///", + }, + { name: "trims whitespace", value: " https://coder.example.com " }, + ])("$name", ({ value }) => { + configurationProvider.set("coder.alternativeWebUrl", value); + expect(resolveUiUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); +}); + +describe("openInBrowser", () => { + let configurationProvider: MockConfigurationProvider; + + beforeEach(() => { + configurationProvider = new MockConfigurationProvider(); + vi.mocked(vscode.env.openExternal).mockClear(); + }); + + it("opens the connection URL joined with the path when no alt URL is set", () => { + openInBrowser("https://coder.example.com:7004", "/templates"); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com:7004/templates"), + ); + }); + + it("opens the alternative URL when configured", () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com", + ); + openInBrowser("https://coder.example.com:7004", "/templates"); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com/templates"), + ); + }); + + it("preserves a path prefix on the alternative URL", () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://proxy.example.com/coder", + ); + openInBrowser("https://coder.example.com:7004", "/templates"); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://proxy.example.com/coder/templates"), + ); + }); + + it("joins paths without a leading slash", () => { + openInBrowser("https://coder.example.com", "templates"); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com/templates"), + ); + }); +}); diff --git a/test/unit/webviews/tasks/tasksPanelProvider.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts index 99526d2af7..1e73477d18 100644 --- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -23,6 +23,7 @@ import { import { createAxiosError, createMockLogger, + MockConfigurationProvider, MockUserInteraction, } from "../../../mocks/testHelpers"; @@ -200,9 +201,13 @@ function createHarness(): Harness { } describe("TasksPanelProvider", () => { + let configurationProvider: MockConfigurationProvider; + beforeEach(() => { // Reset shared vscode mocks between tests vi.resetAllMocks(); + + configurationProvider = new MockConfigurationProvider(); }); describe("getTasks", () => { @@ -678,6 +683,46 @@ describe("TasksPanelProvider", () => { expect(vscode.env.openExternal).not.toHaveBeenCalled(); }); + + it("viewInCoder uses the alternative web URL when configured", async () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com:443", + ); + const h = createHarness(); + h.client.getTask.mockResolvedValue( + task({ id: "task-1", owner_name: "alice" }), + ); + + await h.command(TasksApi.viewInCoder, { taskId: "task-1" }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com:443/tasks/alice/task-1"), + ); + }); + + it("viewLogs uses the alternative web URL when configured", async () => { + configurationProvider.set( + "coder.alternativeWebUrl", + "https://coder.example.com:443", + ); + const h = createHarness(); + h.client.getTask.mockResolvedValue( + task({ + owner_name: "alice", + workspace_name: "my-ws", + workspace_build_number: 42, + }), + ); + + await h.command(TasksApi.viewLogs, { taskId: "task-1" }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse( + "https://coder.example.com:443/@alice/my-ws/builds/42", + ), + ); + }); }); describe("downloadLogs", () => {