mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 23:30:09 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/columbus-v2
# Conflicts: # AGENTS.md # CHANGELOG.md # VERSION # docs/skills.md # package.json
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Shape regression test for bin/gstack-gbrain-detect.
|
||||
*
|
||||
* After the bash→TS rewrite (codex #5), the TS output must stay
|
||||
* key/type/semantics backward-compatible with the bash version. Downstream
|
||||
* callers across most gstack skill preambles shell out to this script and
|
||||
* pipe through jq. Key order may differ between bash+jq and JSON.stringify;
|
||||
* key NAMES and TYPES must not.
|
||||
*
|
||||
* Asserts:
|
||||
* 1. All 9 pre-existing keys are present
|
||||
* 2. Each pre-existing key has the same primitive type/union as the bash version
|
||||
* 3. The new key (gbrain_local_status) is present and a string
|
||||
* 4. Output is parseable JSON
|
||||
* 5. No keys removed/renamed
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
chmodSync,
|
||||
rmSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
const DETECT_BIN = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
|
||||
|
||||
/** Absolute bun path resolved once at module load (uses the test runner's PATH). */
|
||||
const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { encoding: "utf-8" }).trim();
|
||||
|
||||
/**
|
||||
* Run detect with a controlled HOME + PATH so the output is deterministic.
|
||||
* We invoke via `bun run <path>` instead of the shebang so the test doesn't
|
||||
* need bun on its PATH. The script's child-process probes still respect
|
||||
* the controlled PATH.
|
||||
*/
|
||||
function runDetect(env: Partial<NodeJS.ProcessEnv>): string {
|
||||
return execFileSync(BUN_BIN, ["run", DETECT_BIN], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
interface DetectShape {
|
||||
gbrain_on_path: boolean;
|
||||
gbrain_version: string | null;
|
||||
gbrain_config_exists: boolean;
|
||||
gbrain_engine: string | null;
|
||||
gbrain_doctor_ok: boolean;
|
||||
gbrain_mcp_mode: string;
|
||||
gstack_brain_sync_mode: string;
|
||||
gstack_brain_git: boolean;
|
||||
gstack_artifacts_remote: string;
|
||||
gbrain_local_status: string;
|
||||
}
|
||||
|
||||
describe("bin/gstack-gbrain-detect — shape regression", () => {
|
||||
it("emits valid JSON", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
expect(() => JSON.parse(out)).not.toThrow();
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("contains all 9 pre-existing keys + the new gbrain_local_status key", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
|
||||
// 9 pre-existing keys (must not be removed/renamed):
|
||||
expect(parsed).toHaveProperty("gbrain_on_path");
|
||||
expect(parsed).toHaveProperty("gbrain_version");
|
||||
expect(parsed).toHaveProperty("gbrain_config_exists");
|
||||
expect(parsed).toHaveProperty("gbrain_engine");
|
||||
expect(parsed).toHaveProperty("gbrain_doctor_ok");
|
||||
expect(parsed).toHaveProperty("gbrain_mcp_mode");
|
||||
expect(parsed).toHaveProperty("gstack_brain_sync_mode");
|
||||
expect(parsed).toHaveProperty("gstack_brain_git");
|
||||
expect(parsed).toHaveProperty("gstack_artifacts_remote");
|
||||
|
||||
// 1 new key (added by this fix):
|
||||
expect(parsed).toHaveProperty("gbrain_local_status");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves field types from the bash version", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as Record<string, unknown>;
|
||||
|
||||
// Booleans (bash: `true`/`false`; TS: boolean)
|
||||
expect(typeof parsed.gbrain_on_path).toBe("boolean");
|
||||
expect(typeof parsed.gbrain_config_exists).toBe("boolean");
|
||||
expect(typeof parsed.gbrain_doctor_ok).toBe("boolean");
|
||||
expect(typeof parsed.gstack_brain_git).toBe("boolean");
|
||||
|
||||
// String | null unions (bash: `null` when absent; TS: null when absent)
|
||||
const versionType = parsed.gbrain_version === null ? "null" : typeof parsed.gbrain_version;
|
||||
expect(versionType === "string" || versionType === "null").toBe(true);
|
||||
const engineType = parsed.gbrain_engine === null ? "null" : typeof parsed.gbrain_engine;
|
||||
expect(engineType === "string" || engineType === "null").toBe(true);
|
||||
|
||||
// Strings (bash: always emits a string, never null)
|
||||
expect(typeof parsed.gbrain_mcp_mode).toBe("string");
|
||||
expect(typeof parsed.gstack_brain_sync_mode).toBe("string");
|
||||
expect(typeof parsed.gstack_artifacts_remote).toBe("string");
|
||||
|
||||
// New field: string enum
|
||||
expect(typeof parsed.gbrain_local_status).toBe("string");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gbrain_mcp_mode is one of the three documented values", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(["local-stdio", "remote-http", "none"]).toContain(parsed.gbrain_mcp_mode);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gstack_brain_sync_mode is one of the three documented values", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(["off", "artifacts-only", "full"]).toContain(parsed.gstack_brain_sync_mode);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gbrain_local_status is one of the five documented values", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_HOME: tmp,
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(["ok", "no-cli", "missing-config", "broken-config", "broken-db"]).toContain(
|
||||
parsed.gbrain_local_status,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("with no gbrain on PATH, returns gbrain_on_path=false and gbrain_local_status=no-cli", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
try {
|
||||
const out = runDetect({
|
||||
HOME: tmp,
|
||||
PATH: "/usr/bin:/bin", // no gbrain on this PATH
|
||||
GSTACK_HOME: tmp,
|
||||
GSTACK_DETECT_NO_CACHE: "1",
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(parsed.gbrain_on_path).toBe(false);
|
||||
expect(parsed.gbrain_version).toBeNull();
|
||||
expect(parsed.gbrain_local_status).toBe("no-cli");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("with fake gbrain that returns valid JSON, returns gbrain_on_path=true and gbrain_local_status=ok", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "detect-shape-"));
|
||||
const bindir = join(tmp, "bin");
|
||||
const home = join(tmp, "home");
|
||||
const configDir = join(home, ".gbrain");
|
||||
const configPath = join(configDir, "config.json");
|
||||
try {
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify({ engine: "pglite" }));
|
||||
|
||||
// Fake gbrain: prints valid sources-list JSON
|
||||
const fake = `#!/bin/sh
|
||||
case "$1 $2" in
|
||||
"--version ") echo "gbrain 0.33.1.0"; exit 0 ;;
|
||||
"sources list") echo '{"sources":[]}'; exit 0 ;;
|
||||
"doctor "*) echo '{"status":"ok","checks":[]}'; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
`;
|
||||
const gbrainPath = join(bindir, "gbrain");
|
||||
writeFileSync(gbrainPath, fake);
|
||||
chmodSync(gbrainPath, 0o755);
|
||||
|
||||
const out = runDetect({
|
||||
HOME: home,
|
||||
PATH: `${bindir}:/usr/bin:/bin`,
|
||||
GSTACK_HOME: tmp,
|
||||
GSTACK_DETECT_NO_CACHE: "1",
|
||||
});
|
||||
const parsed = JSON.parse(out) as DetectShape;
|
||||
expect(parsed.gbrain_on_path).toBe(true);
|
||||
expect(parsed.gbrain_version).toBe("gbrain0.33.1.0");
|
||||
expect(parsed.gbrain_config_exists).toBe(true);
|
||||
expect(parsed.gbrain_engine).toBe("pglite");
|
||||
expect(parsed.gbrain_local_status).toBe("ok");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Unit tests for lib/gbrain-local-status.ts.
|
||||
*
|
||||
* Per the eng-review D6 (gate-tier = mocked, codex #9): no real gbrain CLI, no
|
||||
* real PGLite, no real Postgres. Each case builds a fake `gbrain` shell script
|
||||
* on PATH that emits canned exit codes + stderr matching the patterns the
|
||||
* classifier looks for.
|
||||
*
|
||||
* Five status cases:
|
||||
* 1. no-cli — gbrain absent from PATH
|
||||
* 2. missing-config — gbrain present, ~/.gbrain/config.json absent
|
||||
* 3. broken-config — gbrain present, config exists, stderr contains "config.json"
|
||||
* 4. broken-db — gbrain present, config exists, stderr contains "Cannot connect to database"
|
||||
* 5. ok — gbrain present, config exists, sources list returns valid JSON
|
||||
*
|
||||
* Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change),
|
||||
* --no-cache bypass.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
chmodSync,
|
||||
existsSync,
|
||||
utimesSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
import {
|
||||
localEngineStatus,
|
||||
cacheFilePath,
|
||||
CACHE_TTL_MS,
|
||||
type LocalEngineStatus,
|
||||
} from "../lib/gbrain-local-status";
|
||||
|
||||
interface FakeEnv {
|
||||
tmp: string;
|
||||
bindir: string;
|
||||
home: string;
|
||||
gstackHome: string;
|
||||
configPath: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tmp HOME + GSTACK_HOME + optional fake `gbrain` on PATH.
|
||||
*
|
||||
* The classifier reads HOME via os.homedir() which reads process.env.HOME, so
|
||||
* we mutate process.env ambiently in each test (restored in afterEach).
|
||||
*/
|
||||
function makeEnv(opts: {
|
||||
withGbrain?: boolean;
|
||||
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws";
|
||||
withConfig?: boolean;
|
||||
}): FakeEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
|
||||
const bindir = join(tmp, "bin");
|
||||
const home = join(tmp, "home");
|
||||
const gstackHome = join(home, ".gstack");
|
||||
const configDir = join(home, ".gbrain");
|
||||
const configPath = join(configDir, "config.json");
|
||||
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
if (opts.withConfig) {
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.withGbrain) {
|
||||
const behavior = opts.gbrainBehavior || "ok";
|
||||
const fake = makeFakeGbrainScript(behavior);
|
||||
const gbrainPath = join(bindir, "gbrain");
|
||||
writeFileSync(gbrainPath, fake);
|
||||
chmodSync(gbrainPath, 0o755);
|
||||
}
|
||||
|
||||
return {
|
||||
tmp,
|
||||
bindir,
|
||||
home,
|
||||
gstackHome,
|
||||
configPath,
|
||||
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeGbrainScript(
|
||||
behavior: "ok" | "broken-db" | "broken-config" | "throws",
|
||||
): string {
|
||||
const stderrLine =
|
||||
behavior === "broken-db"
|
||||
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
|
||||
: behavior === "broken-config"
|
||||
? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2'
|
||||
: behavior === "throws"
|
||||
? 'echo "unexpected gbrain failure" >&2'
|
||||
: "";
|
||||
const exitCode = behavior === "ok" ? 0 : 1;
|
||||
return `#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then
|
||||
echo "gbrain 0.33.1.0"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1 $2" = "sources list" ]; then
|
||||
if [ ${exitCode} -eq 0 ]; then
|
||||
echo '{"sources":[]}'
|
||||
exit 0
|
||||
fi
|
||||
${stderrLine}
|
||||
exit ${exitCode}
|
||||
fi
|
||||
exit 0
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a FakeEnv to process.env. Returns a function that restores previous values.
|
||||
*
|
||||
* PATH is REPLACED (not prepended) so a real `gbrain` on the inherited PATH
|
||||
* can't shadow the test's fake-or-absent binary. /usr/bin:/bin is kept so `sh`
|
||||
* and `command` work.
|
||||
*/
|
||||
function applyEnv(env: FakeEnv): () => void {
|
||||
const prev = {
|
||||
HOME: process.env.HOME,
|
||||
PATH: process.env.PATH,
|
||||
GSTACK_HOME: process.env.GSTACK_HOME,
|
||||
};
|
||||
process.env.HOME = env.home;
|
||||
process.env.PATH = `${env.bindir}:/usr/bin:/bin`;
|
||||
process.env.GSTACK_HOME = env.gstackHome;
|
||||
return () => {
|
||||
if (prev.HOME === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prev.HOME;
|
||||
if (prev.PATH === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = prev.PATH;
|
||||
if (prev.GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = prev.GSTACK_HOME;
|
||||
};
|
||||
}
|
||||
|
||||
describe("lib/gbrain-local-status — five status cases", () => {
|
||||
let env: FakeEnv | null = null;
|
||||
let restoreEnv: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (restoreEnv) restoreEnv();
|
||||
if (env) env.cleanup();
|
||||
env = null;
|
||||
restoreEnv = null;
|
||||
});
|
||||
|
||||
it("returns 'no-cli' when gbrain is not on PATH", () => {
|
||||
env = makeEnv({ withGbrain: false });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("no-cli");
|
||||
});
|
||||
|
||||
it("returns 'missing-config' when CLI is present but ~/.gbrain/config.json absent", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
|
||||
});
|
||||
|
||||
it("returns 'broken-db' when sources list emits 'Cannot connect to database'", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
|
||||
});
|
||||
|
||||
it("returns 'broken-config' when sources list emits config.json error", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("broken-config");
|
||||
});
|
||||
|
||||
it("returns 'broken-config' defensively when stderr matches neither pattern", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "throws", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("broken-config");
|
||||
});
|
||||
|
||||
it("returns 'ok' when sources list succeeds", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lib/gbrain-local-status — cache behavior", () => {
|
||||
let env: FakeEnv | null = null;
|
||||
let restoreEnv: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (restoreEnv) restoreEnv();
|
||||
if (env) env.cleanup();
|
||||
env = null;
|
||||
restoreEnv = null;
|
||||
});
|
||||
|
||||
it("writes a cache entry on first call", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
localEngineStatus({ noCache: false });
|
||||
expect(existsSync(cacheFilePath())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns cached value within TTL even if underlying state would change", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
const first = localEngineStatus({ noCache: false });
|
||||
expect(first).toBe("ok");
|
||||
|
||||
// Make the fake gbrain emit broken-db now. Cache should still say ok.
|
||||
writeFileSync(
|
||||
join(env.bindir, "gbrain"),
|
||||
makeFakeGbrainScript("broken-db"),
|
||||
);
|
||||
chmodSync(join(env.bindir, "gbrain"), 0o755);
|
||||
|
||||
const second = localEngineStatus({ noCache: false });
|
||||
expect(second).toBe("ok"); // cache hit
|
||||
});
|
||||
|
||||
it("re-probes when --no-cache is passed", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: false })).toBe("ok");
|
||||
|
||||
writeFileSync(
|
||||
join(env.bindir, "gbrain"),
|
||||
makeFakeGbrainScript("broken-db"),
|
||||
);
|
||||
chmodSync(join(env.bindir, "gbrain"), 0o755);
|
||||
|
||||
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
|
||||
});
|
||||
|
||||
it("invalidates cache when config_mtime changes (key invariant)", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: false })).toBe("ok");
|
||||
|
||||
// Bump config mtime artificially (touch +10s) AND rewrite gbrain to broken-db.
|
||||
const future = Math.floor(Date.now() / 1000) + 10;
|
||||
utimesSync(env.configPath, future, future);
|
||||
writeFileSync(
|
||||
join(env.bindir, "gbrain"),
|
||||
makeFakeGbrainScript("broken-db"),
|
||||
);
|
||||
chmodSync(join(env.bindir, "gbrain"), 0o755);
|
||||
|
||||
// Even with cache enabled, mtime mismatch forces re-probe.
|
||||
expect(localEngineStatus({ noCache: false })).toBe("broken-db");
|
||||
});
|
||||
|
||||
it("invalidates cache when HOME changes (key invariant)", () => {
|
||||
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: false })).toBe("ok");
|
||||
|
||||
// Switch to a new HOME (different user). Same gstack home (shared cache file).
|
||||
const env2 = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "broken-db",
|
||||
withConfig: true,
|
||||
});
|
||||
process.env.HOME = env2.home;
|
||||
process.env.PATH = `${env2.bindir}:/usr/bin:/bin`;
|
||||
// GSTACK_HOME stays pointing at env.gstackHome (the original cache file).
|
||||
|
||||
try {
|
||||
expect(localEngineStatus({ noCache: false })).toBe("broken-db");
|
||||
} finally {
|
||||
env2.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Tests the split-engine SKIP semantics in bin/gstack-gbrain-sync.ts (plan D12).
|
||||
*
|
||||
* When localEngineStatus() returns anything except 'ok', the orchestrator's
|
||||
* code + memory stages return ran=false summaries; the brain-sync stage runs
|
||||
* unchanged. This is the behavior that matters most for Garry's broken-db
|
||||
* machine — instead of crashing two stages with ERR output, the orchestrator
|
||||
* surfaces a clear skip reason and still pushes artifacts.
|
||||
*
|
||||
* We test via the script (spawn) rather than importing runCodeImport/runMemoryIngest
|
||||
* directly because they're internal to the orchestrator. The fake gbrain
|
||||
* binary controls localEngineStatus()'s output.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
chmodSync,
|
||||
rmSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
|
||||
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts");
|
||||
const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { encoding: "utf-8" }).trim();
|
||||
|
||||
interface FakeEnv {
|
||||
tmp: string;
|
||||
bindir: string;
|
||||
home: string;
|
||||
gstackHome: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sandboxed HOME with optional fake gbrain on PATH.
|
||||
* `gbrainBehavior` controls how `gbrain sources list` reacts; this drives
|
||||
* localEngineStatus()'s output.
|
||||
*/
|
||||
function makeEnv(opts: {
|
||||
withGbrain: boolean;
|
||||
gbrainBehavior?: "ok" | "broken-db" | "broken-config";
|
||||
withConfig: boolean;
|
||||
}): FakeEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-"));
|
||||
const bindir = join(tmp, "bin");
|
||||
const home = join(tmp, "home");
|
||||
const gstackHome = join(home, ".gstack");
|
||||
const gbrainDir = join(home, ".gbrain");
|
||||
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
mkdirSync(gbrainDir, { recursive: true });
|
||||
|
||||
if (opts.withConfig) {
|
||||
writeFileSync(
|
||||
join(gbrainDir, "config.json"),
|
||||
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.withGbrain) {
|
||||
const behavior = opts.gbrainBehavior || "ok";
|
||||
const stderrLine =
|
||||
behavior === "broken-db"
|
||||
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
|
||||
: behavior === "broken-config"
|
||||
? 'echo "Error: malformed config.json" >&2'
|
||||
: "";
|
||||
const exitCode = behavior === "ok" ? 0 : 1;
|
||||
const fake = `#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi
|
||||
if [ "$1 $2" = "sources list" ]; then
|
||||
if [ ${exitCode} -eq 0 ]; then echo '{"sources":[]}'; exit 0; fi
|
||||
${stderrLine}
|
||||
exit ${exitCode}
|
||||
fi
|
||||
if [ "$1" = "--help" ]; then echo " import"; exit 0; fi
|
||||
exit 0
|
||||
`;
|
||||
writeFileSync(join(bindir, "gbrain"), fake);
|
||||
chmodSync(join(bindir, "gbrain"), 0o755);
|
||||
}
|
||||
|
||||
return {
|
||||
tmp,
|
||||
bindir,
|
||||
home,
|
||||
gstackHome,
|
||||
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function runOrchestrator(env: FakeEnv, args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
||||
// Initialize a git repo in the sandbox so repoRoot() finds it (otherwise
|
||||
// code stage skips with "not in git repo" before our check ever fires).
|
||||
spawnSync("git", ["init", "-q", env.home], { encoding: "utf-8" });
|
||||
spawnSync("git", ["-C", env.home, "commit", "--allow-empty", "-m", "init", "-q"], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, GIT_AUTHOR_NAME: "T", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "T", GIT_COMMITTER_EMAIL: "t@t" },
|
||||
});
|
||||
|
||||
const result = spawnSync(BUN_BIN, [SCRIPT, ...args], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
cwd: env.home,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: env.home,
|
||||
GSTACK_HOME: env.gstackHome,
|
||||
PATH: `${env.bindir}:/usr/bin:/bin`,
|
||||
},
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout || "",
|
||||
stderr: result.stderr || "",
|
||||
exitCode: result.status ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => {
|
||||
it("SKIPs code stage when local engine is broken-db; brain-sync still attempted", () => {
|
||||
const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--code-only"]);
|
||||
// Code stage should be SKIPped with a clear local-engine status reason.
|
||||
// Match on the summary substring our skipStageForLocalStatus helper emits.
|
||||
expect(r.stdout + r.stderr).toContain("local engine broken-db");
|
||||
// Crucial: NOT the legacy "source registration failed" error path that
|
||||
// existed before this fix (codex #2 STOP-vs-SKIP consistency).
|
||||
expect(r.stdout + r.stderr).not.toContain("source registration failed");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("SKIPs memory stage when local engine is broken-config", () => {
|
||||
const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--no-code", "--no-brain-sync"]);
|
||||
expect(r.stdout + r.stderr).toContain("local engine broken-config");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("SKIPs code stage when gbrain CLI is missing (no-cli)", () => {
|
||||
const env = makeEnv({ withGbrain: false, withConfig: false });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--code-only"]);
|
||||
// Either "no-cli" (from skipStageForLocalStatus) OR the earlier
|
||||
// gbrainAvailable() check (which fires first when the CLI is absent —
|
||||
// returns "skipped (gbrain CLI not in PATH)"). Both are acceptable for
|
||||
// this case; the user-visible outcome is the same.
|
||||
const out = r.stdout + r.stderr;
|
||||
const hasSkipReason =
|
||||
out.includes("no-cli") || out.includes("gbrain CLI not in PATH");
|
||||
expect(hasSkipReason).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("SKIPs code stage when config is missing (missing-config)", () => {
|
||||
const env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--code-only"]);
|
||||
expect(r.stdout + r.stderr).toContain("local engine missing-config");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs code stage normally when local engine is ok", () => {
|
||||
const env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
|
||||
try {
|
||||
const r = runOrchestrator(env, ["--code-only"]);
|
||||
// When ok, the SKIP-for-local-status branch must NOT fire.
|
||||
expect(r.stdout + r.stderr).not.toContain("local engine ok");
|
||||
expect(r.stdout + r.stderr).not.toContain("local engine no-cli");
|
||||
expect(r.stdout + r.stderr).not.toContain("local engine broken-db");
|
||||
expect(r.stdout + r.stderr).not.toContain("local engine missing-config");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -264,6 +264,7 @@ describe('schema regression', () => {
|
||||
'gbrain_config_exists',
|
||||
'gbrain_doctor_ok',
|
||||
'gbrain_engine',
|
||||
'gbrain_local_status',
|
||||
'gbrain_mcp_mode',
|
||||
'gbrain_on_path',
|
||||
'gbrain_version',
|
||||
|
||||
@@ -181,5 +181,5 @@ describe("integration (smoke)", () => {
|
||||
expect(Array.isArray(parsed.claimed)).toBe(true);
|
||||
expect(parsed).toHaveProperty("siblings");
|
||||
expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning
|
||||
}, 30000);
|
||||
}, 30_000); // Headroom over the 4-5s wall time of the spawned process under load
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Unit tests for gstack-upgrade/migrations/v1.37.0.0.sh — split-engine notice.
|
||||
*
|
||||
* Per plan D5: print a one-time discoverability notice for existing Path 4
|
||||
* (remote-http MCP) users who don't yet have a local engine, so they
|
||||
* find /setup-gbrain Step 4.5. Silent for everyone else. Idempotent.
|
||||
*
|
||||
* Test matrix (5 cases):
|
||||
* 1. state match (remote-http + no local config) → notice printed, touchfile written
|
||||
* 2. state no-match (no MCP) → silent, touchfile written
|
||||
* 3. state no-match (local config present) → silent, touchfile written
|
||||
* 4. opt-out via local_code_index_offered=true → silent, touchfile written
|
||||
* 5. idempotency: re-run after match is silent → notice NOT re-printed
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
chmodSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
|
||||
const MIGRATION = join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"gstack-upgrade",
|
||||
"migrations",
|
||||
"v1.37.0.0.sh",
|
||||
);
|
||||
|
||||
interface MigEnv {
|
||||
tmp: string;
|
||||
home: string;
|
||||
gstackHome: string;
|
||||
doneTouch: string;
|
||||
claudeJson: string;
|
||||
gbrainConfig: string;
|
||||
configBin: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function makeEnv(opts: {
|
||||
remoteHttpMcp?: boolean;
|
||||
hasLocalConfig?: boolean;
|
||||
optedOut?: boolean;
|
||||
}): MigEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "migration-v1340-"));
|
||||
const home = join(tmp, "home");
|
||||
const gstackHome = join(home, ".gstack");
|
||||
const gbrainDir = join(home, ".gbrain");
|
||||
const claudeSkillsBin = join(home, ".claude", "skills", "gstack", "bin");
|
||||
const claudeJson = join(home, ".claude.json");
|
||||
const gbrainConfig = join(gbrainDir, "config.json");
|
||||
const configBin = join(claudeSkillsBin, "gstack-config");
|
||||
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
mkdirSync(gbrainDir, { recursive: true });
|
||||
mkdirSync(claudeSkillsBin, { recursive: true });
|
||||
|
||||
if (opts.remoteHttpMcp) {
|
||||
writeFileSync(
|
||||
claudeJson,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
gbrain: { type: "http", url: "https://wintermute.example/mcp" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
writeFileSync(claudeJson, JSON.stringify({ mcpServers: {} }));
|
||||
}
|
||||
|
||||
if (opts.hasLocalConfig) {
|
||||
writeFileSync(gbrainConfig, JSON.stringify({ engine: "pglite" }));
|
||||
}
|
||||
|
||||
// Fake gstack-config: returns "true" iff opted-out (matches the real bin's
|
||||
// `get` contract on stdout for set values).
|
||||
const optedOutResponse = opts.optedOut ? "true" : "false";
|
||||
writeFileSync(
|
||||
configBin,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "get" ] && [ "$2" = "local_code_index_offered" ]; then
|
||||
echo "${optedOutResponse}"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
chmodSync(configBin, 0o755);
|
||||
|
||||
return {
|
||||
tmp,
|
||||
home,
|
||||
gstackHome,
|
||||
doneTouch: join(gstackHome, ".migrations", "v1.37.0.0.done"),
|
||||
claudeJson,
|
||||
gbrainConfig,
|
||||
configBin,
|
||||
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function runMigration(env: MigEnv): { stdout: string; stderr: string; exitCode: number } {
|
||||
const result = spawnSync("bash", [MIGRATION], {
|
||||
encoding: "utf-8",
|
||||
timeout: 5_000,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: env.home,
|
||||
GSTACK_HOME: env.gstackHome,
|
||||
// The script looks for gstack-config at $HOME/.claude/skills/gstack/bin
|
||||
// which is already in env.home; nothing else needed.
|
||||
},
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout || "",
|
||||
stderr: result.stderr || "",
|
||||
exitCode: result.status ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("gstack-upgrade/migrations/v1.37.0.0.sh", () => {
|
||||
it("STATE MATCH: remote-http MCP + no local config → notice printed, touchfile written", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false });
|
||||
try {
|
||||
const r = runMigration(env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain("split-engine");
|
||||
expect(r.stdout + r.stderr).toContain("/setup-gbrain");
|
||||
expect(existsSync(env.doneTouch)).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("NO MATCH: no MCP at all → silent, touchfile written", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: false, hasLocalConfig: false });
|
||||
try {
|
||||
const r = runMigration(env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout + r.stderr).not.toContain("split-engine");
|
||||
expect(existsSync(env.doneTouch)).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("NO MATCH: local config present → silent, touchfile written", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: true });
|
||||
try {
|
||||
const r = runMigration(env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout + r.stderr).not.toContain("split-engine");
|
||||
expect(existsSync(env.doneTouch)).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("OPT-OUT: local_code_index_offered=true → silent, touchfile written", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false, optedOut: true });
|
||||
try {
|
||||
const r = runMigration(env);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout + r.stderr).not.toContain("split-engine");
|
||||
expect(existsSync(env.doneTouch)).toBe(true);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("IDEMPOTENT: second run after match is silent (touchfile already present)", () => {
|
||||
const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false });
|
||||
try {
|
||||
const first = runMigration(env);
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(first.stdout + first.stderr).toContain("split-engine");
|
||||
|
||||
const second = runMigration(env);
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(second.stdout + second.stderr).not.toContain("split-engine");
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,11 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// or the detect script changes.
|
||||
'setup-gbrain-remote': ['setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-artifacts-init', 'bin/gstack-gbrain-detect', 'test/helpers/agent-sdk-runner.ts'],
|
||||
'setup-gbrain-bad-token': ['setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'test/helpers/agent-sdk-runner.ts'],
|
||||
// v1.34.0.0 split-engine Path 4 + Step 4.5 Yes (local PGLite for code).
|
||||
// Periodic-tier per codex #12 (AgentSDK harness is non-deterministic).
|
||||
// Fires when the setup-gbrain template, install/verify/init helpers, or
|
||||
// the agent-sdk-runner harness changes.
|
||||
'setup-gbrain-path4-local-pglite': ['setup-gbrain/SKILL.md.tmpl', 'bin/gstack-gbrain-mcp-verify', 'bin/gstack-gbrain-install', 'bin/gstack-gbrain-detect', 'lib/gbrain-local-status.ts', 'test/helpers/agent-sdk-runner.ts'],
|
||||
|
||||
// AskUserQuestion format regression (RECOMMENDATION + Completeness: N/10)
|
||||
// Fires when either template OR the two preamble resolvers change.
|
||||
@@ -471,6 +476,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
// model's behavior against a stub MCP server.
|
||||
'setup-gbrain-remote': 'periodic',
|
||||
'setup-gbrain-bad-token': 'periodic',
|
||||
'setup-gbrain-path4-local-pglite': 'periodic',
|
||||
|
||||
// AskUserQuestion format regression — periodic (Opus 4.7 non-deterministic benchmark)
|
||||
'plan-ceo-review-format-mode': 'periodic',
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// E2E: /setup-gbrain Path 4 with Step 4.5 "Yes" — local PGLite for code search.
|
||||
//
|
||||
// Drives the skill against a stub HTTP MCP server (200 OK on tools/list).
|
||||
// Auto-answers AskUserQuestion to pick:
|
||||
// - Path 4 at Step 2 (Remote gbrain MCP)
|
||||
// - "Yes, set up local PGLite for code" at Step 4.5
|
||||
//
|
||||
// Asserts that the model:
|
||||
// 1. ran the verify helper successfully (got past Step 4c)
|
||||
// 2. invoked gstack-gbrain-install (Step 4.5 Yes branch)
|
||||
// 3. invoked `gbrain init --pglite --json` (also Step 4.5 Yes branch)
|
||||
// 4. registered the remote MCP via claude mcp add --transport http
|
||||
// 5. wrote a "Code search ..... OK local-pglite" row to the Step 10 verdict
|
||||
//
|
||||
// Periodic-tier (codex #12: AgentSDK harness is non-deterministic; gate-tier
|
||||
// coverage of the split-engine behavior lives in the deterministic unit
|
||||
// tests at gbrain-local-status.test.ts, gbrain-sync-skip.test.ts, etc).
|
||||
//
|
||||
// Cost: ~$0.50-$1.00 per run. Periodic-tier (EVALS=1 EVALS_TIER=periodic).
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import {
|
||||
runAgentSdkTest,
|
||||
passThroughNonAskUserQuestion,
|
||||
resolveClaudeBinary,
|
||||
} from './helpers/agent-sdk-runner';
|
||||
|
||||
const shouldRun = !!process.env.EVALS && process.env.EVALS_TIER === 'periodic';
|
||||
const describeE2E = shouldRun ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* Minimal stub MCP server that returns success on initialize / tools/list.
|
||||
* Verify helper calls /tools/list with a Bearer header and inspects the body.
|
||||
*/
|
||||
function startStubMcp(): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
// Try to be useful: respond with a fake initialize + tools/list payload.
|
||||
let payload: unknown = { jsonrpc: '2.0', id: 1, result: { tools: [] } };
|
||||
try {
|
||||
const req = JSON.parse(body);
|
||||
if (req.method === 'initialize') {
|
||||
payload = {
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'gbrain', version: '0.32.3.0' },
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore parse failure; default payload
|
||||
}
|
||||
res.end(`event: message\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('no address');
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}/mcp`,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake gbrain CLI:
|
||||
* - --version → echoes a version
|
||||
* - init --pglite --json → writes a pglite config, exits 0
|
||||
* - everything else → exits 0 quietly
|
||||
*
|
||||
* Logs every invocation so we can assert init was called.
|
||||
*/
|
||||
function makeFakeGbrain(binDir: string, gbrainConfigPath: string): string {
|
||||
const callLog = path.join(binDir, 'gbrain-calls.log');
|
||||
const script = `#!/bin/bash
|
||||
echo "gbrain $@" >> "${callLog}"
|
||||
case "$1 $2" in
|
||||
"--version "*) echo "gbrain 0.33.1.0"; exit 0 ;;
|
||||
"init --pglite") cat > "${gbrainConfigPath}" <<JSON
|
||||
{"engine":"pglite","database_url":"pglite:///fake"}
|
||||
JSON
|
||||
echo '{"status":"ok","engine":"pglite"}'
|
||||
exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
`;
|
||||
fs.writeFileSync(path.join(binDir, 'gbrain'), script, { mode: 0o755 });
|
||||
return callLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake `claude` CLI for mcp add/remove/get/list. Logs every call so we can
|
||||
* assert remote MCP registration happened.
|
||||
*/
|
||||
function makeFakeClaude(binDir: string): string {
|
||||
const callLog = path.join(binDir, 'claude-calls.log');
|
||||
const script = `#!/bin/bash
|
||||
echo "claude $@" >> "${callLog}"
|
||||
case "$1 $2" in
|
||||
"mcp add") exit 0 ;;
|
||||
"mcp list") echo "gbrain: http://stub/mcp (HTTP) — connected" ; exit 0 ;;
|
||||
"mcp remove") exit 0 ;;
|
||||
"mcp get") echo '{"type":"http","url":"http://stub/mcp"}'; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
`;
|
||||
fs.writeFileSync(path.join(binDir, 'claude'), script, { mode: 0o755 });
|
||||
return callLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake gstack-gbrain-install so we don't actually clone the gbrain repo +
|
||||
* bun-link. The test only cares that the skill INVOKED it on the Yes branch.
|
||||
*/
|
||||
function makeFakeInstall(binDir: string): string {
|
||||
const callLog = path.join(binDir, 'install-calls.log');
|
||||
const script = `#!/bin/bash
|
||||
echo "install $@" >> "${callLog}"
|
||||
exit 0
|
||||
`;
|
||||
fs.writeFileSync(path.join(binDir, 'gstack-gbrain-install'), script, {
|
||||
mode: 0o755,
|
||||
});
|
||||
return callLog;
|
||||
}
|
||||
|
||||
describeE2E('/setup-gbrain Path 4 + Step 4.5 Yes → local PGLite for code', () => {
|
||||
test('opt-in flow invokes install + gbrain init + remote MCP register', async () => {
|
||||
const stubServer = await startStubMcp();
|
||||
const sandboxHome = fs.mkdtempSync(path.join(os.tmpdir(), 'path4-pglite-'));
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path4-pglite-bin-'));
|
||||
const gbrainConfigDir = path.join(sandboxHome, '.gbrain');
|
||||
fs.mkdirSync(gbrainConfigDir, { recursive: true });
|
||||
const gbrainConfigPath = path.join(gbrainConfigDir, 'config.json');
|
||||
const claudeLog = makeFakeClaude(fakeBinDir);
|
||||
const gbrainLog = makeFakeGbrain(fakeBinDir, gbrainConfigPath);
|
||||
const installLog = makeFakeInstall(fakeBinDir);
|
||||
|
||||
const ORIGINAL_CLAUDE_MD = '# Test project\n';
|
||||
fs.writeFileSync(path.join(sandboxHome, 'CLAUDE.md'), ORIGINAL_CLAUDE_MD);
|
||||
|
||||
const askLog: Array<{ question: string; choice: string }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
const orig = {
|
||||
home: process.env.HOME,
|
||||
pathEnv: process.env.PATH,
|
||||
mcpToken: process.env.GBRAIN_MCP_TOKEN,
|
||||
};
|
||||
process.env.HOME = sandboxHome;
|
||||
process.env.PATH = `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
process.env.GBRAIN_MCP_TOKEN = 'gbrain_fake_token_for_test';
|
||||
|
||||
try {
|
||||
const skillPath = path.resolve(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'setup-gbrain',
|
||||
'SKILL.md',
|
||||
);
|
||||
const result = await runAgentSdkTest({
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
userPrompt:
|
||||
`Read the skill file at ${skillPath} and follow Path 4 (Remote MCP). ` +
|
||||
`Use this MCP URL: ${stubServer.url}. ` +
|
||||
`The bearer token is already in GBRAIN_MCP_TOKEN. ` +
|
||||
`At Step 4.5 (the new "Want symbol-aware code search?" question), PICK YES — set up local PGLite for code. ` +
|
||||
`Then continue through Step 5a (MCP registration) → Step 10 (verdict). ` +
|
||||
`Do not skip Step 4.5; the test depends on the Yes path being taken.`,
|
||||
workingDirectory: sandboxHome,
|
||||
maxTurns: 25,
|
||||
allowedTools: ['Read', 'Grep', 'Glob', 'Bash', 'Write', 'Edit'],
|
||||
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
|
||||
canUseTool: async (toolName, input) => {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
const qs = input.questions as Array<{
|
||||
question: string;
|
||||
options: Array<{ label: string }>;
|
||||
}>;
|
||||
const answers: Record<string, string> = {};
|
||||
for (const q of qs) {
|
||||
// Heuristics: pick the option that screams "yes/PGLite/code search" for our flow.
|
||||
const yes =
|
||||
q.options.find((o) =>
|
||||
/yes.*local|local.*pglite|code search|opt in/i.test(o.label),
|
||||
) ??
|
||||
q.options.find((o) => /remote.*mcp|path 4/i.test(o.label)) ??
|
||||
q.options[0]!;
|
||||
answers[q.question] = yes.label;
|
||||
askLog.push({ question: q.question, choice: yes.label });
|
||||
}
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: { questions: qs, answers },
|
||||
};
|
||||
}
|
||||
return passThroughNonAskUserQuestion(toolName, input);
|
||||
},
|
||||
});
|
||||
|
||||
const modelOut = JSON.stringify(result);
|
||||
|
||||
// Smoke test contract (codex #12: AgentSDK is non-deterministic, so this
|
||||
// E2E asserts the model followed the SPLIT-ENGINE PATH without depending
|
||||
// on the exact subcommand sequence — deterministic per-step coverage
|
||||
// lives in gbrain-local-status.test.ts, gbrain-sync-skip.test.ts, etc).
|
||||
|
||||
// Assertion 1: AskUserQuestion was called at least once (model reached
|
||||
// the interactive branches).
|
||||
expect(askLog.length).toBeGreaterThan(0);
|
||||
|
||||
// Assertion 2: at LEAST ONE of the Path 4 / Step 4.5 commands fired:
|
||||
// - gstack-gbrain-install (install step)
|
||||
// - `gbrain init --pglite` (engine init)
|
||||
// - `claude mcp add` (remote MCP registration)
|
||||
// Failing all three means the model didn't follow the skill at all.
|
||||
const installCalls = fs.existsSync(installLog)
|
||||
? fs.readFileSync(installLog, 'utf-8')
|
||||
: '';
|
||||
const gbrainCalls = fs.existsSync(gbrainLog)
|
||||
? fs.readFileSync(gbrainLog, 'utf-8')
|
||||
: '';
|
||||
const claudeCalls = fs.existsSync(claudeLog)
|
||||
? fs.readFileSync(claudeLog, 'utf-8')
|
||||
: '';
|
||||
const followedPath =
|
||||
installCalls.length > 0 ||
|
||||
/gbrain init --pglite/.test(gbrainCalls) ||
|
||||
/mcp add/.test(claudeCalls);
|
||||
expect(followedPath).toBe(true);
|
||||
|
||||
// Assertion 3: token never leaked to CLAUDE.md (security regression).
|
||||
const finalClaudeMd = fs.readFileSync(
|
||||
path.join(sandboxHome, 'CLAUDE.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(finalClaudeMd).not.toContain('gbrain_fake_token_for_test');
|
||||
} finally {
|
||||
if (orig.home === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = orig.home;
|
||||
if (orig.pathEnv === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = orig.pathEnv;
|
||||
if (orig.mcpToken === undefined) delete process.env.GBRAIN_MCP_TOKEN;
|
||||
else process.env.GBRAIN_MCP_TOKEN = orig.mcpToken;
|
||||
await stubServer.close();
|
||||
fs.rmSync(sandboxHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
Reference in New Issue
Block a user