mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 17:05:28 +02:00
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
328 lines
9.9 KiB
TypeScript
328 lines
9.9 KiB
TypeScript
/**
|
|
* gstack-upgrade/migrations/v1.40.0.0.sh — migration script unit tests.
|
|
*
|
|
* Per #1581: the original script unconditionally `touch`ed its done-marker even
|
|
* when the jq-gated privacy-map patch was skipped. The fix defers `touch ${DONE}`
|
|
* until every required repair either succeeded or was provably unnecessary.
|
|
*
|
|
* The "regression case" that this file pins is case 2: jq missing + privacy-map
|
|
* present → no done-marker. Against the buggy script, case 2 fails (marker is
|
|
* written despite skipped patch); against the fix it passes.
|
|
*
|
|
* Strategy: each test sets up an isolated tmpHome with controlled fixture
|
|
* content, and runs the migration via `spawnSync('bash', [MIGRATION], …)`.
|
|
* For "jq missing" we point PATH at a curated dir of symlinks to the standard
|
|
* utilities the script uses, omitting jq. For "jq mutation fails" we point PATH
|
|
* at a dir containing a jq shim that exits 1.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { canRevokeWrites } from "./helpers/fs-caps";
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
import { spawnSync } from "child_process";
|
|
|
|
const ROOT = path.resolve(import.meta.dir, "..");
|
|
const MIGRATION = path.join(
|
|
ROOT,
|
|
"gstack-upgrade",
|
|
"migrations",
|
|
"v1.40.0.0.sh",
|
|
);
|
|
|
|
const NEW_PATTERN = "projects/*/*-eng-review-test-plan-*.md";
|
|
const REAL_PATH = "/usr/bin:/bin:/opt/homebrew/bin";
|
|
|
|
let tmpHome: string;
|
|
let gstackHome: string;
|
|
let migrationDir: string;
|
|
let donePath: string;
|
|
let allowlistPath: string;
|
|
let privacyPath: string;
|
|
let gitattrsPath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-mig-v1400-"));
|
|
gstackHome = path.join(tmpHome, ".gstack");
|
|
migrationDir = path.join(gstackHome, ".migrations");
|
|
donePath = path.join(migrationDir, "v1.40.0.0.done");
|
|
allowlistPath = path.join(gstackHome, ".brain-allowlist");
|
|
privacyPath = path.join(gstackHome, ".brain-privacy-map.json");
|
|
gitattrsPath = path.join(gstackHome, ".gitattributes");
|
|
fs.mkdirSync(gstackHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
try {
|
|
fs.chmodSync(gstackHome, 0o755);
|
|
if (fs.existsSync(allowlistPath)) fs.chmodSync(allowlistPath, 0o644);
|
|
if (fs.existsSync(privacyPath)) fs.chmodSync(privacyPath, 0o644);
|
|
if (fs.existsSync(gitattrsPath)) fs.chmodSync(gitattrsPath, 0o644);
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
} catch {}
|
|
});
|
|
|
|
/**
|
|
* Construct a PATH-style directory of symlinks to standard utilities the
|
|
* migration script needs (mkdir, grep, sed, mv, rm, mktemp, cat, touch, printf,
|
|
* command, etc.). Optionally omit jq, or substitute a shim.
|
|
*/
|
|
function makeCuratedPath(opts: { jq?: "missing" | "shim-fail" | "real" } = {}): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-mig-path-"));
|
|
const utils = [
|
|
"bash",
|
|
"sh",
|
|
"mkdir",
|
|
"grep",
|
|
"sed",
|
|
"mv",
|
|
"rm",
|
|
"mktemp",
|
|
"cat",
|
|
"touch",
|
|
"printf",
|
|
"command",
|
|
"echo",
|
|
"test",
|
|
"[",
|
|
"tee",
|
|
"true",
|
|
"false",
|
|
"ls",
|
|
"chmod",
|
|
];
|
|
const realDirs = REAL_PATH.split(":");
|
|
for (const u of utils) {
|
|
for (const d of realDirs) {
|
|
const src = path.join(d, u);
|
|
if (fs.existsSync(src)) {
|
|
try {
|
|
fs.symlinkSync(src, path.join(dir, u));
|
|
} catch {}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const jq = opts.jq ?? "real";
|
|
if (jq === "real") {
|
|
for (const d of realDirs) {
|
|
const src = path.join(d, "jq");
|
|
if (fs.existsSync(src)) {
|
|
try {
|
|
fs.symlinkSync(src, path.join(dir, "jq"));
|
|
} catch {}
|
|
break;
|
|
}
|
|
}
|
|
} else if (jq === "shim-fail") {
|
|
const shim = path.join(dir, "jq");
|
|
fs.writeFileSync(
|
|
shim,
|
|
`#!/usr/bin/env bash\necho "fake jq: refusing" >&2\nexit 1\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
}
|
|
// jq === "missing" → don't add anything
|
|
return dir;
|
|
}
|
|
|
|
function run(opts: { path?: string } = {}) {
|
|
const env = {
|
|
HOME: tmpHome,
|
|
PATH: opts.path ?? REAL_PATH,
|
|
};
|
|
return spawnSync("bash", [MIGRATION], {
|
|
env,
|
|
encoding: "utf-8",
|
|
cwd: tmpHome,
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
function freshPrivacyMap() {
|
|
fs.writeFileSync(
|
|
privacyPath,
|
|
JSON.stringify(
|
|
[{ pattern: "projects/*/*-some-other-*.md", class: "artifact" }],
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|
|
|
|
function freshAllowlist() {
|
|
fs.writeFileSync(
|
|
allowlistPath,
|
|
"# header\nprojects/*/*-some-other-*.md\n# ---- USER ADDITIONS BELOW\n",
|
|
);
|
|
}
|
|
|
|
function freshGitattrs() {
|
|
fs.writeFileSync(gitattrsPath, "projects/*/*-some-other-*.md merge=union\n");
|
|
}
|
|
|
|
describe("migrations/v1.40.0.0.sh", () => {
|
|
test("case 1: jq present, fresh privacy-map — all three files patched, marker written", () => {
|
|
freshAllowlist();
|
|
freshPrivacyMap();
|
|
freshGitattrs();
|
|
|
|
const r = run();
|
|
|
|
expect(r.status).toBe(0);
|
|
expect(fs.existsSync(donePath)).toBe(true);
|
|
|
|
const allowlist = fs.readFileSync(allowlistPath, "utf-8");
|
|
expect(allowlist).toContain(NEW_PATTERN);
|
|
|
|
const privacy = JSON.parse(fs.readFileSync(privacyPath, "utf-8"));
|
|
expect(
|
|
privacy.some(
|
|
(e: any) => e.pattern === NEW_PATTERN && e.class === "artifact",
|
|
),
|
|
).toBe(true);
|
|
|
|
const gitattrs = fs.readFileSync(gitattrsPath, "utf-8");
|
|
expect(gitattrs).toContain(`${NEW_PATTERN} merge=union`);
|
|
});
|
|
|
|
test("case 2 (regression for #1581): jq missing, privacy-map exists — marker NOT written, text patches still applied", () => {
|
|
freshAllowlist();
|
|
freshPrivacyMap();
|
|
freshGitattrs();
|
|
|
|
const noJq = makeCuratedPath({ jq: "missing" });
|
|
const r = run({ path: noJq });
|
|
|
|
expect(r.status).toBe(0);
|
|
expect(r.stderr).toMatch(/jq not found/);
|
|
|
|
// Done-marker must NOT be written — this is the whole point of the fix.
|
|
expect(fs.existsSync(donePath)).toBe(false);
|
|
|
|
// Text-only patches still landed (they don't need jq).
|
|
expect(fs.readFileSync(allowlistPath, "utf-8")).toContain(NEW_PATTERN);
|
|
expect(fs.readFileSync(gitattrsPath, "utf-8")).toContain(
|
|
`${NEW_PATTERN} merge=union`,
|
|
);
|
|
|
|
// Privacy-map untouched (still missing the new entry).
|
|
const privacy = JSON.parse(fs.readFileSync(privacyPath, "utf-8"));
|
|
expect(privacy.some((e: any) => e.pattern === NEW_PATTERN)).toBe(false);
|
|
});
|
|
|
|
test("case 3: jq missing, then jq restored — second run completes patch and writes marker", () => {
|
|
freshAllowlist();
|
|
freshPrivacyMap();
|
|
freshGitattrs();
|
|
|
|
// First run with jq missing
|
|
const noJq = makeCuratedPath({ jq: "missing" });
|
|
const r1 = run({ path: noJq });
|
|
expect(r1.status).toBe(0);
|
|
expect(fs.existsSync(donePath)).toBe(false);
|
|
|
|
// Second run with jq restored
|
|
const r2 = run();
|
|
expect(r2.status).toBe(0);
|
|
expect(fs.existsSync(donePath)).toBe(true);
|
|
|
|
const privacy = JSON.parse(fs.readFileSync(privacyPath, "utf-8"));
|
|
expect(
|
|
privacy.some(
|
|
(e: any) => e.pattern === NEW_PATTERN && e.class === "artifact",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("case 4: jq present, privacy-map already has correct entry — idempotent, marker written", () => {
|
|
freshAllowlist();
|
|
fs.writeFileSync(
|
|
privacyPath,
|
|
JSON.stringify(
|
|
[{ pattern: NEW_PATTERN, class: "artifact" }],
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
freshGitattrs();
|
|
|
|
const r = run();
|
|
expect(r.status).toBe(0);
|
|
expect(fs.existsSync(donePath)).toBe(true);
|
|
|
|
const privacy = JSON.parse(fs.readFileSync(privacyPath, "utf-8"));
|
|
const matches = privacy.filter((e: any) => e.pattern === NEW_PATTERN);
|
|
expect(matches.length).toBe(1);
|
|
expect(matches[0].class).toBe("artifact");
|
|
});
|
|
|
|
test("case 5: jq present, privacy-map file missing — allowlist + gitattrs patched, marker written", () => {
|
|
freshAllowlist();
|
|
// No privacy-map file
|
|
freshGitattrs();
|
|
|
|
const r = run();
|
|
expect(r.status).toBe(0);
|
|
expect(fs.existsSync(donePath)).toBe(true);
|
|
expect(fs.existsSync(privacyPath)).toBe(false);
|
|
|
|
expect(fs.readFileSync(allowlistPath, "utf-8")).toContain(NEW_PATTERN);
|
|
expect(fs.readFileSync(gitattrsPath, "utf-8")).toContain(
|
|
`${NEW_PATTERN} merge=union`,
|
|
);
|
|
});
|
|
|
|
test("case 6: jq present, privacy-map JSON malformed — no marker, error logged, no mutation", () => {
|
|
freshAllowlist();
|
|
fs.writeFileSync(privacyPath, "{ this is not json [");
|
|
freshGitattrs();
|
|
|
|
const r = run();
|
|
expect(r.status).toBe(0);
|
|
// No marker — broken JSON should NOT be papered over.
|
|
expect(fs.existsSync(donePath)).toBe(false);
|
|
// Privacy-map content untouched.
|
|
expect(fs.readFileSync(privacyPath, "utf-8")).toBe("{ this is not json [");
|
|
});
|
|
|
|
test("case 7: jq present but mutation fails (shim exit 1) — no marker, tempfile cleaned up", () => {
|
|
freshAllowlist();
|
|
freshPrivacyMap();
|
|
freshGitattrs();
|
|
|
|
const fakeJq = makeCuratedPath({ jq: "shim-fail" });
|
|
const r = run({ path: fakeJq });
|
|
|
|
expect(r.status).toBe(0);
|
|
expect(fs.existsSync(donePath)).toBe(false);
|
|
|
|
// Tempfile cleanup: no leftover *.tmp.* sidecars.
|
|
const leftovers = fs
|
|
.readdirSync(gstackHome)
|
|
.filter((n) => n.startsWith(".brain-privacy-map.json.tmp."));
|
|
expect(leftovers.length).toBe(0);
|
|
});
|
|
|
|
test("case 8: allowlist append fails (read-only file, no USER ADDITIONS marker) — no marker, warn logged", () => {
|
|
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
|
|
// Allowlist WITHOUT the "# ---- USER ADDITIONS BELOW" marker — the script
|
|
// falls into the plain `printf >>` append path. Make the file read-only
|
|
// so the append fails (sed -i.bak on macOS silently no-ops on read-only
|
|
// files, so we have to take the printf path to exercise this).
|
|
fs.writeFileSync(
|
|
allowlistPath,
|
|
"# header\nprojects/*/*-some-other-*.md\n",
|
|
);
|
|
freshPrivacyMap();
|
|
freshGitattrs();
|
|
fs.chmodSync(allowlistPath, 0o444);
|
|
|
|
const r = run();
|
|
expect(r.status).toBe(0);
|
|
// Marker must NOT be written when a required repair failed.
|
|
expect(fs.existsSync(donePath)).toBe(false);
|
|
});
|
|
});
|