mirror of https://github.com/garrytan/gstack.git
205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
/**
|
|
* Tests the .bak-rollback contract used by /setup-gbrain Step 1.5 (broken-db
|
|
* repair) and Step 4.5 (Path 4 opt-in to local PGLite), per plan D7.
|
|
*
|
|
* These code paths live in the skill TEMPLATE, not in a TypeScript helper —
|
|
* the skill follows AI-readable instructions. The instructions specify the
|
|
* exact sequence:
|
|
*
|
|
* 1. mv ~/.gbrain/config.json ~/.gbrain/config.json.gstack-bak-$(date +%s)
|
|
* 2. gbrain init --pglite --json
|
|
* 3. on non-zero exit: mv .bak back; surface error
|
|
*
|
|
* This test extracts that sequence as a shell function and verifies the
|
|
* rollback contract using a fake `gbrain` binary that fails on init. It's
|
|
* the test that proves "what the skill template says, when followed
|
|
* mechanically, actually preserves the user's broken config on failure."
|
|
*
|
|
* Per plan codex #10 / explicit rollback scope: we only promise to restore
|
|
* the config.json file. The PGLite directory at ~/.gbrain/pglite/ may end
|
|
* up in a partial state — that's documented to the user, not auto-cleaned.
|
|
*/
|
|
|
|
import { describe, it, expect } from "bun:test";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
existsSync,
|
|
readdirSync,
|
|
rmSync,
|
|
chmodSync,
|
|
} from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import { spawnSync } from "child_process";
|
|
|
|
interface RollbackEnv {
|
|
tmp: string;
|
|
home: string;
|
|
configPath: string;
|
|
bindir: string;
|
|
cleanup: () => void;
|
|
}
|
|
|
|
function makeEnv(opts: { gbrainBehavior: "succeeds" | "fails" }): RollbackEnv {
|
|
const tmp = mkdtempSync(join(tmpdir(), "gbrain-init-rollback-"));
|
|
const home = join(tmp, "home");
|
|
const gbrainDir = join(home, ".gbrain");
|
|
const configPath = join(gbrainDir, "config.json");
|
|
const bindir = join(tmp, "bin");
|
|
mkdirSync(gbrainDir, { recursive: true });
|
|
mkdirSync(bindir, { recursive: true });
|
|
|
|
// Seed the broken-db config we want to preserve on failure / replace on success.
|
|
writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
engine: "postgres",
|
|
database_url: "postgresql://stale:test@localhost:5435/gbrain_test",
|
|
}),
|
|
);
|
|
|
|
const exitCode = opts.gbrainBehavior === "fails" ? 1 : 0;
|
|
const onInitSuccess =
|
|
opts.gbrainBehavior === "succeeds"
|
|
? `cat > "${configPath}" <<JSON
|
|
{"engine":"pglite","database_url":"pglite://${gbrainDir}/pglite"}
|
|
JSON
|
|
mkdir -p "${gbrainDir}/pglite"
|
|
echo '{"status":"ok"}'`
|
|
: `echo "Error: disk full" >&2`;
|
|
const fake = `#!/bin/sh
|
|
if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi
|
|
if [ "$1 $2" = "init --pglite" ]; then
|
|
${onInitSuccess}
|
|
exit ${exitCode}
|
|
fi
|
|
exit 0
|
|
`;
|
|
writeFileSync(join(bindir, "gbrain"), fake);
|
|
chmodSync(join(bindir, "gbrain"), 0o755);
|
|
|
|
return {
|
|
tmp,
|
|
home,
|
|
configPath,
|
|
bindir,
|
|
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verbatim reimplementation of the skill template's Step 1.5 / 4.5 rollback
|
|
* sequence. The skill instructs the model to execute this bash; we execute
|
|
* the same bash here in a sandboxed environment and assert the contract.
|
|
*
|
|
* If gbrain templates rewrite this sequence, this test should fail until
|
|
* the shell here is updated too. That's the point — keep the test and the
|
|
* skill template aligned.
|
|
*/
|
|
function runRollbackSequence(env: RollbackEnv): { exitCode: number; stderr: string } {
|
|
const script = `
|
|
set -u
|
|
BACKUP="${env.configPath}.gstack-bak-$(date +%s)-$$"
|
|
if [ -f "${env.configPath}" ]; then
|
|
mv "${env.configPath}" "$BACKUP"
|
|
fi
|
|
if ! gbrain init --pglite --json; then
|
|
if [ -n "\${BACKUP:-}" ] && [ -f "$BACKUP" ]; then
|
|
mv "$BACKUP" "${env.configPath}"
|
|
fi
|
|
echo "gbrain init failed. Existing config (if any) was restored." >&2
|
|
exit 1
|
|
fi
|
|
echo "ok"
|
|
`;
|
|
const result = spawnSync("bash", ["-c", script], {
|
|
encoding: "utf-8",
|
|
env: {
|
|
...process.env,
|
|
HOME: env.home,
|
|
PATH: `${env.bindir}:/usr/bin:/bin`,
|
|
},
|
|
});
|
|
return {
|
|
exitCode: result.status ?? 1,
|
|
stderr: result.stderr || "",
|
|
};
|
|
}
|
|
|
|
describe("Step 1.5 / 4.5 .bak-rollback contract (plan D7)", () => {
|
|
it("FAILURE PATH: when `gbrain init` fails, broken config is restored to original path", () => {
|
|
const env = makeEnv({ gbrainBehavior: "fails" });
|
|
try {
|
|
const originalContent = readFileSync(env.configPath, "utf-8");
|
|
|
|
const r = runRollbackSequence(env);
|
|
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("restored");
|
|
|
|
// Original config is back at the original path.
|
|
expect(existsSync(env.configPath)).toBe(true);
|
|
const after = readFileSync(env.configPath, "utf-8");
|
|
expect(after).toBe(originalContent);
|
|
|
|
// No leftover .bak — it was renamed back to the original path.
|
|
const baks = readdirSync(join(env.home, ".gbrain")).filter((f) =>
|
|
f.includes(".gstack-bak-"),
|
|
);
|
|
expect(baks).toEqual([]);
|
|
} finally {
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
it("SUCCESS PATH: when `gbrain init` succeeds, the .bak survives for audit", () => {
|
|
const env = makeEnv({ gbrainBehavior: "succeeds" });
|
|
try {
|
|
const r = runRollbackSequence(env);
|
|
|
|
expect(r.exitCode).toBe(0);
|
|
|
|
// New config is in place (fake gbrain wrote pglite engine).
|
|
expect(existsSync(env.configPath)).toBe(true);
|
|
const after = JSON.parse(readFileSync(env.configPath, "utf-8")) as {
|
|
engine: string;
|
|
};
|
|
expect(after.engine).toBe("pglite");
|
|
|
|
// The .bak survives — user can audit before deleting.
|
|
const baks = readdirSync(join(env.home, ".gbrain")).filter((f) =>
|
|
f.includes(".gstack-bak-"),
|
|
);
|
|
expect(baks.length).toBe(1);
|
|
} finally {
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
it("PGLite directory partial state is NOT auto-cleaned (codex #10 scoped rollback)", () => {
|
|
// Per the rollback scope: we only restore config.json. If gbrain init
|
|
// started writing a PGLite dir before failing, we leave it alone and
|
|
// surface the cleanup hint to the user.
|
|
const env = makeEnv({ gbrainBehavior: "fails" });
|
|
try {
|
|
// Simulate gbrain having created a partial PGLite dir before failure
|
|
const partial = join(env.home, ".gbrain", "pglite");
|
|
mkdirSync(partial, { recursive: true });
|
|
writeFileSync(join(partial, "partial-write.tmp"), "");
|
|
|
|
const r = runRollbackSequence(env);
|
|
|
|
expect(r.exitCode).toBe(1);
|
|
// The partial dir is left in place — user gets the hint, we don't
|
|
// assume responsibility for cleanup.
|
|
expect(existsSync(partial)).toBe(true);
|
|
expect(existsSync(join(partial, "partial-write.tmp"))).toBe(true);
|
|
} finally {
|
|
env.cleanup();
|
|
}
|
|
});
|
|
});
|