From bc1e60ed5f3396287dcd97ade24a961f9d17fc7a Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 14 Apr 2026 18:16:11 +0300 Subject: [PATCH 1/4] feat: add speedtest visualization webview (#888) After running a speed test, results are now displayed in a lightweight Canvas-based chart (4.5KB JS) instead of raw JSON. The webview shows throughput over time with hover tooltips, a summary header, and a "View JSON" button for the raw data. - Add `packages/speedtest/` webview (vanilla TS, no framework) - Extract `createBaseWebviewConfig` from the React-specific variant so lightweight webviews can reuse the shared Vite config - Add typed IPC via `SpeedtestApi` in `@repo/shared` - Accept duration as seconds, show real-time progress bar - Expose `extensionUri` through `ServiceContainer` --- packages/shared/src/index.ts | 3 + packages/shared/src/speedtest/api.ts | 11 ++ packages/speedtest/package.json | 20 +++ packages/speedtest/src/chart.ts | 167 ++++++++++++++++++ packages/speedtest/src/css.d.ts | 1 + packages/speedtest/src/index.css | 94 ++++++++++ packages/speedtest/src/index.ts | 136 ++++++++++++++ packages/speedtest/tsconfig.json | 9 + packages/speedtest/vite.config.ts | 3 + .../webview-shared/createWebviewConfig.ts | 33 +++- pnpm-lock.yaml | 16 ++ src/commands.ts | 68 +++++-- src/core/container.ts | 6 + src/webviews/speedtest/speedtestPanel.ts | 71 ++++++++ .../webviews/speedtest/speedtestPanel.test.ts | 27 +++ 15 files changed, 641 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/speedtest/api.ts create mode 100644 packages/speedtest/package.json create mode 100644 packages/speedtest/src/chart.ts create mode 100644 packages/speedtest/src/css.d.ts create mode 100644 packages/speedtest/src/index.css create mode 100644 packages/speedtest/src/index.ts create mode 100644 packages/speedtest/tsconfig.json create mode 100644 packages/speedtest/vite.config.ts create mode 100644 src/webviews/speedtest/speedtestPanel.ts create mode 100644 test/unit/webviews/speedtest/speedtestPanel.test.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2da0281c..91c727e0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,3 +5,6 @@ export * from "./ipc/protocol"; export * from "./tasks/types"; export * from "./tasks/utils"; export * from "./tasks/api"; + +// Speedtest API +export { SpeedtestApi } from "./speedtest/api"; diff --git a/packages/shared/src/speedtest/api.ts b/packages/shared/src/speedtest/api.ts new file mode 100644 index 00000000..e421539d --- /dev/null +++ b/packages/shared/src/speedtest/api.ts @@ -0,0 +1,11 @@ +import { defineCommand, defineNotification } from "../ipc/protocol"; + +/** + * Speedtest webview IPC API. + */ +export const SpeedtestApi = { + /** Extension pushes JSON results to the webview */ + data: defineNotification("speedtest/data"), + /** Webview requests to open raw JSON in a text editor */ + viewJson: defineCommand("speedtest/viewJson"), +} as const; diff --git a/packages/speedtest/package.json b/packages/speedtest/package.json new file mode 100644 index 00000000..65229fcf --- /dev/null +++ b/packages/speedtest/package.json @@ -0,0 +1,20 @@ +{ + "name": "@repo/speedtest", + "version": "1.0.0", + "description": "Coder Speedtest visualization webview", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/webview-shared": "workspace:*" + }, + "devDependencies": { + "@types/vscode-webview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/speedtest/src/chart.ts b/packages/speedtest/src/chart.ts new file mode 100644 index 00000000..170332ee --- /dev/null +++ b/packages/speedtest/src/chart.ts @@ -0,0 +1,167 @@ +/** + * Lightweight canvas line chart for speedtest results. + * No dependencies — uses Canvas 2D API with VS Code theme colors. + */ + +export interface ChartPoint { + x: number; + y: number; + label: string; +} + +export interface ChartData { + labels: string[]; + values: number[]; + pointLabels: string[]; +} + +/** + * Draw a line chart on the given canvas and return hit-test positions. + */ +export function renderLineChart( + canvas: HTMLCanvasElement, + data: ChartData, +): ChartPoint[] { + const dpr = window.devicePixelRatio || 1; + const container = canvas.parentElement; + const { width, height } = container + ? container.getBoundingClientRect() + : canvas.getBoundingClientRect(); + canvas.width = width * dpr; + canvas.height = height * dpr; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return []; + } + ctx.scale(dpr, dpr); + + const pad = { top: 24, right: 24, bottom: 52, left: 72 }; + const plotW = width - pad.left - pad.right; + const plotH = height - pad.top - pad.bottom; + const maxVal = Math.max(...data.values, 1) * 1.1; + const n = data.values.length; + + // Coordinate helpers + const xAt = (i: number) => pad.left + (i / Math.max(n - 1, 1)) * plotW; + const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH; + + // Read VS Code theme + const s = getComputedStyle(document.documentElement); + const css = (prop: string) => s.getPropertyValue(prop).trim(); + const fg = + css("--vscode-descriptionForeground") || + css("--vscode-editor-foreground") || + "#888"; + const accent = + css("--vscode-charts-blue") || + css("--vscode-terminal-ansiBlue") || + "#3794ff"; + const grid = css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)"; + const family = css("--vscode-font-family") || "sans-serif"; + + // ── Axes ── + + // Y-axis grid lines and labels + ctx.strokeStyle = grid; + ctx.lineWidth = 1; + ctx.fillStyle = fg; + ctx.font = `1em ${family}`; + ctx.textAlign = "right"; + for (let i = 0; i <= 5; i++) { + const y = yAt((i / 5) * maxVal); + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(pad.left + plotW, y); + ctx.stroke(); + ctx.fillText(((i / 5) * maxVal).toFixed(0), pad.left - 12, y + 5); + } + + // Bottom axis line + ctx.strokeStyle = fg; + ctx.beginPath(); + ctx.moveTo(pad.left, pad.top + plotH); + ctx.lineTo(pad.left + plotW, pad.top + plotH); + ctx.stroke(); + + // X-axis labels (auto-thinned, deduped) + ctx.textAlign = "center"; + ctx.fillStyle = fg; + const maxLabels = Math.floor(plotW / 60); + const step = Math.max(1, Math.ceil(n / maxLabels)); + let lastDrawnLabel = ""; + let lastDrawnX = -Infinity; + for (let i = 0; i < n; i += step) { + if (data.labels[i] !== lastDrawnLabel) { + ctx.fillText(data.labels[i], xAt(i), height - pad.bottom + 24); + lastDrawnLabel = data.labels[i]; + lastDrawnX = xAt(i); + } + } + const last = n - 1; + if ( + last > 0 && + last % step !== 0 && + data.labels[last] !== lastDrawnLabel && + xAt(last) - lastDrawnX > 50 + ) { + ctx.fillText(data.labels[last], xAt(last), height - pad.bottom + 24); + } + + // Axis titles + ctx.font = `0.95em ${family}`; + ctx.fillText("Time", pad.left + plotW / 2, height - 4); + ctx.save(); + ctx.translate(14, pad.top + plotH / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText("Mbps", 0, 0); + ctx.restore(); + + if (n === 0) { + return []; + } + + // ── Series ── + + const baseline = pad.top + plotH; + + // Fill area + ctx.beginPath(); + ctx.moveTo(xAt(0), baseline); + for (let i = 0; i < n; i++) { + ctx.lineTo(xAt(i), yAt(data.values[i])); + } + ctx.lineTo(xAt(n - 1), baseline); + ctx.closePath(); + const gradient = ctx.createLinearGradient(0, pad.top, 0, baseline); + gradient.addColorStop(0, accent + "18"); + gradient.addColorStop(1, accent + "04"); + ctx.fillStyle = gradient; + ctx.fill(); + + // Line + ctx.beginPath(); + ctx.moveTo(xAt(0), yAt(data.values[0])); + for (let i = 1; i < n; i++) { + ctx.lineTo(xAt(i), yAt(data.values[i])); + } + ctx.strokeStyle = accent; + ctx.lineWidth = 2; + ctx.stroke(); + + // Dots and hit-test positions + const showDots = n <= 50; + const points: ChartPoint[] = []; + for (let i = 0; i < n; i++) { + const x = xAt(i); + const y = yAt(data.values[i]); + if (showDots) { + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.fillStyle = accent; + ctx.fill(); + } + points.push({ x, y, label: data.pointLabels[i] }); + } + return points; +} diff --git a/packages/speedtest/src/css.d.ts b/packages/speedtest/src/css.d.ts new file mode 100644 index 00000000..cbe652db --- /dev/null +++ b/packages/speedtest/src/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/packages/speedtest/src/index.css b/packages/speedtest/src/index.css new file mode 100644 index 00000000..197e9a00 --- /dev/null +++ b/packages/speedtest/src/index.css @@ -0,0 +1,94 @@ +body { + margin: 0; + padding: 24px; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +.summary { + display: flex; + justify-content: center; + gap: 48px; + margin-bottom: 24px; + text-align: center; + /* Offset to align with the chart plot area (matches canvas left padding) */ + padding-left: 48px; +} + +.stat-label { + display: block; + font-size: 0.8em; + opacity: 0.6; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} + +.stat-value { + font-size: 1.8em; + font-weight: 600; +} + +.stat-value small { + font-size: 0.55em; + font-weight: 400; + opacity: 0.7; +} + +.chart-container { + position: relative; + min-width: 400px; + height: 320px; + margin-bottom: 20px; +} + +.chart-container canvas { + width: 100%; + height: 100%; +} + +.actions { + display: flex; + justify-content: center; + padding-left: 48px; +} + +button { + padding: 6px 16px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 2px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font: inherit; + cursor: pointer; +} + +button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.tooltip { + position: absolute; + padding: 4px 8px; + border-radius: 3px; + background: var(--vscode-editorHoverWidget-background); + border: 1px solid var(--vscode-editorHoverWidget-border); + color: var(--vscode-editorHoverWidget-foreground); + font-size: 0.9em; + white-space: nowrap; + pointer-events: none; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.1s; +} + +.tooltip.visible { + opacity: 1; +} + +.error { + color: var(--vscode-errorForeground); + text-align: center; +} diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts new file mode 100644 index 00000000..5e285298 --- /dev/null +++ b/packages/speedtest/src/index.ts @@ -0,0 +1,136 @@ +import { SpeedtestApi } from "@repo/shared"; +import { postMessage } from "@repo/webview-shared"; + +import { type ChartPoint, renderLineChart } from "./chart"; +import "./index.css"; + +interface SpeedtestInterval { + start_time_seconds: number; + end_time_seconds: number; + throughput_mbits: number; +} + +interface SpeedtestResult { + overall: SpeedtestInterval; + intervals: SpeedtestInterval[]; +} + +let cleanup: (() => void) | undefined; + +window.addEventListener( + "message", + (event: MessageEvent<{ type: string; data?: string }>) => { + if (event.data.type === SpeedtestApi.data.method) { + const json = event.data.data ?? ""; + try { + const data = JSON.parse(json) as SpeedtestResult; + renderPage(data, () => + postMessage({ + method: SpeedtestApi.viewJson.method, + params: json, + }), + ); + } catch { + showError("Failed to parse speedtest data."); + } + } + }, +); + +function renderPage(data: SpeedtestResult, onViewJson: () => void): void { + const root = document.getElementById("root"); + if (!root) { + return; + } + + cleanup?.(); + root.innerHTML = ""; + + // Summary + const summary = document.createElement("div"); + summary.className = "summary"; + summary.innerHTML = ` +
+ Throughput + ${data.overall.throughput_mbits.toFixed(2)} Mbps +
+
+ Duration + ${data.overall.end_time_seconds.toFixed(1)}s +
+
+ Intervals + ${data.intervals.length} +
+ `; + root.appendChild(summary); + + // Chart with tooltip and resize handling + const container = document.createElement("div"); + container.className = "chart-container"; + const canvas = document.createElement("canvas"); + const tooltip = document.createElement("div"); + tooltip.className = "tooltip"; + container.append(canvas, tooltip); + root.appendChild(container); + + const chartData = { + labels: data.intervals.map((iv) => `${iv.end_time_seconds.toFixed(0)}s`), + values: data.intervals.map((iv) => iv.throughput_mbits), + pointLabels: data.intervals.map( + (iv) => + `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, + ), + }; + + let points: ChartPoint[] = []; + const draw = () => { + points = renderLineChart(canvas, chartData); + }; + draw(); + + const observer = new ResizeObserver(draw); + observer.observe(container); + + const onMouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const hit = points.find( + (p) => Math.abs(p.x - mx) < 12 && Math.abs(p.y - my) < 12, + ); + if (hit) { + tooltip.textContent = hit.label; + tooltip.style.left = `${hit.x}px`; + tooltip.style.top = `${hit.y - 32}px`; + tooltip.classList.add("visible"); + } else { + tooltip.classList.remove("visible"); + } + }; + const onMouseLeave = () => tooltip.classList.remove("visible"); + canvas.addEventListener("mousemove", onMouseMove); + canvas.addEventListener("mouseleave", onMouseLeave); + + cleanup = () => { + observer.disconnect(); + canvas.removeEventListener("mousemove", onMouseMove); + canvas.removeEventListener("mouseleave", onMouseLeave); + }; + + // Actions + const actions = document.createElement("div"); + actions.className = "actions"; + const viewBtn = document.createElement("button"); + viewBtn.textContent = "View JSON"; + viewBtn.addEventListener("click", onViewJson); + actions.appendChild(viewBtn); + root.appendChild(actions); +} + +function showError(message: string): void { + const root = document.getElementById("root"); + if (root) { + root.innerHTML = `

${message}

`; + } +} diff --git a/packages/speedtest/tsconfig.json b/packages/speedtest/tsconfig.json new file mode 100644 index 00000000..d7f31093 --- /dev/null +++ b/packages/speedtest/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.packages.json", + "compilerOptions": { + "paths": { + "@repo/webview-shared": ["../webview-shared/src"] + } + }, + "include": ["src"] +} diff --git a/packages/speedtest/vite.config.ts b/packages/speedtest/vite.config.ts new file mode 100644 index 00000000..c89fc115 --- /dev/null +++ b/packages/speedtest/vite.config.ts @@ -0,0 +1,3 @@ +import { createBaseWebviewConfig } from "../webview-shared/createWebviewConfig"; + +export default createBaseWebviewConfig("speedtest", __dirname); diff --git a/packages/webview-shared/createWebviewConfig.ts b/packages/webview-shared/createWebviewConfig.ts index 484d4b07..59b85654 100644 --- a/packages/webview-shared/createWebviewConfig.ts +++ b/packages/webview-shared/createWebviewConfig.ts @@ -1,23 +1,29 @@ import babel from "@rolldown/plugin-babel"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import { resolve } from "node:path"; -import { defineConfig, type UserConfig } from "vite"; +import { defineConfig, type Plugin, type UserConfig } from "vite"; /** - * Create a Vite config for a webview package + * Create a base Vite config for any webview package. + * Use this for lightweight webviews that don't need React. + * * @param webviewName - Name of the webview (used for output path) * @param dirname - __dirname of the calling config file + * @param options.entry - Entry file relative to package root (default: "src/index.ts") + * @param options.plugins - Additional Vite plugins to include */ -export function createWebviewConfig( +export function createBaseWebviewConfig( webviewName: string, dirname: string, + options?: { entry?: string; plugins?: Plugin[] }, ): UserConfig { const production = process.env.NODE_ENV === "production"; + const entry = options?.entry ?? "src/index.ts"; return defineConfig({ // Use relative URLs for assets (fonts, etc.) in CSS base: "./", - plugins: [react(), babel({ presets: [reactCompilerPreset()] })], + plugins: options?.plugins ?? [], build: { outDir: resolve(dirname, `../../dist/webviews/${webviewName}`), emptyOutDir: true, @@ -29,7 +35,7 @@ export function createWebviewConfig( chunkSizeWarningLimit: 600, rollupOptions: { // HTML is generated by the extension with CSP headers - input: resolve(dirname, "src/index.tsx"), + input: resolve(dirname, entry), output: { entryFileNames: "index.js", // Keep fonts with original names for proper CSS references @@ -51,3 +57,20 @@ export function createWebviewConfig( }, }); } + +/** + * Create a Vite config for a React-based webview package. + * Extends the base config with React and Babel plugins. + * + * @param webviewName - Name of the webview (used for output path) + * @param dirname - __dirname of the calling config file + */ +export function createWebviewConfig( + webviewName: string, + dirname: string, +): UserConfig { + return createBaseWebviewConfig(webviewName, dirname, { + entry: "src/index.tsx", + plugins: [react(), babel({ presets: [reactCompilerPreset()] })], + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f87fa859..3cef1ea2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,22 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/speedtest: + dependencies: + '@repo/webview-shared': + specifier: workspace:* + version: link:../webview-shared + devDependencies: + '@types/vscode-webview': + specifier: 'catalog:' + version: 1.57.5 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.0.8(@types/node@24.10.12)(esbuild@0.28.0) + packages/tasks: dependencies: '@repo/shared': diff --git a/src/commands.ts b/src/commands.ts index 3d7d51ea..db5514c4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -35,6 +35,7 @@ import { import { resolveCliAuth } from "./settings/cli"; import { toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; +import { showSpeedtestChart } from "./webviews/speedtest/speedtestPanel"; import { AgentTreeItem, type OpenableTreeItem, @@ -64,6 +65,7 @@ export class Commands { private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; private readonly loginCoordinator: LoginCoordinator; + private readonly extensionUri: vscode.Uri; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not @@ -87,6 +89,7 @@ export class Commands { this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); this.loginCoordinator = serviceContainer.getLoginCoordinator(); + this.extensionUri = serviceContainer.getExtensionUri(); } /** @@ -179,45 +182,72 @@ export class Commands { const { client, workspaceId } = resolved; - const duration = await vscode.window.showInputBox({ + const input = await vscode.window.showInputBox({ title: "Speed Test Duration", - prompt: "Duration for the speed test", - value: "5s", + prompt: "How long should the test run? (seconds)", + value: "5", validateInput: (value) => { - const v = value.trim(); - if (v && !cliExec.isGoDuration(v)) { - return "Invalid Go duration (e.g., 5s, 10s, 1m, 1m30s)"; + const n = Number(value.trim()); + if (!value.trim() || isNaN(n) || n <= 0) { + return "Please enter a positive number"; } return undefined; }, }); - if (duration === undefined) { + if (input === undefined) { return; } - const trimmedDuration = duration.trim(); + const seconds = Number(input.trim()); + const totalMs = seconds * 1000; const result = await withCancellableProgress( async ({ signal, progress }) => { - progress.report({ message: "Resolving CLI..." }); + progress.report({ message: "Connecting..." }); const env = await this.resolveCliEnv(client); - progress.report({ message: "Running..." }); - return cliExec.speedtest(env, workspaceId, trimmedDuration, signal); + + // Report progress based on elapsed time + const startTime = Date.now(); + let lastPercent = 0; + const timer = setInterval(() => { + const elapsed = Date.now() - startTime; + const elapsedSec = Math.floor(elapsed / 1000); + const remaining = Math.max(0, Math.ceil((totalMs - elapsed) / 1000)); + + if (remaining > 0) { + const percent = Math.min(Math.round((elapsed / totalMs) * 100), 95); + const increment = percent - lastPercent; + if (increment > 0) { + progress.report({ + message: `${elapsedSec}s / ${seconds}s`, + increment, + }); + lastPercent = percent; + } + } else { + progress.report({ message: "Collecting results..." }); + } + }, 100); + + try { + return await cliExec.speedtest( + env, + workspaceId, + `${seconds}s`, + signal, + ); + } finally { + clearInterval(timer); + } }, { location: vscode.ProgressLocation.Notification, - title: trimmedDuration - ? `Speed test for ${workspaceId} (${trimmedDuration})` - : `Speed test for ${workspaceId}`, + title: `Running speed test for ${workspaceId}`, cancellable: true, }, ); if (result.ok) { - const doc = await vscode.workspace.openTextDocument({ - content: result.value, - language: "json", - }); - await vscode.window.showTextDocument(doc); + showSpeedtestChart(this.extensionUri, result.value); return; } diff --git a/src/core/container.ts b/src/core/container.ts index ce8ca887..0e7bbc3c 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -16,6 +16,7 @@ import { SecretsManager } from "./secretsManager"; * Centralizes the creation and management of all core services. */ export class ServiceContainer implements vscode.Disposable { + private readonly extensionUri: vscode.Uri; private readonly logger: vscode.LogOutputChannel; private readonly pathResolver: PathResolver; private readonly mementoManager: MementoManager; @@ -26,6 +27,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly loginCoordinator: LoginCoordinator; constructor(context: vscode.ExtensionContext) { + this.extensionUri = context.extensionUri; this.logger = vscode.window.createOutputChannel("Coder", { log: true }); this.pathResolver = new PathResolver( context.globalStorageUri.fsPath, @@ -104,6 +106,10 @@ export class ServiceContainer implements vscode.Disposable { return this.loginCoordinator; } + getExtensionUri(): vscode.Uri { + return this.extensionUri; + } + /** * Dispose of all services and clean up resources. */ diff --git a/src/webviews/speedtest/speedtestPanel.ts b/src/webviews/speedtest/speedtestPanel.ts new file mode 100644 index 00000000..1db0a908 --- /dev/null +++ b/src/webviews/speedtest/speedtestPanel.ts @@ -0,0 +1,71 @@ +import * as vscode from "vscode"; + +import { buildCommandHandlers, SpeedtestApi } from "@repo/shared"; + +import { getWebviewHtml } from "../util"; + +/** + * Opens a webview panel to visualize speedtest results as a chart. + */ +export function showSpeedtestChart( + extensionUri: vscode.Uri, + json: string, +): void { + const panel = vscode.window.createWebviewPanel( + "coderSpeedtest", + "Speed Test Results", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(extensionUri, "dist", "webviews", "speedtest"), + ], + }, + ); + + panel.iconPath = { + light: vscode.Uri.joinPath(extensionUri, "media", "logo-black.svg"), + dark: vscode.Uri.joinPath(extensionUri, "media", "logo-white.svg"), + }; + + panel.webview.html = getWebviewHtml( + panel.webview, + extensionUri, + "speedtest", + "Speed Test Results", + ); + + const sendData = () => { + panel.webview.postMessage({ + type: SpeedtestApi.data.method, + data: json, + }); + }; + + // Send data now, and re-send whenever the panel becomes visible again + sendData(); + panel.onDidChangeViewState(() => { + if (panel.visible) { + sendData(); + } + }); + + const commandHandlers = buildCommandHandlers(SpeedtestApi, { + async viewJson(data: string) { + const doc = await vscode.workspace.openTextDocument({ + content: data, + language: "json", + }); + await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); + }, + }); + + panel.webview.onDidReceiveMessage( + async (message: { method: string; params?: unknown }) => { + const handler = commandHandlers[message.method]; + if (handler) { + await handler(message.params); + } + }, + ); +} diff --git a/test/unit/webviews/speedtest/speedtestPanel.test.ts b/test/unit/webviews/speedtest/speedtestPanel.test.ts new file mode 100644 index 00000000..561b2c8d --- /dev/null +++ b/test/unit/webviews/speedtest/speedtestPanel.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { buildCommandHandlers, SpeedtestApi } from "@repo/shared"; + +describe("SpeedtestApi", () => { + it("defines typed command handlers via buildCommandHandlers", async () => { + let receivedData: string | undefined; + + const handlers = buildCommandHandlers(SpeedtestApi, { + viewJson(data: string) { + receivedData = data; + }, + }); + + // Handler is keyed by the wire method name + expect(handlers[SpeedtestApi.viewJson.method]).toBeDefined(); + + // Dispatching through the handler passes the data correctly + await handlers[SpeedtestApi.viewJson.method]('{"test": true}'); + expect(receivedData).toBe('{"test": true}'); + }); + + it("uses consistent method names for notification and command", () => { + expect(SpeedtestApi.data.method).toBe("speedtest/data"); + expect(SpeedtestApi.viewJson.method).toBe("speedtest/viewJson"); + }); +}); From 130680ac3bc02a7dd40ae5a3b89cd7b8e293d35f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 16 Apr 2026 18:33:28 +0300 Subject: [PATCH 2/4] refactor: improve speedtest chart accuracy and responsiveness - Time-proportional x-axis with dashed leader line from t=0 - Uniform tick labels with smart unit selection (s/m/h) that scale from seconds to hours - Dynamic y-axis padding based on measured label width - Binary search hit-test with crosshair-snap for dense data - ResizeObserver debounced via requestAnimationFrame - Tooltip clamped to container bounds - General cleanup: named constants, single-pass data prep, responsive layout, safer error handling, input validation, missing dependency --- packages/shared/src/index.ts | 3 - packages/speedtest/package.json | 1 + packages/speedtest/src/chart.ts | 132 ++++++++------ packages/speedtest/src/index.css | 15 +- packages/speedtest/src/index.ts | 171 +++++++++++++----- packages/speedtest/tsconfig.json | 1 + .../webview-shared/createWebviewConfig.ts | 12 +- pnpm-lock.yaml | 3 + src/commands.ts | 35 ++-- src/webviews/speedtest/speedtestPanel.ts | 11 +- 10 files changed, 240 insertions(+), 144 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 91c727e0..215bc7e6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,10 +1,7 @@ -// IPC protocol types export * from "./ipc/protocol"; -// Tasks types, utilities, and API export * from "./tasks/types"; export * from "./tasks/utils"; export * from "./tasks/api"; -// Speedtest API export { SpeedtestApi } from "./speedtest/api"; diff --git a/packages/speedtest/package.json b/packages/speedtest/package.json index 65229fcf..9fa4c727 100644 --- a/packages/speedtest/package.json +++ b/packages/speedtest/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@repo/shared": "workspace:*", "@repo/webview-shared": "workspace:*" }, "devDependencies": { diff --git a/packages/speedtest/src/chart.ts b/packages/speedtest/src/chart.ts index 170332ee..cdf6a10d 100644 --- a/packages/speedtest/src/chart.ts +++ b/packages/speedtest/src/chart.ts @@ -1,8 +1,3 @@ -/** - * Lightweight canvas line chart for speedtest results. - * No dependencies — uses Canvas 2D API with VS Code theme colors. - */ - export interface ChartPoint { x: number; y: number; @@ -10,14 +5,42 @@ export interface ChartPoint { } export interface ChartData { - labels: string[]; + xValues: number[]; values: number[]; pointLabels: string[]; } -/** - * Draw a line chart on the given canvas and return hit-test positions. - */ +/** Points above this count are drawn as a line only (no dots). */ +export const DOT_THRESHOLD = 20; + +const DOT_RADIUS = 4; +const MIN_TICK_SPACING = 48; +const LEADER_OPACITY = 0.4; +const Y_GRID_LINES = 5; +const Y_HEADROOM = 1.1; + +const NICE_STEPS = [ + 1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600, +]; + +function niceStep(raw: number): number { + return NICE_STEPS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600; +} + +const TICK_UNITS: Array<[number, string]> = [ + [3600, "h"], + [60, "m"], + [1, "s"], +]; + +function tickFormatter(step: number): (t: number) => string { + const [divisor, suffix] = TICK_UNITS.find(([d]) => step >= d) ?? [1, "s"]; + return (t) => { + const v = t / divisor; + return `${Number.isInteger(v) ? v : v.toFixed(1)}${suffix}`; + }; +} + export function renderLineChart( canvas: HTMLCanvasElement, data: ChartData, @@ -36,17 +59,11 @@ export function renderLineChart( } ctx.scale(dpr, dpr); - const pad = { top: 24, right: 24, bottom: 52, left: 72 }; - const plotW = width - pad.left - pad.right; - const plotH = height - pad.top - pad.bottom; - const maxVal = Math.max(...data.values, 1) * 1.1; const n = data.values.length; + const maxVal = Math.max(...data.values, 1) * Y_HEADROOM; + const maxX = n > 0 ? data.xValues[n - 1] : 1; + const xRange = maxX || 1; - // Coordinate helpers - const xAt = (i: number) => pad.left + (i / Math.max(n - 1, 1)) * plotW; - const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH; - - // Read VS Code theme const s = getComputedStyle(document.documentElement); const css = (prop: string) => s.getPropertyValue(prop).trim(); const fg = @@ -60,55 +77,53 @@ export function renderLineChart( const grid = css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)"; const family = css("--vscode-font-family") || "sans-serif"; - // ── Axes ── + ctx.font = `1em ${family}`; + const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width; + const pad = { + top: 24, + right: 24, + bottom: 52, + left: Math.max(48, yLabelWidth + 24), + }; + const plotW = width - pad.left - pad.right; + const plotH = height - pad.top - pad.bottom; + + const tAt = (t: number) => pad.left + (t / xRange) * plotW; + const xAt = (i: number) => tAt(data.xValues[i]); + const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH; - // Y-axis grid lines and labels ctx.strokeStyle = grid; ctx.lineWidth = 1; ctx.fillStyle = fg; - ctx.font = `1em ${family}`; ctx.textAlign = "right"; - for (let i = 0; i <= 5; i++) { - const y = yAt((i / 5) * maxVal); + for (let i = 0; i <= Y_GRID_LINES; i++) { + const y = yAt((i / Y_GRID_LINES) * maxVal); ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + plotW, y); ctx.stroke(); - ctx.fillText(((i / 5) * maxVal).toFixed(0), pad.left - 12, y + 5); + ctx.fillText( + ((i / Y_GRID_LINES) * maxVal).toFixed(0), + pad.left - 12, + y + 5, + ); } - // Bottom axis line ctx.strokeStyle = fg; ctx.beginPath(); ctx.moveTo(pad.left, pad.top + plotH); ctx.lineTo(pad.left + plotW, pad.top + plotH); ctx.stroke(); - // X-axis labels (auto-thinned, deduped) ctx.textAlign = "center"; ctx.fillStyle = fg; - const maxLabels = Math.floor(plotW / 60); - const step = Math.max(1, Math.ceil(n / maxLabels)); - let lastDrawnLabel = ""; - let lastDrawnX = -Infinity; - for (let i = 0; i < n; i += step) { - if (data.labels[i] !== lastDrawnLabel) { - ctx.fillText(data.labels[i], xAt(i), height - pad.bottom + 24); - lastDrawnLabel = data.labels[i]; - lastDrawnX = xAt(i); - } - } - const last = n - 1; - if ( - last > 0 && - last % step !== 0 && - data.labels[last] !== lastDrawnLabel && - xAt(last) - lastDrawnX > 50 - ) { - ctx.fillText(data.labels[last], xAt(last), height - pad.bottom + 24); + const maxTicks = Math.max(1, Math.floor(plotW / MIN_TICK_SPACING)); + const tickStep = niceStep(xRange / maxTicks); + const formatTick = tickFormatter(tickStep); + for (let t = 0; t <= maxX; t += tickStep) { + ctx.fillText(formatTick(t), tAt(t), height - pad.bottom + 24); } - // Axis titles ctx.font = `0.95em ${family}`; ctx.fillText("Time", pad.left + plotW / 2, height - 4); ctx.save(); @@ -121,13 +136,24 @@ export function renderLineChart( return []; } - // ── Series ── - const baseline = pad.top + plotH; + const firstPx = xAt(0); + + if (data.xValues[0] > 0) { + ctx.beginPath(); + ctx.moveTo(tAt(0), baseline); + ctx.lineTo(firstPx, yAt(data.values[0])); + ctx.setLineDash([4, 4]); + ctx.strokeStyle = accent; + ctx.lineWidth = 1; + ctx.globalAlpha = LEADER_OPACITY; + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + } - // Fill area ctx.beginPath(); - ctx.moveTo(xAt(0), baseline); + ctx.moveTo(firstPx, baseline); for (let i = 0; i < n; i++) { ctx.lineTo(xAt(i), yAt(data.values[i])); } @@ -139,7 +165,6 @@ export function renderLineChart( ctx.fillStyle = gradient; ctx.fill(); - // Line ctx.beginPath(); ctx.moveTo(xAt(0), yAt(data.values[0])); for (let i = 1; i < n; i++) { @@ -149,15 +174,14 @@ export function renderLineChart( ctx.lineWidth = 2; ctx.stroke(); - // Dots and hit-test positions - const showDots = n <= 50; + const showDots = n <= DOT_THRESHOLD; const points: ChartPoint[] = []; for (let i = 0; i < n; i++) { const x = xAt(i); const y = yAt(data.values[i]); if (showDots) { ctx.beginPath(); - ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.arc(x, y, DOT_RADIUS, 0, Math.PI * 2); ctx.fillStyle = accent; ctx.fill(); } diff --git a/packages/speedtest/src/index.css b/packages/speedtest/src/index.css index 197e9a00..572ec86b 100644 --- a/packages/speedtest/src/index.css +++ b/packages/speedtest/src/index.css @@ -1,6 +1,13 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + body { margin: 0; padding: 24px; + min-width: 360px; background: var(--vscode-editor-background); color: var(--vscode-editor-foreground); font-family: var(--vscode-font-family); @@ -9,12 +16,11 @@ body { .summary { display: flex; + flex-wrap: wrap; justify-content: center; - gap: 48px; + gap: 16px 48px; margin-bottom: 24px; text-align: center; - /* Offset to align with the chart plot area (matches canvas left padding) */ - padding-left: 48px; } .stat-label { @@ -39,7 +45,6 @@ body { .chart-container { position: relative; - min-width: 400px; height: 320px; margin-bottom: 20px; } @@ -52,7 +57,6 @@ body { .actions { display: flex; justify-content: center; - padding-left: 48px; } button { @@ -79,7 +83,6 @@ button:hover { font-size: 0.9em; white-space: nowrap; pointer-events: none; - transform: translateX(-50%); opacity: 0; transition: opacity 0.1s; } diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts index 5e285298..c391ccf1 100644 --- a/packages/speedtest/src/index.ts +++ b/packages/speedtest/src/index.ts @@ -1,7 +1,12 @@ import { SpeedtestApi } from "@repo/shared"; import { postMessage } from "@repo/webview-shared"; -import { type ChartPoint, renderLineChart } from "./chart"; +import { + DOT_THRESHOLD, + type ChartData, + type ChartPoint, + renderLineChart, +} from "./chart"; import "./index.css"; interface SpeedtestInterval { @@ -15,38 +20,54 @@ interface SpeedtestResult { intervals: SpeedtestInterval[]; } +const HIT_RADIUS = 12; + let cleanup: (() => void) | undefined; window.addEventListener( "message", (event: MessageEvent<{ type: string; data?: string }>) => { - if (event.data.type === SpeedtestApi.data.method) { - const json = event.data.data ?? ""; - try { - const data = JSON.parse(json) as SpeedtestResult; - renderPage(data, () => - postMessage({ - method: SpeedtestApi.viewJson.method, - params: json, - }), - ); - } catch { - showError("Failed to parse speedtest data."); - } + if (event.data.type !== SpeedtestApi.data.method) { + return; + } + const json = event.data.data ?? ""; + try { + const data = JSON.parse(json) as SpeedtestResult; + cleanup?.(); + cleanup = renderPage(data, () => + postMessage({ + method: SpeedtestApi.viewJson.method, + params: json, + }), + ); + } catch { + showError("Failed to parse speedtest data."); } }, ); -function renderPage(data: SpeedtestResult, onViewJson: () => void): void { +function prepareChartData(intervals: SpeedtestInterval[]): ChartData { + const xValues: number[] = []; + const values: number[] = []; + const pointLabels: string[] = []; + for (const iv of intervals) { + xValues.push(iv.end_time_seconds); + values.push(iv.throughput_mbits); + pointLabels.push( + `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, + ); + } + return { xValues, values, pointLabels }; +} + +function renderPage(data: SpeedtestResult, onViewJson: () => void): () => void { const root = document.getElementById("root"); if (!root) { - return; + return () => undefined; } - cleanup?.(); root.innerHTML = ""; - // Summary const summary = document.createElement("div"); summary.className = "summary"; summary.innerHTML = ` @@ -65,7 +86,6 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { `; root.appendChild(summary); - // Chart with tooltip and resize handling const container = document.createElement("div"); container.className = "chart-container"; const canvas = document.createElement("canvas"); @@ -74,34 +94,38 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { container.append(canvas, tooltip); root.appendChild(container); - const chartData = { - labels: data.intervals.map((iv) => `${iv.end_time_seconds.toFixed(0)}s`), - values: data.intervals.map((iv) => iv.throughput_mbits), - pointLabels: data.intervals.map( - (iv) => - `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, - ), - }; + const chartData = prepareChartData(data.intervals); + const hasDots = chartData.values.length <= DOT_THRESHOLD; let points: ChartPoint[] = []; + let canvasRect = canvas.getBoundingClientRect(); const draw = () => { points = renderLineChart(canvas, chartData); + canvasRect = canvas.getBoundingClientRect(); }; draw(); - const observer = new ResizeObserver(draw); + let rafId = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(draw); + }); observer.observe(container); + const findNearest = hasDots ? findNearestDot : findNearestOnLine; const onMouseMove = (e: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const hit = points.find( - (p) => Math.abs(p.x - mx) < 12 && Math.abs(p.y - my) < 12, - ); + const mx = e.clientX - canvasRect.left; + const my = e.clientY - canvasRect.top; + const hit = findNearest(points, mx, my); + if (hit) { tooltip.textContent = hit.label; - tooltip.style.left = `${hit.x}px`; + const tw = tooltip.offsetWidth; + const left = Math.max( + 0, + Math.min(hit.x - tw / 2, container.offsetWidth - tw), + ); + tooltip.style.left = `${left}px`; tooltip.style.top = `${hit.y - 32}px`; tooltip.classList.add("visible"); } else { @@ -112,13 +136,6 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { canvas.addEventListener("mousemove", onMouseMove); canvas.addEventListener("mouseleave", onMouseLeave); - cleanup = () => { - observer.disconnect(); - canvas.removeEventListener("mousemove", onMouseMove); - canvas.removeEventListener("mouseleave", onMouseLeave); - }; - - // Actions const actions = document.createElement("div"); actions.className = "actions"; const viewBtn = document.createElement("button"); @@ -126,11 +143,77 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { viewBtn.addEventListener("click", onViewJson); actions.appendChild(viewBtn); root.appendChild(actions); + + return () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + canvas.removeEventListener("mousemove", onMouseMove); + canvas.removeEventListener("mouseleave", onMouseLeave); + }; } function showError(message: string): void { const root = document.getElementById("root"); - if (root) { - root.innerHTML = `

${message}

`; + if (!root) { + return; + } + const p = document.createElement("p"); + p.className = "error"; + p.textContent = message; + root.replaceChildren(p); +} + +function findNearestByX( + points: ChartPoint[], + mx: number, +): ChartPoint | undefined { + if (points.length === 0) { + return undefined; + } + let lo = 0; + let hi = points.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (points[mid].x < mx) { + lo = mid + 1; + } else { + hi = mid; + } + } + let best = points[lo]; + if (lo > 0 && Math.abs(points[lo - 1].x - mx) < Math.abs(best.x - mx)) { + best = points[lo - 1]; + } + return best; +} + +function findNearestDot( + points: ChartPoint[], + mx: number, + my: number, +): ChartPoint | null { + const best = findNearestByX(points, mx); + if (!best) { + return null; + } + return Math.abs(best.x - mx) < HIT_RADIUS && + Math.abs(best.y - my) < HIT_RADIUS + ? best + : null; +} + +function findNearestOnLine( + points: ChartPoint[], + mx: number, + _my: number, +): ChartPoint | null { + const best = findNearestByX(points, mx); + if (!best) { + return null; } + const avgGap = + points.length > 1 + ? (points[points.length - 1].x - points[0].x) / (points.length - 1) + : HIT_RADIUS; + return Math.abs(best.x - mx) < avgGap ? best : null; } diff --git a/packages/speedtest/tsconfig.json b/packages/speedtest/tsconfig.json index d7f31093..e1940bf7 100644 --- a/packages/speedtest/tsconfig.json +++ b/packages/speedtest/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../tsconfig.packages.json", "compilerOptions": { "paths": { + "@repo/shared": ["../shared/src"], "@repo/webview-shared": ["../webview-shared/src"] } }, diff --git a/packages/webview-shared/createWebviewConfig.ts b/packages/webview-shared/createWebviewConfig.ts index 59b85654..f4be31a0 100644 --- a/packages/webview-shared/createWebviewConfig.ts +++ b/packages/webview-shared/createWebviewConfig.ts @@ -4,13 +4,7 @@ import { resolve } from "node:path"; import { defineConfig, type Plugin, type UserConfig } from "vite"; /** - * Create a base Vite config for any webview package. - * Use this for lightweight webviews that don't need React. - * - * @param webviewName - Name of the webview (used for output path) - * @param dirname - __dirname of the calling config file - * @param options.entry - Entry file relative to package root (default: "src/index.ts") - * @param options.plugins - Additional Vite plugins to include + * Create a base Vite config for a webview package (no framework). */ export function createBaseWebviewConfig( webviewName: string, @@ -60,10 +54,6 @@ export function createBaseWebviewConfig( /** * Create a Vite config for a React-based webview package. - * Extends the base config with React and Babel plugins. - * - * @param webviewName - Name of the webview (used for output path) - * @param dirname - __dirname of the calling config file */ export function createWebviewConfig( webviewName: string, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cef1ea2..bf01ecd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,9 @@ importers: packages/speedtest: dependencies: + '@repo/shared': + specifier: workspace:* + version: link:../shared '@repo/webview-shared': specifier: workspace:* version: link:../webview-shared diff --git a/src/commands.ts b/src/commands.ts index db5514c4..31d7403b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -188,7 +188,7 @@ export class Commands { value: "5", validateInput: (value) => { const n = Number(value.trim()); - if (!value.trim() || isNaN(n) || n <= 0) { + if (!value.trim() || !Number.isFinite(n) || n <= 0) { return "Please enter a positive number"; } return undefined; @@ -198,33 +198,30 @@ export class Commands { return; } const seconds = Number(input.trim()); - const totalMs = seconds * 1000; const result = await withCancellableProgress( async ({ signal, progress }) => { progress.report({ message: "Connecting..." }); const env = await this.resolveCliEnv(client); - // Report progress based on elapsed time const startTime = Date.now(); - let lastPercent = 0; + let reported = 0; const timer = setInterval(() => { const elapsed = Date.now() - startTime; - const elapsedSec = Math.floor(elapsed / 1000); - const remaining = Math.max(0, Math.ceil((totalMs - elapsed) / 1000)); - - if (remaining > 0) { - const percent = Math.min(Math.round((elapsed / totalMs) * 100), 95); - const increment = percent - lastPercent; - if (increment > 0) { - progress.report({ - message: `${elapsedSec}s / ${seconds}s`, - increment, - }); - lastPercent = percent; - } - } else { - progress.report({ message: "Collecting results..." }); + const pct = Math.min( + Math.round((elapsed / (seconds * 1000)) * 100), + 100, + ); + const increment = pct - reported; + if (increment > 0) { + progress.report({ + message: + pct >= 100 + ? "Collecting results..." + : `${Math.floor(elapsed / 1000)}s / ${seconds}s`, + increment, + }); + reported = pct; } }, 100); diff --git a/src/webviews/speedtest/speedtestPanel.ts b/src/webviews/speedtest/speedtestPanel.ts index 1db0a908..f96d6755 100644 --- a/src/webviews/speedtest/speedtestPanel.ts +++ b/src/webviews/speedtest/speedtestPanel.ts @@ -4,9 +4,6 @@ import { buildCommandHandlers, SpeedtestApi } from "@repo/shared"; import { getWebviewHtml } from "../util"; -/** - * Opens a webview panel to visualize speedtest results as a chart. - */ export function showSpeedtestChart( extensionUri: vscode.Uri, json: string, @@ -35,14 +32,14 @@ export function showSpeedtestChart( "Speed Test Results", ); + // Webview context is discarded when hidden (no retainContextWhenHidden), + // so re-send on visibility change to re-hydrate the chart. const sendData = () => { panel.webview.postMessage({ type: SpeedtestApi.data.method, data: json, }); }; - - // Send data now, and re-send whenever the panel becomes visible again sendData(); panel.onDidChangeViewState(() => { if (panel.visible) { @@ -61,10 +58,10 @@ export function showSpeedtestChart( }); panel.webview.onDidReceiveMessage( - async (message: { method: string; params?: unknown }) => { + (message: { method: string; params?: unknown }) => { const handler = commandHandlers[message.method]; if (handler) { - await handler(message.params); + Promise.resolve(handler(message.params)).catch(() => undefined); } }, ); From 74750f491e13eff374480d90c32fd9942f6d57b9 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 20 Apr 2026 17:28:08 +0300 Subject: [PATCH 3/4] refactor: add workspace context and theme awareness to speedtest chart - Plumb workspaceName from the command through to the panel title and the in-chart heading; drop the generic "Speed Test Results" label. - Read chart accent from the theme's button color so it tracks the active theme instead of being stuck on blue. - Re-send data on active-theme change so canvas pixels repaint against the new theme (DOM CSS vars update live, canvas doesn't). - Restructure chart.ts into readTheme/layoutChart/drawAxes/drawSeries, export niceStep/formatTick for testing. - Rename the panel viewType to coder.speedtestPanel to match the tasks/chat conventions. - Clean up the duration prompt and tests. --- packages/shared/src/index.ts | 2 +- packages/shared/src/speedtest/api.ts | 12 +- packages/speedtest/src/chart.ts | 256 ++++++++++-------- packages/speedtest/src/index.css | 8 + packages/speedtest/src/index.ts | 80 +++--- src/commands.ts | 4 +- src/webviews/speedtest/speedtestPanel.ts | 39 ++- test/tsconfig.json | 3 +- .../webviews/speedtest/speedtestPanel.test.ts | 2 - test/webview/speedtest/chart.test.ts | 42 +++ vitest.config.ts | 4 + 11 files changed, 285 insertions(+), 167 deletions(-) create mode 100644 test/webview/speedtest/chart.test.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 215bc7e6..9a931126 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,4 +4,4 @@ export * from "./tasks/types"; export * from "./tasks/utils"; export * from "./tasks/api"; -export { SpeedtestApi } from "./speedtest/api"; +export { SpeedtestApi, type SpeedtestData } from "./speedtest/api"; diff --git a/packages/shared/src/speedtest/api.ts b/packages/shared/src/speedtest/api.ts index e421539d..fc15ad0a 100644 --- a/packages/shared/src/speedtest/api.ts +++ b/packages/shared/src/speedtest/api.ts @@ -1,11 +1,13 @@ import { defineCommand, defineNotification } from "../ipc/protocol"; -/** - * Speedtest webview IPC API. - */ +export interface SpeedtestData { + json: string; + workspaceName: string; +} + export const SpeedtestApi = { - /** Extension pushes JSON results to the webview */ - data: defineNotification("speedtest/data"), + /** Extension pushes results to the webview */ + data: defineNotification("speedtest/data"), /** Webview requests to open raw JSON in a text editor */ viewJson: defineCommand("speedtest/viewJson"), } as const; diff --git a/packages/speedtest/src/chart.ts b/packages/speedtest/src/chart.ts index cdf6a10d..2651c30d 100644 --- a/packages/speedtest/src/chart.ts +++ b/packages/speedtest/src/chart.ts @@ -4,79 +4,76 @@ export interface ChartPoint { label: string; } -export interface ChartData { - xValues: number[]; - values: number[]; - pointLabels: string[]; -} - -/** Points above this count are drawn as a line only (no dots). */ -export const DOT_THRESHOLD = 20; - -const DOT_RADIUS = 4; -const MIN_TICK_SPACING = 48; -const LEADER_OPACITY = 0.4; +const MIN_TICK_SPACING_PX = 48; const Y_GRID_LINES = 5; +/** 10% padding above the max value so the line doesn't hug the top edge. */ const Y_HEADROOM = 1.1; -const NICE_STEPS = [ +/** Candidate x-axis tick step sizes in seconds (1s, 2s, 5s, ..., 30m, 1h). */ +const TICK_STEP_SECONDS = [ 1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600, ]; -function niceStep(raw: number): number { - return NICE_STEPS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600; -} - -const TICK_UNITS: Array<[number, string]> = [ - [3600, "h"], - [60, "m"], - [1, "s"], -]; - -function tickFormatter(step: number): (t: number) => string { - const [divisor, suffix] = TICK_UNITS.find(([d]) => step >= d) ?? [1, "s"]; - return (t) => { - const v = t / divisor; - return `${Number.isInteger(v) ? v : v.toFixed(1)}${suffix}`; - }; +export function niceStep(raw: number): number { + return ( + TICK_STEP_SECONDS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600 + ); } -export function renderLineChart( - canvas: HTMLCanvasElement, - data: ChartData, -): ChartPoint[] { - const dpr = window.devicePixelRatio || 1; - const container = canvas.parentElement; - const { width, height } = container - ? container.getBoundingClientRect() - : canvas.getBoundingClientRect(); - canvas.width = width * dpr; - canvas.height = height * dpr; - - const ctx = canvas.getContext("2d"); - if (!ctx) { - return []; +export function formatTick(t: number, step: number): string { + if (step >= 3600) { + const h = t / 3600; + return `${Number.isInteger(h) ? h : h.toFixed(1)}h`; } - ctx.scale(dpr, dpr); + if (step >= 60) { + const m = t / 60; + return `${Number.isInteger(m) ? m : m.toFixed(1)}m`; + } + return `${t}s`; +} - const n = data.values.length; - const maxVal = Math.max(...data.values, 1) * Y_HEADROOM; - const maxX = n > 0 ? data.xValues[n - 1] : 1; - const xRange = maxX || 1; +interface Theme { + fg: string; + accent: string; + grid: string; + family: string; +} +/** + * Read VS Code theme colors from CSS custom properties on . Canvas + * pixels don't inherit CSS vars, so we re-read on each render to pick up + * theme switches. + */ +function readTheme(): Theme { const s = getComputedStyle(document.documentElement); const css = (prop: string) => s.getPropertyValue(prop).trim(); - const fg = - css("--vscode-descriptionForeground") || - css("--vscode-editor-foreground") || - "#888"; - const accent = - css("--vscode-charts-blue") || - css("--vscode-terminal-ansiBlue") || - "#3794ff"; - const grid = css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)"; - const family = css("--vscode-font-family") || "sans-serif"; + return { + fg: + css("--vscode-descriptionForeground") || + css("--vscode-editor-foreground") || + "#888", + // Use the button color so the accent tracks the theme; charts-* vars + // are fixed hues by design. + accent: + css("--vscode-button-background") || + css("--vscode-focusBorder") || + css("--vscode-charts-blue") || + "#3794ff", + grid: css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)", + family: css("--vscode-font-family") || "sans-serif", + }; +} +function layoutChart( + ctx: CanvasRenderingContext2D, + samples: ChartPoint[], + width: number, + height: number, + family: string, +) { + const maxVal = samples.reduce((m, s) => Math.max(m, s.y), 1) * Y_HEADROOM; + const maxX = samples.at(-1)?.x ?? 1; + const xRange = maxX || 1; ctx.font = `1em ${family}`; const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width; const pad = { @@ -87,105 +84,148 @@ export function renderLineChart( }; const plotW = width - pad.left - pad.right; const plotH = height - pad.top - pad.bottom; + return { + pad, + plotW, + plotH, + maxVal, + maxX, + xRange, + height, + tAt: (t: number) => pad.left + (t / xRange) * plotW, + yAt: (v: number) => pad.top + plotH - (v / maxVal) * plotH, + }; +} + +type Layout = ReturnType; - const tAt = (t: number) => pad.left + (t / xRange) * plotW; - const xAt = (i: number) => tAt(data.xValues[i]); - const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH; +function drawAxes( + ctx: CanvasRenderingContext2D, + layout: Layout, + theme: Theme, +): void { + const { pad, plotW, plotH, maxVal, maxX, xRange, height, tAt, yAt } = layout; - ctx.strokeStyle = grid; + ctx.strokeStyle = theme.grid; ctx.lineWidth = 1; - ctx.fillStyle = fg; + ctx.fillStyle = theme.fg; ctx.textAlign = "right"; for (let i = 0; i <= Y_GRID_LINES; i++) { - const y = yAt((i / Y_GRID_LINES) * maxVal); + const v = (i / Y_GRID_LINES) * maxVal; + const y = yAt(v); ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + plotW, y); ctx.stroke(); - ctx.fillText( - ((i / Y_GRID_LINES) * maxVal).toFixed(0), - pad.left - 12, - y + 5, - ); + ctx.fillText(v.toFixed(0), pad.left - 12, y + 5); } - ctx.strokeStyle = fg; + ctx.strokeStyle = theme.fg; ctx.beginPath(); ctx.moveTo(pad.left, pad.top + plotH); ctx.lineTo(pad.left + plotW, pad.top + plotH); ctx.stroke(); ctx.textAlign = "center"; - ctx.fillStyle = fg; - const maxTicks = Math.max(1, Math.floor(plotW / MIN_TICK_SPACING)); - const tickStep = niceStep(xRange / maxTicks); - const formatTick = tickFormatter(tickStep); - for (let t = 0; t <= maxX; t += tickStep) { - ctx.fillText(formatTick(t), tAt(t), height - pad.bottom + 24); + const step = niceStep( + xRange / Math.max(1, Math.floor(plotW / MIN_TICK_SPACING_PX)), + ); + for (let t = 0; t <= maxX; t += step) { + ctx.fillText(formatTick(t, step), tAt(t), height - pad.bottom + 24); } - ctx.font = `0.95em ${family}`; + ctx.font = `0.95em ${theme.family}`; ctx.fillText("Time", pad.left + plotW / 2, height - 4); ctx.save(); ctx.translate(14, pad.top + plotH / 2); ctx.rotate(-Math.PI / 2); ctx.fillText("Mbps", 0, 0); ctx.restore(); +} - if (n === 0) { - return []; - } - +function drawSeries( + ctx: CanvasRenderingContext2D, + samples: ChartPoint[], + layout: Layout, + theme: Theme, + showDots: boolean, +): ChartPoint[] { + const { pad, plotH, tAt, yAt } = layout; const baseline = pad.top + plotH; - const firstPx = xAt(0); + const first = samples[0]; + const last = samples.at(-1) ?? first; - if (data.xValues[0] > 0) { + if (first.x > 0) { ctx.beginPath(); ctx.moveTo(tAt(0), baseline); - ctx.lineTo(firstPx, yAt(data.values[0])); + ctx.lineTo(tAt(first.x), yAt(first.y)); ctx.setLineDash([4, 4]); - ctx.strokeStyle = accent; + ctx.strokeStyle = theme.accent; ctx.lineWidth = 1; - ctx.globalAlpha = LEADER_OPACITY; + ctx.globalAlpha = 0.4; ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; } ctx.beginPath(); - ctx.moveTo(firstPx, baseline); - for (let i = 0; i < n; i++) { - ctx.lineTo(xAt(i), yAt(data.values[i])); + ctx.moveTo(tAt(first.x), baseline); + for (const s of samples) { + ctx.lineTo(tAt(s.x), yAt(s.y)); } - ctx.lineTo(xAt(n - 1), baseline); + ctx.lineTo(tAt(last.x), baseline); ctx.closePath(); - const gradient = ctx.createLinearGradient(0, pad.top, 0, baseline); - gradient.addColorStop(0, accent + "18"); - gradient.addColorStop(1, accent + "04"); - ctx.fillStyle = gradient; + const grad = ctx.createLinearGradient(0, pad.top, 0, baseline); + grad.addColorStop(0, theme.accent + "18"); + grad.addColorStop(1, theme.accent + "04"); + ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); - ctx.moveTo(xAt(0), yAt(data.values[0])); - for (let i = 1; i < n; i++) { - ctx.lineTo(xAt(i), yAt(data.values[i])); + ctx.moveTo(tAt(first.x), yAt(first.y)); + for (let i = 1; i < samples.length; i++) { + ctx.lineTo(tAt(samples[i].x), yAt(samples[i].y)); } - ctx.strokeStyle = accent; + ctx.strokeStyle = theme.accent; ctx.lineWidth = 2; ctx.stroke(); - const showDots = n <= DOT_THRESHOLD; - const points: ChartPoint[] = []; - for (let i = 0; i < n; i++) { - const x = xAt(i); - const y = yAt(data.values[i]); + return samples.map((s) => { + const x = tAt(s.x); + const y = yAt(s.y); if (showDots) { ctx.beginPath(); - ctx.arc(x, y, DOT_RADIUS, 0, Math.PI * 2); - ctx.fillStyle = accent; + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.fillStyle = theme.accent; ctx.fill(); } - points.push({ x, y, label: data.pointLabels[i] }); + return { x, y, label: s.label }; + }); +} + +export function renderLineChart( + canvas: HTMLCanvasElement, + samples: ChartPoint[], + showDots: boolean, +): ChartPoint[] { + // Scale the backing store by DPR for crisp rendering on high-DPI + // displays. ctx.scale lets draw calls keep using CSS pixels. + const { width, height } = ( + canvas.parentElement ?? canvas + ).getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return []; } - return points; + ctx.scale(dpr, dpr); + + const theme = readTheme(); + const layout = layoutChart(ctx, samples, width, height, theme.family); + drawAxes(ctx, layout, theme); + return samples.length > 0 + ? drawSeries(ctx, samples, layout, theme, showDots) + : []; } diff --git a/packages/speedtest/src/index.css b/packages/speedtest/src/index.css index 572ec86b..3e986f23 100644 --- a/packages/speedtest/src/index.css +++ b/packages/speedtest/src/index.css @@ -14,6 +14,14 @@ body { font-size: var(--vscode-font-size); } +.workspace-name { + margin: 0 0 32px; + font-size: 1.8em; + font-weight: 600; + text-align: center; + word-break: break-all; +} + .summary { display: flex; flex-wrap: wrap; diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts index c391ccf1..2ac405ac 100644 --- a/packages/speedtest/src/index.ts +++ b/packages/speedtest/src/index.ts @@ -1,12 +1,7 @@ -import { SpeedtestApi } from "@repo/shared"; +import { type SpeedtestData, SpeedtestApi } from "@repo/shared"; import { postMessage } from "@repo/webview-shared"; -import { - DOT_THRESHOLD, - type ChartData, - type ChartPoint, - renderLineChart, -} from "./chart"; +import { type ChartPoint, renderLineChart } from "./chart"; import "./index.css"; interface SpeedtestInterval { @@ -20,47 +15,48 @@ interface SpeedtestResult { intervals: SpeedtestInterval[]; } -const HIT_RADIUS = 12; +const HIT_RADIUS_PX = 12; +/** Above this sample count, render the line alone (no per-point dots). */ +const DOT_THRESHOLD = 20; let cleanup: (() => void) | undefined; window.addEventListener( "message", - (event: MessageEvent<{ type: string; data?: string }>) => { - if (event.data.type !== SpeedtestApi.data.method) { + (event: MessageEvent<{ type: string; data?: SpeedtestData }>) => { + if (event.data.type !== SpeedtestApi.data.method || !event.data.data) { return; } - const json = event.data.data ?? ""; + const { json, workspaceName } = event.data.data; try { - const data = JSON.parse(json) as SpeedtestResult; + const result = JSON.parse(json) as SpeedtestResult; cleanup?.(); - cleanup = renderPage(data, () => + cleanup = renderPage(result, workspaceName, () => postMessage({ method: SpeedtestApi.viewJson.method, params: json, }), ); - } catch { - showError("Failed to parse speedtest data."); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + showError(`Failed to parse speedtest data: ${detail}`); } }, ); -function prepareChartData(intervals: SpeedtestInterval[]): ChartData { - const xValues: number[] = []; - const values: number[] = []; - const pointLabels: string[] = []; - for (const iv of intervals) { - xValues.push(iv.end_time_seconds); - values.push(iv.throughput_mbits); - pointLabels.push( - `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, - ); - } - return { xValues, values, pointLabels }; +function toChartSamples(intervals: SpeedtestInterval[]): ChartPoint[] { + return intervals.map((iv) => ({ + x: iv.end_time_seconds, + y: iv.throughput_mbits, + label: `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, + })); } -function renderPage(data: SpeedtestResult, onViewJson: () => void): () => void { +function renderPage( + data: SpeedtestResult, + workspaceName: string, + onViewJson: () => void, +): () => void { const root = document.getElementById("root"); if (!root) { return () => undefined; @@ -68,6 +64,11 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): () => void { root.innerHTML = ""; + const heading = document.createElement("h1"); + heading.className = "workspace-name"; + heading.textContent = workspaceName; + root.appendChild(heading); + const summary = document.createElement("div"); summary.className = "summary"; summary.innerHTML = ` @@ -94,13 +95,13 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): () => void { container.append(canvas, tooltip); root.appendChild(container); - const chartData = prepareChartData(data.intervals); - const hasDots = chartData.values.length <= DOT_THRESHOLD; + const samples = toChartSamples(data.intervals); + const showDots = samples.length <= DOT_THRESHOLD; let points: ChartPoint[] = []; - let canvasRect = canvas.getBoundingClientRect(); + let canvasRect: DOMRect; const draw = () => { - points = renderLineChart(canvas, chartData); + points = renderLineChart(canvas, samples, showDots); canvasRect = canvas.getBoundingClientRect(); }; draw(); @@ -112,11 +113,12 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): () => void { }); observer.observe(container); - const findNearest = hasDots ? findNearestDot : findNearestOnLine; const onMouseMove = (e: MouseEvent) => { const mx = e.clientX - canvasRect.left; const my = e.clientY - canvasRect.top; - const hit = findNearest(points, mx, my); + const hit = showDots + ? findNearestDot(points, mx, my) + : findNearestOnLine(points, mx); if (hit) { tooltip.textContent = hit.label; @@ -196,8 +198,8 @@ function findNearestDot( if (!best) { return null; } - return Math.abs(best.x - mx) < HIT_RADIUS && - Math.abs(best.y - my) < HIT_RADIUS + return Math.abs(best.x - mx) < HIT_RADIUS_PX && + Math.abs(best.y - my) < HIT_RADIUS_PX ? best : null; } @@ -205,15 +207,15 @@ function findNearestDot( function findNearestOnLine( points: ChartPoint[], mx: number, - _my: number, ): ChartPoint | null { const best = findNearestByX(points, mx); if (!best) { return null; } + const last = points.at(-1) ?? best; const avgGap = points.length > 1 - ? (points[points.length - 1].x - points[0].x) / (points.length - 1) - : HIT_RADIUS; + ? (last.x - points[0].x) / (points.length - 1) + : HIT_RADIUS_PX; return Math.abs(best.x - mx) < avgGap ? best : null; } diff --git a/src/commands.ts b/src/commands.ts index 31d7403b..b1d20ed6 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -184,7 +184,7 @@ export class Commands { const input = await vscode.window.showInputBox({ title: "Speed Test Duration", - prompt: "How long should the test run? (seconds)", + prompt: "Duration in seconds", value: "5", validateInput: (value) => { const n = Number(value.trim()); @@ -244,7 +244,7 @@ export class Commands { ); if (result.ok) { - showSpeedtestChart(this.extensionUri, result.value); + showSpeedtestChart(this.extensionUri, result.value, workspaceId); return; } diff --git a/src/webviews/speedtest/speedtestPanel.ts b/src/webviews/speedtest/speedtestPanel.ts index f96d6755..02ed2288 100644 --- a/src/webviews/speedtest/speedtestPanel.ts +++ b/src/webviews/speedtest/speedtestPanel.ts @@ -1,16 +1,22 @@ import * as vscode from "vscode"; -import { buildCommandHandlers, SpeedtestApi } from "@repo/shared"; +import { + buildCommandHandlers, + type SpeedtestData, + SpeedtestApi, +} from "@repo/shared"; import { getWebviewHtml } from "../util"; export function showSpeedtestChart( extensionUri: vscode.Uri, json: string, + workspaceName: string, ): void { + const title = `Speed Test: ${workspaceName}`; const panel = vscode.window.createWebviewPanel( - "coderSpeedtest", - "Speed Test Results", + "coder.speedtestPanel", + title, vscode.ViewColumn.One, { enableScripts: true, @@ -29,21 +35,36 @@ export function showSpeedtestChart( panel.webview, extensionUri, "speedtest", - "Speed Test Results", + title, ); // Webview context is discarded when hidden (no retainContextWhenHidden), - // so re-send on visibility change to re-hydrate the chart. + // so re-send on visibility change to re-hydrate the chart. Also re-send on + // theme change so the canvas (which caches theme colors into pixels) redraws. + const payload: SpeedtestData = { json, workspaceName }; const sendData = () => { panel.webview.postMessage({ type: SpeedtestApi.data.method, - data: json, + data: payload, }); }; sendData(); - panel.onDidChangeViewState(() => { - if (panel.visible) { - sendData(); + + const disposables: vscode.Disposable[] = [ + panel.onDidChangeViewState(() => { + if (panel.visible) { + sendData(); + } + }), + vscode.window.onDidChangeActiveColorTheme(() => { + if (panel.visible) { + sendData(); + } + }), + ]; + panel.onDidDispose(() => { + for (const d of disposables) { + d.dispose(); } }); diff --git a/test/tsconfig.json b/test/tsconfig.json index 8aefabf3..03446ad2 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -11,7 +11,8 @@ "@repo/shared/*": ["../packages/shared/src/*"], "@repo/webview-shared": ["../packages/webview-shared/src/index.ts"], "@repo/webview-shared/*": ["../packages/webview-shared/src/*"], - "@repo/tasks/*": ["../packages/tasks/src/*"] + "@repo/tasks/*": ["../packages/tasks/src/*"], + "@repo/speedtest/*": ["../packages/speedtest/src/*"] } }, "include": [".", "../src"] diff --git a/test/unit/webviews/speedtest/speedtestPanel.test.ts b/test/unit/webviews/speedtest/speedtestPanel.test.ts index 561b2c8d..636095b7 100644 --- a/test/unit/webviews/speedtest/speedtestPanel.test.ts +++ b/test/unit/webviews/speedtest/speedtestPanel.test.ts @@ -12,10 +12,8 @@ describe("SpeedtestApi", () => { }, }); - // Handler is keyed by the wire method name expect(handlers[SpeedtestApi.viewJson.method]).toBeDefined(); - // Dispatching through the handler passes the data correctly await handlers[SpeedtestApi.viewJson.method]('{"test": true}'); expect(receivedData).toBe('{"test": true}'); }); diff --git a/test/webview/speedtest/chart.test.ts b/test/webview/speedtest/chart.test.ts new file mode 100644 index 00000000..214a624f --- /dev/null +++ b/test/webview/speedtest/chart.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { formatTick, niceStep } from "@repo/speedtest/chart"; + +describe("niceStep", () => { + it("rounds up to the next candidate for sub-hour ranges", () => { + expect(niceStep(0.3)).toBe(1); + expect(niceStep(1)).toBe(1); + expect(niceStep(1.5)).toBe(2); + expect(niceStep(3)).toBe(5); + expect(niceStep(7)).toBe(10); + expect(niceStep(45)).toBe(60); + expect(niceStep(200)).toBe(300); + }); + + it("rounds up to whole hours past the longest candidate", () => { + expect(niceStep(3600)).toBe(3600); + expect(niceStep(4000)).toBe(7200); + expect(niceStep(10000)).toBe(10800); + }); +}); + +describe("formatTick", () => { + it("uses seconds below a minute", () => { + expect(formatTick(0, 1)).toBe("0s"); + expect(formatTick(5, 5)).toBe("5s"); + expect(formatTick(30, 15)).toBe("30s"); + }); + + it("uses minutes between 1m and 1h", () => { + expect(formatTick(60, 60)).toBe("1m"); + expect(formatTick(120, 60)).toBe("2m"); + expect(formatTick(90, 60)).toBe("1.5m"); + expect(formatTick(300, 300)).toBe("5m"); + }); + + it("uses hours at or above 1h", () => { + expect(formatTick(3600, 3600)).toBe("1h"); + expect(formatTick(7200, 3600)).toBe("2h"); + expect(formatTick(5400, 3600)).toBe("1.5h"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1609c2f5..55696ba3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,6 +40,10 @@ export default defineConfig({ alias: { "@repo/webview-shared": webviewSharedAlias, "@repo/tasks": path.resolve(__dirname, "packages/tasks/src"), + "@repo/speedtest": path.resolve( + __dirname, + "packages/speedtest/src", + ), }, }, }, From 1ce040188a2147658cab4412133ba47dfcf44c5f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 20 Apr 2026 19:46:07 +0300 Subject: [PATCH 4/4] refactor: address speedtest webview self-review feedback Validate CLI output with Zod on the extension side so the webview trusts typed data and stops hand-parsing JSON. Share SpeedtestResult types via @repo/shared and flatten the message payload. Webview cleanup: newspaper-ordered index.ts with a main() entrypoint, em-scaled chart layout, named constants in place of magic numbers, empty samples handled with a message, and a subscribeNotification helper that useIpc now delegates to. Pure helpers (niceStep, formatTick, findNearest*, toChartSamples) live in chartUtils.ts for easy unit testing. Panel: extract webview panel logic into SpeedtestPanelFactory, a ServiceContainer-owned class that takes extensionUri + logger in its constructor and exposes show(payload). Surfaces webview handler errors through the logger and tracks every subscription in a disposables array. Drops the now-unused isGoDuration helper. Tests: cover SpeedtestPanelFactory end-to-end with a reusable createMockWebviewPanel harness in testHelpers, plus chartUtils and renderLineChart unit tests. Canvas 2D is stubbed globally in test/webview/setup.ts so chart tests don't need per-test plumbing. Protocol: buildApiHook and useIpc now take RequestDef, CommandDef, and NotificationDef directly in both overloads, no casts needed. Platform: move toError into shared with a serialize hook so the extension keeps util.inspect output, rename createBaseWebviewConfig to createWebviewConfig, add createReactWebviewConfig, extract reportElapsedProgress for reuse by other long running commands, and drop the createWebviewConfig eslint ignore. --- CHANGELOG.md | 8 + eslint.config.mjs | 1 - packages/shared/src/error/utils.ts | 39 ++++ packages/shared/src/index.ts | 13 +- packages/shared/src/ipc/protocol.ts | 22 +- packages/shared/src/speedtest/api.ts | 17 +- packages/speedtest/src/chart.ts | 132 ++++++------ packages/speedtest/src/chartUtils.ts | 103 +++++++++ packages/speedtest/src/index.css | 31 ++- packages/speedtest/src/index.ts | 198 +++++++----------- packages/speedtest/vite.config.ts | 4 +- packages/tasks/vite.config.ts | 4 +- .../webview-shared/createWebviewConfig.ts | 18 +- packages/webview-shared/src/index.ts | 3 + packages/webview-shared/src/notifications.ts | 20 ++ packages/webview-shared/src/react/useIpc.ts | 85 +++----- packages/webview-shared/tsconfig.json | 2 +- src/commands.ts | 58 ++--- src/core/cliExec.ts | 8 - src/core/container.ts | 12 +- src/error/errorUtils.ts | 43 +--- src/progress.ts | 24 +++ src/webviews/speedtest/speedtestPanel.ts | 89 -------- .../speedtest/speedtestPanelFactory.ts | 107 ++++++++++ src/webviews/speedtest/types.ts | 18 ++ test/mocks/testHelpers.ts | 90 ++++++++ test/mocks/vscode.runtime.ts | 2 + test/unit/core/cliExec.test.ts | 20 -- .../webviews/speedtest/speedtestPanel.test.ts | 25 --- .../speedtest/speedtestPanelFactory.test.ts | 138 ++++++++++++ test/webview/setup.ts | 22 ++ test/webview/speedtest/chart.test.ts | 125 ++++++++--- test/webview/speedtest/chartUtils.test.ts | 142 +++++++++++++ 33 files changed, 1100 insertions(+), 523 deletions(-) create mode 100644 packages/shared/src/error/utils.ts create mode 100644 packages/speedtest/src/chartUtils.ts create mode 100644 packages/webview-shared/src/notifications.ts delete mode 100644 src/webviews/speedtest/speedtestPanel.ts create mode 100644 src/webviews/speedtest/speedtestPanelFactory.ts create mode 100644 src/webviews/speedtest/types.ts delete mode 100644 test/unit/webviews/speedtest/speedtestPanel.test.ts create mode 100644 test/unit/webviews/speedtest/speedtestPanelFactory.test.ts create mode 100644 test/webview/speedtest/chartUtils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7a3e63..2a82613f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ from published versions since it shows up in the VS Code extension changelog tab and is confusing to users. Add it back between releases if needed. --> +## Unreleased + +### Changed + +- **Coder: Speed Test Workspace** results now render in an interactive throughput chart with + hover tooltips, a summary header, and a real-time progress bar while the CLI runs. A View JSON + action exposes the raw output. + ## [v1.14.4-pre](https://github.com/coder/vscode-coder/releases/tag/v1.14.4-pre) 2026-04-20 ### Added diff --git a/eslint.config.mjs b/eslint.config.mjs index 4cfcd965..2c3289c6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,7 +17,6 @@ export default defineConfig( "**/*.d.ts", "vitest.config.ts", "**/vite.config*.ts", - "**/createWebviewConfig.ts", ".vscode-test/**", "test/fixtures/scripts/**", ]), diff --git a/packages/shared/src/error/utils.ts b/packages/shared/src/error/utils.ts new file mode 100644 index 00000000..bbb7ff9d --- /dev/null +++ b/packages/shared/src/error/utils.ts @@ -0,0 +1,39 @@ +/** Convert any thrown value into an Error. Pass `serialize` (e.g. `util.inspect` + * in Node) for richer object formatting; the default `JSON.stringify` will + * throw on circular inputs and fall through to `defaultMsg`. */ +export function toError( + value: unknown, + defaultMsg?: string, + serialize: (value: unknown) => string = JSON.stringify, +): Error { + if (value instanceof Error) { + return value; + } + + if (typeof value === "string") { + return new Error(value); + } + + if ( + value !== null && + typeof value === "object" && + "message" in value && + typeof value.message === "string" + ) { + const error = new Error(value.message); + if ("name" in value && typeof value.name === "string") { + error.name = value.name; + } + return error; + } + + if (value === null || value === undefined) { + return new Error(defaultMsg ?? "Unknown error"); + } + + try { + return new Error(serialize(value)); + } catch { + return new Error(defaultMsg ?? "Non-serializable error object"); + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9a931126..8f9ccef3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,7 +1,18 @@ +// IPC protocol types export * from "./ipc/protocol"; +// Error utilities +export { toError } from "./error/utils"; + +// Tasks types, utilities, and API export * from "./tasks/types"; export * from "./tasks/utils"; export * from "./tasks/api"; -export { SpeedtestApi, type SpeedtestData } from "./speedtest/api"; +// Speedtest API +export { + SpeedtestApi, + type SpeedtestData, + type SpeedtestInterval, + type SpeedtestResult, +} from "./speedtest/api"; diff --git a/packages/shared/src/ipc/protocol.ts b/packages/shared/src/ipc/protocol.ts index f30c3208..2f594b64 100644 --- a/packages/shared/src/ipc/protocol.ts +++ b/packages/shared/src/ipc/protocol.ts @@ -166,26 +166,34 @@ export function buildApiHook< api: Api, ipc: { request: ( - def: { method: string; _types?: { params: P; response: R } }, + def: RequestDef, ...args: P extends void ? [] : [params: P] ) => Promise; command:

( - def: { method: string; _types?: { params: P } }, + def: CommandDef

, ...args: P extends void ? [] : [params: P] ) => void; onNotification: ( - def: { method: string; _types?: { data: D } }, + def: NotificationDef, cb: (data: D) => void, ) => () => void; }, ): ApiHook; export function buildApiHook( - api: Record, + api: Record< + string, + | RequestDef + | CommandDef + | NotificationDef + >, ipc: { - request: (def: { method: string }, params?: unknown) => Promise; - command: (def: { method: string }, params?: unknown) => void; + request: ( + def: RequestDef, + params?: unknown, + ) => Promise; + command: (def: CommandDef, params?: unknown) => void; onNotification: ( - def: { method: string }, + def: NotificationDef, cb: (data: unknown) => void, ) => () => void; }, diff --git a/packages/shared/src/speedtest/api.ts b/packages/shared/src/speedtest/api.ts index fc15ad0a..98a16d49 100644 --- a/packages/shared/src/speedtest/api.ts +++ b/packages/shared/src/speedtest/api.ts @@ -1,13 +1,24 @@ import { defineCommand, defineNotification } from "../ipc/protocol"; +export interface SpeedtestInterval { + start_time_seconds: number; + end_time_seconds: number; + throughput_mbits: number; +} + +export interface SpeedtestResult { + overall: SpeedtestInterval; + intervals: SpeedtestInterval[]; +} + export interface SpeedtestData { - json: string; workspaceName: string; + result: SpeedtestResult; } export const SpeedtestApi = { - /** Extension pushes results to the webview */ + /** Extension pushes parsed results to the webview */ data: defineNotification("speedtest/data"), /** Webview requests to open raw JSON in a text editor */ - viewJson: defineCommand("speedtest/viewJson"), + viewJson: defineCommand("speedtest/viewJson"), } as const; diff --git a/packages/speedtest/src/chart.ts b/packages/speedtest/src/chart.ts index 2651c30d..f2a93b02 100644 --- a/packages/speedtest/src/chart.ts +++ b/packages/speedtest/src/chart.ts @@ -1,36 +1,20 @@ -export interface ChartPoint { - x: number; - y: number; - label: string; -} +import { type ChartPoint, formatTick, niceStep } from "./chartUtils"; -const MIN_TICK_SPACING_PX = 48; +const MIN_TICK_SPACING_EM = 4; const Y_GRID_LINES = 5; -/** 10% padding above the max value so the line doesn't hug the top edge. */ +/** 10% headroom above the max so the line doesn't hug the top edge. */ const Y_HEADROOM = 1.1; - -/** Candidate x-axis tick step sizes in seconds (1s, 2s, 5s, ..., 30m, 1h). */ -const TICK_STEP_SECONDS = [ - 1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600, -]; - -export function niceStep(raw: number): number { - return ( - TICK_STEP_SECONDS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600 - ); -} - -export function formatTick(t: number, step: number): string { - if (step >= 3600) { - const h = t / 3600; - return `${Number.isInteger(h) ? h : h.toFixed(1)}h`; - } - if (step >= 60) { - const m = t / 60; - return `${Number.isInteger(m) ? m : m.toFixed(1)}m`; - } - return `${t}s`; -} +const DOT_RADIUS_PX = 4; +const LINE_WIDTH_PX = 2; + +const PLOT_PAD_EM = { top: 2, right: 2, bottom: 3.5 }; +const Y_LABEL_GAP_EM = 1; +const X_LABEL_GAP_EM = 1.5; +const X_AXIS_TITLE_GAP_EM = 0.25; +const Y_AXIS_TITLE_GAP_EM = 1; +/** Room reserved for the rotated "Mbps" title. */ +const Y_AXIS_TITLE_ROOM_EM = 1.5; +const LEFT_PAD_EM = Y_AXIS_TITLE_GAP_EM + Y_AXIS_TITLE_ROOM_EM + Y_LABEL_GAP_EM; interface Theme { fg: string; @@ -39,27 +23,27 @@ interface Theme { family: string; } -/** - * Read VS Code theme colors from CSS custom properties on . Canvas - * pixels don't inherit CSS vars, so we re-read on each render to pick up - * theme switches. - */ +/** Canvas pixels don't inherit CSS vars, so re-read on every render. */ function readTheme(): Theme { const s = getComputedStyle(document.documentElement); const css = (prop: string) => s.getPropertyValue(prop).trim(); return { fg: + css("--vscode-charts-foreground") || css("--vscode-descriptionForeground") || css("--vscode-editor-foreground") || "#888", - // Use the button color so the accent tracks the theme; charts-* vars - // are fixed hues by design. + // focusBorder tracks the theme's accent; charts.blue is a fixed hue + // kept as a late fallback. accent: - css("--vscode-button-background") || + css("--vscode-chart-line") || css("--vscode-focusBorder") || css("--vscode-charts-blue") || "#3794ff", - grid: css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)", + grid: + css("--vscode-chart-guide") || + css("--vscode-charts-lines") || + "rgba(127, 127, 127, 0.35)", family: css("--vscode-font-family") || "sans-serif", }; } @@ -69,18 +53,21 @@ function layoutChart( samples: ChartPoint[], width: number, height: number, + pxPerEm: number, family: string, ) { const maxVal = samples.reduce((m, s) => Math.max(m, s.y), 1) * Y_HEADROOM; const maxX = samples.at(-1)?.x ?? 1; - const xRange = maxX || 1; ctx.font = `1em ${family}`; const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width; const pad = { - top: 24, - right: 24, - bottom: 52, - left: Math.max(48, yLabelWidth + 24), + top: PLOT_PAD_EM.top * pxPerEm, + right: PLOT_PAD_EM.right * pxPerEm, + bottom: PLOT_PAD_EM.bottom * pxPerEm, + left: Math.max( + PLOT_PAD_EM.right * pxPerEm, + yLabelWidth + LEFT_PAD_EM * pxPerEm, + ), }; const plotW = width - pad.left - pad.right; const plotH = height - pad.top - pad.bottom; @@ -90,9 +77,8 @@ function layoutChart( plotH, maxVal, maxX, - xRange, height, - tAt: (t: number) => pad.left + (t / xRange) * plotW, + tAt: (t: number) => pad.left + (t / maxX) * plotW, yAt: (v: number) => pad.top + plotH - (v / maxVal) * plotH, }; } @@ -103,8 +89,9 @@ function drawAxes( ctx: CanvasRenderingContext2D, layout: Layout, theme: Theme, + pxPerEm: number, ): void { - const { pad, plotW, plotH, maxVal, maxX, xRange, height, tAt, yAt } = layout; + const { pad, plotW, plotH, maxVal, maxX, height, tAt, yAt } = layout; ctx.strokeStyle = theme.grid; ctx.lineWidth = 1; @@ -117,7 +104,11 @@ function drawAxes( ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + plotW, y); ctx.stroke(); - ctx.fillText(v.toFixed(0), pad.left - 12, y + 5); + ctx.fillText( + v.toFixed(0), + pad.left - Y_LABEL_GAP_EM * pxPerEm, + y + pxPerEm / 3, + ); } ctx.strokeStyle = theme.fg; @@ -128,16 +119,24 @@ function drawAxes( ctx.textAlign = "center"; const step = niceStep( - xRange / Math.max(1, Math.floor(plotW / MIN_TICK_SPACING_PX)), + maxX / Math.max(1, Math.floor(plotW / (MIN_TICK_SPACING_EM * pxPerEm))), ); for (let t = 0; t <= maxX; t += step) { - ctx.fillText(formatTick(t, step), tAt(t), height - pad.bottom + 24); + ctx.fillText( + formatTick(t, step), + tAt(t), + height - pad.bottom + X_LABEL_GAP_EM * pxPerEm, + ); } ctx.font = `0.95em ${theme.family}`; - ctx.fillText("Time", pad.left + plotW / 2, height - 4); + ctx.fillText( + "Time", + pad.left + plotW / 2, + height - X_AXIS_TITLE_GAP_EM * pxPerEm, + ); ctx.save(); - ctx.translate(14, pad.top + plotH / 2); + ctx.translate(Y_AXIS_TITLE_GAP_EM * pxPerEm, pad.top + plotH / 2); ctx.rotate(-Math.PI / 2); ctx.fillText("Mbps", 0, 0); ctx.restore(); @@ -187,7 +186,7 @@ function drawSeries( ctx.lineTo(tAt(samples[i].x), yAt(samples[i].y)); } ctx.strokeStyle = theme.accent; - ctx.lineWidth = 2; + ctx.lineWidth = LINE_WIDTH_PX; ctx.stroke(); return samples.map((s) => { @@ -195,7 +194,7 @@ function drawSeries( const y = yAt(s.y); if (showDots) { ctx.beginPath(); - ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.arc(x, y, DOT_RADIUS_PX, 0, Math.PI * 2); ctx.fillStyle = theme.accent; ctx.fill(); } @@ -203,16 +202,15 @@ function drawSeries( }); } +/** Render the speedtest chart. Caller must ensure `samples` is non-empty. */ export function renderLineChart( canvas: HTMLCanvasElement, samples: ChartPoint[], showDots: boolean, ): ChartPoint[] { - // Scale the backing store by DPR for crisp rendering on high-DPI - // displays. ctx.scale lets draw calls keep using CSS pixels. - const { width, height } = ( - canvas.parentElement ?? canvas - ).getBoundingClientRect(); + // Scale backing store by DPR so drawing stays crisp on high-DPI screens. + const parent = canvas.parentElement ?? canvas; + const { width, height } = parent.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; @@ -222,10 +220,16 @@ export function renderLineChart( } ctx.scale(dpr, dpr); + const pxPerEm = parseFloat(getComputedStyle(parent).fontSize) || 14; const theme = readTheme(); - const layout = layoutChart(ctx, samples, width, height, theme.family); - drawAxes(ctx, layout, theme); - return samples.length > 0 - ? drawSeries(ctx, samples, layout, theme, showDots) - : []; + const layout = layoutChart( + ctx, + samples, + width, + height, + pxPerEm, + theme.family, + ); + drawAxes(ctx, layout, theme, pxPerEm); + return drawSeries(ctx, samples, layout, theme, showDots); } diff --git a/packages/speedtest/src/chartUtils.ts b/packages/speedtest/src/chartUtils.ts new file mode 100644 index 00000000..0333e8b5 --- /dev/null +++ b/packages/speedtest/src/chartUtils.ts @@ -0,0 +1,103 @@ +import type { SpeedtestResult } from "@repo/shared"; + +export interface ChartPoint { + x: number; + y: number; + label: string; +} + +export const HIT_RADIUS_PX = 12; + +/** Candidate x-axis tick step sizes in seconds (1s, 2s, 5s, ..., 30m, 1h). */ +const TICK_STEP_SECONDS = [ + 1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600, +]; + +/** Round up a raw tick size (seconds) to the next friendly candidate. */ +export function niceStep(raw: number): number { + return ( + TICK_STEP_SECONDS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600 + ); +} + +/** Format a tick value as `Ns`, `Nm`, or `Nh` depending on the step size. */ +export function formatTick(t: number, step: number): string { + if (step >= 3600) { + const h = t / 3600; + return `${Number.isInteger(h) ? h : h.toFixed(1)}h`; + } + if (step >= 60) { + const m = t / 60; + return `${Number.isInteger(m) ? m : m.toFixed(1)}m`; + } + return `${t}s`; +} + +/** Convert speedtest intervals into chart points with hover labels. */ +export function toChartSamples( + intervals: SpeedtestResult["intervals"], +): ChartPoint[] { + return intervals.map((iv) => ({ + x: iv.end_time_seconds, + y: iv.throughput_mbits, + label: `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, + })); +} + +/** Nearest point to the cursor within a square hit box; null if out of range. */ +export function findNearestDot( + points: ChartPoint[], + mx: number, + my: number, +): ChartPoint | null { + const best = findNearestByX(points, mx); + if (!best) { + return null; + } + return Math.abs(best.x - mx) < HIT_RADIUS_PX && + Math.abs(best.y - my) < HIT_RADIUS_PX + ? best + : null; +} + +/** Nearest point to the cursor, accepting any x within the average sample gap. */ +export function findNearestOnLine( + points: ChartPoint[], + mx: number, +): ChartPoint | null { + const best = findNearestByX(points, mx); + if (!best) { + return null; + } + const last = points.at(-1) ?? best; + const avgGap = + points.length > 1 + ? (last.x - points[0].x) / (points.length - 1) + : HIT_RADIUS_PX; + return Math.abs(best.x - mx) < avgGap ? best : null; +} + +/** Binary search for the point whose x is closest to `mx`. */ +export function findNearestByX( + points: ChartPoint[], + mx: number, +): ChartPoint | null { + if (points.length === 0) { + return null; + } + let lo = 0; + let hi = points.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (points[mid].x < mx) { + lo = mid + 1; + } else { + hi = mid; + } + } + let best = points[lo]; + if (lo > 0 && Math.abs(points[lo - 1].x - mx) < Math.abs(best.x - mx)) { + best = points[lo - 1]; + } + return best; +} diff --git a/packages/speedtest/src/index.css b/packages/speedtest/src/index.css index 3e986f23..f9eb68b2 100644 --- a/packages/speedtest/src/index.css +++ b/packages/speedtest/src/index.css @@ -6,8 +6,8 @@ body { margin: 0; - padding: 24px; - min-width: 360px; + padding: 1.5em; + min-width: 22em; background: var(--vscode-editor-background); color: var(--vscode-editor-foreground); font-family: var(--vscode-font-family); @@ -15,7 +15,7 @@ body { } .workspace-name { - margin: 0 0 32px; + margin: 0 0 2em; font-size: 1.8em; font-weight: 600; text-align: center; @@ -26,8 +26,8 @@ body { display: flex; flex-wrap: wrap; justify-content: center; - gap: 16px 48px; - margin-bottom: 24px; + gap: 1em 3em; + margin-bottom: 1.5em; text-align: center; } @@ -37,7 +37,7 @@ body { opacity: 0.6; text-transform: uppercase; letter-spacing: 0.05em; - margin-bottom: 4px; + margin-bottom: 0.25em; } .stat-value { @@ -53,8 +53,8 @@ body { .chart-container { position: relative; - height: 320px; - margin-bottom: 20px; + height: 20em; + margin-bottom: 1.25em; } .chart-container canvas { @@ -68,7 +68,7 @@ body { } button { - padding: 6px 16px; + padding: 0.4em 1em; border: 1px solid var(--vscode-button-border, transparent); border-radius: 2px; background: var(--vscode-button-secondaryBackground); @@ -83,7 +83,7 @@ button:hover { .tooltip { position: absolute; - padding: 4px 8px; + padding: 0.25em 0.5em; border-radius: 3px; background: var(--vscode-editorHoverWidget-background); border: 1px solid var(--vscode-editorHoverWidget-border); @@ -99,7 +99,16 @@ button:hover { opacity: 1; } +.error, +.empty { + text-align: center; + margin: 2em 0; +} + .error { color: var(--vscode-errorForeground); - text-align: center; +} + +.empty { + opacity: 0.7; } diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts index 2ac405ac..bcc71364 100644 --- a/packages/speedtest/src/index.ts +++ b/packages/speedtest/src/index.ts @@ -1,55 +1,33 @@ -import { type SpeedtestData, SpeedtestApi } from "@repo/shared"; -import { postMessage } from "@repo/webview-shared"; - -import { type ChartPoint, renderLineChart } from "./chart"; +import { SpeedtestApi, type SpeedtestResult, toError } from "@repo/shared"; +import { postMessage, subscribeNotification } from "@repo/webview-shared"; + +import { renderLineChart } from "./chart"; +import { + type ChartPoint, + findNearestDot, + findNearestOnLine, + toChartSamples, +} from "./chartUtils"; import "./index.css"; -interface SpeedtestInterval { - start_time_seconds: number; - end_time_seconds: number; - throughput_mbits: number; -} - -interface SpeedtestResult { - overall: SpeedtestInterval; - intervals: SpeedtestInterval[]; -} - -const HIT_RADIUS_PX = 12; /** Above this sample count, render the line alone (no per-point dots). */ const DOT_THRESHOLD = 20; +/** Gap in pixels between the tooltip and the point it describes. */ +const TOOLTIP_GAP_PX = 32; let cleanup: (() => void) | undefined; -window.addEventListener( - "message", - (event: MessageEvent<{ type: string; data?: SpeedtestData }>) => { - if (event.data.type !== SpeedtestApi.data.method || !event.data.data) { - return; - } - const { json, workspaceName } = event.data.data; +function main(): void { + subscribeNotification(SpeedtestApi.data, ({ workspaceName, result }) => { try { - const result = JSON.parse(json) as SpeedtestResult; cleanup?.(); cleanup = renderPage(result, workspaceName, () => - postMessage({ - method: SpeedtestApi.viewJson.method, - params: json, - }), + postMessage({ method: SpeedtestApi.viewJson.method }), ); } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - showError(`Failed to parse speedtest data: ${detail}`); + showError(`Failed to render speedtest: ${toError(err).message}`); } - }, -); - -function toChartSamples(intervals: SpeedtestInterval[]): ChartPoint[] { - return intervals.map((iv) => ({ - x: iv.end_time_seconds, - y: iv.throughput_mbits, - label: `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, - })); + }); } function renderPage( @@ -63,12 +41,30 @@ function renderPage( } root.innerHTML = ""; + root.appendChild(renderHeading(workspaceName)); + root.appendChild(renderSummary(data)); + const samples = toChartSamples(data.intervals); + if (samples.length === 0) { + root.appendChild(renderEmptyMessage()); + root.appendChild(renderActions(onViewJson)); + return () => undefined; + } + + const chart = renderChart(samples); + root.appendChild(chart.container); + root.appendChild(renderActions(onViewJson)); + return chart.cleanup; +} + +function renderHeading(workspaceName: string): HTMLElement { const heading = document.createElement("h1"); heading.className = "workspace-name"; heading.textContent = workspaceName; - root.appendChild(heading); + return heading; +} +function renderSummary(data: SpeedtestResult): HTMLElement { const summary = document.createElement("div"); summary.className = "summary"; summary.innerHTML = ` @@ -85,27 +81,30 @@ function renderPage( ${data.intervals.length} `; - root.appendChild(summary); + return summary; +} +function renderChart(samples: ChartPoint[]): { + container: HTMLElement; + cleanup: () => void; +} { const container = document.createElement("div"); container.className = "chart-container"; const canvas = document.createElement("canvas"); const tooltip = document.createElement("div"); tooltip.className = "tooltip"; container.append(canvas, tooltip); - root.appendChild(container); - const samples = toChartSamples(data.intervals); const showDots = samples.length <= DOT_THRESHOLD; - let points: ChartPoint[] = []; - let canvasRect: DOMRect; + let canvasRect: DOMRect | undefined; const draw = () => { points = renderLineChart(canvas, samples, showDots); canvasRect = canvas.getBoundingClientRect(); }; - draw(); + // ResizeObserver's first callback (fired when the caller appends `container` + // to the DOM) drives the initial paint; later fires handle resizes. let rafId = 0; const observer = new ResizeObserver(() => { cancelAnimationFrame(rafId); @@ -114,44 +113,58 @@ function renderPage( observer.observe(container); const onMouseMove = (e: MouseEvent) => { + if (!canvasRect) { + return; + } const mx = e.clientX - canvasRect.left; const my = e.clientY - canvasRect.top; const hit = showDots ? findNearestDot(points, mx, my) : findNearestOnLine(points, mx); - - if (hit) { - tooltip.textContent = hit.label; - const tw = tooltip.offsetWidth; - const left = Math.max( - 0, - Math.min(hit.x - tw / 2, container.offsetWidth - tw), - ); - tooltip.style.left = `${left}px`; - tooltip.style.top = `${hit.y - 32}px`; - tooltip.classList.add("visible"); - } else { + if (!hit) { tooltip.classList.remove("visible"); + return; } + tooltip.textContent = hit.label; + const tw = tooltip.offsetWidth; + const left = Math.max( + 0, + Math.min(hit.x - tw / 2, container.offsetWidth - tw), + ); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${hit.y - TOOLTIP_GAP_PX}px`; + tooltip.classList.add("visible"); }; const onMouseLeave = () => tooltip.classList.remove("visible"); canvas.addEventListener("mousemove", onMouseMove); canvas.addEventListener("mouseleave", onMouseLeave); + return { + container, + cleanup: () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + canvas.removeEventListener("mousemove", onMouseMove); + canvas.removeEventListener("mouseleave", onMouseLeave); + }, + }; +} + +function renderActions(onViewJson: () => void): HTMLElement { const actions = document.createElement("div"); actions.className = "actions"; const viewBtn = document.createElement("button"); viewBtn.textContent = "View JSON"; viewBtn.addEventListener("click", onViewJson); actions.appendChild(viewBtn); - root.appendChild(actions); + return actions; +} - return () => { - cancelAnimationFrame(rafId); - observer.disconnect(); - canvas.removeEventListener("mousemove", onMouseMove); - canvas.removeEventListener("mouseleave", onMouseLeave); - }; +function renderEmptyMessage(): HTMLElement { + const p = document.createElement("p"); + p.className = "empty"; + p.textContent = "No samples returned from the speed test."; + return p; } function showError(message: string): void { @@ -165,57 +178,4 @@ function showError(message: string): void { root.replaceChildren(p); } -function findNearestByX( - points: ChartPoint[], - mx: number, -): ChartPoint | undefined { - if (points.length === 0) { - return undefined; - } - let lo = 0; - let hi = points.length - 1; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (points[mid].x < mx) { - lo = mid + 1; - } else { - hi = mid; - } - } - let best = points[lo]; - if (lo > 0 && Math.abs(points[lo - 1].x - mx) < Math.abs(best.x - mx)) { - best = points[lo - 1]; - } - return best; -} - -function findNearestDot( - points: ChartPoint[], - mx: number, - my: number, -): ChartPoint | null { - const best = findNearestByX(points, mx); - if (!best) { - return null; - } - return Math.abs(best.x - mx) < HIT_RADIUS_PX && - Math.abs(best.y - my) < HIT_RADIUS_PX - ? best - : null; -} - -function findNearestOnLine( - points: ChartPoint[], - mx: number, -): ChartPoint | null { - const best = findNearestByX(points, mx); - if (!best) { - return null; - } - const last = points.at(-1) ?? best; - const avgGap = - points.length > 1 - ? (last.x - points[0].x) / (points.length - 1) - : HIT_RADIUS_PX; - return Math.abs(best.x - mx) < avgGap ? best : null; -} +main(); diff --git a/packages/speedtest/vite.config.ts b/packages/speedtest/vite.config.ts index c89fc115..90634aa2 100644 --- a/packages/speedtest/vite.config.ts +++ b/packages/speedtest/vite.config.ts @@ -1,3 +1,3 @@ -import { createBaseWebviewConfig } from "../webview-shared/createWebviewConfig"; +import { createWebviewConfig } from "../webview-shared/createWebviewConfig"; -export default createBaseWebviewConfig("speedtest", __dirname); +export default createWebviewConfig("speedtest", __dirname); diff --git a/packages/tasks/vite.config.ts b/packages/tasks/vite.config.ts index 830b84dd..5495da20 100644 --- a/packages/tasks/vite.config.ts +++ b/packages/tasks/vite.config.ts @@ -1,3 +1,3 @@ -import { createWebviewConfig } from "../webview-shared/createWebviewConfig"; +import { createReactWebviewConfig } from "../webview-shared/createWebviewConfig"; -export default createWebviewConfig("tasks", __dirname); +export default createReactWebviewConfig("tasks", __dirname); diff --git a/packages/webview-shared/createWebviewConfig.ts b/packages/webview-shared/createWebviewConfig.ts index f4be31a0..f6d8728e 100644 --- a/packages/webview-shared/createWebviewConfig.ts +++ b/packages/webview-shared/createWebviewConfig.ts @@ -1,15 +1,13 @@ import babel from "@rolldown/plugin-babel"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import { resolve } from "node:path"; -import { defineConfig, type Plugin, type UserConfig } from "vite"; +import { defineConfig, type PluginOption, type UserConfig } from "vite"; -/** - * Create a base Vite config for a webview package (no framework). - */ -export function createBaseWebviewConfig( +/** Create a Vite config for a non-framework webview package. */ +export function createWebviewConfig( webviewName: string, dirname: string, - options?: { entry?: string; plugins?: Plugin[] }, + options?: { entry?: string; plugins?: PluginOption[] }, ): UserConfig { const production = process.env.NODE_ENV === "production"; const entry = options?.entry ?? "src/index.ts"; @@ -52,14 +50,12 @@ export function createBaseWebviewConfig( }); } -/** - * Create a Vite config for a React-based webview package. - */ -export function createWebviewConfig( +/** Create a Vite config for a React-based webview package. */ +export function createReactWebviewConfig( webviewName: string, dirname: string, ): UserConfig { - return createBaseWebviewConfig(webviewName, dirname, { + return createWebviewConfig(webviewName, dirname, { entry: "src/index.tsx", plugins: [react(), babel({ presets: [reactCompilerPreset()] })], }); diff --git a/packages/webview-shared/src/index.ts b/packages/webview-shared/src/index.ts index b2588950..334b27df 100644 --- a/packages/webview-shared/src/index.ts +++ b/packages/webview-shared/src/index.ts @@ -7,3 +7,6 @@ export interface WebviewMessage { // VS Code state API export { getState, setState, postMessage } from "./api"; + +// Notification subscription for non-React webviews +export { subscribeNotification } from "./notifications"; diff --git a/packages/webview-shared/src/notifications.ts b/packages/webview-shared/src/notifications.ts new file mode 100644 index 00000000..e71877b6 --- /dev/null +++ b/packages/webview-shared/src/notifications.ts @@ -0,0 +1,20 @@ +import type { NotificationDef } from "@repo/shared"; + +/** Subscribe to an extension notification. Returns an unsubscribe function. */ +export function subscribeNotification( + def: NotificationDef, + callback: (data: D) => void, +): () => void { + const handler = (event: MessageEvent) => { + const msg = event.data as { type?: string; data?: D } | undefined; + if (!msg || typeof msg !== "object") { + return; + } + if (msg.type !== def.method || msg.data === undefined) { + return; + } + callback(msg.data); + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); +} diff --git a/packages/webview-shared/src/react/useIpc.ts b/packages/webview-shared/src/react/useIpc.ts index b41c3a73..9fe994b3 100644 --- a/packages/webview-shared/src/react/useIpc.ts +++ b/packages/webview-shared/src/react/useIpc.ts @@ -6,8 +6,14 @@ import { useEffect, useRef } from "react"; import { postMessage } from "../api"; +import { subscribeNotification } from "../notifications"; -import type { IpcNotification, IpcResponse } from "@repo/shared"; +import type { + CommandDef, + IpcResponse, + NotificationDef, + RequestDef, +} from "@repo/shared"; interface PendingRequest { resolve: (value: unknown) => void; @@ -22,8 +28,6 @@ export interface UseIpcOptions { timeoutMs?: number; } -type NotificationHandler = (data: unknown) => void; - /** * Hook for type-safe IPC with the extension. * @@ -44,11 +48,8 @@ type NotificationHandler = (data: unknown) => void; export function useIpc(options: UseIpcOptions = {}) { const { timeoutMs = DEFAULT_TIMEOUT_MS } = options; const pendingRequestsRef = useRef>(new Map()); - const notificationHandlersRef = useRef>>( - new Map(), - ); - // Cleanup on unmount + // Cleanup pending requests on unmount useEffect(() => { return () => { for (const req of pendingRequestsRef.current.values()) { @@ -56,43 +57,27 @@ export function useIpc(options: UseIpcOptions = {}) { req.reject(new Error("Component unmounted")); } pendingRequestsRef.current.clear(); - notificationHandlersRef.current.clear(); }; }, []); - // Handle responses and notifications + // Request/response correlation lives here. Notifications are routed via + // the shared subscribeNotification helper (see onNotification below). useEffect(() => { const handler = (event: MessageEvent) => { - const msg = event.data as IpcResponse | IpcNotification | undefined; - - if (!msg || typeof msg !== "object") { - return; - } + const msg = event.data as IpcResponse | undefined; + if (!msg || typeof msg !== "object") return; + if (!("requestId" in msg) || !("success" in msg)) return; - // Response handling (has requestId + success) - if ("requestId" in msg && "success" in msg) { - const pending = pendingRequestsRef.current.get(msg.requestId); - if (!pending) return; + const pending = pendingRequestsRef.current.get(msg.requestId); + if (!pending) return; - clearTimeout(pending.timeout); - pendingRequestsRef.current.delete(msg.requestId); + clearTimeout(pending.timeout); + pendingRequestsRef.current.delete(msg.requestId); - if (msg.success) { - pending.resolve(msg.data); - } else { - pending.reject(new Error(msg.error || "Request failed")); - } - return; - } - - // Notification handling (has type, no requestId) - if ("type" in msg && !("requestId" in msg)) { - const handlers = notificationHandlersRef.current.get(msg.type); - if (handlers) { - for (const h of handlers) { - h(msg.data); - } - } + if (msg.success) { + pending.resolve(msg.data); + } else { + pending.reject(new Error(msg.error || "Request failed")); } }; @@ -102,10 +87,7 @@ export function useIpc(options: UseIpcOptions = {}) { /** Send request and await typed response */ function request( - definition: { - method: string; - _types?: { params: P; response: R }; - }, + definition: RequestDef, ...args: P extends void ? [] : [params: P] ): Promise { const requestId = crypto.randomUUID(); @@ -135,7 +117,7 @@ export function useIpc(options: UseIpcOptions = {}) { /** Send command without waiting (fire-and-forget) */ function command

( - definition: { method: string; _types?: { params: P } }, + definition: CommandDef

, ...args: P extends void ? [] : [params: P] ): void { postMessage({ @@ -158,27 +140,10 @@ export function useIpc(options: UseIpcOptions = {}) { * ``` */ function onNotification( - definition: { method: string; _types?: { data: D } }, + definition: NotificationDef, callback: (data: D) => void, ): () => void { - const method = definition.method; - let handlers = notificationHandlersRef.current.get(method); - if (!handlers) { - handlers = new Set(); - notificationHandlersRef.current.set(method, handlers); - } - handlers.add(callback as NotificationHandler); - - // Return unsubscribe function - return () => { - const h = notificationHandlersRef.current.get(method); - if (h) { - h.delete(callback as NotificationHandler); - if (h.size === 0) { - notificationHandlersRef.current.delete(method); - } - } - }; + return subscribeNotification(definition, callback); } return { request, command, onNotification }; diff --git a/packages/webview-shared/tsconfig.json b/packages/webview-shared/tsconfig.json index 4b2e2bcd..9758f673 100644 --- a/packages/webview-shared/tsconfig.json +++ b/packages/webview-shared/tsconfig.json @@ -5,5 +5,5 @@ "@repo/shared": ["../shared/src"] } }, - "include": ["src"] + "include": ["src", "createWebviewConfig.ts"] } diff --git a/src/commands.ts b/src/commands.ts index b1d20ed6..c7441576 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -26,7 +26,11 @@ import { toError } from "./error/errorUtils"; import { type FeatureSet, featureSetForVersion } from "./featureSet"; import { type Logger } from "./logging/logger"; import { type LoginCoordinator } from "./login/loginCoordinator"; -import { withCancellableProgress, withProgress } from "./progress"; +import { + reportElapsedProgress, + withCancellableProgress, + withProgress, +} from "./progress"; import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { RECOMMENDED_SSH_SETTINGS, @@ -35,7 +39,8 @@ import { import { resolveCliAuth } from "./settings/cli"; import { toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; -import { showSpeedtestChart } from "./webviews/speedtest/speedtestPanel"; +import { type SpeedtestPanelFactory } from "./webviews/speedtest/speedtestPanelFactory"; +import { parseSpeedtestResult } from "./webviews/speedtest/types"; import { AgentTreeItem, type OpenableTreeItem, @@ -65,7 +70,7 @@ export class Commands { private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; private readonly loginCoordinator: LoginCoordinator; - private readonly extensionUri: vscode.Uri; + private readonly speedtestPanelFactory: SpeedtestPanelFactory; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not @@ -89,7 +94,7 @@ export class Commands { this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); this.loginCoordinator = serviceContainer.getLoginCoordinator(); - this.extensionUri = serviceContainer.getExtensionUri(); + this.speedtestPanelFactory = serviceContainer.getSpeedtestPanelFactory(); } /** @@ -204,27 +209,14 @@ export class Commands { progress.report({ message: "Connecting..." }); const env = await this.resolveCliEnv(client); - const startTime = Date.now(); - let reported = 0; - const timer = setInterval(() => { - const elapsed = Date.now() - startTime; - const pct = Math.min( - Math.round((elapsed / (seconds * 1000)) * 100), - 100, - ); - const increment = pct - reported; - if (increment > 0) { - progress.report({ - message: - pct >= 100 - ? "Collecting results..." - : `${Math.floor(elapsed / 1000)}s / ${seconds}s`, - increment, - }); - reported = pct; - } - }, 100); - + const stopProgress = reportElapsedProgress({ + progress, + totalMs: seconds * 1000, + format: (pct, elapsedMs) => + pct >= 100 + ? "Collecting results..." + : `${Math.floor(elapsedMs / 1000)}s / ${seconds}s`, + }); try { return await cliExec.speedtest( env, @@ -233,7 +225,7 @@ export class Commands { signal, ); } finally { - clearInterval(timer); + stopProgress(); } }, { @@ -244,7 +236,19 @@ export class Commands { ); if (result.ok) { - showSpeedtestChart(this.extensionUri, result.value, workspaceId); + try { + const parsed = parseSpeedtestResult(result.value); + this.speedtestPanelFactory.show({ + result: parsed, + rawJson: result.value, + workspaceName: workspaceId, + }); + } catch (err) { + this.logger.error("Failed to parse speedtest output", err); + vscode.window.showErrorMessage( + `Speed test returned unexpected output: ${toError(err).message}`, + ); + } return; } diff --git a/src/core/cliExec.ts b/src/core/cliExec.ts index 22455928..dcdd0807 100644 --- a/src/core/cliExec.ts +++ b/src/core/cliExec.ts @@ -34,14 +34,6 @@ function cliError(error: unknown): Error { return toError(error); } -/** Go duration regex: one or more {number}{unit} segments (e.g. 5s, 1h30m). */ -const GO_DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/; - -/** Returns true if the string is a valid Go duration. */ -export function isGoDuration(value: string): boolean { - return GO_DURATION_RE.test(value); -} - /** * Return the version from the binary. Throw if unable to execute the binary or * find the version for any reason. diff --git a/src/core/container.ts b/src/core/container.ts index 0e7bbc3c..17a834a3 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; import { type Logger } from "../logging/logger"; import { LoginCoordinator } from "../login/loginCoordinator"; +import { SpeedtestPanelFactory } from "../webviews/speedtest/speedtestPanelFactory"; import { CliCredentialManager } from "./cliCredentialManager"; import { CliManager } from "./cliManager"; @@ -16,7 +17,6 @@ import { SecretsManager } from "./secretsManager"; * Centralizes the creation and management of all core services. */ export class ServiceContainer implements vscode.Disposable { - private readonly extensionUri: vscode.Uri; private readonly logger: vscode.LogOutputChannel; private readonly pathResolver: PathResolver; private readonly mementoManager: MementoManager; @@ -25,9 +25,9 @@ export class ServiceContainer implements vscode.Disposable { private readonly cliManager: CliManager; private readonly contextManager: ContextManager; private readonly loginCoordinator: LoginCoordinator; + private readonly speedtestPanelFactory: SpeedtestPanelFactory; constructor(context: vscode.ExtensionContext) { - this.extensionUri = context.extensionUri; this.logger = vscode.window.createOutputChannel("Coder", { log: true }); this.pathResolver = new PathResolver( context.globalStorageUri.fsPath, @@ -72,6 +72,10 @@ export class ServiceContainer implements vscode.Disposable { this.cliCredentialManager, context.extension.id, ); + this.speedtestPanelFactory = new SpeedtestPanelFactory( + context.extensionUri, + this.logger, + ); } getPathResolver(): PathResolver { @@ -106,8 +110,8 @@ export class ServiceContainer implements vscode.Disposable { return this.loginCoordinator; } - getExtensionUri(): vscode.Uri { - return this.extensionUri; + getSpeedtestPanelFactory(): SpeedtestPanelFactory { + return this.speedtestPanelFactory; } /** diff --git a/src/error/errorUtils.ts b/src/error/errorUtils.ts index c61109d1..18283b90 100644 --- a/src/error/errorUtils.ts +++ b/src/error/errorUtils.ts @@ -1,9 +1,9 @@ import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; import util from "node:util"; -/** - * Check whether an unknown thrown value is an AbortError (signal cancellation). - */ +import { toError as baseToError } from "@repo/shared"; + +/** Check whether an unknown thrown value is an AbortError (signal cancellation). */ export function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } @@ -19,40 +19,7 @@ export const getErrorDetail = (error: unknown): string | undefined | null => { return null; }; -/** - * Convert any value into an Error instance. - * Handles Error instances, strings, error-like objects, null/undefined, and primitives. - */ +/** Node flavor of toError: uses `util.inspect` for the richer object format. */ export function toError(value: unknown, defaultMsg?: string): Error { - if (value instanceof Error) { - return value; - } - - if (typeof value === "string") { - return new Error(value); - } - - if ( - value !== null && - typeof value === "object" && - "message" in value && - typeof value.message === "string" - ) { - const error = new Error(value.message); - if ("name" in value && typeof value.name === "string") { - error.name = value.name; - } - return error; - } - - if (value === null || value === undefined) { - return new Error(defaultMsg || "Unknown error"); - } - - try { - return new Error(util.inspect(value)); - } catch { - // Just in case - return new Error(defaultMsg || "Non-serializable error object"); - } + return baseToError(value, defaultMsg, util.inspect); } diff --git a/src/progress.ts b/src/progress.ts index c91b9b41..f08da274 100644 --- a/src/progress.ts +++ b/src/progress.ts @@ -84,3 +84,27 @@ export function withProgress( ): Thenable { return vscode.window.withProgress(options, (progress) => fn(progress)); } + +/** Drive a progress bar from wall-clock time over an expected duration. + * Returns a stop function; call it (typically in `finally`) when the work + * finishes. */ +export function reportElapsedProgress(opts: { + progress: vscode.Progress<{ message?: string; increment?: number }>; + totalMs: number; + format: (pct: number, elapsedMs: number) => string; + intervalMs?: number; +}): () => void { + const { progress, totalMs, format, intervalMs = 100 } = opts; + const startTime = Date.now(); + let reported = 0; + const timer = setInterval(() => { + const elapsed = Date.now() - startTime; + const pct = Math.min(Math.round((elapsed / totalMs) * 100), 100); + const increment = pct - reported; + if (increment > 0) { + progress.report({ message: format(pct, elapsed), increment }); + reported = pct; + } + }, intervalMs); + return () => clearInterval(timer); +} diff --git a/src/webviews/speedtest/speedtestPanel.ts b/src/webviews/speedtest/speedtestPanel.ts deleted file mode 100644 index 02ed2288..00000000 --- a/src/webviews/speedtest/speedtestPanel.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as vscode from "vscode"; - -import { - buildCommandHandlers, - type SpeedtestData, - SpeedtestApi, -} from "@repo/shared"; - -import { getWebviewHtml } from "../util"; - -export function showSpeedtestChart( - extensionUri: vscode.Uri, - json: string, - workspaceName: string, -): void { - const title = `Speed Test: ${workspaceName}`; - const panel = vscode.window.createWebviewPanel( - "coder.speedtestPanel", - title, - vscode.ViewColumn.One, - { - enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(extensionUri, "dist", "webviews", "speedtest"), - ], - }, - ); - - panel.iconPath = { - light: vscode.Uri.joinPath(extensionUri, "media", "logo-black.svg"), - dark: vscode.Uri.joinPath(extensionUri, "media", "logo-white.svg"), - }; - - panel.webview.html = getWebviewHtml( - panel.webview, - extensionUri, - "speedtest", - title, - ); - - // Webview context is discarded when hidden (no retainContextWhenHidden), - // so re-send on visibility change to re-hydrate the chart. Also re-send on - // theme change so the canvas (which caches theme colors into pixels) redraws. - const payload: SpeedtestData = { json, workspaceName }; - const sendData = () => { - panel.webview.postMessage({ - type: SpeedtestApi.data.method, - data: payload, - }); - }; - sendData(); - - const disposables: vscode.Disposable[] = [ - panel.onDidChangeViewState(() => { - if (panel.visible) { - sendData(); - } - }), - vscode.window.onDidChangeActiveColorTheme(() => { - if (panel.visible) { - sendData(); - } - }), - ]; - panel.onDidDispose(() => { - for (const d of disposables) { - d.dispose(); - } - }); - - const commandHandlers = buildCommandHandlers(SpeedtestApi, { - async viewJson(data: string) { - const doc = await vscode.workspace.openTextDocument({ - content: data, - language: "json", - }); - await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); - }, - }); - - panel.webview.onDidReceiveMessage( - (message: { method: string; params?: unknown }) => { - const handler = commandHandlers[message.method]; - if (handler) { - Promise.resolve(handler(message.params)).catch(() => undefined); - } - }, - ); -} diff --git a/src/webviews/speedtest/speedtestPanelFactory.ts b/src/webviews/speedtest/speedtestPanelFactory.ts new file mode 100644 index 00000000..be1e87c9 --- /dev/null +++ b/src/webviews/speedtest/speedtestPanelFactory.ts @@ -0,0 +1,107 @@ +import * as vscode from "vscode"; + +import { + buildCommandHandlers, + type SpeedtestData, + SpeedtestApi, + type SpeedtestResult, +} from "@repo/shared"; + +import { getWebviewHtml } from "../util"; + +import type { Logger } from "../../logging/logger"; + +export interface SpeedtestChartPayload { + result: SpeedtestResult; + rawJson: string; + workspaceName: string; +} + +/** Creates webview panels that render speedtest runs as interactive charts. */ +export class SpeedtestPanelFactory { + public constructor( + private readonly extensionUri: vscode.Uri, + private readonly logger: Logger, + ) {} + + public show({ result, rawJson, workspaceName }: SpeedtestChartPayload): void { + const title = `Speed Test: ${workspaceName}`; + const panel = vscode.window.createWebviewPanel( + "coder.speedtestPanel", + title, + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath( + this.extensionUri, + "dist", + "webviews", + "speedtest", + ), + ], + }, + ); + + panel.iconPath = { + light: vscode.Uri.joinPath(this.extensionUri, "media", "logo-black.svg"), + dark: vscode.Uri.joinPath(this.extensionUri, "media", "logo-white.svg"), + }; + + panel.webview.html = getWebviewHtml( + panel.webview, + this.extensionUri, + "speedtest", + title, + ); + + // Webview JS is discarded when hidden (no retainContextWhenHidden), and + // the canvas caches theme colors into pixels, so we re-send on visibility + // or theme change to rehydrate and redraw. + const payload: SpeedtestData = { workspaceName, result }; + const sendData = () => + panel.webview.postMessage({ + type: SpeedtestApi.data.method, + data: payload, + }); + const sendIfVisible = () => { + if (panel.visible) { + sendData(); + } + }; + sendData(); + + const commandHandlers = buildCommandHandlers(SpeedtestApi, { + async viewJson() { + const doc = await vscode.workspace.openTextDocument({ + content: rawJson, + language: "json", + }); + await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); + }, + }); + + const disposables: vscode.Disposable[] = [ + panel.onDidChangeViewState(sendIfVisible), + vscode.window.onDidChangeActiveColorTheme(sendIfVisible), + panel.webview.onDidReceiveMessage( + (message: { method: string; params?: unknown }) => { + const handler = commandHandlers[message.method]; + if (handler) { + Promise.resolve(handler(message.params)).catch((err: unknown) => { + this.logger.error( + "Unhandled error in speedtest message handler", + err, + ); + }); + } + }, + ), + ]; + panel.onDidDispose(() => { + for (const d of disposables) { + d.dispose(); + } + }); + } +} diff --git a/src/webviews/speedtest/types.ts b/src/webviews/speedtest/types.ts new file mode 100644 index 00000000..b84a0aa4 --- /dev/null +++ b/src/webviews/speedtest/types.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import type { SpeedtestResult } from "@repo/shared"; + +const SpeedtestIntervalSchema = z.object({ + start_time_seconds: z.number(), + end_time_seconds: z.number(), + throughput_mbits: z.number(), +}); + +const SpeedtestResultSchema = z.object({ + overall: SpeedtestIntervalSchema, + intervals: z.array(SpeedtestIntervalSchema), +}) satisfies z.ZodType; + +export function parseSpeedtestResult(json: string): SpeedtestResult { + return SpeedtestResultSchema.parse(JSON.parse(json)); +} diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index d3dec642..91f919db 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -8,6 +8,8 @@ import axios, { import { vi } from "vitest"; import * as vscode from "vscode"; +import { window as vscodeWindow } from "./vscode.runtime"; + import type { Experiment, User } from "coder/site/src/api/typesGenerated"; import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; import type { IncomingMessage } from "node:http"; @@ -441,6 +443,94 @@ export function createMockLogger(): Logger { }; } +/** Update the mocked active color theme and fire onDidChangeActiveColorTheme. */ +export function setActiveColorTheme(kind: vscode.ColorThemeKind): void { + vscodeWindow.__setActiveColorThemeKind(kind); +} + +/** Hooks to drive lifecycle and inspect messages on a mocked WebviewPanel. */ +export interface WebviewPanelTestHooks { + setVisible(visible: boolean): void; + fireDispose(): void; + sendFromWebview(msg: unknown): void; + readonly postedMessages: readonly unknown[]; +} + +/** + * Build a WebviewPanel for tests. Signature matches + * vscode.window.createWebviewPanel so it drops in via mockImplementation. + */ +export function createMockWebviewPanel( + viewType: string, + title: string, + showOptions: + | vscode.ViewColumn + | { + readonly viewColumn: vscode.ViewColumn; + readonly preserveFocus?: boolean; + }, + options?: vscode.WebviewPanelOptions & vscode.WebviewOptions, +): { panel: vscode.WebviewPanel; hooks: WebviewPanelTestHooks } { + const viewStateEmitter = + new vscode.EventEmitter(); + const disposeEmitter = new vscode.EventEmitter(); + const messageEmitter = new vscode.EventEmitter(); + + const viewColumn = + typeof showOptions === "object" ? showOptions.viewColumn : showOptions; + const postedMessages: unknown[] = []; + let visible = true; + + const webview: vscode.Webview = { + options: options ?? { enableScripts: true, localResourceRoots: [] }, + html: "", + cspSource: "mock-csp", + onDidReceiveMessage: messageEmitter.event, + postMessage: (msg) => { + postedMessages.push(msg); + return Promise.resolve(true); + }, + asWebviewUri: (uri) => uri, + }; + + const panel: vscode.WebviewPanel = { + viewType, + title, + iconPath: undefined, + webview, + options: options ?? {}, + get viewColumn() { + return viewColumn; + }, + get active() { + return visible; + }, + get visible() { + return visible; + }, + onDidChangeViewState: viewStateEmitter.event, + onDidDispose: disposeEmitter.event, + reveal: () => undefined, + dispose: () => disposeEmitter.fire(), + }; + + const hooks: WebviewPanelTestHooks = { + setVisible(next) { + visible = next; + viewStateEmitter.fire({ webviewPanel: panel }); + }, + fireDispose() { + disposeEmitter.fire(); + }, + sendFromWebview(msg) { + messageEmitter.fire(msg); + }, + postedMessages, + }; + + return { panel, hooks }; +} + export function createMockStream( content: string, options: { diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 9535b94f..c4bdd20b 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -139,6 +139,7 @@ export const window = { clear: vi.fn(), })), createStatusBarItem: vi.fn(), + createWebviewPanel: vi.fn(), registerUriHandler: vi.fn(() => ({ dispose: vi.fn() })), }; @@ -156,6 +157,7 @@ export const workspace = { stat: vi.fn(), readDirectory: vi.fn(), }, + openTextDocument: vi.fn(), onDidChangeConfiguration: onDidChangeConfiguration.event, onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, diff --git a/test/unit/core/cliExec.test.ts b/test/unit/core/cliExec.test.ts index 6c335a1d..995e9e6e 100644 --- a/test/unit/core/cliExec.test.ts +++ b/test/unit/core/cliExec.test.ts @@ -214,24 +214,4 @@ describe("cliExec", () => { ).rejects.toThrow("workspace not found"); }); }); - - describe("isGoDuration", () => { - it.each([ - "5s", - "10m", - "1h", - "1h30m", - "500ms", - "1.5s", - "2h45m10s", - "100ns", - "50us", - "50µs", - ])("accepts %s", (v) => expect(cliExec.isGoDuration(v)).toBe(true)); - - it.each(["", "bjbmn", "5", "s", "5x", "1h 30m", "-5s", "5S"])( - "rejects %s", - (v) => expect(cliExec.isGoDuration(v)).toBe(false), - ); - }); }); diff --git a/test/unit/webviews/speedtest/speedtestPanel.test.ts b/test/unit/webviews/speedtest/speedtestPanel.test.ts deleted file mode 100644 index 636095b7..00000000 --- a/test/unit/webviews/speedtest/speedtestPanel.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { buildCommandHandlers, SpeedtestApi } from "@repo/shared"; - -describe("SpeedtestApi", () => { - it("defines typed command handlers via buildCommandHandlers", async () => { - let receivedData: string | undefined; - - const handlers = buildCommandHandlers(SpeedtestApi, { - viewJson(data: string) { - receivedData = data; - }, - }); - - expect(handlers[SpeedtestApi.viewJson.method]).toBeDefined(); - - await handlers[SpeedtestApi.viewJson.method]('{"test": true}'); - expect(receivedData).toBe('{"test": true}'); - }); - - it("uses consistent method names for notification and command", () => { - expect(SpeedtestApi.data.method).toBe("speedtest/data"); - expect(SpeedtestApi.viewJson.method).toBe("speedtest/viewJson"); - }); -}); diff --git a/test/unit/webviews/speedtest/speedtestPanelFactory.test.ts b/test/unit/webviews/speedtest/speedtestPanelFactory.test.ts new file mode 100644 index 00000000..d7567e44 --- /dev/null +++ b/test/unit/webviews/speedtest/speedtestPanelFactory.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { SpeedtestPanelFactory } from "@/webviews/speedtest/speedtestPanelFactory"; + +import { type SpeedtestResult, SpeedtestApi } from "@repo/shared"; + +import { + createMockLogger, + createMockWebviewPanel, + setActiveColorTheme, + type WebviewPanelTestHooks, +} from "../../../mocks/testHelpers"; + +const sampleResult: SpeedtestResult = { + overall: { + start_time_seconds: 0, + end_time_seconds: 5, + throughput_mbits: 100, + }, + intervals: [ + { start_time_seconds: 0, end_time_seconds: 1, throughput_mbits: 95 }, + { start_time_seconds: 1, end_time_seconds: 2, throughput_mbits: 105 }, + ], +}; + +interface Harness { + panel: vscode.WebviewPanel; + hooks: WebviewPanelTestHooks; +} + +function openChart(rawJson = '{"raw":true}'): Harness { + let panel!: vscode.WebviewPanel; + let hooks!: WebviewPanelTestHooks; + + vi.mocked(vscode.window.createWebviewPanel).mockImplementation((...args) => { + const built = createMockWebviewPanel(...args); + panel = built.panel; + hooks = built.hooks; + return panel; + }); + + const factory = new SpeedtestPanelFactory( + vscode.Uri.file("/ext"), + createMockLogger(), + ); + + factory.show({ + result: sampleResult, + rawJson, + workspaceName: "my-workspace", + }); + return { panel, hooks }; +} + +describe("SpeedtestPanelFactory", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("opens a titled webview with HTML and pushes the initial payload", () => { + const { panel, hooks } = openChart(); + + expect(panel.viewType).toBe("coder.speedtestPanel"); + expect(panel.title).toBe("Speed Test: my-workspace"); + expect(panel.webview.html).toContain("Speed Test: my-workspace"); + expect(hooks.postedMessages).toEqual([ + { + type: SpeedtestApi.data.method, + data: { workspaceName: "my-workspace", result: sampleResult }, + }, + ]); + }); + + it("re-pushes the payload when the panel returns to visible", () => { + const { hooks } = openChart(); + const before = hooks.postedMessages.length; + + hooks.setVisible(true); + + expect(hooks.postedMessages.length - before).toBe(1); + }); + + it("does not push while the panel is hidden", () => { + const { hooks } = openChart(); + const before = hooks.postedMessages.length; + + hooks.setVisible(false); + + expect(hooks.postedMessages.length).toBe(before); + }); + + it("re-pushes the payload on theme change while visible", () => { + const { hooks } = openChart(); + const before = hooks.postedMessages.length; + + setActiveColorTheme(vscode.ColorThemeKind.Light); + + expect(hooks.postedMessages.length - before).toBe(1); + }); + + it("opens the raw JSON beside when the webview requests viewJson", async () => { + const doc = { uri: vscode.Uri.file("/tmp/doc") } as vscode.TextDocument; + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(doc); + + const { hooks } = openChart('{"ok":1}'); + hooks.sendFromWebview({ method: SpeedtestApi.viewJson.method }); + + await vi.waitFor(() => + expect(vscode.window.showTextDocument).toHaveBeenCalledWith( + doc, + vscode.ViewColumn.Beside, + ), + ); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith({ + content: '{"ok":1}', + language: "json", + }); + }); + + it("ignores unknown message methods", () => { + const { hooks } = openChart(); + expect(() => + hooks.sendFromWebview({ method: "speedtest/bogus" }), + ).not.toThrow(); + }); + + it("stops responding to visibility and theme events after disposal", () => { + const { hooks } = openChart(); + hooks.fireDispose(); + const before = hooks.postedMessages.length; + + hooks.setVisible(true); + setActiveColorTheme(vscode.ColorThemeKind.Light); + + expect(hooks.postedMessages.length).toBe(before); + }); +}); diff --git a/test/webview/setup.ts b/test/webview/setup.ts index f733b67e..cd2f3250 100644 --- a/test/webview/setup.ts +++ b/test/webview/setup.ts @@ -25,6 +25,28 @@ globalThis.ResizeObserver = class { disconnect() {} }; +// jsdom has no Canvas 2D; return a Proxy that accepts any prop read or method +// call. measureText and createLinearGradient must return real shapes because +// the caller reads fields off them. +const noop = () => undefined; +const stubCtx = new Proxy( + { + measureText: (s: string) => ({ width: s.length * 6 }), + createLinearGradient: () => ({ addColorStop: noop }), + } as Record, + { + get(target, prop) { + if (prop in target) return target[prop as string]; + // Return noop for any method/property the chart calls (ctx.beginPath, + // ctx.moveTo, etc). Symbol lookups (Symbol.toPrimitive) stay undefined. + return typeof prop === "string" ? noop : undefined; + }, + set: () => true, + }, +); +HTMLCanvasElement.prototype.getContext = ((type: string) => + type === "2d" ? stubCtx : null) as HTMLCanvasElement["getContext"]; + // VscodeSingleSelect fires internal slot-change events that read properties // jsdom doesn't support (e.g. textContent of slotted elements). Suppress // these uncaught errors from the third-party web component. diff --git a/test/webview/speedtest/chart.test.ts b/test/webview/speedtest/chart.test.ts index 214a624f..5e9348ca 100644 --- a/test/webview/speedtest/chart.test.ts +++ b/test/webview/speedtest/chart.test.ts @@ -1,42 +1,107 @@ import { describe, expect, it } from "vitest"; -import { formatTick, niceStep } from "@repo/speedtest/chart"; - -describe("niceStep", () => { - it("rounds up to the next candidate for sub-hour ranges", () => { - expect(niceStep(0.3)).toBe(1); - expect(niceStep(1)).toBe(1); - expect(niceStep(1.5)).toBe(2); - expect(niceStep(3)).toBe(5); - expect(niceStep(7)).toBe(10); - expect(niceStep(45)).toBe(60); - expect(niceStep(200)).toBe(300); +import { renderLineChart } from "@repo/speedtest/chart"; + +function makeCanvas(width: number, height: number): HTMLCanvasElement { + const parent = document.createElement("div"); + Object.defineProperty(parent, "getBoundingClientRect", { + value: () => ({ + width, + height, + top: 0, + left: 0, + right: width, + bottom: height, + x: 0, + y: 0, + toJSON: () => ({}), + }), }); + parent.style.fontSize = "16px"; + const canvas = document.createElement("canvas"); + parent.appendChild(canvas); + return canvas; +} + +describe("renderLineChart", () => { + it("scales the canvas backing store by devicePixelRatio", () => { + Object.defineProperty(window, "devicePixelRatio", { + value: 2, + configurable: true, + }); + const canvas = makeCanvas(600, 300); + + renderLineChart( + canvas, + [ + { x: 0, y: 10, label: "a" }, + { x: 1, y: 20, label: "b" }, + ], + true, + ); - it("rounds up to whole hours past the longest candidate", () => { - expect(niceStep(3600)).toBe(3600); - expect(niceStep(4000)).toBe(7200); - expect(niceStep(10000)).toBe(10800); + expect(canvas.width).toBe(1200); + expect(canvas.height).toBe(600); }); -}); -describe("formatTick", () => { - it("uses seconds below a minute", () => { - expect(formatTick(0, 1)).toBe("0s"); - expect(formatTick(5, 5)).toBe("5s"); - expect(formatTick(30, 15)).toBe("30s"); + it("returns one point per sample, preserving input order and labels", () => { + const canvas = makeCanvas(600, 300); + + const points = renderLineChart( + canvas, + [ + { x: 0, y: 10, label: "alpha" }, + { x: 5, y: 20, label: "beta" }, + { x: 10, y: 5, label: "gamma" }, + ], + true, + ); + + expect(points.map((p) => p.label)).toEqual(["alpha", "beta", "gamma"]); }); - it("uses minutes between 1m and 1h", () => { - expect(formatTick(60, 60)).toBe("1m"); - expect(formatTick(120, 60)).toBe("2m"); - expect(formatTick(90, 60)).toBe("1.5m"); - expect(formatTick(300, 300)).toBe("5m"); + it("places returned points inside the canvas bounds, left-to-right", () => { + const canvas = makeCanvas(600, 300); + + const points = renderLineChart( + canvas, + [ + { x: 0, y: 10, label: "a" }, + { x: 5, y: 20, label: "b" }, + { x: 10, y: 5, label: "c" }, + ], + true, + ); + + for (const p of points) { + expect(p.x).toBeGreaterThanOrEqual(0); + expect(p.x).toBeLessThanOrEqual(600); + expect(p.y).toBeGreaterThanOrEqual(0); + expect(p.y).toBeLessThanOrEqual(300); + } + expect(points[0].x).toBeLessThan(points[1].x); + expect(points[1].x).toBeLessThan(points[2].x); + }); + + it("maps the higher sample to a smaller y (pixel y grows downward)", () => { + const canvas = makeCanvas(600, 300); + + const points = renderLineChart( + canvas, + [ + { x: 0, y: 10, label: "low" }, + { x: 1, y: 100, label: "high" }, + ], + true, + ); + + expect(points[1].y).toBeLessThan(points[0].y); }); - it("uses hours at or above 1h", () => { - expect(formatTick(3600, 3600)).toBe("1h"); - expect(formatTick(7200, 3600)).toBe("2h"); - expect(formatTick(5400, 3600)).toBe("1.5h"); + it("renders a single sample without throwing", () => { + const canvas = makeCanvas(600, 300); + expect(() => + renderLineChart(canvas, [{ x: 0, y: 10, label: "solo" }], true), + ).not.toThrow(); }); }); diff --git a/test/webview/speedtest/chartUtils.test.ts b/test/webview/speedtest/chartUtils.test.ts new file mode 100644 index 00000000..d54b4091 --- /dev/null +++ b/test/webview/speedtest/chartUtils.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { + findNearestByX, + findNearestDot, + findNearestOnLine, + formatTick, + niceStep, + toChartSamples, +} from "@repo/speedtest/chartUtils"; + +describe("niceStep", () => { + it("rounds up to the next candidate for sub-hour ranges", () => { + expect(niceStep(0.3)).toBe(1); + expect(niceStep(1)).toBe(1); + expect(niceStep(1.5)).toBe(2); + expect(niceStep(3)).toBe(5); + expect(niceStep(7)).toBe(10); + expect(niceStep(45)).toBe(60); + expect(niceStep(200)).toBe(300); + }); + + it("rounds up to whole hours past the longest candidate", () => { + expect(niceStep(3600)).toBe(3600); + expect(niceStep(4000)).toBe(7200); + expect(niceStep(10000)).toBe(10800); + }); +}); + +describe("formatTick", () => { + it("uses seconds below a minute", () => { + expect(formatTick(0, 1)).toBe("0s"); + expect(formatTick(5, 5)).toBe("5s"); + expect(formatTick(30, 15)).toBe("30s"); + }); + + it("uses minutes between 1m and 1h", () => { + expect(formatTick(60, 60)).toBe("1m"); + expect(formatTick(120, 60)).toBe("2m"); + expect(formatTick(90, 60)).toBe("1.5m"); + expect(formatTick(300, 300)).toBe("5m"); + }); + + it("uses hours at or above 1h", () => { + expect(formatTick(3600, 3600)).toBe("1h"); + expect(formatTick(7200, 3600)).toBe("2h"); + expect(formatTick(5400, 3600)).toBe("1.5h"); + }); +}); + +describe("toChartSamples", () => { + it("maps intervals to points with throughput labels", () => { + const samples = toChartSamples([ + { start_time_seconds: 0, end_time_seconds: 1, throughput_mbits: 95.5 }, + { start_time_seconds: 1, end_time_seconds: 2, throughput_mbits: 110 }, + ]); + expect(samples).toEqual([ + { x: 1, y: 95.5, label: "95.50 Mbps (0\u20131s)" }, + { x: 2, y: 110, label: "110.00 Mbps (1\u20132s)" }, + ]); + }); + + it("returns an empty array for no intervals", () => { + expect(toChartSamples([])).toEqual([]); + }); +}); + +describe("findNearestByX", () => { + const points = [ + { x: 10, y: 5, label: "a" }, + { x: 20, y: 6, label: "b" }, + { x: 30, y: 7, label: "c" }, + ]; + + it("returns null for an empty list", () => { + expect(findNearestByX([], 5)).toBe(null); + }); + + it("returns the closest point by x coordinate", () => { + expect(findNearestByX(points, 11)?.label).toBe("a"); + expect(findNearestByX(points, 16)?.label).toBe("b"); + expect(findNearestByX(points, 24)?.label).toBe("b"); + expect(findNearestByX(points, 28)?.label).toBe("c"); + }); + + it("handles queries before the first and after the last point", () => { + expect(findNearestByX(points, -100)?.label).toBe("a"); + expect(findNearestByX(points, 1000)?.label).toBe("c"); + }); + + it("returns the single point when the list has one entry", () => { + const single = [{ x: 5, y: 1, label: "only" }]; + expect(findNearestByX(single, -20)?.label).toBe("only"); + expect(findNearestByX(single, 5)?.label).toBe("only"); + expect(findNearestByX(single, 999)?.label).toBe("only"); + }); +}); + +describe("findNearestDot", () => { + const points = [ + { x: 50, y: 50, label: "p" }, + { x: 100, y: 80, label: "q" }, + ]; + + it("returns the point when the cursor is within the hit radius", () => { + expect(findNearestDot(points, 52, 51)?.label).toBe("p"); + }); + + it("returns null when outside hit radius on x", () => { + expect(findNearestDot(points, 70, 50)).toBe(null); + }); + + it("returns null when outside hit radius on y", () => { + expect(findNearestDot(points, 50, 70)).toBe(null); + }); + + it("returns null for empty points", () => { + expect(findNearestDot([], 0, 0)).toBe(null); + }); +}); + +describe("findNearestOnLine", () => { + it("returns the closest point when within the average gap window", () => { + const points = [ + { x: 0, y: 0, label: "a" }, + { x: 10, y: 5, label: "b" }, + { x: 20, y: 10, label: "c" }, + ]; + expect(findNearestOnLine(points, 12)?.label).toBe("b"); + }); + + it("falls back to HIT_RADIUS_PX when only one point exists", () => { + const single = [{ x: 50, y: 10, label: "only" }]; + expect(findNearestOnLine(single, 55)?.label).toBe("only"); + // HIT_RADIUS_PX default is 12; 70 is 20 away from 50, outside the window. + expect(findNearestOnLine(single, 70)).toBe(null); + }); + + it("returns null for empty points", () => { + expect(findNearestOnLine([], 50)).toBe(null); + }); +});