2026-06-22 14:11:48 +08:00
|
|
|
import { formatCompact, formatPulls } from "@landing/lib/stats";
|
2026-07-25 18:45:38 +08:00
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
|
|
|
|
|
// getStarCount/getImagePulls memoize per module instance so a build fetches once
|
|
|
|
|
// rather than once per page. Tests therefore need a FRESH module each time, or
|
|
|
|
|
// the first test's cached result leaks into every later assertion.
|
|
|
|
|
async function freshStats() {
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
return import("@landing/lib/stats");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Match the exact host, not a substring. `url.includes("hub.docker.com")` would
|
|
|
|
|
// also match hub.docker.com.evil.test, which is the incomplete-URL-sanitization
|
|
|
|
|
// pattern CodeQL flags, and it is worth not teaching that shape in test code.
|
|
|
|
|
function isDockerHub(url: string): boolean {
|
|
|
|
|
return new URL(url).hostname === "hub.docker.com";
|
|
|
|
|
}
|
2026-06-22 14:11:48 +08:00
|
|
|
|
|
|
|
|
describe("formatCompact", () => {
|
|
|
|
|
it("formats thousands with one decimal", () => {
|
|
|
|
|
expect(formatCompact(1720)).toBe("1.7k");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drops a trailing .0 on whole thousands", () => {
|
|
|
|
|
expect(formatCompact(1000)).toBe("1k");
|
|
|
|
|
expect(formatCompact(12_000)).toBe("12k");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves sub-thousand counts untouched", () => {
|
|
|
|
|
expect(formatCompact(999)).toBe("999");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("formats millions with an M suffix", () => {
|
|
|
|
|
expect(formatCompact(1_000_000)).toBe("1M");
|
|
|
|
|
expect(formatCompact(2_300_000)).toBe("2.3M");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("formatPulls", () => {
|
|
|
|
|
it("rounds down to the nearest 10K below 1M", () => {
|
|
|
|
|
expect(formatPulls(140_801)).toBe("140K+");
|
|
|
|
|
expect(formatPulls(104_801)).toBe("100K+");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("stays conservative just under a 10K boundary", () => {
|
|
|
|
|
expect(formatPulls(99_999)).toBe("90K+");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("switches to millions at 1M", () => {
|
|
|
|
|
expect(formatPulls(1_000_000)).toBe("1M+");
|
|
|
|
|
expect(formatPulls(999_999)).toBe("990K+");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rounds millions down to one decimal", () => {
|
|
|
|
|
expect(formatPulls(1_250_000)).toBe("1.2M+");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-25 18:45:38 +08:00
|
|
|
|
|
|
|
|
// These fetchers used to swallow every upstream failure without a word, which
|
|
|
|
|
// let the hardcoded fallbacks drift ~55% out of date unnoticed. The point of
|
|
|
|
|
// these tests is not the constants themselves (they move); it is that a
|
|
|
|
|
// degraded build stays conservative AND says so out loud.
|
|
|
|
|
describe("stat fetchers when upstream is unavailable", () => {
|
|
|
|
|
let warn: ReturnType<typeof vi.spyOn>;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
vi.unstubAllGlobals();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back and warns when the fetch throws", async () => {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn(async () => {
|
|
|
|
|
throw new Error("simulated outage");
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const stats = await freshStats();
|
|
|
|
|
const stars = await stats.getStarCount();
|
|
|
|
|
const pulls = await stats.getImagePulls();
|
|
|
|
|
|
|
|
|
|
expect(stars).toBeGreaterThan(0);
|
|
|
|
|
expect(pulls.total).toBeGreaterThan(0);
|
|
|
|
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(warn.mock.calls.flat().join(" ")).toContain("simulated outage");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back and warns on a non-ok response", async () => {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const stats = await freshStats();
|
|
|
|
|
await stats.getStarCount();
|
|
|
|
|
await stats.getImagePulls();
|
|
|
|
|
|
|
|
|
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(warn.mock.calls.flat().join(" ")).toContain("HTTP 503");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("warns when the response parses but omits the field it needs", async () => {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const stats = await freshStats();
|
|
|
|
|
await stats.getStarCount();
|
|
|
|
|
await stats.getImagePulls();
|
|
|
|
|
|
|
|
|
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(warn.mock.calls.flat().join(" ")).toContain("missing");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses live values and stays quiet when upstream responds", async () => {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn(async (url: string) =>
|
|
|
|
|
isDockerHub(url)
|
|
|
|
|
? { ok: true, status: 200, json: async () => ({ pull_count: 500_000 }) }
|
|
|
|
|
: { ok: true, status: 200, json: async () => ({ stargazers_count: 4242 }) },
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const stats = await freshStats();
|
|
|
|
|
expect(await stats.getStarCount()).toBe(4242);
|
|
|
|
|
// Live Docker Hub count plus the GHCR estimate, so assert the floor, not equality.
|
|
|
|
|
expect((await stats.getImagePulls()).total).toBeGreaterThanOrEqual(500_000);
|
|
|
|
|
expect(warn).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Regression guard. Navbar and TrustSignals render on every one of ~800 built
|
|
|
|
|
// pages, so an un-memoized fetch meant ~800 unauthenticated GitHub calls per
|
|
|
|
|
// build. GitHub 403s after 60, so early pages baked in the live count and
|
|
|
|
|
// later ones baked in the fallback: one site, two different star numbers.
|
|
|
|
|
it("fetches once per build no matter how many pages ask", async () => {
|
|
|
|
|
const fetchSpy = vi.fn(async (url: string) =>
|
|
|
|
|
isDockerHub(url)
|
|
|
|
|
? { ok: true, status: 200, json: async () => ({ pull_count: 500_000 }) }
|
|
|
|
|
: { ok: true, status: 200, json: async () => ({ stargazers_count: 4242 }) },
|
|
|
|
|
);
|
|
|
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
|
|
|
const stats = await freshStats();
|
|
|
|
|
|
|
|
|
|
// Simulate many pages rendering concurrently, as Astro does.
|
|
|
|
|
const stars = await Promise.all(Array.from({ length: 50 }, () => stats.getStarCount()));
|
|
|
|
|
const pulls = await Promise.all(Array.from({ length: 50 }, () => stats.getImagePulls()));
|
|
|
|
|
|
|
|
|
|
expect(new Set(stars)).toEqual(new Set([4242]));
|
|
|
|
|
expect(new Set(pulls.map((p) => p.display)).size).toBe(1);
|
|
|
|
|
// One call for GitHub, one for Docker Hub. Not 100.
|
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the degraded figure conservative rather than inflated", async () => {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn(async () => {
|
|
|
|
|
throw new Error("down");
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { total, display } = await (await freshStats()).getImagePulls();
|
|
|
|
|
// formatPulls rounds down and appends "+", so a stale build understates.
|
|
|
|
|
expect(Number(display.replace(/[^\d.]/g, "")) * 1000).toBeLessThanOrEqual(total);
|
|
|
|
|
});
|
|
|
|
|
});
|