Skip to content

Commit b81afc6

Browse files
feat: #750
Add speed test command + tests. Runs the speedtest command through the Coder Cli and opens a json for the user to view.
1 parent c9e552d commit b81afc6

File tree

13 files changed

+282
-22
lines changed

13 files changed

+282
-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+
return false;
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: 74 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,73 @@ 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+
if (!workspace) {
176+
vscode.window.showInformationMessage("No workspace connected.");
177+
return;
178+
}
179+
180+
const duration = await vscode.window.showInputBox({
181+
title: "Speed Test Duration",
182+
prompt: "Duration for the speed test (e.g., 5s, 10s, 1m)",
183+
value: "5s",
184+
validateInput: (v) => {
185+
return /^\d+[smh]$/.test(v.trim())
186+
? null
187+
: "Enter a duration like 5s, 10s, or 1m";
188+
},
189+
});
190+
if (duration === undefined) {
191+
return;
192+
}
193+
194+
const result = await withCancellableProgress(
195+
async ({ signal }) => {
196+
const baseUrl = this.requireExtensionBaseUrl();
197+
const safeHost = toSafeHost(baseUrl);
198+
const binary = await this.cliManager.fetchBinary(this.extensionClient);
199+
const version = semver.parse(await cliUtils.version(binary));
200+
const featureSet = featureSetForVersion(version);
201+
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
202+
const configs = vscode.workspace.getConfiguration();
203+
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
204+
const globalFlags = getGlobalFlags(configs, auth);
205+
const workspaceName = createWorkspaceIdentifier(workspace);
206+
207+
return cliUtils.speedtest(binary, globalFlags, workspaceName, {
208+
signal,
209+
duration: duration.trim(),
210+
});
211+
},
212+
{
213+
location: vscode.ProgressLocation.Notification,
214+
title: `Running ${duration.trim()} speed test...`,
215+
cancellable: true,
216+
},
217+
);
218+
219+
if (!result.ok) {
220+
if (!result.cancelled) {
221+
this.logger.error("Speed test failed", result.error);
222+
vscode.window.showErrorMessage(
223+
`Speed test failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`,
224+
);
225+
}
226+
return;
227+
}
228+
229+
const doc = await vscode.workspace.openTextDocument({
230+
content: result.value,
231+
language: "json",
232+
});
233+
vscode.window.showTextDocument(doc);
234+
}
235+
165236
/**
166237
* View the logs for the currently connected workspace.
167238
*/
@@ -505,7 +576,7 @@ export class Commands {
505576
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
506577
const configs = vscode.workspace.getConfiguration();
507578
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
508-
const globalFlags = getGlobalFlags(configs, auth);
579+
const globalFlags = getGlobalShellFlags(configs, auth);
509580
terminal.sendText(
510581
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
511582
);

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)