mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38: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>
172 lines
7.6 KiB
TypeScript
172 lines
7.6 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { spawnSync } from 'child_process';
|
|
import * as path from 'path';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-paths');
|
|
|
|
// Invoke via `bash` rather than executing the shebang-script directly.
|
|
// On Windows, spawnSync(scriptPath, ...) goes through CreateProcess, which
|
|
// doesn't parse `#!/usr/bin/env bash`. Production usage always sources the
|
|
// helper from inside a bash block (`eval "$(~/.claude/skills/gstack/bin/gstack-paths)"`)
|
|
// so bash is always the executor — this matches that contract.
|
|
//
|
|
// USERPROFILE: '' is a Windows-specific override. Git Bash auto-populates
|
|
// HOME from USERPROFILE at shell startup if HOME is unset/empty, which
|
|
// silently breaks the "HOME unset" test scenarios. Clearing USERPROFILE
|
|
// alongside HOME prevents that auto-population on Windows runners.
|
|
function run(env: Record<string, string | undefined>): Record<string, string> {
|
|
const result = spawnSync('bash', [BIN], {
|
|
env: { PATH: process.env.PATH, USERPROFILE: '', ...env } as Record<string, string>,
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(`gstack-paths failed (status ${result.status}): ${result.stderr}`);
|
|
}
|
|
const out: Record<string, string> = {};
|
|
for (const line of result.stdout.split('\n')) {
|
|
const eq = line.indexOf('=');
|
|
if (eq > 0) out[line.slice(0, eq)] = line.slice(eq + 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe('gstack-paths', () => {
|
|
test('GSTACK_HOME wins over CLAUDE_PLUGIN_DATA and HOME', () => {
|
|
const got = run({
|
|
GSTACK_HOME: '/tmp/explicit-state',
|
|
CLAUDE_PLUGIN_DATA: '/tmp/plugin-data',
|
|
HOME: '/tmp/home',
|
|
});
|
|
expect(got.GSTACK_STATE_ROOT).toBe('/tmp/explicit-state');
|
|
});
|
|
|
|
test('CLAUDE_PLUGIN_DATA ignored when CLAUDE_PLUGIN_ROOT is absent or non-gstack', () => {
|
|
// Without CLAUDE_PLUGIN_ROOT, falls through to HOME path.
|
|
const noRoot = run({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data', HOME: '/tmp/home' });
|
|
expect(noRoot.GSTACK_STATE_ROOT).toBe('/tmp/home/.gstack');
|
|
|
|
// With a CLAUDE_PLUGIN_ROOT that doesn't contain "gstack" (e.g. the codex plugin),
|
|
// still falls through to HOME path — this is the cross-plugin contamination scenario.
|
|
const wrongRoot = run({
|
|
CLAUDE_PLUGIN_DATA: '/tmp/codex-data',
|
|
CLAUDE_PLUGIN_ROOT: '/tmp/openai-codex',
|
|
HOME: '/tmp/home',
|
|
});
|
|
expect(wrongRoot.GSTACK_STATE_ROOT).toBe('/tmp/home/.gstack');
|
|
});
|
|
|
|
test('CLAUDE_PLUGIN_DATA respected when CLAUDE_PLUGIN_ROOT identifies gstack', () => {
|
|
const got = run({
|
|
CLAUDE_PLUGIN_DATA: '/tmp/gstack-plugin-data',
|
|
CLAUDE_PLUGIN_ROOT: '/tmp/gstack-garrytan',
|
|
HOME: '/tmp/home',
|
|
});
|
|
expect(got.GSTACK_STATE_ROOT).toBe('/tmp/gstack-plugin-data');
|
|
});
|
|
|
|
test('HOME-derived state root when GSTACK_HOME and CLAUDE_PLUGIN_DATA unset', () => {
|
|
const got = run({ HOME: '/tmp/myhome' });
|
|
expect(got.GSTACK_STATE_ROOT).toBe('/tmp/myhome/.gstack');
|
|
});
|
|
|
|
test('CWD fallback when HOME also unset (container env)', () => {
|
|
// Skip on Windows: Git Bash auto-derives HOME from USERPROFILE,
|
|
// HOMEDRIVE, and HOMEPATH at shell startup. Even with all three
|
|
// cleared, bash falls back to /c/Users/<user>. The container env
|
|
// (HOME genuinely unset) is unreachable on Windows runners. The bash
|
|
// script's CWD fallback IS correct — exercised on Linux/Mac CI.
|
|
if (process.platform === 'win32') return;
|
|
const got = run({ HOME: '' });
|
|
expect(got.GSTACK_STATE_ROOT).toBe('.gstack');
|
|
});
|
|
|
|
test('PLAN_ROOT chain: GSTACK_PLAN_DIR > CLAUDE_PLANS_DIR > HOME > CWD', () => {
|
|
expect(run({ GSTACK_PLAN_DIR: '/tmp/explicit', HOME: '/h' }).PLAN_ROOT).toBe('/tmp/explicit');
|
|
expect(run({ CLAUDE_PLANS_DIR: '/tmp/claude', HOME: '/h' }).PLAN_ROOT).toBe('/tmp/claude');
|
|
expect(run({ HOME: '/tmp/myhome' }).PLAN_ROOT).toBe('/tmp/myhome/.claude/plans');
|
|
// CWD fallback only verifiable on POSIX — Git Bash auto-populates HOME.
|
|
if (process.platform !== 'win32') {
|
|
expect(run({ HOME: '' }).PLAN_ROOT).toBe('.claude/plans');
|
|
}
|
|
});
|
|
|
|
test('TMP_ROOT chain: TMPDIR > TMP > .gstack/tmp', () => {
|
|
expect(run({ TMPDIR: '/tmp/x', HOME: '/h' }).TMP_ROOT).toBe('/tmp/x');
|
|
expect(run({ TMP: '/tmp/y', HOME: '/h' }).TMP_ROOT).toBe('/tmp/y');
|
|
expect(run({ HOME: '' }).TMP_ROOT).toBe('.gstack/tmp');
|
|
});
|
|
|
|
test('emits all three exports on every invocation', () => {
|
|
const got = run({ HOME: '/tmp/h' });
|
|
expect(got).toHaveProperty('GSTACK_STATE_ROOT');
|
|
expect(got).toHaveProperty('PLAN_ROOT');
|
|
expect(got).toHaveProperty('TMP_ROOT');
|
|
});
|
|
|
|
// Regression: values must survive `eval "$(gstack-paths)"`, which is the
|
|
// documented calling convention. A bare `echo` emits an unquoted RHS, so eval
|
|
// re-parses it: backslashes become escapes and spaces become word separators.
|
|
// On Windows $TMP is always a backslash path, so every skill that then runs
|
|
// mktemp "$TMP_ROOT/..." fails and the bash block dies before doing any work.
|
|
// These run identically on POSIX — the values are just strings.
|
|
function evalRoundTrip(env: Record<string, string | undefined>, varName: string): string {
|
|
const result = spawnSync(
|
|
'bash',
|
|
['-c', `eval "$(bash "$1")"; printf '%s' "\${${varName}}"`, 'sh', BIN],
|
|
{
|
|
env: { PATH: process.env.PATH, USERPROFILE: '', ...env } as Record<string, string>,
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`eval round-trip failed (status ${result.status}): ${result.stderr}`);
|
|
}
|
|
return result.stdout;
|
|
}
|
|
|
|
// Values are POSIX-shaped on purpose: MSYS/Git Bash rewrites `C:\...` env
|
|
// values to `/c/...` before bash sees them, so a literal Windows path would
|
|
// assert the translation layer rather than the quoting. A backslash is a
|
|
// backslash to eval either way, which is the behavior under test.
|
|
test('eval round-trip preserves backslashes (#2374)', () => {
|
|
// Skip on Windows: MSYS also rewrites backslashes to forward slashes in
|
|
// env values, so a literal backslash cannot be injected through the
|
|
// environment on a Git Bash runner. The escape-eating this guards against
|
|
// is pure eval semantics, so exercising it on Linux/macOS CI is sufficient
|
|
// — same reasoning as the HOME-unset skips above.
|
|
if (process.platform === 'win32') return;
|
|
const backslashed = '/tmp/back\\slash/dir';
|
|
expect(evalRoundTrip({ TMPDIR: backslashed, HOME: '/h' }, 'TMP_ROOT')).toBe(backslashed);
|
|
});
|
|
|
|
test('eval round-trip preserves spaces (#2374)', () => {
|
|
// Bare echo made eval word-split this, leaving the variable empty and
|
|
// emitting `<second-word>: command not found`.
|
|
const spaced = '/tmp/two words/dir';
|
|
expect(evalRoundTrip({ TMPDIR: spaced, HOME: '/h' }, 'TMP_ROOT')).toBe(spaced);
|
|
});
|
|
|
|
test('eval round-trip preserves quotes, and leaves plain paths alone (#2374)', () => {
|
|
expect(evalRoundTrip({ TMPDIR: "/tmp/o'brien", HOME: '/h' }, 'TMP_ROOT')).toBe("/tmp/o'brien");
|
|
expect(evalRoundTrip({ GSTACK_HOME: '/tmp/state root' }, 'GSTACK_STATE_ROOT')).toBe(
|
|
'/tmp/state root',
|
|
);
|
|
expect(evalRoundTrip({ HOME: '/tmp/myhome' }, 'PLAN_ROOT')).toBe('/tmp/myhome/.claude/plans');
|
|
});
|
|
|
|
test('output is shell-evalable: only KEY=VALUE lines, no extra prose', () => {
|
|
const result = spawnSync('bash', [BIN], {
|
|
env: { PATH: process.env.PATH, USERPROFILE: '', HOME: '/tmp/h' } as Record<string, string>,
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
const lines = result.stdout.split('\n').filter(Boolean);
|
|
for (const line of lines) {
|
|
expect(line).toMatch(/^[A-Z_]+=.*/);
|
|
}
|
|
});
|
|
});
|