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();