Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/speedtest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"cf": minor
---

Add `cf speedtest` to measure your connection's speed to Cloudflare

Measure download and upload speed, latency, and jitter against
speed.cloudflare.com with `cf speedtest` or its alias `cf speed-test`. In a
terminal, each reading updates live with a sparkline of its samples; the results
print as JSON along with the Cloudflare data center that served the test.
It follows the method of the speed.cloudflare.com browser test, without packet
loss, and needs no Cloudflare login. Root commands can now declare aliases.
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ cf/
β”‚ β”‚ β”‚ β”œβ”€β”€ deploy/ # hand-written: `cf deploy` (Build Output β†’ upload)
β”‚ β”‚ β”‚ β”œβ”€β”€ migrate/ # hand-written: Wrangler-to-cf config migration
β”‚ β”‚ β”‚ β”œβ”€β”€ previews/ # hand-written: `cf previews deploy`
β”‚ β”‚ β”‚ β”œβ”€β”€ speedtest/ # hand-written: `cf speedtest` (speed.cloudflare.com)
β”‚ β”‚ β”‚ β”œβ”€β”€ init/ # hand-written: `cf init` / `cf init workers`
β”‚ β”‚ β”‚ β”‚ # (hello-world scaffold or autoconfig setup)
β”‚ β”‚ β”‚ β”œβ”€β”€ containers/ # hand-written: local image build/push leaves
Expand Down Expand Up @@ -492,6 +493,13 @@ website's public AI Search endpoint, outside the platform API/SDK. It needs no
Cloudflare credentials and returns ranked JSON page metadata. Agents fetch
returned URLs with `Accept: text/markdown` to read complete pages.

`cf speedtest` (alias `cf speed-test`) is a hand-written root workflow that
times requests to speed.cloudflare.com's public download and upload endpoints,
outside the platform API/SDK. It follows `@cloudflare/speedtest`'s method
without the WebRTC packet-loss test, which Node cannot run, needs no Cloudflare
credentials, and returns JSON. A root's `aliases` in `hand-written.ts` are
registered with yargs and must also be listed in its `meta.ts`.

**Leaf override** (`kind: "leafOverride"`) β€” the spec _does_ describe the
operation, but not well enough for a generated command to be useful. The
generator keeps deriving the operation's identity (path, verb,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,24 @@ describe("hand-written command metadata", () => {
"cf migrate",
"cf previews deploy",
"cf schema",
"cf speedtest",
"cf tools",
])
);
});

it("publishes root command aliases", () => {
expect(commands.get("cf speedtest")?.aliases).toEqual(["speed-test"]);
});

it("keeps root hand-written commands free of API identity fields", () => {
for (const commandName of [
"cf auth login",
"cf deploy",
"cf dev",
"cf docs search",
"cf schema",
"cf speedtest",
"cf tools",
]) {
const command = commands.get(commandName);
Expand Down Expand Up @@ -179,7 +185,7 @@ describe("hand-written-only command metadata", () => {
);

it("contains only hand-written commands", () => {
expect(commands.size).toBe(50);
expect(commands.size).toBe(51);
expect(commands.has("cf pages deploy")).toBe(true);
expect(commands.has("cf tunnels diag")).toBe(true);
expect(commands.has("cf tunnels login")).toBe(true);
Expand Down
168 changes: 168 additions & 0 deletions packages/cli/src/__tests__/commands/speedtest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { runInTempDir } from "@cloudflare/workers-utils/test-helpers";
import { http, HttpResponse } from "msw";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "vite-plus/test";
import { sparkline } from "../../commands/speedtest/index.js";
import {
jitter,
parseDataCenter,
parseServerTime,
percentile,
} from "../../commands/speedtest/measure.js";
import { captureOutput } from "../helpers/capture-output.js";
import { server, setupMsw } from "../helpers/msw.js";
import { runCf } from "../helpers/run-cf.js";

const DOWN_URL = "https://speed.cloudflare.com/__down";
const UP_URL = "https://speed.cloudflare.com/__up";

describe("speed test calculations", () => {
it("draws the latest samples as bars scaled to the largest", () => {
expect(sparkline([])).toBe("");
expect(sparkline([0, 0])).toBe("▁▁");
expect(sparkline([0, 1, 2, 3, 4, 5, 6, 7])).toBe("β–β–‚β–ƒβ–„β–…β–†β–‡β–ˆ");
expect(sparkline(Array.from({ length: 30 }, (_, i) => i))).toHaveLength(24);
});

it("interpolates percentiles", () => {
expect(percentile([], 0.5)).toBe(0);
expect(percentile([3, 1, 2], 0.5)).toBe(2);
expect(percentile([10, 20], 0.5)).toBe(15);
expect(percentile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 0.9)).toBeCloseTo(9.1);
});

it("averages the differences between consecutive pings", () => {
expect(jitter([12])).toBeNull();
expect(jitter([10, 20, 15, 15])).toBe(5);
});

it("reads the server's processing time from Server-Timing", () => {
expect(parseServerTime(null)).toBe(0);
expect(
parseServerTime(
"cfSpeedEdge;dur=7, cfSpeedWorker;dur=36, cfReqDur;dur=54.283"
)
).toBe(54.283);
expect(parseServerTime("cfSpeedEdge;dur=7, cfSpeedWorker;dur=36")).toBe(43);
expect(parseServerTime('cfL4;desc="?proto=TCP&rtt=0"')).toBe(0);
});

it("reads the data center from cf-ray", () => {
expect(parseDataCenter("8f2b4c1d2e3f4a5b-LHR")).toBe("LHR");
expect(parseDataCenter("a4eab98078a888b7-SFO-DOG")).toBe("SFO");
expect(parseDataCenter("invalid")).toBeUndefined();
expect(parseDataCenter(null)).toBeUndefined();
});
});

describe("cf speedtest", () => {
runInTempDir();
setupMsw();
let output: ReturnType<typeof captureOutput>;
let clock: number;

beforeEach(() => {
output = captureOutput();
vi.stubEnv("CI", "true");
vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined);
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", undefined);
clock = 0;
vi.spyOn(performance, "now").mockImplementation(() => clock);
});
afterEach(() => vi.restoreAllMocks());

it("measures without authentication and returns JSON", async () => {
const requests: string[] = [];
const headers: Headers[] = [];
let latencyRequests = 0;
server.use(
http.get(DOWN_URL, ({ request }) => {
const bytes = Number(new URL(request.url).searchParams.get("bytes"));
requests.push(`down ${bytes}`);
headers.push(request.headers);
if (bytes === 0) {
// Alternating 25ms and 15ms pings once the server's 5ms is removed.
clock += latencyRequests++ % 2 === 1 ? 30 : 20;
return new HttpResponse(null, {
headers: {
"cf-ray": "8f2b4c1d2e3f4a5b-LHR",
"server-timing": "cfSpeedWorker;dur=2, cfReqDur;dur=5",
},
});
}
clock += bytes / 500;
return new HttpResponse(new Uint8Array(bytes));
}),
http.post(UP_URL, async ({ request }) => {
const bytes = (await request.arrayBuffer()).byteLength;
requests.push(`up ${bytes}`);
clock += bytes / 250;
return new HttpResponse(null);
})
);

expect(await runCf(["speedtest"])).toEqual({ exitCode: 0 });

expect(JSON.parse(output.stdout())).toEqual({
downloadMbps: 4.02,
uploadMbps: 2.01,
latencyMs: 20,
jitterMs: 10,
dataCenter: "LHR",
});
// Each direction stops after its first set of requests over a second.
expect(requests).toEqual([
...Array<string>(21).fill("down 0"),
...Array<string>(10).fill("down 100000"),
...Array<string>(8).fill("down 1000000"),
...Array<string>(8).fill("up 100000"),
...Array<string>(6).fill("up 1000000"),
]);
for (const header of headers) {
expect(header.get("authorization")).toBeNull();
expect(header.get("user-agent")).toMatch(/^cf-cli\//);
}
});

it("accepts the speed-test alias", async () => {
expect(await runCf(["speed-test", "--dry-run"])).toEqual({ exitCode: 0 });
expect(JSON.parse(output.stdout())).toMatchObject({
command: "cf speed-test",
executed: false,
});
});

it.each([
[503, "Speed test request failed (HTTP 503)."],
[429, "speed.cloudflare.com is rate limiting requests. Try again later."],
])("reports HTTP %i without emitting results", async (status, message) => {
server.use(http.get(DOWN_URL, () => new HttpResponse(null, { status })));
await expect(runCf(["speedtest"])).rejects.toThrow(message);
expect(output.stdout()).toBe("");
});

it("reports a network failure", async () => {
server.use(http.get(DOWN_URL, () => HttpResponse.error()));
await expect(runCf(["speedtest"])).rejects.toThrow(
"Unable to reach speed.cloudflare.com."
);
expect(output.stdout()).toBe("");
});

it("reports a timeout", async () => {
vi.spyOn(AbortSignal, "timeout").mockReturnValue(
AbortSignal.abort(new DOMException("Timed out", "TimeoutError"))
);
server.use(http.get(DOWN_URL, () => new HttpResponse(null)));
await expect(runCf(["speedtest"])).rejects.toThrow(
"The speed test timed out."
);
expect(output.stdout()).toBe("");
});
});
32 changes: 32 additions & 0 deletions packages/cli/src/__tests__/lib/progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { withProgress } from "../../lib/progress.js";
const spinner = vi.hoisted(() =>
vi.fn(() => ({
error: vi.fn(),
message: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}))
Expand Down Expand Up @@ -84,6 +85,37 @@ describe("withProgress", () => {
expect(spinner).not.toHaveBeenCalled();
});

it("shows live updates and keeps the last one on success", async () => {
vi.spyOn(terminalProgress, "setIndeterminate").mockImplementation(() => {});
vi.spyOn(terminalProgress, "clear").mockImplementation(() => {});

await withProgress("Loading", async (update) => {
update("Loading 1/2");
update("Loaded 2/2");
});

const drawn = spinner.mock.results[0]?.value;
expect(drawn?.start).toHaveBeenCalledWith("Loading");
expect(drawn?.message.mock.calls).toEqual([
["Loading 1/2"],
["Loaded 2/2"],
]);
expect(drawn?.stop).toHaveBeenCalledWith("Loaded 2/2");
});

it("ignores updates when not drawing", async () => {
ciInfo.isCI = true;

await expect(
withProgress("Loading", async (update) => {
update("Loaded");
return "ok";
})
).resolves.toBe("ok");

expect(spinner).not.toHaveBeenCalled();
});

it("exits as SIGINT and SIGTERM would while drawing", async () => {
vi.spyOn(terminalProgress, "setIndeterminate").mockImplementation(() => {});
vi.spyOn(terminalProgress, "clear").mockImplementation(() => {});
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/commands/_generated/_meta/commands.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli/src/commands/hand-written-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import pagesDeployMeta from "./pages/deploy/meta.js";
import previewsDeployMeta from "./previews/deploy/meta.js";
import registrarRegistrationsCreateMeta from "./registrar/registrations/create/meta.js";
import schemaMeta from "./schema/meta.js";
import speedtestMeta from "./speedtest/meta.js";
import toolsMeta from "./tools/meta.js";
import tunnelsDiagMeta from "./tunnels/diag/meta.js";
import tunnelsLoginMeta from "./tunnels/login/meta.js";
Expand Down Expand Up @@ -62,6 +63,7 @@ export const handWrittenMeta = {
docs: docsMeta,
init: initMeta,
migrate: migrateMeta,
speedtest: speedtestMeta,
cli: cliMeta,
schema: schemaMeta,
tools: toolsMeta,
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/commands/hand-written.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface RootHandWrittenCommand {
/** Every executable command must choose a {@link HandWrittenDryRunStrategy}. */
dryRun: HandWrittenDryRunStrategy;
command: string;
/** Other names yargs accepts; list them in the `meta.ts` sidecar too. */
aliases?: readonly string[];
describe: string | false;
dir: string;
}
Expand Down Expand Up @@ -138,6 +140,14 @@ const registry = [
describe: "Migrate a Wrangler project to cf",
dir: "migrate",
},
{
kind: "root",
dryRun: "preview",
command: "speedtest",
aliases: ["speed-test"],
describe: "Measure your connection's speed to Cloudflare",
dir: "speedtest",
},
{
kind: "root",
dryRun: "preview",
Expand Down
Loading
Loading