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 f05a0bfd..7c94b5b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,22 @@ importers: specifier: 'catalog:' version: 6.0.2 + 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.2 + vite: + specifier: 'catalog:' + version: 8.0.5(@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 eef2835d..96f930d8 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"); + }); +});