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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,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.

**The package build skips itself when nothing it reads changed.** `bun run build` in `packages/ui` is `scripts/build-package.ts`: it hashes the bytes of every input (`INPUTS` in `packages/ui/scripts/build-inputs.ts`: the package sources and scripts, the site's demos, docs, data and changelog parser, `bun.lock`, the versions of bun and svelte-package; `scripts/content-hash.ts`) as they are on disk, uncommitted edits included, and stops with `build: dist is current` when the hash is the one `dist/.ca-input-hash` holds (0.1 s instead of 12 to 15 s; the land gate builds, then `validate` on the push builds again). The stamp is written only after the last step succeeds. `tests/build-cache.test.ts` fails when a file the build scripts import is not under an input. A release never builds from the skip: `release.js` and `test:full` set `CA_BUILD_COLD=1`; set it yourself to force a build.

**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:
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@
"!dist/**/*.spec.*"
],
"scripts": {
"build": "svelte-package -i src/lib && node ../../scripts/prune-dist.js dist && bun scripts/build-manifest.ts && bun scripts/build-equivalents.ts && bun scripts/build-bin.ts",
"build": "bun scripts/build-package.ts",
"check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
"build:watch": "svelte-package -i src/lib --watch",
"test": "bun run test:unit && bun ../../scripts/test-run.ts vitest run",
Expand Down
38 changes: 38 additions & 0 deletions packages/ui/scripts/build-inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/** What the package build reads and the tools that transform it: the key of its skip (`build-package.ts`). */
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

export const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
export const ROOT = join(PKG, "../..");

/** Everything the five build steps read, relative to the repo root. */
export const INPUTS = [
"packages/ui/src",
"packages/ui/scripts",
"packages/ui/bin",
"packages/ui/equivalents",
"packages/ui/migrations",
"packages/ui/MIGRATION.md",
"packages/ui/package.json",
"packages/ui/svelte.config.js",
"packages/ui/tsconfig.json",
"scripts/prune-dist.js",
"scripts/content-hash.ts",
"site/src/demos",
"site/src/data",
"site/src/lib/changelog",
"site/src/content/docs",
"site/functions/_lib",
"site/public/api/v1",
"bun.lock",
];

const version = (pkgJson: string) =>
existsSync(pkgJson) ? JSON.parse(readFileSync(pkgJson, "utf8")).version : "none";

export const toolVersions = () => [
`bun ${Bun.version}`,
`svelte-package ${version(join(PKG, "node_modules/@sveltejs/package/package.json"))}`,
`svelte ${version(join(ROOT, "node_modules/svelte/package.json"))}`,
];
48 changes: 48 additions & 0 deletions packages/ui/scripts/build-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* `bun run build` of the package: `svelte-package`, prune, manifest, equivalents, bin. When the bytes of every input
* are the same as the last build that finished, `dist/` is already what the build would write, and the build says
* so and stops: the land gate builds the package, then `validate` on the push builds it again, and every fresh
* worktree starts without one (9.6 of its 12.7 s is `svelte-package`).
*
* CA_BUILD_COLD=1 always build (the release gate sets it: a release is never built from a cache)
*
* The stamp is `dist/.ca-input-hash`, written after the last step succeeded, so a build that failed halfway
* leaves no stamp and the next one runs. The key is `scripts/content-hash.ts`: the bytes on disk, uncommitted edits
* included, plus the versions of the tools that transform them. `dist/` missing means no stamp, so it builds.
* The generated files the manifest step writes into the tree (`site/public/api/v1`, the skill's API.md) are inputs
* of nothing here and unchanged when the inputs are, so skipping them leaves the tree as a build would.
*/
import { existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { contentKey, upToDate } from "../../../scripts/content-hash.ts";
import { INPUTS, PKG, ROOT, toolVersions } from "./build-inputs.ts";

const stamp = join(PKG, "dist/.ca-input-hash");

const key = contentKey(ROOT, INPUTS, toolVersions());
if (!process.env.CA_BUILD_COLD && existsSync(join(PKG, "dist/index.js")) && upToDate(stamp, key)) {
console.log(
`build: dist is current (inputs ${key.slice(0, 10)}); CA_BUILD_COLD=1 forces a build`
);
process.exit(0);
}

const steps = [
["svelte-package", "-i", "src/lib"],
["node", "../../scripts/prune-dist.js", "dist"],
["bun", "scripts/build-manifest.ts"],
["bun", "scripts/build-equivalents.ts"],
["bun", "scripts/build-bin.ts"],
];
const bins = [join(PKG, "node_modules/.bin"), join(ROOT, "node_modules/.bin")].join(":");
for (const cmd of steps) {
const p = Bun.spawnSync(cmd, {
cwd: PKG,
stdio: ["inherit", "inherit", "inherit"],
// `svelte-package` is a package binary: resolve it as `bun run` does, so the script also works run directly
env: { ...process.env, PATH: `${bins}:${process.env.PATH}` },
});
if (p.exitCode !== 0) process.exit(p.exitCode ?? 1);
}
// the manifest step rewrites tracked inputs (API.md, the index): stamp what is on disk NOW, not what we hashed first
writeFileSync(stamp, contentKey(ROOT, INPUTS, toolVersions()));
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 @@ -242,3 +242,4 @@
}
</style>


72 changes: 72 additions & 0 deletions packages/ui/tests/build-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "bun:test";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { INPUTS } from "../scripts/build-inputs";

// The package build skips itself when the bytes of its inputs are the ones the last finished build had. A release is
// never built from that skip, and INPUTS must cover everything the build steps import: a directory missing from it
// leaves a stale `dist` current after an edit there (an atlas data edit, a changelog parser change), every test green.
const ROOT = join(import.meta.dir, "../../..");
const read = (p: string) => readFileSync(join(ROOT, p), "utf8");

describe("release builds cold", () => {
it("the release script and the release gate both set CA_BUILD_COLD", () => {
expect(read("scripts/release.js")).toContain('CA_BUILD_COLD = "1"');
expect(read("scripts/test-full.ts")).toContain('CA_BUILD_COLD: "1"');
});
});

/** Every local file the build entry scripts import, transitively (static `from` and dynamic `import()`). */
function importClosure(entries: string[]): string[] {
const seen = new Set<string>();
const queue = entries.map((e) => join(ROOT, e));
const resolveSpec = (from: string, spec: string) =>
[
resolve(dirname(from), spec),
`${resolve(dirname(from), spec)}.ts`,
join(resolve(dirname(from), spec), "index.ts"),
].find((c) => existsSync(c) && statSync(c).isFile());
for (let file = queue.pop(); file; file = queue.pop()) {
if (seen.has(file)) continue;
seen.add(file);
for (const m of readFileSync(file, "utf8").matchAll(
/(?:from|import\()\s*["'](\.[^"']+)["']/g
)) {
const target = resolveSpec(file, m[1]);
if (target && !target.includes("node_modules")) queue.push(target);
}
}
return [...seen].map((f) => relative(ROOT, f));
}

describe("build inputs", () => {
it("cover every file the build steps import", () => {
const closure = importClosure([
"packages/ui/scripts/build-manifest.ts",
"packages/ui/scripts/build-equivalents.ts",
"packages/ui/scripts/build-bin.ts",
]);
expect(closure.length).toBeGreaterThan(20);
const missing = closure.filter((f) => !INPUTS.some((i) => f === i || f.startsWith(`${i}/`)));
expect(missing).toEqual([]);
});
});

// The skip path runs the real script: a typo in the entry point (an undefined name) type-checks and passes every unit
// test above, and then fails the land gate the first time a build is needed. `dist` is current right after any build.
describe("the build entry point", () => {
it.skipIf(!existsSync(join(ROOT, "packages/ui/dist/.ca-input-hash")))(
"stops with 'dist is current' when nothing changed since the last build",
() => {
const run = Bun.spawnSync([process.execPath, "scripts/build-package.ts"], {
cwd: join(ROOT, "packages/ui"),
env: { ...process.env, CA_BUILD_COLD: "" },
});
const out = run.stdout.toString();
// either current (exit 0, says so) or the tree changed since: then it must have rebuilt successfully
expect(run.exitCode, run.stderr.toString()).toBe(0);
expect(out.length).toBeGreaterThan(0);
},
120_000
);
});
106 changes: 106 additions & 0 deletions packages/ui/tests/content-hash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { afterAll, describe, expect, it } from "bun:test";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { contentKey, upToDate } from "../../../scripts/content-hash";
import { cleanTmp, tmpDir } from "./tmp";

// A cached result is trusted only when the BYTES of every input are the same: not a git object id (an uncommitted
// edit has none), not a file time (a rebase rewrites them). Two worktrees with different edits must never share a
// key, and a change anywhere in the input set must miss.
afterAll(cleanTmp);

const tree = (files: Record<string, string>) => {
const dir = tmpDir("ca-key-");
for (const [name, text] of Object.entries(files)) {
mkdirSync(join(dir, name, ".."), { recursive: true });
writeFileSync(join(dir, name), text);
}
return dir;
};
const base = {
"src/a.ts": "export const a = 1;",
"src/deep/b.ts": "export const b = 2;",
lock: "v1",
};
const inputs = ["src", "lock"];

describe("contentKey", () => {
it("is the same for the same bytes, wherever the tree lives", () => {
expect(contentKey(tree(base), inputs)).toBe(contentKey(tree(base), inputs));
});

it("misses when one byte of an input changes, committed or not", () => {
const dir = tree(base);
const before = contentKey(dir, inputs);
writeFileSync(join(dir, "src/a.ts"), "export const a = 2;");
expect(contentKey(dir, inputs)).not.toBe(before);
});

it("misses when a file deep in a directory input changes (the transitive case)", () => {
const dir = tree(base);
const before = contentKey(dir, inputs);
writeFileSync(join(dir, "src/deep/b.ts"), "export const b = 3;");
expect(contentKey(dir, inputs)).not.toBe(before);
});

it("misses when a dependency pin changes", () => {
const a = tree(base);
const b = tree({ ...base, lock: "v2" });
expect(contentKey(a, inputs)).not.toBe(contentKey(b, inputs));
});

it("misses when a file is added or removed, even with the same total bytes", () => {
const a = tree(base);
const b = tree({
"src/a.ts": "export const a = 1;",
"src/deep/c.ts": "export const b = 2;",
lock: "v1",
});
expect(contentKey(a, inputs)).not.toBe(contentKey(b, inputs));
});

it("two worktrees with different edits never share a key", () => {
const main = tree(base);
const feature = tree({ ...base, "src/a.ts": "export const a = 'edited';" });
const other = tree({ ...base, "src/deep/b.ts": "export const b = 'edited';" });
const keys = new Set([main, feature, other].map((d) => contentKey(d, inputs)));
expect(keys.size).toBe(3);
});

it("the tool versions are part of the key", () => {
const dir = tree(base);
expect(contentKey(dir, inputs, ["bun 1.4.4"])).not.toBe(contentKey(dir, inputs, ["bun 1.5.0"]));
});

it("an input that does not exist is part of the key, not an error", () => {
const dir = tree(base);
expect(contentKey(dir, [...inputs, "missing"])).not.toBe(contentKey(dir, inputs));
});

it("a file named like an Object.prototype member is still an input", () => {
const dir = tree(base);
const before = contentKey(dir, inputs);
writeFileSync(join(dir, "src/constructor"), "changes the build");
expect(contentKey(dir, inputs)).not.toBe(before);
});

it("ignores the output it stamps itself and generated noise", () => {
const dir = tree(base);
const before = contentKey(dir, inputs);
mkdirSync(join(dir, "src/node_modules/x"), { recursive: true });
writeFileSync(join(dir, "src/node_modules/x/i.js"), "noise");
writeFileSync(join(dir, "src/.DS_Store"), "noise");
expect(contentKey(dir, inputs)).toBe(before);
});
});

describe("upToDate", () => {
it("is true only for the key a build stamped, and false for a missing stamp", () => {
const dir = tmpDir("ca-stamp-");
const stamp = join(dir, ".ca-input-hash");
expect(upToDate(stamp, "k1")).toBe(false);
writeFileSync(stamp, "k1");
expect(upToDate(stamp, "k1")).toBe(true);
expect(upToDate(stamp, "k2")).toBe(false);
});
});
53 changes: 53 additions & 0 deletions scripts/content-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* The key of a cached result: a sha256 over the BYTES of every input as it is on disk right now, uncommitted edits
* included. Not a git object id (an edit nobody committed has none, and two worktrees would share the id of the
* committed file), not a file time (a rebase rewrites them). The path of each file is hashed with its bytes, so
* adding, removing or renaming a file changes the key even when the total size does not.
*
* contentKey(root, ["src", "bun.lock"], ["bun 1.4.4"]) -> 64 hex chars
*
* `inputs` are files or directories relative to `root`, directories walked in name order; `extra` carries what is
* not on disk (tool versions). A missing input is part of the key (its name, marked absent), never an error.
* `node_modules`, test artifacts and editor noise are skipped: they are outputs, not inputs.
*/
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";

const SKIP: Record<string, true> = {
node_modules: true,
__screenshots__: true,
__traces__: true,
".vitest": true,
".DS_Store": true,
".git": true,
};

function* walk(path: string): Generator<string> {
if (!statSync(path).isDirectory()) return yield path;
for (const name of readdirSync(path).sort())
if (!Object.hasOwn(SKIP, name)) yield* walk(join(path, name));
}

export function contentKey(root: string, inputs: string[], extra: string[] = []): string {
const hash = createHash("sha256");
for (const input of [...inputs].sort()) {
const path = join(root, input);
if (!existsSync(path)) {
hash.update(`absent\0${input}\0`);
continue;
}
for (const file of walk(path)) {
hash.update(`${relative(root, file)}\0`);
hash.update(readFileSync(file));
hash.update("\0");
}
}
for (const e of extra) hash.update(`extra\0${e}\0`);
return hash.digest("hex");
}

/** Whether the stamp a build left holds `key`; false when there is no stamp. */
export function upToDate(stampFile: string, key: string): boolean {
return existsSync(stampFile) && readFileSync(stampFile, "utf8") === key;
}
2 changes: 2 additions & 0 deletions scripts/release.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ if (args.has("--smoke")) {
step("Type check packages/ui");
tryRun("svelte-check", "bun", ["run", "--cwd", "packages/ui", "check"]);
step("Build package");
// a release is never built from a cache (packages/ui/scripts/build-package.ts)
process.env.CA_BUILD_COLD = "1";
tryRun("bun run build:pkg", "bun", ["run", "build:pkg"]);
step("Tarball smoke test");
smokeTest();
Expand Down
3 changes: 2 additions & 1 deletion scripts/test-full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { fileURLToPath } from "node:url";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const testRun = (cmd: string[]) => [join(ROOT, "scripts/test-run.ts"), ...cmd];
const env = { ...process.env, E2E_PREBUILT: "1" };
// the release gate builds the package from scratch, never from the input-hash skip (packages/ui/scripts/build-package.ts)
const env = { ...process.env, E2E_PREBUILT: "1", CA_BUILD_COLD: "1" };
const run = (cmd: string[], cwd = ROOT) =>
spawnSync(process.execPath, testRun(cmd), { cwd, stdio: "inherit", env }).status ?? 1;
const runAsync = (cmd: string[], cwd = ROOT) => {
Expand Down