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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Unit tests run on `bun test` (repo root, `bunfig.toml`); component and visual te
| Command | What it runs | Budget | Measured |
|---|---|---|---|
| `bun run test` = `test:changed` | what the diff affects: `bun test --changed` (unit); the component tests whose imports reach a changed file (`scripts/test-graph.ts`: a static import graph with the barrel flattened, because Vitest's own `--changed` selects every file in browser mode); the screenshots of those components; the docs routes mapped by `scripts/route-impact.ts`. Prints what it selected and why. Every component test runs when a shared file changed (styles, tokens, theme, stores, primitives, a test setup file). The site part needs a fresh `site/dist` (a build is ~75 s, over the budget) and says so when it skips | 60 s | 28 s for a one-component edit with a fresh `dist` (unit 0.4, components 4.6, screenshots 6.7, routes 16.6; load ~15) |
| `bun scripts/test-changed.ts --land` | the same plus the package and site builds and 5 canary routes (home, recipes, app-shell, templates/app, theme). This is the gate `issue-land.sh` runs; `validate` runs on the push. **When a shared file changed** (styles, tokens, theme, stores, primitives, a test setup file, the site layout, or a file the route map cannot place) it runs every component test and every route and **no screenshots**, under the larger budget; `--print-budget` says which one applies | 180 s; 420 s when a shared file changed | 104 s for a one-component edit (builds 17 + 58, routes 17; load ~15). Shared case at load 10: unit 5 + components 109 + builds 99 + routes 132 = 346 s; its sweep audits the gallery through 6 representative items (`E2E_GALLERY=representatives`), `test:full` audits all ~400 |
| `bun scripts/test-changed.ts --land` | the same plus the package and site builds and 5 canary routes (home, recipes, app-shell, templates/app, theme). This is the gate `issue-land.sh` runs; `validate` runs on the push. The package builds first (the unit tests that read `dist` need it), then the site build runs beside the unit and component tests (the component tests render from `src`). **When a shared file changed** (styles, tokens, theme, stores, primitives, a test setup file, the site layout, or a file the route map cannot place) it runs every component test and a **thinned route sweep** and **no screenshots**, under the larger budget; `--print-budget` says which one applies. The thinned sweep (`E2E_GALLERY=representatives`) keeps 6 gallery items, 3 atlas terms and the fewest docs pages that still render every component folder and subpath (`docsRepresentatives` in `scripts/route-impact.ts`: 81 of 160), plus every page the diff itself changed (`E2E_KEEP`); `test:full` audits all of them. The ~880 social cards are cached by the sha256 of their HTML and the browser version in `$TMPDIR/ca-og-cards` (shared by every worktree, `CA_OG_CACHE_DIR` overrides, cards unused for 30 days are pruned) and rendered four at a time | 180 s; 240 s when a shared file changed | 104 s for a one-component edit (builds 17 + 58, routes 17; load ~15). Shared case at load 7 (components ALL 210 files): 193.5 s with a warm card cache (package 11 + site build 94 beside the tests + routes 88) and 224.8 s with a cold one (site build 125); it was 382.5 s before #598 (unit 8 + components 80 + package 12 + site 134 + routes 148) |
| `bun run validate` | themes, token lint, Biome, unit tests, both type checks, build, size | 90 s | 51 s at load 11 |
| `bun run test:full` | unit, every component test, every route (layout, axe, dist-html, every project including WebKit), fixtures. Release gate, part 1 of 2 | 300 s | components 109 s (load 25) + routes 168 s (load 29) + unit 40 s + fixtures 7 s: over budget on a busy machine, not measured on a quiet one |
| `bun run test:unit` | `bun test` on `packages/ui/tests/*.test.ts` (fails any test without an `expect`) | | 30 to 40 s |
Expand Down
103 changes: 103 additions & 0 deletions packages/ui/tests/og-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterAll, describe, expect, it } from "bun:test";
import { existsSync, readdirSync, readFileSync, utimesSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { cardKey, placeCard, pruneCards } from "../../../site/scripts/og-cache";
import { cleanTmp, tmpDir } from "./tmp";

// The site build renders ~880 social cards (63 s of a 135 s build). The land gate rebuilds the site after every
// rebase, so the cards are cached outside `dist` by what they are made of: a hit must be byte-identical to a
// render, and nothing that changes a pixel may hit.
afterAll(cleanTmp);

const render = (calls: string[]) => async (html: string) => {
calls.push(html);
return new TextEncoder().encode(`png:${html}`);
};

describe("og card cache", () => {
it("renders a card once, then serves the same bytes from the cache", async () => {
const dir = tmpDir("ca-og-");
const calls: string[] = [];
const first = join(dir, "dist-1/a.png");
const second = join(dir, "dist-2/a.png");

expect(
await placeCard(join(dir, "cache"), "chromium-1", "<h1>A</h1>", first, render(calls))
).toBe(false);
// the next build starts from an empty dist
expect(
await placeCard(join(dir, "cache"), "chromium-1", "<h1>A</h1>", second, render(calls))
).toBe(true);

expect(calls).toEqual(["<h1>A</h1>"]);
expect(readFileSync(second, "utf8")).toBe(readFileSync(first, "utf8"));
});

it("a changed card or a new browser renders again", async () => {
const dir = tmpDir("ca-og-");
const cache = join(dir, "cache");
const calls: string[] = [];
await placeCard(cache, "chromium-1", "<h1>A</h1>", join(dir, "a.png"), render(calls));
await placeCard(cache, "chromium-1", "<h1>B</h1>", join(dir, "b.png"), render(calls));
await placeCard(cache, "chromium-2", "<h1>A</h1>", join(dir, "c.png"), render(calls));
expect(calls).toEqual(["<h1>A</h1>", "<h1>B</h1>", "<h1>A</h1>"]);
expect(readFileSync(join(dir, "b.png"), "utf8")).toBe("png:<h1>B</h1>");
});

it("a render that throws leaves nothing in the cache and nothing in dist", async () => {
const dir = tmpDir("ca-og-");
const cache = join(dir, "cache");
const target = join(dir, "dist/a.png");
await expect(
placeCard(cache, "chromium-1", "<h1>A</h1>", target, async () => {
throw new Error("browser died");
})
).rejects.toThrow("browser died");
expect(existsSync(target)).toBe(false);
expect(existsSync(join(cache, `${cardKey("chromium-1", "<h1>A</h1>")}.png`))).toBe(false);
});

it("the key separates the browser from the markup", () => {
expect(cardKey("ab", "c")).not.toBe(cardKey("a", "bc"));
});
});

describe("og card cache pruning", () => {
const DAY = 86_400_000;
const age = (path: string, days: number) => {
const t = new Date(Date.now() - days * DAY);
utimesSync(path, t, t);
};

it("drops cards unused for longer than the limit and keeps the rest", async () => {
const dir = tmpDir("ca-og-");
const cache = join(dir, "cache");
const png = async () => new Uint8Array([1]);
await placeCard(cache, "v", "old", join(dir, "o.png"), png);
await placeCard(cache, "v", "new", join(dir, "n.png"), png);
age(join(cache, `${cardKey("v", "old")}.png`), 40);

expect(pruneCards(cache, 30)).toBe(1);
expect(readdirSync(cache)).toEqual([`${cardKey("v", "new")}.png`]);
});

it("a card served again counts as used, however old it was made", async () => {
const dir = tmpDir("ca-og-");
const cache = join(dir, "cache");
const png = async () => new Uint8Array([1]);
await placeCard(cache, "v", "card", join(dir, "a.png"), png);
age(join(cache, `${cardKey("v", "card")}.png`), 40);
await placeCard(cache, "v", "card", join(dir, "b.png"), png);

expect(pruneCards(cache, 30)).toBe(0);
});

it("leaves files that are not cards alone, and a missing directory is fine", () => {
const dir = tmpDir("ca-og-");
writeFileSync(join(dir, "notes.txt"), "x");
age(join(dir, "notes.txt"), 400);
expect(pruneCards(dir, 30)).toBe(0);
expect(existsSync(join(dir, "notes.txt"))).toBe(true);
expect(pruneCards(join(dir, "missing"), 30)).toBe(0);
});
});
46 changes: 46 additions & 0 deletions packages/ui/tests/route-impact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, it } from "bun:test";
import {
ATLAS_REPRESENTATIVES,
buildImpact,
docsRepresentatives,
lockReachesRuntime,
ownRoutes,
select,
} from "../../../scripts/route-impact";
import { TERMS, termSlug } from "../../../site/src/data/atlas/atlas";
Expand Down Expand Up @@ -165,6 +167,50 @@ describe("route-impact", () => {
});
});

// A shared change reaches every docs page through the same chrome, and each page differs only by the components
// its demos render. The land gate keeps the smallest set of docs routes that still renders every component folder
// and every subpath the docs use at least once; `test:full` audits all of them (#598).
describe("docs representatives", () => {
const docs = [...impact.files.keys()].filter((r) => r.startsWith("/docs/"));
const reps = docsRepresentatives(impact);
const folderOf = (route: string) =>
new Set(
[...(impact.symbols.get(route) ?? [])].map((s) =>
s.startsWith("@") ? s : (impact.folders.get(s) ?? s)
)
);

it("are real docs routes, and fewer than all of them", () => {
expect(reps.length).toBeGreaterThan(0);
expect(reps.length).toBeLessThan(docs.length);
for (const r of reps) expect(docs).toContain(r);
});

it("render every component folder and subpath that some docs route renders", () => {
const all = new Set(docs.flatMap((r) => [...folderOf(r)]));
const kept = new Set(reps.flatMap((r) => [...folderOf(r)]));
expect([...all].filter((f) => !kept.has(f))).toEqual([]);
});

it("are the same on every call, so two land runs audit the same pages", () => {
expect(docsRepresentatives(impact)).toEqual(reps);
});

it("keep a page that renders nothing from the package, which only the chrome covers", () => {
const prose = docs.filter((r) => !impact.symbols.get(r)?.size);
for (const r of prose) expect(reps).toContain(r);
});

it("a changed page is still audited when a shared file selects every route", () => {
const page = "site/src/content/docs/inputs/button.mdx";
expect(select([page, "packages/ui/src/lib/styles/base.css"], impact).all).toBe(true);
expect(ownRoutes([page, "packages/ui/src/lib/styles/base.css"], impact)).toEqual([
"/docs/inputs/button/",
]);
expect(ownRoutes(["packages/ui/src/lib/styles/base.css"], impact)).toEqual([]);
});
});

// bun.lock is shared by every page because a runtime dependency can change what a page renders. A
// lock change that only touches a workspace's devDependencies (a test tool) cannot.
describe("lockfile", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/tests/test-run-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("test-run budgets", () => {
changed: 60,
validate: 90,
land: 180,
landShared: 420,
landShared: 240,
full: 300,
vrt: 180,
});
Expand Down
7 changes: 4 additions & 3 deletions scripts/issue-land.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ git diff --cached --quiet || git commit -qm "chore: regenerate lockfile and mani
[ -z "$(git status --porcelain --untracked-files=no)" ] || { git status --short; echo "uncommitted changes in $WT" >&2; exit 1; }
# Budgeted gate, chosen by what the diff touches: the tests and screenshots it affects plus the route canaries
# (`land`, 3 minutes), or, when a SHARED file changed (tokens, styles, theme, primitives, a test setup file),
# every component test and every route without screenshots (`landShared`, 6 minutes). The screenshots of a
# shared change run at release (`bun run test:vrt`) and on demand. `validate` (90 s) runs on the push below, so
# it is not run twice. The release gate is `test:full` plus `test:vrt`.
# every component test and the route sweep thinned to representatives (gallery items, atlas terms and the docs pages
# that still render every component; the pages the diff changed always stay) without screenshots (`landShared`,
# 4 minutes). The full sweep and the screenshots of a shared change run at release (`test:full`, `bun run test:vrt`)
# and on demand. `validate` (90 s) runs on the push below, so it is not run twice.
BUDGET=$(bun scripts/test-changed.ts --land --print-budget)
bun scripts/test-run.ts --budget="$BUDGET" bun scripts/test-changed.ts --land
git push -q --force-with-lease
Expand Down
49 changes: 49 additions & 0 deletions scripts/route-impact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,55 @@ export const GALLERY_REPRESENTATIVES = [
*/
export const ATLAS_REPRESENTATIVES = ["brutalism", "bauhaus", "personal-operating-system-ui"];

/**
* The docs routes that stand for all of them when a shared change (a stylesheet, a token, a primitive) reaches every
* page (#598). Every docs page is the same shell around its own MDX and demos, so what can differ between two pages is
* the set of components they render: a greedy set cover keeps the fewest routes that still render every component
* folder and every subpath (`@charts`, `@map`...) some docs route renders. A route that renders nothing from the package
* (prose only) is kept, since only the shell covers it and the shell is the thing that changed. Ties break on the route
* name, so two runs audit the same pages. `test:full` (release) audits every route.
*/
export function docsRepresentatives(impact: RouteImpact): string[] {
const tokens = (route: string) =>
new Set(
[...(impact.symbols.get(route) ?? [])].map((s) =>
s.startsWith("@") ? s : (impact.folders.get(s) ?? s)
)
);
const docs = [...impact.files.keys()].filter((r) => r.startsWith("/docs/")).sort();
const kept = docs.filter((r) => tokens(r).size === 0);
const left = new Set(docs.flatMap((r) => [...tokens(r)]));
const rest = docs.filter((r) => tokens(r).size > 0);
while (left.size) {
let best = "";
let gain = 0;
for (const r of rest) {
const n = [...tokens(r)].filter((t) => left.has(t)).length;
if (n > gain) {
best = r;
gain = n;
}
}
kept.push(best);
for (const t of tokens(best)) left.delete(t);
}
return kept.sort();
}

/**
* The routes each changed file maps to on its own, ignoring the files that select everything. `select` stops at the
* first file that does, so a diff of a stylesheet and an MDX page reports `all` and loses the page; the land gate thins
* a full sweep to representatives and must still audit the page that changed.
*/
export function ownRoutes(changed: string[], impact = buildImpact()): string[] {
const out = new Set<string>();
for (const f of changed) {
const one = select([f], impact);
if (!one.all) for (const r of one.routes) out.add(r);
}
return [...out].sort();
}

export function buildImpact(): RouteImpact {
const symbols = new Map<string, Set<string>>();
const files = new Map<string, Set<string>>();
Expand Down
Loading