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 2da0281c..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"; + +// 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 new file mode 100644 index 00000000..98a16d49 --- /dev/null +++ b/packages/shared/src/speedtest/api.ts @@ -0,0 +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 { + workspaceName: string; + result: SpeedtestResult; +} + +export const SpeedtestApi = { + /** 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"), +} as const; diff --git a/packages/speedtest/package.json b/packages/speedtest/package.json new file mode 100644 index 00000000..9fa4c727 --- /dev/null +++ b/packages/speedtest/package.json @@ -0,0 +1,21 @@ +{ + "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/shared": "workspace:*", + "@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..f2a93b02 --- /dev/null +++ b/packages/speedtest/src/chart.ts @@ -0,0 +1,235 @@ +import { type ChartPoint, formatTick, niceStep } from "./chartUtils"; + +const MIN_TICK_SPACING_EM = 4; +const Y_GRID_LINES = 5; +/** 10% headroom above the max so the line doesn't hug the top edge. */ +const Y_HEADROOM = 1.1; +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; + accent: string; + grid: string; + family: string; +} + +/** 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", + // focusBorder tracks the theme's accent; charts.blue is a fixed hue + // kept as a late fallback. + accent: + css("--vscode-chart-line") || + css("--vscode-focusBorder") || + css("--vscode-charts-blue") || + "#3794ff", + grid: + css("--vscode-chart-guide") || + css("--vscode-charts-lines") || + "rgba(127, 127, 127, 0.35)", + family: css("--vscode-font-family") || "sans-serif", + }; +} + +function layoutChart( + ctx: CanvasRenderingContext2D, + 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; + ctx.font = `1em ${family}`; + const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width; + const pad = { + 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; + return { + pad, + plotW, + plotH, + maxVal, + maxX, + height, + tAt: (t: number) => pad.left + (t / maxX) * plotW, + yAt: (v: number) => pad.top + plotH - (v / maxVal) * plotH, + }; +} + +type Layout = ReturnType; + +function drawAxes( + ctx: CanvasRenderingContext2D, + layout: Layout, + theme: Theme, + pxPerEm: number, +): void { + const { pad, plotW, plotH, maxVal, maxX, height, tAt, yAt } = layout; + + ctx.strokeStyle = theme.grid; + ctx.lineWidth = 1; + ctx.fillStyle = theme.fg; + ctx.textAlign = "right"; + for (let i = 0; i <= Y_GRID_LINES; i++) { + 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( + v.toFixed(0), + pad.left - Y_LABEL_GAP_EM * pxPerEm, + y + pxPerEm / 3, + ); + } + + 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"; + const step = niceStep( + 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 + X_LABEL_GAP_EM * pxPerEm, + ); + } + + ctx.font = `0.95em ${theme.family}`; + ctx.fillText( + "Time", + pad.left + plotW / 2, + height - X_AXIS_TITLE_GAP_EM * pxPerEm, + ); + ctx.save(); + ctx.translate(Y_AXIS_TITLE_GAP_EM * pxPerEm, pad.top + plotH / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText("Mbps", 0, 0); + ctx.restore(); +} + +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 first = samples[0]; + const last = samples.at(-1) ?? first; + + if (first.x > 0) { + ctx.beginPath(); + ctx.moveTo(tAt(0), baseline); + ctx.lineTo(tAt(first.x), yAt(first.y)); + ctx.setLineDash([4, 4]); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.4; + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + } + + ctx.beginPath(); + ctx.moveTo(tAt(first.x), baseline); + for (const s of samples) { + ctx.lineTo(tAt(s.x), yAt(s.y)); + } + ctx.lineTo(tAt(last.x), baseline); + ctx.closePath(); + 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(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 = theme.accent; + ctx.lineWidth = LINE_WIDTH_PX; + ctx.stroke(); + + return samples.map((s) => { + const x = tAt(s.x); + const y = yAt(s.y); + if (showDots) { + ctx.beginPath(); + ctx.arc(x, y, DOT_RADIUS_PX, 0, Math.PI * 2); + ctx.fillStyle = theme.accent; + ctx.fill(); + } + return { x, y, label: s.label }; + }); +} + +/** Render the speedtest chart. Caller must ensure `samples` is non-empty. */ +export function renderLineChart( + canvas: HTMLCanvasElement, + samples: ChartPoint[], + showDots: boolean, +): ChartPoint[] { + // 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; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return []; + } + ctx.scale(dpr, dpr); + + const pxPerEm = parseFloat(getComputedStyle(parent).fontSize) || 14; + const theme = readTheme(); + 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/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..f9eb68b2 --- /dev/null +++ b/packages/speedtest/src/index.css @@ -0,0 +1,114 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 1.5em; + min-width: 22em; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +.workspace-name { + margin: 0 0 2em; + font-size: 1.8em; + font-weight: 600; + text-align: center; + word-break: break-all; +} + +.summary { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1em 3em; + margin-bottom: 1.5em; + text-align: center; +} + +.stat-label { + display: block; + font-size: 0.8em; + opacity: 0.6; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.25em; +} + +.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; + height: 20em; + margin-bottom: 1.25em; +} + +.chart-container canvas { + width: 100%; + height: 100%; +} + +.actions { + display: flex; + justify-content: center; +} + +button { + padding: 0.4em 1em; + 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: 0.25em 0.5em; + 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; + opacity: 0; + transition: opacity 0.1s; +} + +.tooltip.visible { + opacity: 1; +} + +.error, +.empty { + text-align: center; + margin: 2em 0; +} + +.error { + color: var(--vscode-errorForeground); +} + +.empty { + opacity: 0.7; +} diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts new file mode 100644 index 00000000..bcc71364 --- /dev/null +++ b/packages/speedtest/src/index.ts @@ -0,0 +1,181 @@ +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"; + +/** 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; + +function main(): void { + subscribeNotification(SpeedtestApi.data, ({ workspaceName, result }) => { + try { + cleanup?.(); + cleanup = renderPage(result, workspaceName, () => + postMessage({ method: SpeedtestApi.viewJson.method }), + ); + } catch (err) { + showError(`Failed to render speedtest: ${toError(err).message}`); + } + }); +} + +function renderPage( + data: SpeedtestResult, + workspaceName: string, + onViewJson: () => void, +): () => void { + const root = document.getElementById("root"); + if (!root) { + return () => undefined; + } + + 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; + return heading; +} + +function renderSummary(data: SpeedtestResult): HTMLElement { + 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} +
+ `; + 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); + + const showDots = samples.length <= DOT_THRESHOLD; + let points: ChartPoint[] = []; + let canvasRect: DOMRect | undefined; + const draw = () => { + points = renderLineChart(canvas, samples, showDots); + canvasRect = canvas.getBoundingClientRect(); + }; + + // 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); + rafId = requestAnimationFrame(draw); + }); + 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.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); + return actions; +} + +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 { + const root = document.getElementById("root"); + if (!root) { + return; + } + const p = document.createElement("p"); + p.className = "error"; + p.textContent = message; + root.replaceChildren(p); +} + +main(); diff --git a/packages/speedtest/tsconfig.json b/packages/speedtest/tsconfig.json new file mode 100644 index 00000000..e1940bf7 --- /dev/null +++ b/packages/speedtest/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.packages.json", + "compilerOptions": { + "paths": { + "@repo/shared": ["../shared/src"], + "@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..90634aa2 --- /dev/null +++ b/packages/speedtest/vite.config.ts @@ -0,0 +1,3 @@ +import { createWebviewConfig } from "../webview-shared/createWebviewConfig"; + +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 484d4b07..f6d8728e 100644 --- a/packages/webview-shared/createWebviewConfig.ts +++ b/packages/webview-shared/createWebviewConfig.ts @@ -1,23 +1,21 @@ 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 PluginOption, type UserConfig } from "vite"; -/** - * Create a Vite config for a webview package - * @param webviewName - Name of the webview (used for output path) - * @param dirname - __dirname of the calling config file - */ +/** Create a Vite config for a non-framework webview package. */ export function createWebviewConfig( webviewName: string, dirname: string, + options?: { entry?: string; plugins?: PluginOption[] }, ): 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 +27,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 +49,14 @@ export function createWebviewConfig( }, }); } + +/** Create a Vite config for a React-based webview package. */ +export function createReactWebviewConfig( + webviewName: string, + dirname: string, +): UserConfig { + 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/pnpm-lock.yaml b/pnpm-lock.yaml index f87fa859..bf01ecd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,25 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/speedtest: + dependencies: + '@repo/shared': + specifier: workspace:* + version: link:../shared + '@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..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,6 +39,8 @@ import { import { resolveCliAuth } from "./settings/cli"; import { toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; +import { type SpeedtestPanelFactory } from "./webviews/speedtest/speedtestPanelFactory"; +import { parseSpeedtestResult } from "./webviews/speedtest/types"; import { AgentTreeItem, type OpenableTreeItem, @@ -64,6 +70,7 @@ export class Commands { private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; private readonly loginCoordinator: LoginCoordinator; + 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 @@ -87,6 +94,7 @@ export class Commands { this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); this.loginCoordinator = serviceContainer.getLoginCoordinator(); + this.speedtestPanelFactory = serviceContainer.getSpeedtestPanelFactory(); } /** @@ -179,45 +187,68 @@ 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: "Duration in 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() || !Number.isFinite(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 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); + + 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, + workspaceId, + `${seconds}s`, + signal, + ); + } finally { + stopProgress(); + } }, { 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); + 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 ce8ca887..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"; @@ -24,6 +25,7 @@ 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.logger = vscode.window.createOutputChannel("Coder", { log: true }); @@ -70,6 +72,10 @@ export class ServiceContainer implements vscode.Disposable { this.cliCredentialManager, context.extension.id, ); + this.speedtestPanelFactory = new SpeedtestPanelFactory( + context.extensionUri, + this.logger, + ); } getPathResolver(): PathResolver { @@ -104,6 +110,10 @@ export class ServiceContainer implements vscode.Disposable { return this.loginCoordinator; } + getSpeedtestPanelFactory(): SpeedtestPanelFactory { + return this.speedtestPanelFactory; + } + /** * Dispose of all services and clean up resources. */ 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/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/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/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/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 new file mode 100644 index 00000000..5e9348ca --- /dev/null +++ b/test/webview/speedtest/chart.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +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, + ); + + expect(canvas.width).toBe(1200); + expect(canvas.height).toBe(600); + }); + + 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("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("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); + }); +}); 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", + ), }, }, },