The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
realpathSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { describe, expect, it } from "vitest";
|
|
import { tmp } from "./helpers/tmp.js";
|
|
|
|
const run = promisify(execFile);
|
|
|
|
// These tests drive the REAL install.sh — not a reimplementation of its
|
|
// logic — via CAST_INSTALL_SOURCE (the offline channel the installer carries
|
|
// for exactly this, rig's RIG_INSTALL_SOURCE precedent) and an npm PATH shim
|
|
// whose `run build` drops a tiny runnable cli.js. Every assertion reads the
|
|
// resulting tree, the symlink chain, or the shim's invocation log. The real
|
|
// npm-ci-and-tsc build path runs end to end in CI's install job instead —
|
|
// here it would cost minutes per test for no extra layout coverage.
|
|
|
|
const INSTALL_SH = join(process.cwd(), "install.sh");
|
|
const REAL_BIN_CAST = join(process.cwd(), "bin", "cast");
|
|
|
|
// What `npm run build` produces in a fixture tree: enough of a cli.js that
|
|
// the launcher chain — BINDIR/cast -> current -> versions/<v>/bin/cast ->
|
|
// node dist/cli.js — can be asserted end to end, --version included (cmd_use
|
|
// verifies the flip through it).
|
|
const FAKE_CLI = `const path = require("path");
|
|
const root = path.resolve(__dirname, "..");
|
|
const pkg = require(path.join(root, "package.json"));
|
|
const [cmd] = process.argv.slice(2);
|
|
if (cmd === "--version" || cmd === "-V") {
|
|
console.log("cast " + pkg.version + " (" + root + ")");
|
|
process.exit(0);
|
|
}
|
|
console.log("fake-cast " + pkg.version);
|
|
`;
|
|
|
|
const NPM_SHIM = `#!/usr/bin/env bash
|
|
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
|
|
case "\${1:-}" in
|
|
run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;;
|
|
esac
|
|
`;
|
|
|
|
type Sandbox = {
|
|
root: string;
|
|
dest: string;
|
|
bindir: string;
|
|
npmLog: string;
|
|
env: Record<string, string>;
|
|
};
|
|
|
|
function sandbox(): Sandbox {
|
|
const root = tmp("cast-install-");
|
|
const stubs = join(root, "stubs");
|
|
const home = join(root, "home");
|
|
const dest = join(root, "cast-home");
|
|
const bindir = join(root, "bin");
|
|
mkdirSync(stubs);
|
|
mkdirSync(home);
|
|
const npmLog = join(root, "npm.log");
|
|
const fakeCli = join(root, "fake-cli.js");
|
|
writeFileSync(npmLog, "");
|
|
writeFileSync(fakeCli, FAKE_CLI);
|
|
writeFileSync(join(stubs, "npm"), NPM_SHIM);
|
|
chmodSync(join(stubs, "npm"), 0o755);
|
|
return {
|
|
root,
|
|
dest,
|
|
bindir,
|
|
npmLog,
|
|
env: {
|
|
PATH: `${stubs}:${process.env.PATH}`,
|
|
HOME: home,
|
|
SHELL: "/bin/bash",
|
|
CAST_HOME: dest,
|
|
CAST_BIN: bindir,
|
|
CAST_NO_MODIFY_PATH: "1",
|
|
CAST_TEST_NPM_LOG: npmLog,
|
|
CAST_TEST_FAKECLI: fakeCli,
|
|
},
|
|
};
|
|
}
|
|
|
|
// A source tree the installer can build: the REPO'S OWN bin/cast (so the
|
|
// launcher and its layout verbs are the code under review), a package.json
|
|
// carrying the version, and a src/ marker.
|
|
function sourceTree(sb: Sandbox, version: string): string {
|
|
const src = join(sb.root, `src-${version.replace(/[^A-Za-z0-9.]/g, "_")}`);
|
|
mkdirSync(join(src, "bin"), { recursive: true });
|
|
mkdirSync(join(src, "src"), { recursive: true });
|
|
copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast"));
|
|
writeFileSync(
|
|
join(src, "package.json"),
|
|
`${JSON.stringify({ name: "cast", version })}\n`,
|
|
);
|
|
writeFileSync(join(src, "src", "cli.ts"), "// fixture\n");
|
|
return src;
|
|
}
|
|
|
|
async function install(sb: Sandbox, extraEnv: Record<string, string>) {
|
|
return run("bash", [INSTALL_SH], { env: { ...sb.env, ...extraEnv } });
|
|
}
|
|
|
|
function currentTarget(sb: Sandbox): string {
|
|
return realpathSync(join(sb.dest, "current"));
|
|
}
|
|
|
|
describe("install.sh — the versioned layout", () => {
|
|
it("lands versions/<v>, points current and the PATH link through it, and the chain answers", async () => {
|
|
const sb = sandbox();
|
|
const src = sourceTree(sb, "0.5.0");
|
|
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src });
|
|
|
|
expect(stdout).toContain("done (local source");
|
|
// The layout: one tree per version, named by package.json's version.
|
|
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
|
|
expect(
|
|
readFileSync(join(sb.dest, "versions/0.5.0/dist/cli.js"), "utf8"),
|
|
).toContain("pkg.version");
|
|
expect(
|
|
readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"),
|
|
).toBe(`local:${src}\n`);
|
|
// The chain: current -> versions/0.5.0, BINDIR/cast -> current/bin/cast.
|
|
expect(currentTarget(sb)).toBe(
|
|
realpathSync(join(sb.dest, "versions/0.5.0")),
|
|
);
|
|
expect(readlinkSync(join(sb.bindir, "cast"))).toBe(
|
|
join(sb.dest, "current/bin/cast"),
|
|
);
|
|
// And it ANSWERS, through the whole chain.
|
|
const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], {
|
|
env: sb.env,
|
|
});
|
|
expect(v.trim()).toBe(
|
|
`cast 0.5.0 (${realpathSync(join(sb.dest, "versions/0.5.0"))})`,
|
|
);
|
|
// The build ran: ci, build, prune — in that order.
|
|
expect(readFileSync(sb.npmLog, "utf8")).toBe(
|
|
"npm ci --silent\nnpm run build --silent\nnpm prune --omit=dev --silent\n",
|
|
);
|
|
});
|
|
|
|
it("re-running the same version is a converging no-op — nothing rebuilt, nothing touched", async () => {
|
|
const sb = sandbox();
|
|
const src = sourceTree(sb, "0.5.0");
|
|
await install(sb, { CAST_INSTALL_SOURCE: src });
|
|
const npmCallsAfterFirst = readFileSync(sb.npmLog, "utf8");
|
|
writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "survives\n");
|
|
|
|
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src });
|
|
expect(stdout).toContain("cast 0.5.0 is already installed");
|
|
expect(stdout).toContain("nothing was built");
|
|
// No second build, and the installed tree was not replaced.
|
|
expect(readFileSync(sb.npmLog, "utf8")).toBe(npmCallsAfterFirst);
|
|
expect(readFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "utf8")).toBe(
|
|
"survives\n",
|
|
);
|
|
});
|
|
|
|
it("CAST_REINSTALL=1 replaces that version's tree — no partial overlays", async () => {
|
|
const sb = sandbox();
|
|
const src = sourceTree(sb, "0.5.0");
|
|
await install(sb, { CAST_INSTALL_SOURCE: src });
|
|
writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "stale\n");
|
|
|
|
const { stdout } = await install(sb, {
|
|
CAST_INSTALL_SOURCE: src,
|
|
CAST_REINSTALL: "1",
|
|
});
|
|
expect(stdout).toContain("replacing the installed 0.5.0 tree");
|
|
// A replaced tree, not an overlay: the stale file is gone.
|
|
expect(existsSync(join(sb.dest, "versions/0.5.0/SENTINEL"))).toBe(false);
|
|
expect(existsSync(join(sb.dest, "versions/0.5.0/dist/cli.js"))).toBe(true);
|
|
});
|
|
|
|
it("a NEW version installs beside the old one and becomes the default", async () => {
|
|
const sb = sandbox();
|
|
await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") });
|
|
const { stdout } = await install(sb, {
|
|
CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0"),
|
|
});
|
|
|
|
expect(stdout).toContain(
|
|
"default version switched: 0.5.0 -> 0.6.0 ('cast use 0.5.0' switches back)",
|
|
);
|
|
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
|
|
expect(currentTarget(sb)).toBe(
|
|
realpathSync(join(sb.dest, "versions/0.6.0")),
|
|
);
|
|
const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], {
|
|
env: sb.env,
|
|
});
|
|
expect(v).toContain("cast 0.6.0");
|
|
});
|
|
|
|
it("re-running an installed NON-default version never moves the default", async () => {
|
|
const sb = sandbox();
|
|
const old = sourceTree(sb, "0.5.0");
|
|
await install(sb, { CAST_INSTALL_SOURCE: old });
|
|
await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0") });
|
|
|
|
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: old });
|
|
expect(stdout).toContain(
|
|
"the default stays 0.6.0 — 'cast use 0.5.0' switches.",
|
|
);
|
|
expect(currentTarget(sb)).toBe(
|
|
realpathSync(join(sb.dest, "versions/0.6.0")),
|
|
);
|
|
});
|
|
|
|
it("migrates a pre-versioning flat install in place, preserving the tree", async () => {
|
|
const sb = sandbox();
|
|
// The OLD layout: the tree sits flat at $DEST, bin/cast directly under it.
|
|
mkdirSync(join(sb.dest, "bin"), { recursive: true });
|
|
mkdirSync(join(sb.dest, "dist"), { recursive: true });
|
|
copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast"));
|
|
writeFileSync(
|
|
join(sb.dest, "package.json"),
|
|
`${JSON.stringify({ name: "cast", version: "0.4.0" })}\n`,
|
|
);
|
|
writeFileSync(
|
|
join(sb.dest, "SENTINEL"),
|
|
"the operator's tree, bit for bit\n",
|
|
);
|
|
|
|
const { stdout } = await install(sb, {
|
|
CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0"),
|
|
});
|
|
expect(stdout).toContain("found a pre-versioning flat install at");
|
|
expect(stdout).toContain("migrating it into the versioned layout");
|
|
// The flat tree moved — preserved, not rebuilt — and the new version
|
|
// installed beside it and took the default.
|
|
expect(readFileSync(join(sb.dest, "versions/0.4.0/SENTINEL"), "utf8")).toBe(
|
|
"the operator's tree, bit for bit\n",
|
|
);
|
|
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
|
|
expect(currentTarget(sb)).toBe(
|
|
realpathSync(join(sb.dest, "versions/0.5.0")),
|
|
);
|
|
});
|
|
|
|
it("refuses a source whose package.json version is not a sane directory name", async () => {
|
|
const sb = sandbox();
|
|
const src = sourceTree(sb, "../evil");
|
|
await expect(
|
|
install(sb, { CAST_INSTALL_SOURCE: src }),
|
|
).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("not a sane directory name: '../evil'"),
|
|
});
|
|
expect(existsSync(sb.dest)).toBe(false);
|
|
});
|
|
|
|
it("refuses to migrate a flat install whose version would escape versions/", async () => {
|
|
const sb = sandbox();
|
|
mkdirSync(join(sb.dest, "bin"), { recursive: true });
|
|
copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast"));
|
|
writeFileSync(
|
|
join(sb.dest, "package.json"),
|
|
`${JSON.stringify({ name: "cast", version: "../evil" })}\n`,
|
|
);
|
|
|
|
await expect(
|
|
install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") }),
|
|
).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("not a sane directory name"),
|
|
});
|
|
// Refused BEFORE anything moved: the flat tree is untouched.
|
|
expect(existsSync(join(sb.dest, "bin/cast"))).toBe(true);
|
|
});
|
|
|
|
it("downloads refs/heads/<ref> when no local source is given", async () => {
|
|
const sb = sandbox();
|
|
// A curl shim standing in for GitHub: logs every URL asked for, and
|
|
// serves the fixture tarball ONLY for the refs/heads URL — a branch has
|
|
// no release asset and no tag, which is exactly the shape CAST_REF=main
|
|
// meets in the wild. (The channels themselves — assets, refusals, the
|
|
// latest-release resolution — are test/release.test.ts's.)
|
|
sourceTree(sb, "0.5.0");
|
|
await run("tar", [
|
|
"-C",
|
|
sb.root,
|
|
"-czf",
|
|
join(sb.root, "src.tgz"),
|
|
"src-0.5.0",
|
|
]);
|
|
const stubs = join(sb.root, "stubs");
|
|
const curlLog = join(sb.root, "curl.log");
|
|
writeFileSync(curlLog, "");
|
|
writeFileSync(
|
|
join(stubs, "curl"),
|
|
`#!/usr/bin/env bash
|
|
out=""; url=""
|
|
for a in "$@"; do
|
|
case "$prev" in -o) out="$a" ;; esac
|
|
case "$a" in http*) url="$a" ;; esac
|
|
prev="$a"
|
|
done
|
|
printf '%s\\n' "$url" >> "${curlLog}"
|
|
case "$url" in
|
|
*/archive/refs/heads/*) cp "${join(sb.root, "src.tgz")}" "$out" ;;
|
|
*) exit 22 ;;
|
|
esac
|
|
`,
|
|
);
|
|
chmodSync(join(stubs, "curl"), 0o755);
|
|
|
|
const { stdout } = await install(sb, { CAST_REF: "dev-branch" });
|
|
// The channel's try-order, in full: the release asset, then refs/tags,
|
|
// then refs/heads — where a branch finally answers.
|
|
expect(readFileSync(curlLog, "utf8").trim().split("\n")).toEqual([
|
|
"https://github.com/heavy-duty/cast/releases/download/dev-branch/cast-dev-branch.tgz",
|
|
"https://github.com/heavy-duty/cast/archive/refs/tags/dev-branch.tar.gz",
|
|
"https://github.com/heavy-duty/cast/archive/refs/heads/dev-branch.tar.gz",
|
|
]);
|
|
expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)");
|
|
expect(
|
|
readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"),
|
|
).toBe("heavy-duty/cast@dev-branch\n");
|
|
});
|
|
});
|
|
|
|
describe("the shared gates cannot drift", () => {
|
|
// install.sh and bin/cast each carry valid_version and pkg_version — the
|
|
// same trust boundary enforced in two places. A byte-identical diff is the
|
|
// rig-precedent guard that an edit to one cannot quietly miss the other.
|
|
function extractFunction(file: string, name: string): string {
|
|
const text = readFileSync(file, "utf8");
|
|
const start = text.indexOf(`${name}() {`);
|
|
expect(start, `${name}() not found in ${file}`).toBeGreaterThan(-1);
|
|
const end = text.indexOf("\n}", start);
|
|
return text.slice(start, end + 2);
|
|
}
|
|
|
|
for (const fn of ["valid_version", "pkg_version", "flip_current"]) {
|
|
it(`${fn}() is byte-identical between install.sh and bin/cast`, () => {
|
|
expect(extractFunction(INSTALL_SH, fn)).toBe(
|
|
extractFunction(REAL_BIN_CAST, fn),
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("portability — cast runs on the operator's own machine, macOS included", () => {
|
|
// box#79/rig#36 could assume GNU userland and bash 4+ because boxes run
|
|
// Linux; cast cannot. These greps pin the two spellings that bit (or
|
|
// nearly bit) for real: `mv -T` (GNU-only — BSD/macOS mv has no -T, the
|
|
// flip now rides rename(2) via node) and `mapfile` (bash 4 — macOS ships
|
|
// bash 3.2). A reintroduction fails HERE, not on the first operator Mac.
|
|
for (const file of [INSTALL_SH, REAL_BIN_CAST]) {
|
|
const name = file.split("/").pop();
|
|
it(`${name} carries no GNU mv -T and no bash-4 mapfile`, () => {
|
|
const text = readFileSync(file, "utf8");
|
|
// "mv -T" as an invocation — the comments explaining WHY it is absent
|
|
// spell it "mv says … with -T", which this must not match.
|
|
expect(text).not.toMatch(/\bmv\s+-[A-Za-z]*T/);
|
|
expect(text).not.toMatch(/^\s*mapfile\b/m);
|
|
});
|
|
}
|
|
});
|