From 740b196fcff0f78182c8cb00b3453483a3f77731 Mon Sep 17 00:00:00 2001 From: Yazan Amer Date: Fri, 27 Mar 2026 16:41:18 +0300 Subject: [PATCH 1/3] feat: #750 --- package.json | 9 +++++++++ src/commands.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++ src/core/cliUtils.ts | 30 ++++++++++++++++++++++++++++ src/extension.ts | 4 ++++ 4 files changed, 90 insertions(+) diff --git a/package.json b/package.json index a78b2b5b..d2cc9c3d 100644 --- a/package.json +++ b/package.json @@ -313,6 +313,11 @@ "category": "Coder", "icon": "$(refresh)" }, + { + "command": "coder.speedTest", + "title": "Run Speed Test", + "category": "Coder" + }, { "command": "coder.viewLogs", "title": "Coder: View Logs", @@ -371,6 +376,10 @@ "command": "coder.createWorkspace", "when": "coder.authenticated" }, + { + "command": "coder.speedTest", + "when": "coder.workspace.connected" + }, { "command": "coder.navigateToWorkspace", "when": "coder.workspace.connected" diff --git a/src/commands.ts b/src/commands.ts index 3357f456..fe7568bd 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -146,6 +146,53 @@ export class Commands { this.logger.debug("Login complete to deployment:", url); } + /** + * Run a speed test against the currently connected workspace and display the + * results in a new editor document. + */ + public async speedTest(): Promise { + if (!this.workspace) { + vscode.window.showInformationMessage("No workspace connected."); + return; + } + + await withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Running speed test...", + }, + async () => { + const baseUrl = this.requireExtensionBaseUrl(); + const safeHost = toSafeHost(baseUrl); + const binary = await this.cliManager.fetchBinary(this.extensionClient); + const version = semver.parse(await cliUtils.version(binary)); + const featureSet = featureSetForVersion(version); + const configDir = this.pathResolver.getGlobalConfigDir(safeHost); + const configs = vscode.workspace.getConfiguration(); + const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir); + const workspaceName = createWorkspaceIdentifier(this.workspace!); + + try { + const stdout = await cliUtils.speedtest( + binary, + auth, + workspaceName, + ); + const doc = await vscode.workspace.openTextDocument({ + content: stdout, + language: "json", + }); + await vscode.window.showTextDocument(doc); + } catch (error) { + this.logger.error("Speed test failed", error); + vscode.window.showErrorMessage( + `Speed test failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + ); + } + /** * View the logs for the currently connected workspace. */ diff --git a/src/core/cliUtils.ts b/src/core/cliUtils.ts index 4d2f7c55..19f229a7 100644 --- a/src/core/cliUtils.ts +++ b/src/core/cliUtils.ts @@ -6,6 +6,8 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; +import type { CliAuth } from "../cliConfig"; + /** * Custom error thrown when a binary file is locked (typically on Windows). */ @@ -72,6 +74,34 @@ export async function version(binPath: string): Promise { return json.version; } +/** + * Run a speed test against the specified workspace and return the JSON output. + * Throw if unable to execute the binary or parse the output. + */ +export async function speedtest( + binPath: string, + auth: CliAuth, + workspaceName: string, +): Promise { + const result = await promisify(execFile)(binPath, [ + ...authArgs(auth), + "speedtest", + workspaceName, + "--output", + "json", + ]); + return result.stdout; +} + +/** + * Build CLI auth flags for execFile (no shell escaping). + */ +function authArgs(auth: CliAuth): string[] { + return auth.mode === "url" + ? ["--url", auth.url] + : ["--global-config", auth.configDir]; +} + export interface RemovalResult { fileName: string; error: unknown; diff --git a/src/extension.ts b/src/extension.ts index df2ecd6a..71d8dfc2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -281,6 +281,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { void myWorkspacesProvider.fetchAndRefresh(); void allWorkspacesProvider.fetchAndRefresh(); }), + vscode.commands.registerCommand( + "coder.speedTest", + commands.speedTest.bind(commands), + ), vscode.commands.registerCommand( "coder.viewLogs", commands.viewLogs.bind(commands), From d167396491b16063464f3553cd112498dcab7fc5 Mon Sep 17 00:00:00 2001 From: Yazan Amer Date: Fri, 27 Mar 2026 16:55:31 +0300 Subject: [PATCH 2/3] feat: #750 --- test/fixtures/scripts/echo-args.bash | 6 +++ test/unit/core/cliUtils.test.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 test/fixtures/scripts/echo-args.bash diff --git a/test/fixtures/scripts/echo-args.bash b/test/fixtures/scripts/echo-args.bash new file mode 100644 index 00000000..5875d319 --- /dev/null +++ b/test/fixtures/scripts/echo-args.bash @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +# Prints each argument on its own line, so tests can verify exact args. +for arg in "$@"; do + echo "$arg" +done diff --git a/test/unit/core/cliUtils.test.ts b/test/unit/core/cliUtils.test.ts index dd1c56f0..6bb8038d 100644 --- a/test/unit/core/cliUtils.test.ts +++ b/test/unit/core/cliUtils.test.ts @@ -142,6 +142,63 @@ describe("CliUtils", () => { ]); }); + describe("speedtest", () => { + const echoArgsBin = path.join(tmp, "echo-args"); + + beforeAll(async () => { + const tmpl = await fs.readFile( + getFixturePath("scripts", "echo-args.bash"), + "utf8", + ); + await fs.writeFile(echoArgsBin, tmpl); + await fs.chmod(echoArgsBin, "755"); + }); + + it("passes global-config auth flags", async () => { + const result = await cliUtils.speedtest( + echoArgsBin, + { mode: "global-config", configDir: "/tmp/test-config" }, + "owner/workspace", + ); + const args = result.trim().split("\n"); + expect(args).toEqual([ + "--global-config", + "/tmp/test-config", + "speedtest", + "owner/workspace", + "--output", + "json", + ]); + }); + + it("passes url auth flags", async () => { + const result = await cliUtils.speedtest( + echoArgsBin, + { mode: "url", url: "http://localhost:3000" }, + "owner/workspace", + ); + const args = result.trim().split("\n"); + expect(args).toEqual([ + "--url", + "http://localhost:3000", + "speedtest", + "owner/workspace", + "--output", + "json", + ]); + }); + + it("throws when binary does not exist", async () => { + await expect( + cliUtils.speedtest( + "/nonexistent/binary", + { mode: "global-config", configDir: "/tmp" }, + "owner/workspace", + ), + ).rejects.toThrow("ENOENT"); + }); + }); + it("ETag", async () => { const binPath = path.join(tmp, "hash"); From 7671617c45e1401e8bac5ee8983b30d043aa18b9 Mon Sep 17 00:00:00 2001 From: Yazan Amer Date: Wed, 1 Apr 2026 12:46:56 +0300 Subject: [PATCH 3/3] feat: #750 --- eslint.config.mjs | 1 + src/cliConfig.ts | 28 ++++++-- src/commands.ts | 99 ++++++++++++++++++++-------- src/core/cliUtils.ts | 32 ++++----- test/fixtures/scripts/echo-args.bash | 6 -- test/fixtures/scripts/echo-args.js | 3 + test/unit/cliConfig.test.ts | 38 +++++++++++ test/unit/core/cliUtils.test.ts | 48 ++++++++++---- 8 files changed, 184 insertions(+), 71 deletions(-) delete mode 100644 test/fixtures/scripts/echo-args.bash create mode 100644 test/fixtures/scripts/echo-args.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 0c01a374..fd883a13 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,7 @@ export default defineConfig( "**/vite.config*.ts", "**/createWebviewConfig.ts", ".vscode-test/**", + "test/fixtures/scripts/**", ]), // Base ESLint recommended rules (for JS/TS/TSX files only) diff --git a/src/cliConfig.ts b/src/cliConfig.ts index be1326c1..0c7d7bca 100644 --- a/src/cliConfig.ts +++ b/src/cliConfig.ts @@ -20,17 +20,37 @@ export function getGlobalFlagsRaw( } /** - * Returns global configuration flags for Coder CLI commands. - * Includes either `--global-config` or `--url` depending on the auth mode. + * Returns global configuration flags for Coder CLI commands with auth values + * escaped for shell use (e.g., `terminal.sendText`, `spawn({ shell: true })`). */ export function getGlobalFlags( configs: Pick, auth: CliAuth, ): string[] { + return buildGlobalFlags(configs, auth, true); +} + +/** + * Returns global configuration flags for Coder CLI commands with raw auth + * values suitable for `execFile` (no shell escaping). + */ +export function getGlobalFlagsForExec( + configs: Pick, + auth: CliAuth, +): string[] { + return buildGlobalFlags(configs, auth, false); +} + +function buildGlobalFlags( + configs: Pick, + auth: CliAuth, + escapeAuth: boolean, +): string[] { + const esc = escapeAuth ? escapeCommandArg : (s: string) => s; const authFlags = auth.mode === "url" - ? ["--url", escapeCommandArg(auth.url)] - : ["--global-config", escapeCommandArg(auth.configDir)]; + ? ["--url", esc(auth.url)] + : ["--global-config", esc(auth.configDir)]; const raw = getGlobalFlagsRaw(configs); const filtered = stripManagedFlags(raw); diff --git a/src/commands.ts b/src/commands.ts index fe7568bd..1c427bdc 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,13 +3,18 @@ import { type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; import * as fs from "node:fs/promises"; +import * as os from "node:os"; import * as path from "node:path"; import * as semver from "semver"; import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { type CoderApi } from "./api/coderApi"; -import { getGlobalFlags, resolveCliAuth } from "./cliConfig"; +import { + getGlobalFlags, + getGlobalFlagsForExec, + resolveCliAuth, +} from "./cliConfig"; import { type CliManager } from "./core/cliManager"; import * as cliUtils from "./core/cliUtils"; import { type ServiceContainer } from "./core/container"; @@ -22,7 +27,7 @@ import { toError } from "./error/errorUtils"; import { featureSetForVersion } from "./featureSet"; import { type Logger } from "./logging/logger"; import { type LoginCoordinator } from "./login/loginCoordinator"; -import { withProgress } from "./progress"; +import { withCancellableProgress, withProgress } from "./progress"; import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { RECOMMENDED_SSH_SETTINGS, @@ -147,21 +152,32 @@ export class Commands { } /** - * Run a speed test against the currently connected workspace and display the - * results in a new editor document. + * Run a speed test against the currently connected workspace and save the + * results to a file chosen by the user. */ public async speedTest(): Promise { - if (!this.workspace) { + const workspace = this.workspace; + if (!workspace) { vscode.window.showInformationMessage("No workspace connected."); return; } - await withProgress( - { - location: vscode.ProgressLocation.Notification, - title: "Running speed test...", + const duration = await vscode.window.showInputBox({ + title: "Speed Test Duration", + prompt: "Duration for the speed test (e.g., 5s, 10s, 1m)", + value: "5s", + validateInput: (v) => { + return /^\d+[sm]$/.test(v.trim()) + ? null + : "Enter a duration like 5s, 10s, or 1m"; }, - async () => { + }); + if (duration === undefined) { + return; + } + + const result = await withCancellableProgress( + async ({ signal }) => { const baseUrl = this.requireExtensionBaseUrl(); const safeHost = toSafeHost(baseUrl); const binary = await this.cliManager.fetchBinary(this.extensionClient); @@ -170,27 +186,54 @@ export class Commands { const configDir = this.pathResolver.getGlobalConfigDir(safeHost); const configs = vscode.workspace.getConfiguration(); const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir); - const workspaceName = createWorkspaceIdentifier(this.workspace!); + const globalFlags = getGlobalFlagsForExec(configs, auth); + const workspaceName = createWorkspaceIdentifier(workspace); - try { - const stdout = await cliUtils.speedtest( - binary, - auth, - workspaceName, - ); - const doc = await vscode.workspace.openTextDocument({ - content: stdout, - language: "json", - }); - await vscode.window.showTextDocument(doc); - } catch (error) { - this.logger.error("Speed test failed", error); - vscode.window.showErrorMessage( - `Speed test failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } + return cliUtils.speedtest(binary, globalFlags, workspaceName, { + signal, + duration: duration.trim(), + }); + }, + { + location: vscode.ProgressLocation.Notification, + title: `Running speed test (${duration.trim()})...`, + cancellable: true, }, ); + + if (!result.ok) { + if (!result.cancelled) { + this.logger.error("Speed test failed", result.error); + vscode.window.showErrorMessage( + `Speed test failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`, + ); + } + return; + } + + const defaultName = `speedtest-${workspace.name}-${new Date().toISOString().slice(0, 10)}.json`; + const defaultUri = vscode.Uri.joinPath( + vscode.workspace.workspaceFolders?.[0]?.uri ?? + vscode.Uri.file(os.homedir()), + defaultName, + ); + const uri = await vscode.window.showSaveDialog({ + defaultUri, + filters: { JSON: ["json"] }, + }); + if (!uri) { + return; + } + + await vscode.workspace.fs.writeFile(uri, Buffer.from(result.value)); + const action = await vscode.window.showInformationMessage( + "Speed test results saved.", + "Open File", + ); + if (action === "Open File") { + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + } } /** diff --git a/src/core/cliUtils.ts b/src/core/cliUtils.ts index 19f229a7..38fcc522 100644 --- a/src/core/cliUtils.ts +++ b/src/core/cliUtils.ts @@ -6,8 +6,6 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import type { CliAuth } from "../cliConfig"; - /** * Custom error thrown when a binary file is locked (typically on Windows). */ @@ -75,33 +73,25 @@ export async function version(binPath: string): Promise { } /** - * Run a speed test against the specified workspace and return the JSON output. - * Throw if unable to execute the binary or parse the output. + * Run a speed test against the specified workspace and return the raw output. + * Throw if unable to execute the binary. */ export async function speedtest( binPath: string, - auth: CliAuth, + globalFlags: string[], workspaceName: string, + options?: { signal?: AbortSignal; duration?: string }, ): Promise { - const result = await promisify(execFile)(binPath, [ - ...authArgs(auth), - "speedtest", - workspaceName, - "--output", - "json", - ]); + const args = [...globalFlags, "speedtest", workspaceName, "--output", "json"]; + if (options?.duration) { + args.push("-t", options.duration); + } + const result = await promisify(execFile)(binPath, args, { + signal: options?.signal, + }); return result.stdout; } -/** - * Build CLI auth flags for execFile (no shell escaping). - */ -function authArgs(auth: CliAuth): string[] { - return auth.mode === "url" - ? ["--url", auth.url] - : ["--global-config", auth.configDir]; -} - export interface RemovalResult { fileName: string; error: unknown; diff --git a/test/fixtures/scripts/echo-args.bash b/test/fixtures/scripts/echo-args.bash deleted file mode 100644 index 5875d319..00000000 --- a/test/fixtures/scripts/echo-args.bash +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -# Prints each argument on its own line, so tests can verify exact args. -for arg in "$@"; do - echo "$arg" -done diff --git a/test/fixtures/scripts/echo-args.js b/test/fixtures/scripts/echo-args.js new file mode 100644 index 00000000..328b7bda --- /dev/null +++ b/test/fixtures/scripts/echo-args.js @@ -0,0 +1,3 @@ +/* eslint-env node */ +// Prints each argument on its own line, so tests can verify exact args. +process.argv.slice(2).forEach((arg) => console.log(arg)); diff --git a/test/unit/cliConfig.test.ts b/test/unit/cliConfig.test.ts index 14a02697..9b342040 100644 --- a/test/unit/cliConfig.test.ts +++ b/test/unit/cliConfig.test.ts @@ -5,6 +5,7 @@ import { it, expect, describe, vi } from "vitest"; import { type CliAuth, getGlobalFlags, + getGlobalFlagsForExec, getGlobalFlagsRaw, getSshFlags, isKeyringEnabled, @@ -172,6 +173,43 @@ describe("cliConfig", () => { ); }); + describe("getGlobalFlagsForExec", () => { + const urlAuth: CliAuth = { mode: "url", url: "https://dev.coder.com" }; + + it("should not escape auth flags", () => { + const config = new MockConfigurationProvider(); + expect(getGlobalFlagsForExec(config, globalConfigAuth)).toStrictEqual([ + "--global-config", + "/config/dir", + ]); + expect(getGlobalFlagsForExec(config, urlAuth)).toStrictEqual([ + "--url", + "https://dev.coder.com", + ]); + }); + + it("should still escape header-command flags", () => { + const config = new MockConfigurationProvider(); + config.set("coder.headerCommand", "echo test"); + expect(getGlobalFlagsForExec(config, globalConfigAuth)).toStrictEqual([ + "--global-config", + "/config/dir", + "--header-command", + quoteCommand("echo test"), + ]); + }); + + it("should include user global flags", () => { + const config = new MockConfigurationProvider(); + config.set("coder.globalFlags", ["--verbose"]); + expect(getGlobalFlagsForExec(config, globalConfigAuth)).toStrictEqual([ + "--verbose", + "--global-config", + "/config/dir", + ]); + }); + }); + describe("getGlobalFlagsRaw", () => { it("returns empty array when no global flags configured", () => { const config = new MockConfigurationProvider(); diff --git a/test/unit/core/cliUtils.test.ts b/test/unit/core/cliUtils.test.ts index 6bb8038d..3479e62a 100644 --- a/test/unit/core/cliUtils.test.ts +++ b/test/unit/core/cliUtils.test.ts @@ -143,21 +143,25 @@ describe("CliUtils", () => { }); describe("speedtest", () => { - const echoArgsBin = path.join(tmp, "echo-args"); + const echoArgsBin = isWindows() + ? path.join(tmp, "echo-args.cmd") + : path.join(tmp, "echo-args"); beforeAll(async () => { - const tmpl = await fs.readFile( - getFixturePath("scripts", "echo-args.bash"), - "utf8", - ); - await fs.writeFile(echoArgsBin, tmpl); - await fs.chmod(echoArgsBin, "755"); + const scriptPath = getFixturePath("scripts", "echo-args.js"); + if (isWindows()) { + await fs.writeFile(echoArgsBin, `@node "${scriptPath}" %*\r\n`); + } else { + const content = await fs.readFile(scriptPath, "utf8"); + await fs.writeFile(echoArgsBin, `#!/usr/bin/env node\n${content}`); + await fs.chmod(echoArgsBin, "755"); + } }); - it("passes global-config auth flags", async () => { + it("passes global flags", async () => { const result = await cliUtils.speedtest( echoArgsBin, - { mode: "global-config", configDir: "/tmp/test-config" }, + ["--global-config", "/tmp/test-config"], "owner/workspace", ); const args = result.trim().split("\n"); @@ -171,11 +175,29 @@ describe("CliUtils", () => { ]); }); - it("passes url auth flags", async () => { + it("passes url flags", async () => { + const result = await cliUtils.speedtest( + echoArgsBin, + ["--url", "http://localhost:3000"], + "owner/workspace", + ); + const args = result.trim().split("\n"); + expect(args).toEqual([ + "--url", + "http://localhost:3000", + "speedtest", + "owner/workspace", + "--output", + "json", + ]); + }); + + it("passes duration flag", async () => { const result = await cliUtils.speedtest( echoArgsBin, - { mode: "url", url: "http://localhost:3000" }, + ["--url", "http://localhost:3000"], "owner/workspace", + { duration: "10s" }, ); const args = result.trim().split("\n"); expect(args).toEqual([ @@ -185,6 +207,8 @@ describe("CliUtils", () => { "owner/workspace", "--output", "json", + "-t", + "10s", ]); }); @@ -192,7 +216,7 @@ describe("CliUtils", () => { await expect( cliUtils.speedtest( "/nonexistent/binary", - { mode: "global-config", configDir: "/tmp" }, + ["--global-config", "/tmp"], "owner/workspace", ), ).rejects.toThrow("ENOENT");