mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +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>
269 lines
9.6 KiB
TypeScript
269 lines
9.6 KiB
TypeScript
/**
|
|
* 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" | "engine-locked" | "slow";
|
|
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";
|
|
// "slow": healthy engine, cold pooler connection (#1964) — sleeps past the
|
|
// (test-lowered) probe timeout on `sources list`, then answers fine.
|
|
const sourcesBlock =
|
|
behavior === "slow"
|
|
? ` sleep 2
|
|
echo '{"sources":[]}'
|
|
exit 0`
|
|
: behavior === "ok"
|
|
? ` echo '{"sources":[]}'
|
|
exit 0`
|
|
: behavior === "engine-locked"
|
|
? ` echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2
|
|
exit 124`
|
|
: ` ${
|
|
behavior === "broken-db"
|
|
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
|
|
: 'echo "Error: malformed config.json" >&2'
|
|
}
|
|
exit 1`;
|
|
const fake = `#!/bin/sh
|
|
if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi
|
|
if [ "$1 $2" = "sources list" ]; then
|
|
${sourcesBlock}
|
|
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[],
|
|
extraEnv: Record<string, 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", timeout: 30_000 });
|
|
spawnSync("git", ["-C", env.home, "commit", "--allow-empty", "-m", "init", "-q"], {
|
|
encoding: "utf-8",
|
|
timeout: 30_000,
|
|
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`,
|
|
...extraEnv,
|
|
},
|
|
});
|
|
return {
|
|
stdout: result.stdout || "",
|
|
stderr: result.stderr || "",
|
|
exitCode: result.status ?? 1,
|
|
};
|
|
}
|
|
|
|
describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => {
|
|
it("PROCEEDS (with warning) when the engine probe times out — slow is not broken (#1964)", () => {
|
|
const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
|
|
try {
|
|
const r = runOrchestrator(env, ["--code-only"], {
|
|
GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300",
|
|
});
|
|
const out = r.stdout + r.stderr;
|
|
// The stage must NOT be skipped with the local-engine reason...
|
|
expect(out).not.toContain("local engine timeout");
|
|
expect(out).not.toContain("config.json is malformed");
|
|
// ...and the proceed-with-warning line must name the env knob.
|
|
expect(out).toContain("GSTACK_GBRAIN_PROBE_TIMEOUT_MS");
|
|
} finally {
|
|
env.cleanup();
|
|
}
|
|
}, 30_000); // proceeding runs the real code-import path against the slow fake (~11s)
|
|
|
|
it("memory stage also PROCEEDS (with warning) on probe timeout (#1964)", () => {
|
|
const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
|
|
try {
|
|
const r = runOrchestrator(env, ["--no-code", "--no-brain-sync"], {
|
|
GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300",
|
|
});
|
|
const out = r.stdout + r.stderr;
|
|
expect(out).not.toContain("local engine timeout");
|
|
expect(out).toContain("memory: engine probe timed out");
|
|
} finally {
|
|
env.cleanup();
|
|
}
|
|
}, 30_000);
|
|
|
|
it("dream stage also PROCEEDS (with warning) on probe timeout (#1964)", () => {
|
|
const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
|
|
try {
|
|
const r = runOrchestrator(
|
|
env,
|
|
["--dream", "--no-code", "--no-memory", "--no-brain-sync"],
|
|
{ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300" },
|
|
);
|
|
const out = r.stdout + r.stderr;
|
|
expect(out).not.toContain("local engine timeout");
|
|
expect(out).toContain("dream: engine probe timed out");
|
|
} finally {
|
|
env.cleanup();
|
|
}
|
|
}, 30_000);
|
|
|
|
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 with actionable guidance when PGLite is held by gbrain serve (#2194)", () => {
|
|
const env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true });
|
|
try {
|
|
const r = runOrchestrator(env, ["--code-only"]);
|
|
const out = r.stdout + r.stderr;
|
|
expect(out).toContain("local engine engine-locked");
|
|
expect(out).toContain("gbrain serve");
|
|
expect(out).toContain("outside the live Claude session");
|
|
expect(out).not.toContain("config.json is malformed");
|
|
} 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();
|
|
}
|
|
});
|
|
});
|