Skip to content

Commit b199320

Browse files
feat: add Coder Speed Test command (#864)
Add speed test command that runs speedtest through the Coder CLI and displays the results as JSON for the user to view. Closes #750
1 parent c9e552d commit b199320

File tree

13 files changed

+283
-22
lines changed

13 files changed

+283
-22
lines changed

eslint.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default defineConfig(
1919
"**/vite.config*.ts",
2020
"**/createWebviewConfig.ts",
2121
".vscode-test/**",
22+
"test/fixtures/scripts/**",
2223
]),
2324

2425
// Base ESLint recommended rules (for JS/TS/TSX files only)
@@ -62,6 +63,7 @@ export default defineConfig(
6263
"error",
6364
{ considerDefaultExhaustiveForUnions: true },
6465
],
66+
"@typescript-eslint/no-non-null-assertion": "error",
6567
"@typescript-eslint/no-unused-vars": [
6668
"error",
6769
{ varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
@@ -136,6 +138,8 @@ export default defineConfig(
136138
"@typescript-eslint/unbound-method": "off",
137139
// Empty callbacks are common in test stubs
138140
"@typescript-eslint/no-empty-function": "off",
141+
// Test assertions often use non-null assertions for brevity
142+
"@typescript-eslint/no-non-null-assertion": "off",
139143
// Test mocks often have loose typing - relax unsafe rules
140144
"@typescript-eslint/no-unsafe-assignment": "off",
141145
"@typescript-eslint/no-unsafe-call": "off",

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,11 @@
319319
"category": "Coder",
320320
"icon": "$(refresh)"
321321
},
322+
{
323+
"command": "coder.speedTest",
324+
"title": "Run Speed Test",
325+
"category": "Coder"
326+
},
322327
{
323328
"command": "coder.viewLogs",
324329
"title": "Coder: View Logs",
@@ -383,6 +388,10 @@
383388
"command": "coder.createWorkspace",
384389
"when": "coder.authenticated"
385390
},
391+
{
392+
"command": "coder.speedTest",
393+
"when": "coder.workspace.connected"
394+
},
386395
{
387396
"command": "coder.navigateToWorkspace",
388397
"when": "coder.workspace.connected"

src/api/authInterceptor.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,12 @@ export class AuthInterceptor implements vscode.Disposable {
123123
return this.authRequiredPromise;
124124
}
125125

126+
if (!this.onAuthRequired) {
127+
throw new Error("No auth handler registered");
128+
}
129+
126130
this.logger.debug("Triggering re-authentication");
127-
this.authRequiredPromise = this.onAuthRequired!(hostname);
131+
this.authRequiredPromise = this.onAuthRequired(hostname);
128132

129133
try {
130134
return await this.authRequiredPromise;

src/api/workspace.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { spawn } from "node:child_process";
88
import * as vscode from "vscode";
99

1010
import { type FeatureSet } from "../featureSet";
11-
import { type CliAuth, getGlobalFlags } from "../settings/cli";
11+
import { getGlobalShellFlags, type CliAuth } from "../settings/cli";
1212
import { escapeCommandArg } from "../util";
1313
import { type UnidirectionalStream } from "../websocket/eventStreamConnection";
1414

@@ -65,7 +65,7 @@ export async function startWorkspaceIfStoppedOrFailed(
6565

6666
return new Promise((resolve, reject) => {
6767
const startArgs = [
68-
...getGlobalFlags(vscode.workspace.getConfiguration(), auth),
68+
...getGlobalShellFlags(vscode.workspace.getConfiguration(), auth),
6969
"start",
7070
"--yes",
7171
createWorkspaceIdentifier(workspace),

src/commands.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ import { toError } from "./error/errorUtils";
2121
import { featureSetForVersion } from "./featureSet";
2222
import { type Logger } from "./logging/logger";
2323
import { type LoginCoordinator } from "./login/loginCoordinator";
24-
import { withProgress } from "./progress";
24+
import { withCancellableProgress, withProgress } from "./progress";
2525
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
2626
import {
2727
RECOMMENDED_SSH_SETTINGS,
2828
applySettingOverrides,
2929
} from "./remote/sshOverrides";
30-
import { getGlobalFlags, resolveCliAuth } from "./settings/cli";
30+
import {
31+
getGlobalFlags,
32+
getGlobalShellFlags,
33+
resolveCliAuth,
34+
} from "./settings/cli";
3135
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
3236
import { vscodeProposed } from "./vscodeProposed";
3337
import {
@@ -162,6 +166,74 @@ export class Commands {
162166
this.logger.debug("Login complete to deployment:", url);
163167
}
164168

169+
/**
170+
* Run a speed test against the currently connected workspace and display the
171+
* results in a new editor document.
172+
*/
173+
public async speedTest(): Promise<void> {
174+
const workspace = this.workspace;
175+
const client = this.remoteWorkspaceClient;
176+
if (!workspace || !client) {
177+
vscode.window.showInformationMessage("No workspace connected.");
178+
return;
179+
}
180+
181+
const duration = await vscode.window.showInputBox({
182+
title: "Speed Test Duration",
183+
prompt: "Duration for the speed test (e.g., 5s, 10s, 1m)",
184+
value: "5s",
185+
});
186+
if (duration === undefined) {
187+
return;
188+
}
189+
190+
const result = await withCancellableProgress(
191+
async ({ signal }) => {
192+
const baseUrl = client.getAxiosInstance().defaults.baseURL;
193+
if (!baseUrl) {
194+
throw new Error("No deployment URL for the connected workspace");
195+
}
196+
const safeHost = toSafeHost(baseUrl);
197+
const binary = await this.cliManager.fetchBinary(client);
198+
const version = semver.parse(await cliUtils.version(binary));
199+
const featureSet = featureSetForVersion(version);
200+
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
201+
const configs = vscode.workspace.getConfiguration();
202+
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
203+
const globalFlags = getGlobalFlags(configs, auth);
204+
const workspaceName = createWorkspaceIdentifier(workspace);
205+
206+
return cliUtils.speedtest(binary, globalFlags, workspaceName, {
207+
signal,
208+
duration: duration.trim(),
209+
});
210+
},
211+
{
212+
location: vscode.ProgressLocation.Notification,
213+
title: `Running ${duration.trim()} speed test...`,
214+
cancellable: true,
215+
},
216+
);
217+
218+
if (result.ok) {
219+
const doc = await vscode.workspace.openTextDocument({
220+
content: result.value,
221+
language: "json",
222+
});
223+
await vscode.window.showTextDocument(doc);
224+
return;
225+
}
226+
227+
if (result.cancelled) {
228+
return;
229+
}
230+
231+
this.logger.error("Speed test failed", result.error);
232+
vscode.window.showErrorMessage(
233+
`Speed test failed: ${toError(result.error).message}`,
234+
);
235+
}
236+
165237
/**
166238
* View the logs for the currently connected workspace.
167239
*/
@@ -505,7 +577,7 @@ export class Commands {
505577
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
506578
const configs = vscode.workspace.getConfiguration();
507579
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
508-
const globalFlags = getGlobalFlags(configs, auth);
580+
const globalFlags = getGlobalShellFlags(configs, auth);
509581
terminal.sendText(
510582
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
511583
);

src/core/cliUtils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ export async function version(binPath: string): Promise<string> {
7272
return json.version;
7373
}
7474

75+
/**
76+
* Run a speed test against the specified workspace and return the raw output.
77+
* Throw if unable to execute the binary.
78+
*/
79+
export async function speedtest(
80+
binPath: string,
81+
globalFlags: string[],
82+
workspaceName: string,
83+
options: { signal?: AbortSignal; duration?: string },
84+
): Promise<string> {
85+
const args = [...globalFlags, "speedtest", workspaceName, "--output", "json"];
86+
if (options.duration) {
87+
args.push("-t", options.duration);
88+
}
89+
const result = await promisify(execFile)(binPath, args, {
90+
signal: options.signal,
91+
});
92+
return result.stdout;
93+
}
94+
7595
export interface RemovalResult {
7696
fileName: string;
7797
error: unknown;

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
289289
void myWorkspacesProvider.fetchAndRefresh();
290290
void allWorkspacesProvider.fetchAndRefresh();
291291
}),
292+
vscode.commands.registerCommand(
293+
"coder.speedTest",
294+
commands.speedTest.bind(commands),
295+
),
292296
vscode.commands.registerCommand(
293297
"coder.viewLogs",
294298
commands.viewLogs.bind(commands),

src/remote/remote.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ import { type LoginCoordinator } from "../login/loginCoordinator";
3636
import { OAuthSessionManager } from "../oauth/sessionManager";
3737
import {
3838
type CliAuth,
39-
getGlobalFlags,
4039
getGlobalFlagsRaw,
40+
getGlobalShellFlags,
4141
getSshFlags,
4242
resolveCliAuth,
4343
} from "../settings/cli";
@@ -674,7 +674,7 @@ export class Remote {
674674
const vscodeConfig = vscode.workspace.getConfiguration();
675675

676676
const escapedBinaryPath = escapeCommandArg(binaryPath);
677-
const globalConfig = getGlobalFlags(vscodeConfig, cliAuth);
677+
const globalConfig = getGlobalShellFlags(vscodeConfig, cliAuth);
678678
const logArgs = await this.getLogArgs(logDir);
679679

680680
if (useWildcardSSH) {
@@ -863,7 +863,9 @@ export class Remote {
863863
const titleMap = new Map(settings.map((s) => [s.setting, s.title]));
864864

865865
return watchConfigurationChanges(settings, (changedSettings) => {
866-
const changedTitles = changedSettings.map((s) => titleMap.get(s)!);
866+
const changedTitles = changedSettings
867+
.map((s) => titleMap.get(s))
868+
.filter((t) => t !== undefined);
867869

868870
const message =
869871
changedTitles.length === 1

src/settings/cli.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,36 @@ export function getGlobalFlagsRaw(
2121
}
2222

2323
/**
24-
* Returns global configuration flags for Coder CLI commands.
25-
* Includes either `--global-config` or `--url` depending on the auth mode.
24+
* Returns global configuration flags for Coder CLI commands with auth values
25+
* escaped for shell use (e.g., `terminal.sendText`, `spawn({ shell: true })`).
26+
*/
27+
export function getGlobalShellFlags(
28+
configs: Pick<WorkspaceConfiguration, "get">,
29+
auth: CliAuth,
30+
): string[] {
31+
return buildGlobalFlags(configs, auth, escapeCommandArg);
32+
}
33+
34+
/**
35+
* Returns global configuration flags for Coder CLI commands with raw auth
36+
* values suitable for `execFile` (no shell escaping).
2637
*/
2738
export function getGlobalFlags(
2839
configs: Pick<WorkspaceConfiguration, "get">,
2940
auth: CliAuth,
41+
): string[] {
42+
return buildGlobalFlags(configs, auth, (s) => s);
43+
}
44+
45+
function buildGlobalFlags(
46+
configs: Pick<WorkspaceConfiguration, "get">,
47+
auth: CliAuth,
48+
esc: (s: string) => string,
3049
): string[] {
3150
const authFlags =
3251
auth.mode === "url"
33-
? ["--url", escapeCommandArg(auth.url)]
34-
: ["--global-config", escapeCommandArg(auth.configDir)];
52+
? ["--url", esc(auth.url)]
53+
: ["--global-config", esc(auth.configDir)];
3554

3655
const raw = getGlobalFlagsRaw(configs);
3756
const filtered = stripManagedFlags(raw);

src/util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function findPort(text: string): number | null {
3434
}
3535

3636
// Get the last match, which is the most recent port.
37-
const lastMatch = allMatches.at(-1)!;
37+
const lastMatch = allMatches[allMatches.length - 1];
3838
// Each capture group corresponds to a different Remote SSH extension log format:
3939
// [0] full match, [1] and [2] ms-vscode-remote.remote-ssh,
4040
// [3] windsurf/open-remote-ssh/antigravity, [4] anysphere.remote-ssh

0 commit comments

Comments
 (0)