diff --git a/AGENTS.md b/AGENTS.md
index 43a1e869..cc29aad5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 |
diff --git a/packages/ui/tests/og-cache.test.ts b/packages/ui/tests/og-cache.test.ts
new file mode 100644
index 00000000..77ae4ee0
--- /dev/null
+++ b/packages/ui/tests/og-cache.test.ts
@@ -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", "
A
", first, render(calls))
+ ).toBe(false);
+ // the next build starts from an empty dist
+ expect(
+ await placeCard(join(dir, "cache"), "chromium-1", "A
", second, render(calls))
+ ).toBe(true);
+
+ expect(calls).toEqual(["A
"]);
+ 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", "A
", join(dir, "a.png"), render(calls));
+ await placeCard(cache, "chromium-1", "B
", join(dir, "b.png"), render(calls));
+ await placeCard(cache, "chromium-2", "A
", join(dir, "c.png"), render(calls));
+ expect(calls).toEqual(["A
", "B
", "A
"]);
+ expect(readFileSync(join(dir, "b.png"), "utf8")).toBe("png:B
");
+ });
+
+ 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", "A
", target, async () => {
+ throw new Error("browser died");
+ })
+ ).rejects.toThrow("browser died");
+ expect(existsSync(target)).toBe(false);
+ expect(existsSync(join(cache, `${cardKey("chromium-1", "A
")}.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);
+ });
+});
diff --git a/packages/ui/tests/route-impact.test.ts b/packages/ui/tests/route-impact.test.ts
index bcc21a79..4404352f 100644
--- a/packages/ui/tests/route-impact.test.ts
+++ b/packages/ui/tests/route-impact.test.ts
@@ -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";
@@ -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", () => {
diff --git a/packages/ui/tests/test-run-budget.test.ts b/packages/ui/tests/test-run-budget.test.ts
index 0f4e2132..d3317790 100644
--- a/packages/ui/tests/test-run-budget.test.ts
+++ b/packages/ui/tests/test-run-budget.test.ts
@@ -20,7 +20,7 @@ describe("test-run budgets", () => {
changed: 60,
validate: 90,
land: 180,
- landShared: 420,
+ landShared: 240,
full: 300,
vrt: 180,
});
diff --git a/scripts/issue-land.sh b/scripts/issue-land.sh
index 62b57f21..8b63884d 100755
--- a/scripts/issue-land.sh
+++ b/scripts/issue-land.sh
@@ -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
diff --git a/scripts/route-impact.ts b/scripts/route-impact.ts
index 74087e3d..46b48c81 100644
--- a/scripts/route-impact.ts
+++ b/scripts/route-impact.ts
@@ -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();
+ 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>();
const files = new Map>();
diff --git a/scripts/test-changed.ts b/scripts/test-changed.ts
index 3c5a7293..9633926f 100755
--- a/scripts/test-changed.ts
+++ b/scripts/test-changed.ts
@@ -8,7 +8,7 @@
* A SHARED change (a shared component file, or a route-wide one: styles, tokens, theme, primitives, the
* site layout, a test setup file, or a file the route map cannot place) runs every component test and, when
* it reaches the site, every route, and NO screenshots: those run at release (`test:vrt`) and on demand.
- * `--print-budget` prints the budget this selection runs under (`land`, 180 s, or `landShared`, 420 s).
+ * `--print-budget` prints the budget this selection runs under (`land`, 180 s, or `landShared`, 240 s).
*
* unit `bun test --changed ` (module graph);
* components the component tests whose imports reach a changed file (scripts/test-graph.ts; Vitest's
@@ -19,19 +19,21 @@
* project when a changed route is one of its five pages (landing, templates, theme);
* site the docs routes whose source changed (scripts/route-impact.ts), or EVERY route when a
* changed file is shared by all pages or unknown to the map; changed non-route Playwright
- * specs run themselves. A dev run needs `site/dist` to be fresh (a build is ~75 s, over
+ * specs run themselves. A dev run needs `site/dist` to be fresh (a build is ~80 s, over
* the 60 s budget) and says so when it is not; `--land` builds and adds the canaries below.
*
* `CA_TEST_BASE=[` compares against another ref (`CA_TEST_BASE=HEAD`: only what is not committed yet).
*
- * Steps run one after the other under scripts/test-run.ts (machine-wide lock, 25% browser cap).
+ * Steps run under scripts/test-run.ts (machine-wide lock, 25% browser cap). At land the site build runs
+ * beside the unit and component tests (after the package build, which the unit tests read); everything
+ * else is sequential, and the screenshots and the route sweep never share the machine with another run.
* Extra args go to Vitest: `bun run test:changed -- --reporter=minimal`.
*/
-import { spawnSync } from "node:child_process";
+import { spawn, spawnSync } from "node:child_process";
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { distStale } from "./dist-fresh.ts";
-import { lockReachesRuntime, ROOT, select } from "./route-impact.ts";
+import { lockReachesRuntime, ownRoutes, ROOT, select } from "./route-impact.ts";
import { affected, buildGraph, isSharedComponent, landBudget } from "./test-graph.ts";
if (!import.meta.main) throw new Error("scripts/test-changed.ts is a command, not a module");
@@ -139,50 +141,103 @@ else if (routes.length) {
if (specs.length)
console.log(` specs ${specs.map((f) => f.replace("site/tests/", "")).join(" ")}`);
+const runEnv = (env: Record) => ({ ...process.env, ...env });
+const testRun = (cmd: string[]) => [join(ROOT, "scripts/test-run.ts"), ...cmd];
const run = (cmd: string[], env: Record = {}, cwd = ROOT) =>
- spawnSync(process.execPath, [join(ROOT, "scripts/test-run.ts"), ...cmd], {
- cwd,
- stdio: "inherit",
- env: { ...process.env, ...env },
- }).status ?? 1;
+ spawnSync(process.execPath, testRun(cmd), { cwd, stdio: "inherit", env: runEnv(env) }).status ??
+ 1;
+/** The same as `run`, without blocking: the land gate builds the site while the package tests run. */
+const runAsync = (cmd: string[], env: Record = {}, cwd = ROOT) => {
+ const { promise, resolve } = Promise.withResolvers();
+ const child = spawn(process.execPath, testRun(cmd), { cwd, stdio: "inherit", env: runEnv(env) });
+ child.on("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
+ child.on("error", () => resolve(127));
+ return promise;
+};
const failed: string[] = [];
-let lap = performance.now();
-const step = (name: string, code: number) => {
- const seconds = ((performance.now() - lap) / 1000).toFixed(1);
- console.log(`test:changed: ${name} ${code === 0 ? "ok" : "FAILED"} (${seconds}s)`);
- lap = performance.now();
+/** Runs one step and reports its own wall time; steps can overlap, so there is no shared lap clock. */
+const step = async (name: string, work: () => number | Promise, note = "") => {
+ const t0 = performance.now();
+ const code = await work();
+ const seconds = ((performance.now() - t0) / 1000).toFixed(1);
+ console.log(`test:changed: ${name} ${code === 0 ? "ok" : "FAILED"} (${seconds}s${note})`);
if (code !== 0) failed.push(name);
};
-// Unit tests: only the files the diff reaches. `bun test` exits 1 when nothing matched, which is not a failure here.
-const unit = run(["bun", "test", `--changed=${base}`]);
-step("unit", unit === 1 && !changed.some((f) => /\.(ts|js|svelte)$/.test(f)) ? 0 : unit);
-
const vitestExtra = process.argv.slice(2).filter((a) => a !== "--" && a !== "--land");
const inPkg = (files: string[]) => files.map((f) => f.replace("packages/ui/", ""));
const pkgDir = join(ROOT, "packages/ui");
-if (compTests.length || ssrTests.length)
- step(
- "components",
- run(
- [
- "vitest",
- "run",
- ...(compTests.length ? ["--project", "browser"] : []),
- ...(ssrTests.length ? ["--project", "ssr"] : []),
- ...(sharedFile ? [] : inPkg([...compTests, ...ssrTests])),
- ...vitestExtra,
- ],
- {},
- pkgDir
- )
- );
-// Screenshots of the changed components only (the platform's own baselines, rendered natively).
+/** Unit tests (only the files the diff reaches), then the component tests. */
+const packageTests = async () => {
+ await step("unit", () => {
+ // `bun test` exits 1 when nothing matched, which is not a failure here.
+ const unit = run(["bun", "test", `--changed=${base}`]);
+ return unit === 1 && !changed.some((f) => /\.(ts|js|svelte)$/.test(f)) ? 0 : unit;
+ });
+ if (compTests.length || ssrTests.length)
+ await step("components", () =>
+ runAsync(
+ [
+ "vitest",
+ "run",
+ ...(compTests.length ? ["--project", "browser"] : []),
+ ...(ssrTests.length ? ["--project", "ssr"] : []),
+ ...(sharedFile ? [] : inPkg([...compTests, ...ssrTests])),
+ ...vitestExtra,
+ ],
+ {},
+ pkgDir
+ )
+ );
+};
+
+// What the site tests need built. The route specs read `site/dist`: rebuild only what is older than the diff.
+const mtime = (p: string) => (existsSync(p) ? statSync(p).mtimeMs : 0);
+const pkgDist = mtime(join(ROOT, "packages/ui/dist/index.js"));
+const siteDist = mtime(join(ROOT, "site/dist/index.html"));
+const newer = (re: RegExp, than: number) =>
+ changed.some((f) => re.test(f) && mtime(join(ROOT, f)) > than);
+const pkgStale =
+ siteRun &&
+ distStale({
+ land,
+ exists: pkgDist > 0,
+ changedAfter: newer(/^packages\/ui\/(src|scripts|package\.json)/, pkgDist),
+ });
+const siteStale =
+ siteRun &&
+ (pkgStale ||
+ distStale({
+ land,
+ exists: siteDist > 0,
+ changedAfter: newer(/^site\/(src|public|astro\.config\.mjs|package\.json)/, siteDist),
+ }));
+// A build is ~80 s (package 12 s + site 70 s), more than the whole 60 s dev budget: only the land gate
+// builds. A dev run uses `site/dist` when it is fresh, and says so when it cannot.
+const buildHere = land && (pkgStale || siteStale);
+
+// The unit tests that read `packages/ui/dist` (the CLIs, the MCP server) need the package built first; the
+// component tests render from `src` and the site build reads only the built package. So the package builds
+// first, then the site build (mostly one busy core) runs beside the package tests, which hold their own quarter
+// of the cores: ~50 s of a land run that used to wait for each in turn.
+if (buildHere && pkgStale)
+ await step("build package", () => run(["bun", "run", "--cwd", "packages/ui", "build"]));
+// Started now, awaited after the package tests: it runs beside them.
+const building =
+ buildHere && siteStale
+ ? step(
+ "build site",
+ () => runAsync(["bun", "run", "--cwd", "site", "build"]),
+ ", beside the tests"
+ )
+ : undefined;
+await packageTests();
+await building;
+
if (!shared && vrtTests.length)
- step(
- "visual (components)",
+ await step("visual (components)", () =>
run(
[
"vitest",
@@ -197,41 +252,22 @@ if (!shared && vrtTests.length)
)
);
-if (siteRun) {
- // The route specs read `site/dist`: rebuild only what is older than the diff.
- const mtime = (p: string) => (existsSync(p) ? statSync(p).mtimeMs : 0);
- const pkgDist = mtime(join(ROOT, "packages/ui/dist/index.js"));
- const siteDist = mtime(join(ROOT, "site/dist/index.html"));
- const newer = (re: RegExp, than: number) =>
- changed.some((f) => re.test(f) && mtime(join(ROOT, f)) > than);
- const pkgStale = distStale({
- land,
- exists: pkgDist > 0,
- changedAfter: newer(/^packages\/ui\/(src|scripts|package\.json)/, pkgDist),
- });
- const siteStale =
- pkgStale ||
- distStale({
- land,
- exists: siteDist > 0,
- changedAfter: newer(/^site\/(src|public|astro\.config\.mjs|package\.json)/, siteDist),
- });
- // A build is ~75 s (package 17 s + site 58 s), more than the whole 60 s dev budget: only the land gate
- // builds. A dev run uses `site/dist` when it is fresh, and says so when it cannot.
- if ((pkgStale || siteStale) && !land) {
- console.log(
- "test:changed: site SKIPPED: site/dist is older than the diff. Run `bun run build` once and repeat, or let the land gate (`issue-land.sh`) run it."
- );
- process.exit(failed.length ? 1 : 0);
- }
- if (pkgStale) step("build package", run(["bun", "run", "--cwd", "packages/ui", "build"]));
- if (siteStale) step("build site", run(["bun", "run", "--cwd", "site", "build"]));
+if (!land && (pkgStale || siteStale)) {
+ console.log(
+ "test:changed: site SKIPPED: site/dist is older than the diff. Run `bun run build` once and repeat, or let the land gate (`issue-land.sh`) run it."
+ );
+ process.exit(failed.length ? 1 : 0);
+}
+if (siteRun) {
const env = {
E2E_PREBUILT: "1",
...(routeRun && !sweepAll ? { E2E_ROUTES: JSON.stringify(routes) } : {}),
- // A full sweep at land audits the gallery through its representatives; `test:full` audits every item.
- ...(land && sweepAll ? { E2E_GALLERY: "representatives" } : {}),
+ // A full sweep at land audits the gallery, the atlas and the docs through representatives (plus the pages the
+ // diff itself changed); `test:full` audits every one.
+ ...(land && sweepAll
+ ? { E2E_GALLERY: "representatives", E2E_KEEP: JSON.stringify(ownRoutes(changed)) }
+ : {}),
};
const args = [
"playwright",
@@ -248,10 +284,9 @@ if (siteRun) {
: ["--project=desktop", "--project=mobile"]),
...specs.map((f) => f.replace("site/tests/", "")),
];
- step("site", run(args, env, join(ROOT, "site")));
+ await step("site", () => run(args, env, join(ROOT, "site")));
if (!shared && visualRun)
- step(
- "visual (site)",
+ await step("visual (site)", () =>
run(
["playwright", "test", "--project=visual-desktop", "--project=visual-mobile"],
{ E2E_PREBUILT: "1" },
diff --git a/scripts/test-run.ts b/scripts/test-run.ts
index 7afab5a8..caad8b0e 100755
--- a/scripts/test-run.ts
+++ b/scripts/test-run.ts
@@ -201,13 +201,16 @@ export const BUDGETS = {
/** `issue-land.sh`: `test:changed --land` (affected tests, changed screenshots, the route canaries) */
land: 180,
/**
- * `issue-land.sh` when a SHARED file changed: every component test and every route (the gallery through its
- * representatives), no screenshots. Measured at load 10: unit 5 s + components 109 s + builds 99 s + routes 132 s = 346 s;
- * the 300 s it started with was never measured (#546). Since then the land gate always rebuilds (#591) and the atlas,
- * concepts, styles and index pages added routes: measured at load 7.5 on #583, unit 8 s + components 80 s + package 12 s +
- * site 134 s + routes 148 s = 382.5 s with nothing failing, so 420 s (#596).
+ * `issue-land.sh` when a SHARED file changed: every component test, and the route sweep thinned to representatives
+ * (gallery items, atlas terms, the docs pages that still render every component folder; the pages the diff changed
+ * stay), no screenshots. It was 420 s (#596): unit 8 s + components 80 s + package 12 s + site 134 s + routes 148 s =
+ * 382.5 s, which broke the rule that no test command takes 5 minutes. Now the social cards are cached and rendered
+ * four at a time, the site build runs beside the unit and component tests, and the sweep is 649 tests instead of
+ * 1044 (#598). Measured at load 7 on 12 cores, a shared change (components ALL 210 files): 224.8 s with a cold card
+ * cache (package 11 s, then site build 125 s beside the tests, then routes 88 s) and 193.5 s with a warm one (site
+ * build 94 s). The full sweep stays at release (`test:full`). Do not raise it again: make the run faster.
*/
- landShared: 420,
+ landShared: 240,
/** `bun run test:full`: unit, every component test, every route, fixtures (release gate, part 1) */
full: 300,
/** `bun run test:vrt`: every component and site screenshot (release gate, part 2) */
diff --git a/site/scripts/og-cache.ts b/site/scripts/og-cache.ts
new file mode 100644
index 00000000..598de9b0
--- /dev/null
+++ b/site/scripts/og-cache.ts
@@ -0,0 +1,70 @@
+import { createHash, randomUUID } from "node:crypto";
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ renameSync,
+ rmSync,
+ statSync,
+ utimesSync,
+ writeFileSync,
+} from "node:fs";
+import { dirname, join } from "node:path";
+
+/**
+ * Rendered social cards survive `dist` being emptied by the next build. A card is a pure function of its HTML
+ * (title, description, eyebrow, mark, colors and the template's own CSS are all in it) and of the Chromium that
+ * rasterised it, so `sha256(browser version + html)` names the cached PNG: an edit to the template, a copy change
+ * or a browser update misses the cache and renders again. The land gate builds the site after every rebase and
+ * used to pay 63 s for 880 renders that had not changed; a release build and a fresh checkout start cold.
+ */
+export const cardKey = (browserVersion: string, html: string) =>
+ createHash("sha256").update(browserVersion).update("\0").update(html).digest("hex");
+
+/** Puts the card for `html` at `target`, rendering it only when the cache has no PNG for it. Returns whether it hit. */
+export async function placeCard(
+ cacheDir: string,
+ browserVersion: string,
+ html: string,
+ target: string,
+ render: (html: string) => Promise
+): Promise {
+ const cached = join(cacheDir, `${cardKey(browserVersion, html)}.png`);
+ const hit = existsSync(cached);
+ if (hit) {
+ // "used" is what pruning goes by, not "made"
+ const now = new Date();
+ utimesSync(cached, now, now);
+ } else {
+ mkdirSync(cacheDir, { recursive: true });
+ // rename is atomic: a build killed mid-write never leaves a truncated PNG that later builds would trust; the
+ // name is unique per call so two renders of the same card at once cannot interleave
+ const partial = `${cached}.${process.pid}.${randomUUID()}.tmp`;
+ writeFileSync(partial, await render(html));
+ renameSync(partial, cached);
+ }
+ mkdirSync(dirname(target), { recursive: true });
+ copyFileSync(cached, target);
+ return hit;
+}
+
+/**
+ * Deletes cards not served for `maxAgeDays`. Every template or copy change leaves ~880 cards (~115 MB) that nothing
+ * will ask for again, and the directory is shared by every worktree of the machine. Only `.png` files are
+ * touched. Returns how many it removed.
+ */
+export function pruneCards(cacheDir: string, maxAgeDays: number): number {
+ if (!existsSync(cacheDir)) return 0;
+ const limit = Date.now() - maxAgeDays * 86_400_000;
+ let removed = 0;
+ for (const name of readdirSync(cacheDir)) {
+ if (!/^[0-9a-f]{64}\.png$/.test(name)) continue;
+ const file = join(cacheDir, name);
+ if (statSync(file).mtimeMs < limit) {
+ rmSync(file, { force: true });
+ removed += 1;
+ }
+ }
+ return removed;
+}
diff --git a/site/scripts/og-integration.ts b/site/scripts/og-integration.ts
index 4cbc791b..4f36a12c 100644
--- a/site/scripts/og-integration.ts
+++ b/site/scripts/og-integration.ts
@@ -3,18 +3,30 @@
// names each card after a hash of what it shows (template, mark, colors, title, description),
// writes it to dist/brand/og/ and rewrites the placeholder in that page's HTML. One code path owns
// the naming, so a page can never point at a card that was not rendered.
-import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { brandHash, ogCard, renderMark, renderOgImage, resolveBrand } from "@codeatlasdev/ui/vite";
import { chromium } from "@playwright/test";
import type { AstroIntegration } from "astro";
import { theme } from "../src/theme.ts";
+import { placeCard, pruneCards } from "./og-cache.ts";
/** Bump when the card template or its CSS changes: every card is renamed. */
const TEMPLATE_VERSION = "2";
const SITE_NAME = "CodeAtlas UI";
const markFile = new URL("../src/brand/mark.svg", import.meta.url);
+/** Cards not served for this long are deleted from the shared cache. */
+const CACHE_DAYS = 30;
+/** Cards rendered at once (a Chromium page each). */
+const CARD_WORKERS = 4;
+/**
+ * Rendered cards live outside the checkout, like the test lock: every issue is its own worktree, so a cache inside
+ * the checkout would be cold at every land. The key is the card's full HTML plus the browser version, so sharing
+ * it across worktrees and branches is safe (see og-cache.ts). `CA_OG_CACHE_DIR` overrides it.
+ */
+const cacheDir = process.env.CA_OG_CACHE_DIR ?? join(tmpdir(), "ca-og-cards");
const PLACEHOLDER = /\/brand\/og\/([a-z0-9-]+)\.png/;
const decodeEntities = (s: string) =>
@@ -54,18 +66,24 @@ export function ogCards(): AstroIntegration {
.filter(({ html }) => PLACEHOLDER.test(html));
const browser = await chromium.launch();
+ const version = browser.version();
+ let hits = 0;
try {
- for (const { file, html } of todo) {
- const slug = html.match(PLACEHOLDER)?.[1] as string;
- const title = decodeEntities(content(html, "property", "og:title") ?? SITE_NAME);
- const description = decodeEntities(content(html, "property", "og:description") ?? "");
- const eyebrow = slug.startsWith("docs")
- ? "Documentation"
- : slug.startsWith("templates")
- ? "Template"
- : undefined;
- const name = `${slug}.${brandHash(TEMPLATE_VERSION, mark, JSON.stringify(colors), title, description, eyebrow ?? "")}.png`;
- if (!existsSync(join(outDir, name))) {
+ // Chromium renders in its own processes: four at once cost 40 ms a card instead of 72 (measured), and
+ // only cards the cache lacks pay anything.
+ let next = 0;
+ const worker = async () => {
+ for (let i = next++; i < todo.length; i = next++) {
+ const { file, html } = todo[i];
+ const slug = html.match(PLACEHOLDER)?.[1] as string;
+ const title = decodeEntities(content(html, "property", "og:title") ?? SITE_NAME);
+ const description = decodeEntities(content(html, "property", "og:description") ?? "");
+ const eyebrow = slug.startsWith("docs")
+ ? "Documentation"
+ : slug.startsWith("templates")
+ ? "Template"
+ : undefined;
+ const name = `${slug}.${brandHash(TEMPLATE_VERSION, mark, JSON.stringify(colors), title, description, eyebrow ?? "")}.png`;
const card = ogCard({
title: title.replace(` ยท ${SITE_NAME}`, ""),
description,
@@ -74,17 +92,24 @@ export function ogCards(): AstroIntegration {
mark: filled,
colors,
});
+ const hit = await placeCard(cacheDir, version, card, join(outDir, name), (h) =>
+ renderOgImage(browser, h, { format: "png" })
+ );
+ if (hit) hits += 1;
writeFileSync(
- join(outDir, name),
- await renderOgImage(browser, card, { format: "png" })
+ file,
+ html.replaceAll(new RegExp(PLACEHOLDER, "g"), `/brand/og/${name}`)
);
}
- writeFileSync(file, html.replaceAll(new RegExp(PLACEHOLDER, "g"), `/brand/og/${name}`));
- }
+ };
+ await Promise.all(Array.from({ length: CARD_WORKERS }, worker));
} finally {
await browser.close();
}
- logger.info(`${todo.length} social cards in ${relative(process.cwd(), outDir)}`);
+ const pruned = pruneCards(cacheDir, CACHE_DAYS);
+ logger.info(
+ `${todo.length} social cards in ${relative(process.cwd(), outDir)} (${hits} from the cache, ${pruned} stale ones pruned)`
+ );
},
},
};
diff --git a/site/tests/routes.ts b/site/tests/routes.ts
index 0a17994e..5931959c 100644
--- a/site/tests/routes.ts
+++ b/site/tests/routes.ts
@@ -1,7 +1,12 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
-import { ATLAS_REPRESENTATIVES, GALLERY_REPRESENTATIVES } from "../../scripts/route-impact";
+import {
+ ATLAS_REPRESENTATIVES,
+ buildImpact,
+ docsRepresentatives,
+ GALLERY_REPRESENTATIVES,
+} from "../../scripts/route-impact";
export const DIST =
process.env.E2E_DIST ?? join(dirname(fileURLToPath(import.meta.url)), "../dist");
@@ -30,17 +35,22 @@ const only: string[] | undefined = process.env.E2E_ROUTES
// `E2E_GALLERY=representatives` (the land gate, on a full sweep) keeps the gallery's item pages to the
// representatives: they are one viewer chrome around ~400 demos, each audited on its docs route and on its
// own item when it changes. `bun run test:full` (release) leaves it unset and audits every item.
-const items =
- process.env.E2E_GALLERY === "representatives" ? new Set(GALLERY_REPRESENTATIVES) : undefined;
+const thin = process.env.E2E_GALLERY === "representatives";
+const items = thin ? new Set(GALLERY_REPRESENTATIVES) : undefined;
const isItem = (p: string) => /^\/gallery\/[^/]+\/$/.test(p);
// The same for the atlas: one template over every term, a few of them stand for all at land.
-const terms =
- process.env.E2E_GALLERY === "representatives" ? new Set(ATLAS_REPRESENTATIVES) : undefined;
+const terms = thin ? new Set(ATLAS_REPRESENTATIVES) : undefined;
const isTerm = (p: string) => /^\/atlas\/[^/]+\/$/.test(p);
+// And for the docs: the fewest pages that still render every component folder and subpath (#598).
+const docs = thin ? new Set(docsRepresentatives(buildImpact())) : undefined;
+const isDoc = (p: string) => p.startsWith("/docs/");
+// A page the diff itself changed is audited even when the thinning would drop it (`E2E_KEEP`, from test:changed).
+const keep = new Set(process.env.E2E_KEEP ? JSON.parse(process.env.E2E_KEEP) : []);
export const ROUTES: string[] = walk(DIST)
.map((file) => relative(DIST, dirname(file)))
.map((p) => (p === "" ? "/" : `/${p}/`))
.filter((p) => !p.startsWith("/landing-embed/") && (!only || only.includes(p)))
- .filter((p) => !items || !isItem(p) || items.has(p.split("/")[2]))
- .filter((p) => !terms || !isTerm(p) || terms.has(p.split("/")[2]))
+ .filter((p) => keep.has(p) || !items || !isItem(p) || items.has(p.split("/")[2]))
+ .filter((p) => keep.has(p) || !terms || !isTerm(p) || terms.has(p.split("/")[2]))
+ .filter((p) => keep.has(p) || !docs || !isDoc(p) || docs.has(p))
.sort();
]