/** * 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}" <&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(); } }); });