Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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 `<main>`, 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) |
Expand All @@ -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, `<Seo>` 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) | | |

Expand All @@ -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/<id>.json` and tags every browser it starts with `--ca-test-run=<id>`;
- tears down the whole test process group on exit, Ctrl+C, SIGTERM or SIGHUP;
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/components/Avatar/Avatar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,4 @@
:global(.ca-avatar__status--away) { background: var(--ca-semantic-warning); }
}
</style>

101 changes: 101 additions & 0 deletions packages/ui/tests/caches.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
50 changes: 49 additions & 1 deletion packages/ui/tests/dist-fresh.test.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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 });
});
});
16 changes: 11 additions & 5 deletions packages/ui/tests/og-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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-");
Expand All @@ -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`]);
});

Expand All @@ -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);
});
});
Loading