Files
gstack/test/gbrain-sources.test.ts
T
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

364 lines
14 KiB
TypeScript

/**
* Unit tests for lib/gbrain-sources.ts (per /plan-eng-review D3 DRY extraction).
*
* The helper shells out to the real `gbrain` CLI. To test idempotency
* deterministically without a live brain, we put a fake `gbrain` binary on
* PATH that emits canned `sources list --json` output and records its
* invocations. The same trick `test/gstack-gbrain-source-wireup.test.ts` uses.
*/
import { describe, it, expect } from "bun:test";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync, symlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { usePrivateStateRoot } from "./helpers/private-state-root";
usePrivateStateRoot();
import { ensureSourceRegistered, probeSource, sourcePageCount } from "../lib/gbrain-sources";
interface FakeGbrainSetup {
bindir: string;
statePath: string;
logPath: string;
/**
* Env to pass to helper calls. Bun's execFileSync does NOT respect runtime
* mutations of process.env.PATH; we have to pass env explicitly. Production
* callers leave this unset (inherit process.env) — the helper signature has
* an optional `env` param specifically for tests.
*/
env: NodeJS.ProcessEnv;
cleanup: () => void;
}
/**
* Build a temp dir with a fake `gbrain` shell script on PATH. The fake honors:
* gbrain sources list --json → cat $STATE_PATH
* gbrain sources add <id> --path <p> [--federated] → append to state, log
* gbrain sources remove <id> --confirm-destructive → drop from state, log
* (#1985: remove WITHOUT
* --confirm-destructive
* fails like gbrain >= 0.42)
* gbrain --version → echo "gbrain 0.42.40.0"
* Anything else exits 1.
*/
function makeFakeGbrain(initialState: { sources: Array<{ id: string; local_path: string; federated?: boolean; page_count?: number }> }): FakeGbrainSetup {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-sources-test-"));
const bindir = join(tmp, "bin");
mkdirSync(bindir, { recursive: true });
const statePath = join(tmp, "state.json");
const logPath = join(tmp, "calls.log");
writeFileSync(statePath, JSON.stringify(initialState));
writeFileSync(logPath, "");
const fake = `#!/bin/sh
echo "$@" >> "${logPath}"
case "$1 $2" in
"--version ")
echo "gbrain 0.42.40.0"
exit 0
;;
"sources list")
cat "${statePath}"
exit 0
;;
"sources add")
ID="$3"
shift 3
PATH_VAL=""
FED="false"
while [ $# -gt 0 ]; do
case "$1" in
--path) PATH_VAL="$2"; shift 2 ;;
--federated) FED="true"; shift ;;
*) shift ;;
esac
done
NEW=$(jq --arg id "$ID" --arg path "$PATH_VAL" --argjson fed "$FED" \
'.sources += [{id: $id, local_path: $path, federated: $fed, page_count: 0}]' "${statePath}")
echo "$NEW" > "${statePath}"
exit 0
;;
"sources remove")
ID="$3"
# #1985: gbrain >= 0.42 gates remove behind --confirm-destructive; --yes
# alone no longer suppresses the data-loss prompt. Refuse without it so the
# drift re-register path is exercised against real gbrain 0.42 behavior.
case " $* " in
*" --confirm-destructive "*) : ;;
*)
echo "This will permanently delete pages. To proceed, pass --confirm-destructive" >&2
exit 1
;;
esac
NEW=$(jq --arg id "$ID" '.sources = (.sources | map(select(.id != $id)))' "${statePath}")
echo "$NEW" > "${statePath}"
exit 0
;;
esac
echo "fake gbrain: unknown command: $@" >&2
exit 1
`;
const fakePath = join(bindir, "gbrain");
writeFileSync(fakePath, fake);
chmodSync(fakePath, 0o755);
// Build the env override we'll pass to helper calls. We do NOT mutate
// process.env globally because Bun's execFileSync caches PATH at process
// start; explicit env is the only reliable way to redirect spawn-time PATH.
const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` };
return {
bindir,
statePath,
logPath,
env,
cleanup: () => {
rmSync(tmp, { recursive: true, force: true });
},
};
}
describe("probeSource", () => {
it("returns absent when source id is not in the list", () => {
const fake = makeFakeGbrain({ sources: [{ id: "other-source", local_path: "/x" }] });
const state = probeSource("gstack-code-foo", fake.env);
expect(state.status).toBe("absent");
expect(state.registered_path).toBeUndefined();
fake.cleanup();
});
it("returns match when source id is registered (path included)", () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/Users/me/repo" }],
});
const state = probeSource("gstack-code-foo", fake.env);
expect(state.status).toBe("match");
expect(state.registered_path).toBe("/Users/me/repo");
fake.cleanup();
});
});
describe("ensureSourceRegistered", () => {
it("adds source when absent, returns changed=true", async () => {
const fake = makeFakeGbrain({ sources: [] });
const result = await ensureSourceRegistered("gstack-code-foo", "/Users/me/repo", {
federated: true,
env: fake.env,
});
expect(result.changed).toBe(true);
expect(result.state.status).toBe("match");
expect(result.state.registered_path).toBe("/Users/me/repo");
const log = readFileSync(fake.logPath, "utf-8");
expect(log).toContain("sources add gstack-code-foo --path /Users/me/repo --federated");
expect(log).not.toContain("sources remove");
fake.cleanup();
});
it("is a no-op when source is already at the correct path, returns changed=false", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/Users/me/repo" }],
});
const result = await ensureSourceRegistered("gstack-code-foo", "/Users/me/repo", { env: fake.env });
expect(result.changed).toBe(false);
expect(result.state.status).toBe("match");
const log = readFileSync(fake.logPath, "utf-8");
expect(log).toContain("sources list --json");
expect(log).not.toContain("sources add");
expect(log).not.toContain("sources remove");
fake.cleanup();
});
// Drift-path tests pin the #1734 guard inputs (inactive autopilot, allowed
// remove) so a REAL autopilot running on the dev machine can't flip them.
const guardsPinnedInactive = {
autopilotProbe: { lockPaths: [], processRunning: () => false },
removeDecision: { keepStorage: false },
} as const;
it("recreates source when path differs (gbrain has no `sources update`), returns changed=true", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
federated: true,
env: fake.env,
...guardsPinnedInactive,
});
expect(result.changed).toBe(true);
expect(result.state.status).toBe("match");
expect(result.state.registered_path).toBe("/new/path");
const log = readFileSync(fake.logPath, "utf-8");
// #1985: the remove must carry --confirm-destructive (gbrain >= 0.42 gate).
expect(log).toContain("sources remove gstack-code-foo --yes --confirm-destructive");
expect(log).toContain("sources add gstack-code-foo --path /new/path --federated");
fake.cleanup();
});
// #1985: regression. gbrain >= 0.42 refuses `sources remove` without
// --confirm-destructive. On the drift path that surfaces as "source
// registration failed" and aborts the /sync-gbrain code stage for every
// already-registered source whose path drifted. Before the fix the remove
// was issued with `--yes` only, so the guard-simulating fake rejects it and
// ensureSourceRegistered throws. The fix passes --confirm-destructive, so
// the re-register succeeds.
it("re-registers across the gbrain >= 0.42 destructive-remove guard (does not throw)", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
federated: true,
env: fake.env,
...guardsPinnedInactive,
});
expect(result.changed).toBe(true);
expect(result.state.status).toBe("match");
expect(result.state.registered_path).toBe("/new/path");
// The old source was actually removed (the guarded remove succeeded), then
// the new path was added — not left behind as a stale duplicate.
const finalState = JSON.parse(readFileSync(fake.statePath, "utf-8"));
expect(finalState.sources).toHaveLength(1);
expect(finalState.sources[0].local_path).toBe("/new/path");
fake.cleanup();
});
// #1734 tripwire: the drift remove deletes pages/chunks/embeddings, so it
// must refuse while a gbrain autopilot is active — and refuse LOUDLY (throw),
// not silently return changed=false. Before the guard routing, this path
// issued the remove unconditionally.
it("REFUSES the drift remove while autopilot is active (throws, no remove issued)", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
await expect(
ensureSourceRegistered("gstack-code-foo", "/new/path", {
env: fake.env,
autopilotProbe: { lockPaths: [], processRunning: () => true },
removeDecision: { keepStorage: false },
}),
).rejects.toThrow(/autopilot active/);
const log = readFileSync(fake.logPath, "utf-8");
expect(log).not.toContain("sources remove");
expect(log).not.toContain("sources add");
fake.cleanup();
});
it("REFUSES the drift remove when decideSourceRemove disallows (fail closed, throws)", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
await expect(
ensureSourceRegistered("gstack-code-foo", "/new/path", {
env: fake.env,
autopilotProbe: { lockPaths: [], processRunning: () => false },
// A sources-list read failure makes decideSourceRemove fail closed.
removeDecision: {
keepStorage: false,
fetchRows: () => {
throw new Error("sources list unavailable");
},
},
}),
).rejects.toThrow(/fail closed/);
const log = readFileSync(fake.logPath, "utf-8");
expect(log).not.toContain("sources remove");
fake.cleanup();
});
it("propagates decideSourceRemove extraArgs (--keep-storage) to the drift remove", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
env: fake.env,
autopilotProbe: { lockPaths: [], processRunning: () => false },
removeDecision: { keepStorage: true },
});
expect(result.changed).toBe(true);
const log = readFileSync(fake.logPath, "utf-8");
expect(log).toContain(
"sources remove gstack-code-foo --yes --confirm-destructive --keep-storage",
);
fake.cleanup();
});
// Realpath normalization: a registered path that is a symlink alias of the
// requested path is a MATCH, not drift. Declaring it drift triggers a
// destructive remove + full re-index for a no-op (#1985 reporter hit the
// remove on an unmoved repo; macOS /tmp -> /private/tmp is the usual cause).
it("does NOT declare drift when registered path is a symlink alias of the requested path", async () => {
const base = mkdtempSync(join(tmpdir(), "gbrain-sources-realpath-"));
const realDir = join(base, "real-repo");
const linkDir = join(base, "link-repo");
mkdirSync(realDir, { recursive: true });
symlinkSync(realDir, linkDir);
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: realDir }],
});
const result = await ensureSourceRegistered("gstack-code-foo", linkDir, {
env: fake.env,
...guardsPinnedInactive,
});
expect(result.changed).toBe(false);
expect(result.state.status).toBe("match");
const log = readFileSync(fake.logPath, "utf-8");
expect(log).not.toContain("sources remove");
expect(log).not.toContain("sources add");
fake.cleanup();
rmSync(base, { recursive: true, force: true });
});
it("when reregister_on_drift=false and source is at different path, returns changed=false", async () => {
const fake = makeFakeGbrain({
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
});
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
reregister_on_drift: false,
env: fake.env,
});
expect(result.changed).toBe(false);
expect(result.state.status).toBe("drift");
expect(result.state.registered_path).toBe("/old/path");
const log = readFileSync(fake.logPath, "utf-8");
expect(log).not.toContain("sources remove");
expect(log).not.toContain("sources add");
fake.cleanup();
});
});
describe("sourcePageCount", () => {
it("returns the page_count when the source is registered", () => {
const fake = makeFakeGbrain({
sources: [
{ id: "gstack-code-foo", local_path: "/x", page_count: 1247 },
{ id: "other-source", local_path: "/y", page_count: 99 },
],
});
expect(sourcePageCount("gstack-code-foo", fake.env)).toBe(1247);
expect(sourcePageCount("other-source", fake.env)).toBe(99);
fake.cleanup();
});
it("returns null when the source is absent", () => {
const fake = makeFakeGbrain({ sources: [{ id: "other", local_path: "/x", page_count: 5 }] });
expect(sourcePageCount("missing", fake.env)).toBeNull();
fake.cleanup();
});
it("returns null when page_count is missing from the source object", () => {
const fake = makeFakeGbrain({ sources: [{ id: "no-count", local_path: "/x" } as { id: string; local_path: string }] });
expect(sourcePageCount("no-count", fake.env)).toBeNull();
fake.cleanup();
});
});