mirror of https://github.com/garrytan/gstack.git
test(hermetic): fix env-restore leak, isolate machine state, gate absent capabilities
- gstack-memory-helpers.test.ts: env vars were saved in beforeEach but restored in afterAll, so the restore leaked the previous test's dead temp dir into GSTACK_HOME/GBRAIN_HOME/HOME/PATH for every file that ran later (broke gstack-config and make-pdf format-gate in full-suite runs). Capture once at collection time instead. - gstack-config.test.ts: scrub inherited GSTACK_STATE_ROOT/GSTACK_HOME in run() unless a test passes them explicitly (defense in depth). - gbrain-detect-install.test.ts: append a bun-only shim dir to SAFE_PATH — the script's '#!/usr/bin/env -S bun run' shebang needs bun on PATH, but ~/.bun/bin also carries a real gbrain on dev machines. - gen-skill-docs / openclaw-native-skills: skipIf(!Bun.YAML) — parser lands in bun 1.3; CI pins bun latest so strict-YAML coverage remains. - codex-resume-flag-semantics: availability probe upgraded from 'which codex' to 'codex --version' exit 0 (half-installed wrapper resolved but could not execute). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
25f4e1a649
commit
e60e9924e2
|
|
@ -15,12 +15,20 @@ const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config');
|
|||
let stateDir: string;
|
||||
|
||||
function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
|
||||
// The script resolves GSTACK_STATE_ROOT > GSTACK_HOME > GSTACK_STATE_DIR.
|
||||
// Scrub the two higher-priority overrides from the inherited env so a
|
||||
// leaked GSTACK_HOME/GSTACK_STATE_ROOT from another test file (or the
|
||||
// operator shell) can never redirect reads/writes away from this test's
|
||||
// temp stateDir.
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
GSTACK_STATE_DIR: stateDir,
|
||||
...extraEnv,
|
||||
};
|
||||
if (!('GSTACK_STATE_ROOT' in extraEnv)) delete env.GSTACK_STATE_ROOT;
|
||||
if (!('GSTACK_HOME' in extraEnv)) delete env.GSTACK_HOME;
|
||||
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
GSTACK_STATE_DIR: stateDir,
|
||||
...extraEnv,
|
||||
},
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,8 +15,21 @@
|
|||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
// "On PATH" is not enough: a broken install (e.g. the npm wrapper missing its
|
||||
// platform binary @openai/codex-darwin-arm64) resolves via `which` but can't
|
||||
// answer any subcommand, so every assertion would fail on wrapper stack traces
|
||||
// instead of real flag-semantics regressions. Probe `codex --version` and only
|
||||
// run when the CLI actually executes; a working codex still gets the full
|
||||
// assertions.
|
||||
const codexPath = spawnSync('which', ['codex'], { encoding: 'utf-8' }).stdout.trim();
|
||||
const codexAvailable = codexPath.length > 0;
|
||||
const codexRuns =
|
||||
codexPath.length > 0 &&
|
||||
spawnSync('codex', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10_000,
|
||||
}).status === 0;
|
||||
const codexAvailable = codexRuns;
|
||||
|
||||
describe.skipIf(!codexAvailable)(
|
||||
'codex exec resume — flag semantics (live CLI smoke; closes #1270 regex-only gap)',
|
||||
|
|
|
|||
|
|
@ -25,7 +25,15 @@ const INSTALL = path.join(ROOT, 'bin', 'gstack-gbrain-install');
|
|||
// dirs — this keeps `gbrain` out of PATH deterministically across dev machines
|
||||
// while still finding jq, git, curl, sed, cat, etc. Each test can prepend a
|
||||
// fake-gbrain dir when it wants to simulate presence.
|
||||
const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin';
|
||||
//
|
||||
// One wrinkle: bin/gstack-gbrain-detect's shebang is `#!/usr/bin/env -S bun run`,
|
||||
// so the child's PATH must contain `bun`. On dev machines bun usually lives in
|
||||
// ~/.bun/bin, which we deliberately exclude (it also holds a real `gbrain` from
|
||||
// `bun link`). Expose ONLY the running bun interpreter through a shim dir,
|
||||
// appended last so system dirs still win for everything else.
|
||||
const BUN_SHIM_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'bun-shim-'));
|
||||
fs.symlinkSync(process.execPath, path.join(BUN_SHIM_DIR, 'bun'));
|
||||
const SAFE_PATH = `/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:${BUN_SHIM_DIR}`;
|
||||
|
||||
let tmpHome: string;
|
||||
let tmpHomeReal: string;
|
||||
|
|
|
|||
|
|
@ -177,6 +177,13 @@ describe('gen-skill-docs', () => {
|
|||
// whose plain `description:` scalar contains an interior ": " (read as a nested
|
||||
// mapping). Parse EVERY generated frontmatter block with a strict YAML parser,
|
||||
// not just string-check that name:/description: exist.
|
||||
//
|
||||
// Bun.YAML landed in bun 1.3; older local bun has no in-process strict YAML
|
||||
// parser (the repo is deliberately zero-dep for YAML). CI pins
|
||||
// `bun-version: latest`, so coverage is guaranteed there — on an old local
|
||||
// bun we skip rather than false-fail.
|
||||
const hasBunYaml = typeof (Bun as any).YAML?.parse === 'function';
|
||||
|
||||
function frontmatterBlock(content: string): string {
|
||||
expect(content.startsWith('---\n')).toBe(true);
|
||||
const end = content.indexOf('\n---', 4);
|
||||
|
|
@ -184,7 +191,7 @@ describe('gen-skill-docs', () => {
|
|||
return content.slice(4, end);
|
||||
}
|
||||
|
||||
test('every generated SKILL.md frontmatter parses as strict YAML', () => {
|
||||
test.skipIf(!hasBunYaml)('every generated SKILL.md frontmatter parses as strict YAML', () => {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
const fm = frontmatterBlock(content);
|
||||
|
|
@ -196,7 +203,7 @@ describe('gen-skill-docs', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
|
||||
test.skipIf(!hasBunYaml)('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
|
||||
const agentsDir = path.join(ROOT, '.agents', 'skills');
|
||||
if (!fs.existsSync(agentsDir)) return; // skip if external hosts not generated
|
||||
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
||||
|
|
|
|||
|
|
@ -249,11 +249,14 @@ body
|
|||
// ── withErrorContext ───────────────────────────────────────────────────────
|
||||
|
||||
describe("withErrorContext", () => {
|
||||
let savedHome: string | undefined;
|
||||
// Captured ONCE at collection time, NOT in beforeEach. Saving inside
|
||||
// beforeEach clobbers the real value with the previous test's temp dir
|
||||
// after test #1, so the afterAll restore leaked a dead temp path into
|
||||
// process.env for every test file that ran after this one.
|
||||
const savedHome = process.env.GSTACK_HOME;
|
||||
let testHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
savedHome = process.env.GSTACK_HOME;
|
||||
testHome = mkdtempSync(join(tmpdir(), "gstack-test-home-"));
|
||||
process.env.GSTACK_HOME = testHome;
|
||||
});
|
||||
|
|
@ -312,18 +315,20 @@ describe("withErrorContext", () => {
|
|||
// ── detectEngineTier ───────────────────────────────────────────────────────
|
||||
|
||||
describe("detectEngineTier", () => {
|
||||
let savedHome: string | undefined;
|
||||
let savedGbrainHome: string | undefined;
|
||||
let savedRealHome: string | undefined;
|
||||
let savedPath: string | undefined;
|
||||
// Captured ONCE at collection time, NOT in beforeEach. Saving inside
|
||||
// beforeEach clobbers the real values with the previous test's temp dirs
|
||||
// after test #1, so the afterAll restore leaked dead gstack-test-engine-*
|
||||
// temp paths into GSTACK_HOME / GBRAIN_HOME / HOME for every test file
|
||||
// that ran after this one (broke gstack-config + make-pdf format-gate in
|
||||
// full-suite runs).
|
||||
const savedHome = process.env.GSTACK_HOME;
|
||||
const savedGbrainHome = process.env.GBRAIN_HOME;
|
||||
const savedRealHome = process.env.HOME;
|
||||
const savedPath = process.env.PATH;
|
||||
let testHome: string;
|
||||
let testGbrainHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
savedHome = process.env.GSTACK_HOME;
|
||||
savedGbrainHome = process.env.GBRAIN_HOME;
|
||||
savedRealHome = process.env.HOME;
|
||||
savedPath = process.env.PATH;
|
||||
testHome = mkdtempSync(join(tmpdir(), "gstack-test-engine-"));
|
||||
testGbrainHome = mkdtempSync(join(tmpdir(), "gstack-test-gbrain-"));
|
||||
process.env.GSTACK_HOME = testHome;
|
||||
|
|
|
|||
|
|
@ -18,8 +18,14 @@ function extractFrontmatter(content: string): string {
|
|||
return content.slice(4, fmEnd);
|
||||
}
|
||||
|
||||
// Bun.YAML landed in bun 1.3; older local bun has no in-process strict YAML
|
||||
// parser (the repo is deliberately zero-dep for YAML). CI pins
|
||||
// `bun-version: latest`, so coverage is guaranteed there — on an old local
|
||||
// bun we skip rather than false-fail.
|
||||
const hasBunYaml = typeof (Bun as any).YAML?.parse === 'function';
|
||||
|
||||
describe('OpenClaw native skills', () => {
|
||||
test('frontmatter parses as YAML and keeps only name + description', () => {
|
||||
test.skipIf(!hasBunYaml)('frontmatter parses as YAML and keeps only name + description', () => {
|
||||
for (const skill of OPENCLAW_NATIVE_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||||
const frontmatter = extractFrontmatter(content);
|
||||
|
|
|
|||
Loading…
Reference in New Issue