diff --git a/AGENTS.md b/AGENTS.md index 6d986827..e0d621a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,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 or a test-runtime dependency changed (styles, tokens, theme, stores, primitives, a test setup file, `package.json`, `bun.lock`): the run prints `widened to all components because ` and the budget widens with it. The site part needs a fresh `site/dist` (a build is ~75 s, over the budget) and says so when it skips | 60 s narrow; 240 s (`landShared`) when widened to all components (`scripts/test-changed.sh` reads it from `--print-budget`) | 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). Widened by the happy-dom bump (#588): 95 s at load 10.5 for the full component set (~2190 tests), which is why the narrow 60 s does not apply to it | -| `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 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`). **A narrow run builds only the pages it audits** (`CA_SITE_ROUTES`, `site/src/lib/only-routes.ts` filters the gallery, atlas and docs paths): 81 pages in ~45 s instead of 915 in 120 s, and `static` (the zero-browser HTML checks) reads 96 pages in 4 s instead of 918 in 47 s. A run that audits every page (a shared change, a changed behaviour spec) or any behaviour spec builds the whole site. `site/dist/.ca-scope.json` records what a build holds (written by the build, `site/scripts/scope-integration.ts`), so a `dist` is reused only when it covers the pages a run needs, and `release-sweep.test.ts` skips on a narrow one. **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` | `scripts/test-full.ts`: package build, then the site build beside the unit tests and every component test, then the fixtures. The build is part of the gate: a gate run on a `dist` older than `src` tested the previous release (#610). Release gate, part 1 of 4 | 300 s | 164 s at load 6 to 16 (package 13 s, unit 35 s, components 111 s, site build 146 s beside them, fixtures 5 s) | | `bun run test:e2e` | the site's behaviour specs at both viewports, the iPhone WebKit specs, the overlay and theme-studio a11y specs, and `static`: the zero-browser HTML checks (`dist-html.spec.ts`: title, lang, viewport, description, canonical, og:title, one `
`, image alt, link and button names, frame titles, `rel=noopener`, skip target, duplicate ids) and brand assets on **every page**, gallery items and atlas terms included (~7 ms a page). Release gate, part 2 of 4; reads the `site/dist` of `test:full` | 300 s | 213 s at load 7 to 16 (1,282 tests) | @@ -68,6 +68,7 @@ Unit tests run on `bun test` (repo root, `bunfig.toml`); component and visual te | `bun run test:vrt` | every screenshot against the committed baselines of the platform you are on: package Vitest `vrt` (405) and the site's `visual-desktop`/`visual-mobile` (landing, templates, theme at the viewport). Release gate, part 4 of 4; also the way to check screenshots after a shared change, which the land gate leaves out | 180 s | components 105 to 137 s + site 7 to 11 s | | `bun run test:vrt:update` | the same, rewriting the baselines of your platform. Review the changed images against an intended change before committing; run `test:vrt` twice afterwards to prove they are stable | | components 133 s, site 70 s | | `bun run lighthouse` | Lighthouse (SEO and accessibility must be 100, performance floor) plus the installability contract (standalone manifest with `id`, 192/512/maskable icons, `twitter:title`, `og:image`, `lang`) on the built site served locally, offline. Not in `validate`: it needs a production build and a Chrome, is CPU-heavy and its performance score moves with machine load, which AGENTS.md forbids as a gate. Run it when you touch SEO, the manifest, `` or `init` output; `E2E_PREBUILT=1` skips the build | | | +| `bun run clean:caches` | the size of every store the tooling writes outside the source tree (`STORES` in `scripts/caches.ts`: OG cards, Playwright's per-launch directories in `$TMPDIR`, run timings, the last run's reports and traces) against its cap, and what an eviction would free; `-- --clean` frees it | | | | `bun run test:status` | live and dead test runs of this repo, the test lock and its holder, and the browser count | | | | `bun run test:reap` | kill the browsers of runs whose runner died (this repo only) | | | @@ -77,6 +78,8 @@ Unit tests run on `bun test` (repo root, `bunfig.toml`); component and visual te **One suite at a time.** A run takes a machine-wide lock (`$TMPDIR/ca-test-lock`, shared by every worktree and checkout; `CA_TEST_LOCK_DIR` overrides it) and a second run waits, printing who holds it, instead of starting more browsers. A holder that died is taken over, and a nested `test-run` (the commands above call each other) reuses its parent's lock. +**Nothing the tooling writes grows without bound.** Every store has an age limit and a size cap in one table (`STORES`, `scripts/caches.ts`); `scripts/test-run.ts` enforces them each time it takes the lock (so nothing else is running), and a top-level run also empties the previous run's reports, traces and attachments (`site/test-results`, `site/playwright-report`, `packages/ui/.vitest`): a run keeps its own only. Playwright leaves one empty directory per browser launch in `$TMPDIR` and never removes it (2,059 had piled up); they are reaped after an hour. `tests/caches.test.ts` runs a store twice and asserts the footprint does not grow. A cache or artifact directory a script adds goes into `STORES` in the same commit. + **Never call `vitest` / `playwright test` directly for browser tests: always go through the scripts above.** They run under `scripts/test-run.ts`, which: - records each run in `.cache/test-runs/.json` and tags every browser it starts with `--ca-test-run=`; - tears down the whole test process group on exit, Ctrl+C, SIGTERM or SIGHUP; diff --git a/package.json b/package.json index 4248f0c7..1285dd22 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "issue:done": "bun scripts/issue.ts done", "test:vrt": "bun scripts/test-run.ts --budget=vrt sh -c 'bun run --cwd packages/ui test:vrt && bun run --cwd site test:vrt'", "test:vrt:update": "bun scripts/test-run.ts sh -c 'bun run --cwd packages/ui test:vrt:update && bun run --cwd site test:vrt:update'", + "clean:caches": "bun scripts/caches.ts", "test:reap": "bun scripts/test-run.ts --reap", "test:status": "bun scripts/test-run.ts --status" }, diff --git a/packages/ui/src/lib/components/Avatar/Avatar.svelte b/packages/ui/src/lib/components/Avatar/Avatar.svelte index 974d436a..b6e0e63a 100644 --- a/packages/ui/src/lib/components/Avatar/Avatar.svelte +++ b/packages/ui/src/lib/components/Avatar/Avatar.svelte @@ -241,3 +241,4 @@ :global(.ca-avatar__status--away) { background: var(--ca-semantic-warning); } } + diff --git a/packages/ui/tests/caches.test.ts b/packages/ui/tests/caches.test.ts new file mode 100644 index 00000000..4f8944a2 --- /dev/null +++ b/packages/ui/tests/caches.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { evict, footprint, type Store } from "../../../scripts/caches"; + +// Every cache the tooling writes has a hard size cap and an age limit, and the leftovers of other tools +// (Playwright leaves one empty directory per browser launch in $TMPDIR, ~14 per run) are reaped, so running the +// suite twice never grows the disk. The audit that motivated it: 2,059 orphaned tracing directories, a 366 MB +// reporter file nothing wrote any more, an OG card cache with no size cap. +const scratch = () => mkdtempSync(join(tmpdir(), "ca-caches-test-")); +const old = (path: string, hoursAgo: number) => { + const t = new Date(Date.now() - hoursAgo * 3_600_000); + utimesSync(path, t, t); +}; +const file = (dir: string, name: string, bytes: number, hoursAgo = 0) => { + const path = join(dir, name); + writeFileSync(path, Buffer.alloc(bytes)); + old(path, hoursAgo); + return path; +}; + +describe("evict by age", () => { + it("removes entries older than maxAgeHours and keeps newer ones", () => { + const dir = scratch(); + const stale = file(dir, "a.png", 10, 48); + const fresh = file(dir, "b.png", 10, 1); + const store: Store = { name: "t", dir, maxAgeHours: 24, maxBytes: 1_000_000 }; + expect(evict(store).removed).toBe(1); + expect(existsSync(stale)).toBe(false); + expect(existsSync(fresh)).toBe(true); + rmSync(dir, { recursive: true }); + }); +}); + +describe("evict by size", () => { + it("drops the least recently used entries until the cap holds", () => { + const dir = scratch(); + const oldest = file(dir, "1", 400, 3); + const middle = file(dir, "2", 400, 2); + const newest = file(dir, "3", 400, 1); + evict({ name: "t", dir, maxAgeHours: 1000, maxBytes: 900 }); + expect(existsSync(oldest)).toBe(false); + expect(existsSync(middle)).toBe(true); + expect(existsSync(newest)).toBe(true); + expect(footprint(dir)).toBeLessThanOrEqual(900); + rmSync(dir, { recursive: true }); + }); +}); + +describe("evict only touches what a store owns", () => { + it("matches by prefix, so another tool's files in the same directory stay", () => { + const dir = scratch(); + const ours = file(dir, "playwright-tracing-abc", 0, 5); + const theirs = file(dir, "someone-elses-file", 10, 5); + evict({ name: "t", dir, prefix: "playwright-tracing-", maxAgeHours: 1, maxBytes: 1_000_000 }); + expect(existsSync(ours)).toBe(false); + expect(existsSync(theirs)).toBe(true); + rmSync(dir, { recursive: true }); + }); + + it("removes empty directories (a launch's leftover) and non-empty ones the same way", () => { + const dir = scratch(); + mkdirSync(join(dir, "pw-1")); + mkdirSync(join(dir, "pw-2")); + writeFileSync(join(dir, "pw-2", "x"), "x"); + old(join(dir, "pw-1"), 5); + old(join(dir, "pw-2"), 5); + expect( + evict({ name: "t", dir, prefix: "pw-", maxAgeHours: 1, maxBytes: 1_000_000 }).removed + ).toBe(2); + rmSync(dir, { recursive: true }); + }); + + it("a missing directory is nothing to do", () => { + expect( + evict({ name: "t", dir: join(tmpdir(), "ca-nope-nope"), maxAgeHours: 1, maxBytes: 1 }).removed + ).toBe(0); + }); +}); + +describe("the footprint does not grow across runs", () => { + it("a second run over the same store frees what the first left and never adds", () => { + const dir = scratch(); + const store: Store = { name: "t", dir, prefix: "run-", maxAgeHours: 1, maxBytes: 500 }; + const run = (n: number) => { + // a run leaves five directories with one 200-byte artifact each, as a test run does + for (let i = 0; i < 5; i++) { + mkdirSync(join(dir, `run-${n}-${i}`)); + writeFileSync(join(dir, `run-${n}-${i}`, "trace.zip"), Buffer.alloc(200)); + } + evict(store); + return footprint(dir); + }; + const first = run(1); + const second = run(2); + expect(first).toBeLessThanOrEqual(500); + expect(second).toBeLessThanOrEqual(first); + rmSync(dir, { recursive: true }); + }); +}); diff --git a/packages/ui/tests/dist-fresh.test.ts b/packages/ui/tests/dist-fresh.test.ts index b25f29dd..c4ccd0bd 100644 --- a/packages/ui/tests/dist-fresh.test.ts +++ b/packages/ui/tests/dist-fresh.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { distStale } from "../../../scripts/dist-fresh"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { distCovers, distStale, readScope } from "../../../scripts/dist-fresh"; // The land gate decides whether to rebuild `dist` before it tests the site. A dist built before the // rebase holds old code even when the PR's own diff touches no package file (#591). @@ -22,3 +25,48 @@ describe("distStale", () => { expect(distStale({ land: true, ...none })).toBe(true); }); }); + +// A narrow site build holds only the pages one diff reaches. Reusing it for another diff would audit pages that +// do not exist (a missing route fails) or, worse, skip the ones that changed. +describe("distCovers", () => { + const avatar = ["/", "/docs/display/avatar/"]; + + it("a full build covers every run, narrow or whole", () => { + expect(distCovers("all", avatar)).toBe(true); + expect(distCovers("all", undefined)).toBe(true); + }); + + it("a narrow build covers a run whose pages it holds", () => { + expect(distCovers(avatar, ["/docs/display/avatar/"])).toBe(true); + }); + + it("a narrow build does not cover another page, or the whole site", () => { + expect(distCovers(avatar, ["/docs/display/badge/"])).toBe(false); + expect(distCovers(avatar, undefined)).toBe(false); + }); + + it("a build without a stamp is unknown and covers nothing", () => { + expect(distCovers(undefined, ["/"])).toBe(false); + }); +}); + +describe("readScope", () => { + const dist = mkdtempSync(join(tmpdir(), "ca-scope-test-")); + const stamp = join(dist, ".ca-scope.json"); + + it("reads what the build wrote", () => { + writeFileSync(stamp, JSON.stringify(["/"])); + expect(readScope(dist)).toEqual(["/"]); + writeFileSync(stamp, JSON.stringify("all")); + expect(readScope(dist)).toBe("all"); + rmSync(dist, { recursive: true, force: true }); + }); + + it("is undefined without a stamp or with a broken one", () => { + expect(readScope(join(tmpdir(), "ca-scope-missing"))).toBeUndefined(); + const broken = mkdtempSync(join(tmpdir(), "ca-scope-test-")); + writeFileSync(join(broken, ".ca-scope.json"), "{nope"); + expect(readScope(broken)).toBeUndefined(); + rmSync(broken, { recursive: true, force: true }); + }); +}); diff --git a/packages/ui/tests/og-cache.test.ts b/packages/ui/tests/og-cache.test.ts index 77ae4ee0..8630e5b0 100644 --- a/packages/ui/tests/og-cache.test.ts +++ b/packages/ui/tests/og-cache.test.ts @@ -1,7 +1,8 @@ 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 { evict, STORES, type Store } from "../../../scripts/caches"; +import { cardKey, placeCard } 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 @@ -68,6 +69,11 @@ describe("og card cache pruning", () => { const t = new Date(Date.now() - days * DAY); utimesSync(path, t, t); }; + // the store the build prunes with (scripts/caches.ts), pointed at a scratch directory + const store = (dir: string): Store => ({ + ...(STORES.find((s) => s.name === "og cards") as Store), + dir, + }); it("drops cards unused for longer than the limit and keeps the rest", async () => { const dir = tmpDir("ca-og-"); @@ -77,7 +83,7 @@ describe("og card cache pruning", () => { 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(evict(store(cache)).removed).toBe(1); expect(readdirSync(cache)).toEqual([`${cardKey("v", "new")}.png`]); }); @@ -89,15 +95,15 @@ describe("og card cache pruning", () => { age(join(cache, `${cardKey("v", "card")}.png`), 40); await placeCard(cache, "v", "card", join(dir, "b.png"), png); - expect(pruneCards(cache, 30)).toBe(0); + expect(evict(store(cache)).removed).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(evict(store(dir)).removed).toBe(0); expect(existsSync(join(dir, "notes.txt"))).toBe(true); - expect(pruneCards(join(dir, "missing"), 30)).toBe(0); + expect(evict(store(join(dir, "missing"))).removed).toBe(0); }); }); diff --git a/packages/ui/tests/only-routes.test.ts b/packages/ui/tests/only-routes.test.ts new file mode 100644 index 00000000..ebecfe91 --- /dev/null +++ b/packages/ui/tests/only-routes.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; + +// `CA_SITE_ROUTES` narrows the three big dynamic pages (gallery, atlas, docs) to the routes a diff reaches; unset, +// the build is complete. The module reads the variable once at load, so each case runs in its own process. +const MODULE = join(import.meta.dir, "../../../site/src/lib/only-routes.ts"); + +const filter = async (env: string | undefined) => { + const proc = Bun.spawn( + [ + process.execPath, + "-e", + `import { onlyRoutes } from ${JSON.stringify(MODULE)}; + const ids = ["001", "002", "003"]; + console.log(JSON.stringify(onlyRoutes(ids, (id) => "/gallery/" + id + "/")));`, + ], + { + env: { ...process.env, CA_SITE_ROUTES: env ?? "" }, + stdout: "pipe", + } + ); + return JSON.parse(await new Response(proc.stdout).text()) as string[]; +}; + +describe("onlyRoutes", () => { + it("keeps every entry when no route list is set", async () => { + expect(await filter(undefined)).toEqual(["001", "002", "003"]); + }); + + it("keeps only the entries whose route is listed", async () => { + expect(await filter(JSON.stringify(["/gallery/002/", "/docs/x/"]))).toEqual(["002"]); + }); + + it("an empty list builds none of them", async () => { + expect(await filter("[]")).toEqual([]); + }); +}); + +// The three pages are ~75 of the 80 s of page generation. One that builds its paths without the filter makes a narrow +// build full-size again with every test still green, so the wiring is the thing to pin. +describe("the big dynamic pages are filtered", () => { + const pages = ["gallery/[id].astro", "atlas/[slug].astro", "docs/[...slug].astro"]; + for (const page of pages) + it(`${page} returns its paths through onlyRoutes`, async () => { + const src = await Bun.file(join(import.meta.dir, "../../../site/src/pages", page)).text(); + const body = src.slice( + src.indexOf("getStaticPaths"), + src.indexOf("\n}\n", src.indexOf("getStaticPaths")) + ); + expect(body).toContain("onlyRoutes("); + }); +}); diff --git a/packages/ui/tests/release-sweep.test.ts b/packages/ui/tests/release-sweep.test.ts index d1da40f8..7087e1e3 100644 --- a/packages/ui/tests/release-sweep.test.ts +++ b/packages/ui/tests/release-sweep.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { readScope } from "../../../scripts/dist-fresh"; import { ATLAS_REPRESENTATIVES, GALLERY_REPRESENTATIVES } from "../../../scripts/route-impact"; // The release gate (`test:routes`, `E2E_SWEEP=templates`) loads gallery items and atlas terms in a browser through @@ -32,7 +33,9 @@ const sweep = async (env: Record) => { }; }; -describe.skipIf(!Bun.file(join(DIST, "index.html")).size)("release route sweep", () => { +// A narrow build (`CA_SITE_ROUTES`, a land run of one component) holds only the pages that diff reaches: the sweep +// rules below are about the whole site, so they run on a full build only. +describe.skipIf(readScope(DIST) !== "all")("release route sweep", () => { it("ALL_ROUTES is every prerendered page, whatever the sweep mode", async () => { const built = new Set(pagesOf(DIST).filter((p) => !p.startsWith("/landing-embed/"))); const { ALL_ROUTES } = await sweep({ E2E_SWEEP: "templates" }); diff --git a/scripts/caches.ts b/scripts/caches.ts new file mode 100755 index 00000000..1ee2e0c6 --- /dev/null +++ b/scripts/caches.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env bun +/** + * Everything our tooling writes outside the source tree, with a hard size cap and an age limit each, in ONE table. + * + * bun scripts/caches.ts show the size of every store and what an eviction would free (`clean:caches`) + * bun scripts/caches.ts --clean evict now + * + * `evict` also runs at the start of every `scripts/test-run.ts` run, so a store never outgrows its cap between two + * manual cleanups. Eviction is by age first, then least-recently-used until the cap holds; a store with a `prefix` + * only ever touches entries of that name, so another tool's files in the same directory are safe. + * + * Why this exists (audit, 2026-10-25): `$TMPDIR` held 2,059 orphaned `playwright-tracing-*` directories (Playwright + * leaves one per browser launch, about 14 per test run), the OG card cache had an age limit but no size cap, and a + * 366 MB reporter file in `.cache` was written by nothing any more. + */ +import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface Store { + name: string; + dir: string; + /** Only entries whose name starts with this belong to the store (default: all of them). */ + prefix?: string; + /** Only entries whose name matches belong to the store (a cache of `.png` leaves any other file alone). */ + match?: RegExp; + maxAgeHours: number; + maxBytes: number; + /** Artifacts of the last run only: a top-level run empties the store before it starts (`evictAll(false, true)`). */ + lastRunOnly?: boolean; +} + +const MB = 1024 * 1024; +// `import.meta.url`, not Bun's `import.meta.dir`: the site build loads this file through Vite on Node +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const tmp = process.env.TMPDIR ? process.env.TMPDIR : tmpdir(); + +/** The one table. Raise a cap only with the measured reason. */ +export const STORES: Store[] = [ + // Social cards (site/scripts/og-integration.ts): ~920 PNGs, 119 MB today; a template change replaces them all. + { + name: "og cards", + dir: process.env.CA_OG_CACHE_DIR ?? join(tmp, "ca-og-cards"), + match: /^[0-9a-f]{64}\.png$/, + maxAgeHours: 30 * 24, + maxBytes: 256 * MB, + }, + // Playwright's per-launch directories: empty or a few KB, never removed by Playwright itself. + { + name: "playwright tracing", + dir: tmp, + prefix: "playwright-tracing-", + maxAgeHours: 1, + maxBytes: 1 * MB, + }, + { + name: "playwright artifacts", + dir: tmp, + prefix: "playwright-artifacts-", + maxAgeHours: 1, + maxBytes: 64 * MB, + }, + { + name: "chromium profiles", + dir: tmp, + prefix: "playwright_chromiumdev_profile-", + maxAgeHours: 1, + maxBytes: 64 * MB, + }, + { + name: "webkit profiles", + dir: tmp, + prefix: "playwright_webkitdev_profile-", + maxAgeHours: 1, + maxBytes: 64 * MB, + }, + // One timings file per top-level run (scripts/test-run.ts removes its own; a killed run leaves it). + { name: "run timings", dir: tmp, prefix: "ca-timings-", maxAgeHours: 24, maxBytes: 8 * MB }, + // Test artifacts of the LAST run only: reports, traces and attachments of an older run explain nothing. + { + name: "vitest report", + lastRunOnly: true, + dir: join(root, "packages/ui/.vitest"), + maxAgeHours: 24, + maxBytes: 64 * MB, + }, + { + name: "playwright report", + lastRunOnly: true, + dir: join(root, "site/playwright-report"), + maxAgeHours: 24, + maxBytes: 64 * MB, + }, + { + name: "playwright results", + lastRunOnly: true, + dir: join(root, "site/test-results"), + maxAgeHours: 24, + maxBytes: 128 * MB, + }, + { name: "run records", dir: join(root, ".cache/test-runs"), maxAgeHours: 24, maxBytes: 1 * MB }, +]; + +/** Bytes under `path` (a file counts as itself). */ +export function footprint(path: string): number { + if (!existsSync(path)) return 0; + const s = statSync(path); + if (!s.isDirectory()) return s.size; + return readdirSync(path).reduce((sum, name) => sum + footprint(join(path, name)), 0); +} + +interface Entry { + path: string; + bytes: number; + used: number; +} + +function entriesOf(store: Store): Entry[] { + if (!existsSync(store.dir)) return []; + return readdirSync(store.dir) + .filter((name) => !store.prefix || name.startsWith(store.prefix)) + .filter((name) => !store.match || store.match.test(name)) + .map((name) => { + const path = join(store.dir, name); + return { path, bytes: footprint(path), used: statSync(path).mtimeMs }; + }); +} + +/** Removes what is past the age limit, then the least recently used until the cap holds. */ +export function evict(store: Store, dryRun = false): { removed: number; freed: number } { + const entries = entriesOf(store).sort((a, b) => a.used - b.used); + const limit = Date.now() - store.maxAgeHours * 3_600_000; + let total = entries.reduce((sum, e) => sum + e.bytes, 0); + let removed = 0; + let freed = 0; + for (const e of entries) { + if (e.used >= limit && total <= store.maxBytes) continue; + if (!dryRun) rmSync(e.path, { recursive: true, force: true }); + total -= e.bytes; + freed += e.bytes; + removed += 1; + } + return { removed, freed }; +} + +/** + * Evicts every store; returns the totals. `newRun` (a top-level test run, not one nested in it) also empties the + * stores that keep the last run's artifacts only. + */ +export function evictAll(dryRun = false, newRun = false): { removed: number; freed: number } { + return STORES.map((s) => + evict(newRun && s.lastRunOnly ? { ...s, maxAgeHours: 0 } : s, dryRun) + ).reduce((a, b) => ({ removed: a.removed + b.removed, freed: a.freed + b.freed }), { + removed: 0, + freed: 0, + }); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const clean = process.argv.includes("--clean"); + const mb = (n: number) => `${(n / MB).toFixed(1)} MB`; + for (const s of STORES) { + const size = entriesOf(s).reduce((sum, e) => sum + e.bytes, 0); + const count = entriesOf(s).length; + const { removed, freed } = evict(s, !clean); + console.log( + `${s.name.padEnd(20)} ${String(count).padStart(5)} entries ${mb(size).padStart(9)} cap ${mb(s.maxBytes)}, ${s.maxAgeHours} h ${clean ? "freed" : "would free"} ${removed} (${mb(freed)})` + ); + } + if (!clean) console.log("\nbun run clean:caches -- --clean to free it"); +} diff --git a/scripts/dist-fresh.ts b/scripts/dist-fresh.ts index f564f89b..5ad324b5 100644 --- a/scripts/dist-fresh.ts +++ b/scripts/dist-fresh.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + /** * Whether a build output (`packages/ui/dist`, `site/dist`) has to be rebuilt before the site is tested. * A dev run trusts file times: the output is stale when it is missing or a changed file is newer. @@ -7,3 +10,28 @@ export function distStale(o: { land: boolean; exists: boolean; changedAfter: boolean }): boolean { return o.land || !o.exists || o.changedAfter; } + +/** + * What `site/dist` holds: `"all"` for a full build, or the routes a narrow build was limited to + * (`CA_SITE_ROUTES`, site/src/lib/only-routes.ts). Written by the build itself (site/scripts/scope-integration.ts). + */ +export type DistScope = "all" | string[]; + +/** What `dist` was built with; `undefined` without a readable stamp: an unknown build covers nothing. */ +export function readScope(dist: string): DistScope | undefined { + const file = join(dist, ".ca-scope.json"); + if (!existsSync(file)) return undefined; + try { + return JSON.parse(readFileSync(file, "utf8")) as DistScope; + } catch { + return undefined; + } +} + +/** Whether a build of `have` contains every page a run needs (`needs` undefined = the whole site). */ +export function distCovers(have: DistScope | undefined, needs: string[] | undefined): boolean { + if (have === "all") return true; + if (have === undefined || needs === undefined) return false; + const built = new Set(have); + return needs.every((r) => built.has(r)); +} diff --git a/scripts/test-changed.ts b/scripts/test-changed.ts index 48a15ebf..37a395d2 100755 --- a/scripts/test-changed.ts +++ b/scripts/test-changed.ts @@ -35,7 +35,7 @@ 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 { distCovers, distStale, readScope } from "./dist-fresh.ts"; import { lockReachesRuntime, ownRoutes, ROOT, select } from "./route-impact.ts"; import { affected, @@ -118,6 +118,13 @@ const sweepAll = sel.all || (land && sharedFile !== undefined); const siteRun = sweepAll || routes.length > 0 || specs.length > 0; const visualRun = sel.all || sel.routes.some((r) => VISUAL_ROUTES.has(r)); const routeRun = sweepAll || routes.length > 0; +// A narrow site run builds only the pages it audits (`CA_SITE_ROUTES`, site/src/lib/only-routes.ts): a behaviour spec +// visits arbitrary pages and a sweep audits all of them, so those need the whole site. 915 pages are ~80 s of the +// page generation, 80 pages ~11 s. +const builtRoutes: string[] | undefined = + !sweepAll && specs.length === 0 + ? [...new Set([...routes, ...(visualRun ? [...VISUAL_ROUTES] : [])])].sort() + : undefined; // Shared: everything that can reach every test runs, and screenshots are left to `test:vrt`. const shared = sel.all || sharedFile !== undefined; const widened = widenedBecause(changed); @@ -231,7 +238,9 @@ const siteStale = land, exists: siteDist > 0, changedAfter: newer(/^site\/(src|public|astro\.config\.mjs|package\.json)/, siteDist), - })); + }) || + // a narrow build (or one of unknown scope) lacks pages this run audits + !distCovers(readScope(join(ROOT, "site/dist")), builtRoutes)); // 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); @@ -247,8 +256,12 @@ const building = buildHere && siteStale ? step( "build site", - () => runAsync(["bun", "run", "--cwd", "site", "build"]), - ", beside the tests" + () => + runAsync( + ["bun", "run", "--cwd", "site", "build"], + builtRoutes ? { CA_SITE_ROUTES: JSON.stringify(builtRoutes) } : {} + ), + builtRoutes ? `, ${builtRoutes.length} routes, beside the tests` : ", beside the tests" ) : undefined; await packageTests(); diff --git a/scripts/test-run.ts b/scripts/test-run.ts index 1c3c5d68..b53cda4e 100755 --- a/scripts/test-run.ts +++ b/scripts/test-run.ts @@ -30,6 +30,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync import { availableParallelism, loadavg, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { evictAll } from "./caches.ts"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const runsDir = join(root, ".cache/test-runs"); @@ -294,6 +295,10 @@ async function run(cmd: string[], budget?: BudgetName): Promise { cwd: process.cwd(), cmd, }); + // Holding the lock, nothing else is running: enforce every cache's age limit and size cap (scripts/caches.ts), so a + // store never outgrows its cap between two cleanups. The run that took the lock also drops the reports, traces and + // attachments of the previous one; a nested run (`test:changed` calling vitest and playwright) keeps its parent's. + evictAll(false, locked); const t0 = performance.now(); // One timings file per top-level run; a nested run appends to its parent's. diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 28d3efa7..543891d2 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -4,6 +4,7 @@ import svelte from "@astrojs/svelte"; import { caUI } from "@codeatlasdev/ui/astro"; import { defineConfig } from "astro/config"; import { changelogPosts } from "./scripts/changelog-integration.ts"; +import { distScope } from "./scripts/scope-integration.ts"; import { ogCards } from "./scripts/og-integration.ts"; import { theme } from "./src/theme.ts"; @@ -11,7 +12,7 @@ export default defineConfig({ site: "https://ui.codeatlas.com.br", output: "static", trailingSlash: "always", - integrations: [svelte(), mdx(), caUI(theme), sitemap(), ogCards(), changelogPosts()], + integrations: [svelte(), mdx(), caUI(theme), sitemap(), ogCards(), changelogPosts(), distScope()], markdown: { shikiConfig: { themes: { light: "github-light-high-contrast", dark: "github-dark-high-contrast" }, diff --git a/site/scripts/og-cache.ts b/site/scripts/og-cache.ts index 598de9b0..6c307315 100644 --- a/site/scripts/og-cache.ts +++ b/site/scripts/og-cache.ts @@ -3,10 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, - readdirSync, renameSync, - rmSync, - statSync, utimesSync, writeFileSync, } from "node:fs"; @@ -48,23 +45,3 @@ export async function placeCard( 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 4f36a12c..3d5e4a68 100644 --- a/site/scripts/og-integration.ts +++ b/site/scripts/og-integration.ts @@ -10,15 +10,14 @@ 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 { evict, STORES, type Store } from "../../scripts/caches.ts"; import { theme } from "../src/theme.ts"; -import { placeCard, pruneCards } from "./og-cache.ts"; +import { placeCard } 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; /** @@ -106,7 +105,8 @@ export function ogCards(): AstroIntegration { } finally { await browser.close(); } - const pruned = pruneCards(cacheDir, CACHE_DAYS); + // age and size cap come from the one table of caches (scripts/caches.ts) + const pruned = evict(STORES.find((s) => s.name === "og cards") as Store).removed; logger.info( `${todo.length} social cards in ${relative(process.cwd(), outDir)} (${hits} from the cache, ${pruned} stale ones pruned)` ); diff --git a/site/scripts/scope-integration.ts b/site/scripts/scope-integration.ts new file mode 100644 index 00000000..abe420f2 --- /dev/null +++ b/site/scripts/scope-integration.ts @@ -0,0 +1,18 @@ +// Astro integration: records what a finished build contains (`dist/.ca-scope.json`: "all", or the routes +// `CA_SITE_ROUTES` limited it to), so a test run knows whether this `dist` holds the pages it audits +// (scripts/dist-fresh.ts `distCovers`). The build writes it, not the caller: a build by hand is stamped too. +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { AstroIntegration } from "astro"; + +export function distScope(): AstroIntegration { + return { + name: "codeatlas-dist-scope", + hooks: { + "astro:build:done": ({ dir }) => { + const scope = process.env.CA_SITE_ROUTES ? JSON.parse(process.env.CA_SITE_ROUTES) : "all"; + writeFileSync(`${fileURLToPath(dir)}.ca-scope.json`, JSON.stringify(scope)); + }, + }, + }; +} diff --git a/site/src/lib/only-routes.ts b/site/src/lib/only-routes.ts new file mode 100644 index 00000000..66b03b83 --- /dev/null +++ b/site/src/lib/only-routes.ts @@ -0,0 +1,12 @@ +/** + * `CA_SITE_ROUTES` (a JSON array of routes, set by `scripts/test-changed.ts` for a narrow run) limits the three + * big dynamic pages (gallery 411, atlas 270, docs 160 pages: ~75 of the 80 s of page generation) to the routes the + * diff reaches. Unset, the build is complete: release, `test:full` and a widened run never set it. + */ +const only: Set | undefined = process.env.CA_SITE_ROUTES + ? new Set(JSON.parse(process.env.CA_SITE_ROUTES)) + : undefined; + +/** Keeps the entries whose route is wanted; every entry when no filter is set. */ +export const onlyRoutes = (entries: T[], route: (entry: T) => string): T[] => + only ? entries.filter((e) => only.has(route(e))) : entries; diff --git a/site/src/pages/atlas/[slug].astro b/site/src/pages/atlas/[slug].astro index aaf26fba..1d40c488 100644 --- a/site/src/pages/atlas/[slug].astro +++ b/site/src/pages/atlas/[slug].astro @@ -26,13 +26,15 @@ import { import { STRENGTH_LABEL } from "~/data/evidence/references"; import Base from "~/layouts/Base.astro"; import { loadDocs } from "~/lib/docs"; +import { onlyRoutes } from "~/lib/only-routes"; import { SECTIONS } from "~/lib/nav"; export function getStaticPaths() { - return TERMS.map((term, i) => ({ + const paths = TERMS.map((term, i) => ({ params: { slug: termSlug(term) }, props: { term, prev: TERMS[i - 1], next: TERMS[i + 1] }, })); + return onlyRoutes(paths, (p) => `/atlas/${p.params.slug}/`); } const { term: t, prev, next } = Astro.props; diff --git a/site/src/pages/docs/[...slug].astro b/site/src/pages/docs/[...slug].astro index 58da270e..309f4eaf 100644 --- a/site/src/pages/docs/[...slug].astro +++ b/site/src/pages/docs/[...slug].astro @@ -4,12 +4,13 @@ import SiteDocsShell from "~/components/docs/SiteDocsShell.svelte"; import SiteFooter from "~/components/site/SiteFooter.astro"; import Base from "~/layouts/Base.astro"; import { loadDocs } from "~/lib/docs"; +import { onlyRoutes } from "~/lib/only-routes"; import { startPage } from "~/lib/heading-ids"; import { docHref, SECTIONS } from "~/lib/nav"; export async function getStaticPaths() { const { entries } = await loadDocs(); - return entries.map((entry) => ({ params: { slug: entry.id === "index" ? undefined : entry.id }, props: { entry } })); + return onlyRoutes(entries, (entry) => (entry.id === "index" ? "/docs/" : `/docs/${entry.id}/`)).map((entry) => ({ params: { slug: entry.id === "index" ? undefined : entry.id }, props: { entry } })); } const { entry } = Astro.props; diff --git a/site/src/pages/gallery/[id].astro b/site/src/pages/gallery/[id].astro index 2bb09ce2..25e0b5ff 100644 --- a/site/src/pages/gallery/[id].astro +++ b/site/src/pages/gallery/[id].astro @@ -4,6 +4,7 @@ import SiteDocsShell from "~/components/docs/SiteDocsShell.svelte"; import SiteFooter from "~/components/site/SiteFooter.astro"; import Base from "~/layouts/Base.astro"; import { ITEMS } from "~/lib/gallery-items"; +import { onlyRoutes } from "~/lib/only-routes"; import { SECTIONS } from "~/lib/nav"; import { THEME_PRESETS } from "@codeatlasdev/ui"; import { getCollection } from "astro:content"; @@ -14,7 +15,7 @@ import catalog from "../../../public/api/v1/catalog.json"; import index from "../../../public/api/v1/index.json"; export function getStaticPaths() { - return ITEMS.map((item) => ({ params: { id: item.id }, props: { item } })); + return onlyRoutes(ITEMS, (item) => `/gallery/${item.id}/`).map((item) => ({ params: { id: item.id }, props: { item } })); } const { item } = Astro.props;