Skip to content
Merged
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
30 changes: 26 additions & 4 deletions apps/data/bin/build-manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,18 @@
*
* node bin/build-manifest.mts --out suite/results gh-pages-data suite/results
*
* Writes only index.json, manifest.json and the manifest/ shards. It never
* Writes only index.json, manifest.json, the manifest/ shards and the summary/
* files. It never
* copies or re-emits the published date directories (decision D2), and never
* writes an index.html.
*/
import { mkdirSync, statSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildManifest } from "../src/manifest.ts";
import { scanRoots } from "../src/scan.ts";
import { INDEX_FILE, MANIFEST_DIR, MANIFEST_FILE } from "../src/paths.ts";
import { buildSummary } from "../src/summary.ts";
import { INDEX_FILE, MANIFEST_DIR, MANIFEST_FILE, SUMMARY_DIR } from "../src/paths.ts";
import { isDateKey } from "@benchmarks/types";

const argv = process.argv.slice(2);
let out: string | undefined;
Expand Down Expand Up @@ -107,9 +110,28 @@ for (const [name, entry] of Object.entries(manifest.entries)) {
shardCount++;
}

// One results summary per date (src/summary.ts), deterministic for the same
// reason as the shards. latest/ is merged rather than a run, so it gets none.
const summaryDir = join(out, SUMMARY_DIR);
mkdirSync(summaryDir, { recursive: true });
let summaryCount = 0;
const skipped: string[] = [];
for (const listing of listings) {
if (!isDateKey(listing.name) || listing.dir === undefined) continue;
const dir = listing.dir;
const built = buildSummary(listing, (file) => readFileSync(join(dir, file), "utf8"));
skipped.push(...built.skipped.map((file) => `${listing.name}/${file}`));
writeFileSync(join(summaryDir, `${listing.name}.json`), `${JSON.stringify(built.summary)}\n`);
summaryCount++;
}
if (skipped.length > 0) {
console.warn(`left out of the summaries, not benchmark files: ${skipped.join(", ")}`);
}

const dirCount = Object.keys(manifest.entries).length;
console.log(
`wrote ${INDEX_FILE}, ${MANIFEST_FILE} and ${shardCount} ${MANIFEST_DIR}/ shards to ${out}: ` +
`wrote ${INDEX_FILE}, ${MANIFEST_FILE}, ${shardCount} ${MANIFEST_DIR}/ shards and ` +
`${summaryCount} ${SUMMARY_DIR}/ files to ${out}: ` +
`${index.dates.length} dates, ${dirCount} directories, latest=${index.latest ?? "none"}, ` +
`${index.fixtures.length} fixtures, ${index.variations.length} variations`,
);
7 changes: 7 additions & 0 deletions apps/data/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import {
manifestShardPath,
packageCountFile,
processCountFile,
summaryPath,
VERSIONS_FILE,
} from "./paths.ts";
import type { DateSummary } from "./summary.ts";

export type FetchLike = (url: string) => Promise<{
ok: boolean;
Expand Down Expand Up @@ -129,6 +131,11 @@ export class DataClient {
return this.#get<ProcessCount>(date, processCountFile(fixture, variation));
}

/** Every benchmark file of one date in one fetch (src/summary.ts); 404 until a deploy has written it. */
getSummary(date: string): Promise<DateSummary> {
return this.#get<DateSummary>(...summaryPath(date));
}

getChartData(date: string): Promise<ChartData> {
return this.#get<ChartData>(date, CHART_DATA_FILE);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/data/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ export {
packageCountFile,
parseDataFile,
processCountFile,
SUMMARY_DIR,
summaryPath,
VERSIONS_FILE,
type FileKind,
type ParsedDataFile,
} from "./paths.ts";
export { scanRoots } from "./scan.ts";
export { buildSummary, type DateSummary, type SummaryResult } from "./summary.ts";
2 changes: 2 additions & 0 deletions apps/data/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface DataManifest {
export interface DirectoryListing {
name: string;
files: string[];
/** Where the directory is on disk, when it was scanned from one. */
dir?: string;
}

const addPair = (into: VariationsByFixture, fixture: string, variation: string): void => {
Expand Down
5 changes: 5 additions & 0 deletions apps/data/src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export const MANIFEST_DIR = "manifest";
*/
export const manifestShardPath = (name: string): string[] => [MANIFEST_DIR, `${name}.json`];

/** Directory holding one results summary per date (src/summary.ts), beside the shards. */
export const SUMMARY_DIR = "summary";

export const summaryPath = (date: string): string[] => [SUMMARY_DIR, `${date}.json`];

const PACKAGE_COUNT_SUFFIX = "-package-count.json";
const PROCESS_COUNT_SUFFIX = "-process-count.json";
const JSON_SUFFIX = ".json";
Expand Down
2 changes: 1 addition & 1 deletion apps/data/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const scanRoots = (roots: string[]): DirectoryListing[] => {
} catch {
continue;
}
byName.set(name, { name, files });
byName.set(name, { name, files, dir });
}
}

Expand Down
54 changes: 54 additions & 0 deletions apps/data/src/summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* One date's benchmark results, in one file.
*
* A trend over many scenarios -- the "average" view averages every scenario
* on every fixture -- otherwise costs one fetch per benchmark file per run:
* 54 a run for package managers, about 6,500 for 120 days. This is the same
* numbers, lifted verbatim out of those files, so it costs one.
*
* Not `chart-data.json`. That file gives a failed run a time, and nothing in
* it says the run failed. Every result here keeps its `exit_codes`, so a
* consumer applies the same rule to it as to the file it came from.
*/
import type { BenchmarkResult } from "@benchmarks/types";
import type { DirectoryListing } from "./manifest.ts";
import { parseDataFile } from "./paths.ts";

/** What a trend needs of a result, exactly as the benchmark file has it. */
export type SummaryResult = Pick<BenchmarkResult, "command" | "mean" | "stddev" | "exit_codes">;

/** Variation, then fixture, to that file's results. */
export type DateSummary = {
[variation: string]: { [fixture: string]: SummaryResult[] | undefined } | undefined;
};

/**
* Summarise one date directory. `read` returns a file's text; a file that
* does not parse as a benchmark file is reported in `skipped`, not guessed at.
*/
export const buildSummary = (
listing: DirectoryListing,
read: (file: string) => string,
): { summary: DateSummary; skipped: string[] } => {
// A variation is whatever the file name says, `__proto__` included.
const summary: DateSummary = Object.create(null);
const skipped: string[] = [];
for (const file of listing.files) {
const parsed = parseDataFile(file);
if (parsed?.kind !== "benchmark") continue;
let results: unknown;
try {
results = (JSON.parse(read(file)) as { results?: unknown }).results;
} catch {
results = undefined;
}
if (!Array.isArray(results) || !results.every((r) => typeof r === "object" && r !== null)) {
skipped.push(file);
continue;
}
(summary[parsed.variation] ??= {})[parsed.fixture] = (results as BenchmarkResult[]).map(
({ command, mean, stddev, exit_codes }) => ({ command, mean, stddev, exit_codes }),
);
}
return { summary, skipped };
};
37 changes: 37 additions & 0 deletions apps/data/test/summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildSummary } from "../src/summary.ts";

describe("buildSummary", () => {
it("lifts each benchmark file's results verbatim, exit codes and all", () => {
const files: { [name: string]: string } = {
"next-clean.json": JSON.stringify({
results: [
{ command: "npm", mean: 2, stddev: 0.1, median: 2, times: [2], exit_codes: [0] },
{ command: "zpm", mean: 0, stddev: 0, median: 0, times: [0], exit_codes: [1] },
],
}),
"next-registry-clean.json": JSON.stringify({ results: [] }),
"next-clean-package-count.json": "{}",
"chart-data.json": "{}",
"astro-clean.json": "not json",
"vue-clean.json": JSON.stringify({ results: [null] }),
"next-__proto__.json": JSON.stringify({ results: [] }),
};
const { summary, skipped } = buildSummary(
{ name: "2026-10-19", files: Object.keys(files) },
(file) => files[file] ?? "",
);
expect(summary).toEqual({
clean: {
next: [
{ command: "npm", mean: 2, stddev: 0.1, exit_codes: [0] },
{ command: "zpm", mean: 0, stddev: 0, exit_codes: [1] },
],
},
"registry-clean": { next: [] },
["__proto__"]: { next: [] },
});
expect(Object.prototype).not.toHaveProperty("next");
expect(skipped).toEqual(["astro-clean.json", "vue-clean.json"]);
});
});
1 change: 1 addition & 0 deletions apps/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test": "vitest run"
},
"dependencies": {
"@base-ui/react": "1.9.0",
"@benchmarks/data": "workspace:*",
"@benchmarks/types": "workspace:*",
"@mdx-js/loader": "^3.1.1",
Expand Down
41 changes: 29 additions & 12 deletions apps/www/src/app/[section]/[[...params]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { Fragment, Suspense } from "react";
import { notFound, redirect } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { ArrowRight, ChartLine } from "lucide-react";
import { InlineCode, Marked } from "@/components/inline-code";
import { Button } from "@/components/ui/button";
import { TimeSeriesChart } from "@/components/charts-echarts";
Expand All @@ -13,6 +13,7 @@ import { NoData } from "@/components/no-data";
import { Callout, Figure, ReportSection, Stat } from "@/components/report";
import { DataAndSource } from "@/components/raw-results";
import { FixtureMenu } from "@/components/fixture-menu";
import { HeadingLink } from "@/components/heading-anchor";
import { PathSkeleton } from "@/components/path-skeleton";
import { PendingLink, PendingProvider, PendingRegion } from "@/components/report-pending";
import {
Expand Down Expand Up @@ -637,26 +638,39 @@ const Report = async ({
</>
}
action={
run.isLatest ? undefined : (
<Button asChild size="sm">
<PendingLink href={path}>
See the latest run
<ArrowRight aria-hidden />
</PendingLink>
<div className="flex flex-wrap gap-2">
<Button
asChild
size="sm"
variant={run.isLatest ? "default" : "outline"}
data-narrative
>
<HeadingLink to="time-trend">
<ChartLine aria-hidden />
See performance over time
</HeadingLink>
</Button>
)
{run.isLatest ? null : (
<Button asChild size="sm">
<PendingLink href={path}>
See the latest run
<ArrowRight aria-hidden />
</PendingLink>
</Button>
)}
</div>
}
/>
{caveats.qualifier === null ? null : (
<div className="mt-6">
<div data-narrative className="mt-6">
<Callout title="A faster mean is not ranked">
<Marked text={caveats.qualifier} />
</Callout>
</div>
)}

{report.stats === null ? null : (
<dl className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<dl data-narrative className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{report.stats.map((stat) => (
<Stat
key={stat.label}
Expand All @@ -675,7 +689,7 @@ const Report = async ({
)}

{caveats.hidden === null ? null : (
<div className="mt-10">
<div data-narrative className="mt-10">
<Callout title={caveats.hidden.title}>
{caveats.hidden.body}{" "}
<ShowAllTools className="text-foreground underline underline-offset-4" />
Expand All @@ -684,7 +698,10 @@ const Report = async ({
)}

{caveats.disclosure === null ? null : (
<p className="mt-10 max-w-[78ch] text-sm leading-relaxed text-muted-foreground">
<p
data-narrative
className="mt-10 max-w-[78ch] text-sm leading-relaxed text-muted-foreground"
>
<span className="font-medium text-foreground">Disclosure.</span> {caveats.disclosure}
</p>
)}
Expand Down
23 changes: 23 additions & 0 deletions apps/www/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
*/

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* Data mode (components/view-toggle.tsx). */
@custom-variant data-mode (&:where([data-view="data"] *));

/* The snapshot banner is in the header while a past run is shown. */
:root:has([data-snapshot-banner]) {
Expand Down Expand Up @@ -502,3 +504,24 @@
background-position: 0 0;
}
}

/* Data mode (components/view-toggle.tsx): the charts, tables and numbers, without the prose around them. */
[data-view="data"] [data-narrative] {
display: none;
}

/* The actions row goes once every link in it has (the latest run's link stays). */
[data-view="data"] [data-actions]:not(:has(a:not([data-narrative]))) {
display: none;
}

/* The finding is the page's h1, so it stays for a screen reader. */
[data-view="data"] [data-narrative="heading"] {
display: block;
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
15 changes: 13 additions & 2 deletions apps/www/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ const RootLayout = async ({ children }: { children: ReactNode }) => {
className={`${sans.variable} ${mono.variable} ${pixel.variable} h-full antialiased`}
suppressHydrationWarning
>
<head>
{/* The reader's Report or Data choice, on <html> before paint (components/view-toggle.tsx). */}
<script
dangerouslySetInnerHTML={{
__html: `try{if(localStorage.getItem("view")==="data")document.documentElement.dataset.view="data"}catch(e){}`,
}}
/>
</head>
<body className="flex min-h-full flex-col text-body">
<ThemeProvider>
{/* nuqs: every query parameter is defined in lib/params.ts. */}
Expand All @@ -86,9 +94,12 @@ const RootLayout = async ({ children }: { children: ReactNode }) => {
latest={catalog.latest}
className="sticky top-(--header-height) h-[calc(100svh-var(--header-height))]!"
/>
<SidebarInset className="min-w-0 px-4 pt-4 pb-8 md:px-16 md:pb-12">
<SidebarInset className="min-w-0 px-4 pt-4 pb-8 md:px-16 md:pb-12 data-mode:md:pl-8">
<div aria-hidden className="page-fade page-fade-top" />
<div className="mx-auto w-full max-w-6xl">{children}</div>
{/* Data mode is charts and tables, so it takes more of the screen (the right gutter stays, for the minimap). */}
<div className="mx-auto w-full max-w-6xl data-mode:max-w-[96rem]">
{children}
</div>
<div aria-hidden className="page-fade page-fade-bottom" />
</SidebarInset>
</div>
Expand Down
Loading