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>
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
/**
|
|
* Tests for bin/gstack-config bash script.
|
|
*
|
|
* Uses Bun.spawnSync to invoke the script with temp dirs and
|
|
* GSTACK_STATE_DIR env override for full isolation.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config');
|
|
|
|
let stateDir: string;
|
|
|
|
function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
|
|
// The script resolves its state dir as GSTACK_STATE_ROOT > GSTACK_HOME >
|
|
// GSTACK_STATE_DIR > $HOME/.gstack. Strip the higher-precedence vars so a
|
|
// stray value in the harness env (another test file's leftovers, operator
|
|
// shell) can never outrank the per-test GSTACK_STATE_DIR isolation.
|
|
const env: Record<string, string | undefined> = {
|
|
...process.env,
|
|
GSTACK_STATE_DIR: stateDir,
|
|
};
|
|
delete env.GSTACK_STATE_ROOT;
|
|
delete env.GSTACK_HOME;
|
|
Object.assign(env, extraEnv); // per-test overrides always win, deliberately
|
|
|
|
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
|
|
env,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
timeout: 30_000,
|
|
});
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.toString().trim(),
|
|
stderr: result.stderr.toString().trim(),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
stateDir = mkdtempSync(join(tmpdir(), 'gstack-config-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('gstack-config', () => {
|
|
// ─── get ──────────────────────────────────────────────────
|
|
test('get on missing file returns the default, exit 0', () => {
|
|
// auto_upgrade has a default of false; get falls back to the defaults table.
|
|
const { exitCode, stdout } = run(['get', 'auto_upgrade']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toBe('false');
|
|
});
|
|
|
|
test('get unknown key on missing file returns empty, exit 1 (#2611)', () => {
|
|
// #2611: an unknown key exits 1 so `|| echo fallback` callers can fire —
|
|
// "" with exit 0 was indistinguishable from a real empty value.
|
|
const { exitCode, stdout } = run(['get', 'some_unknown_key']);
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout).toBe('');
|
|
});
|
|
|
|
test('get existing key returns value', () => {
|
|
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n');
|
|
const { exitCode, stdout } = run(['get', 'auto_upgrade']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toBe('true');
|
|
});
|
|
|
|
test('get missing key returns empty, exit 1 (#2611)', () => {
|
|
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n');
|
|
const { exitCode, stdout } = run(['get', 'nonexistent']);
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout).toBe('');
|
|
});
|
|
|
|
test('get returns last value when key appears multiple times', () => {
|
|
writeFileSync(join(stateDir, 'config.yaml'), 'foo: bar\nfoo: baz\n');
|
|
const { exitCode, stdout } = run(['get', 'foo']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toBe('baz');
|
|
});
|
|
|
|
// ─── set ──────────────────────────────────────────────────
|
|
test('set creates file and writes key on missing file', () => {
|
|
const { exitCode } = run(['set', 'auto_upgrade', 'true']);
|
|
expect(exitCode).toBe(0);
|
|
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
|
|
expect(content).toContain('auto_upgrade: true');
|
|
});
|
|
|
|
test('set appends new key to existing file', () => {
|
|
writeFileSync(join(stateDir, 'config.yaml'), 'foo: bar\n');
|
|
const { exitCode } = run(['set', 'auto_upgrade', 'true']);
|
|
expect(exitCode).toBe(0);
|
|
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
|
|
expect(content).toContain('foo: bar');
|
|
expect(content).toContain('auto_upgrade: true');
|
|
});
|
|
|
|
test('set replaces existing key in-place', () => {
|
|
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: false\n');
|
|
const { exitCode } = run(['set', 'auto_upgrade', 'true']);
|
|
expect(exitCode).toBe(0);
|
|
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
|
|
expect(content).toContain('auto_upgrade: true');
|
|
expect(content).not.toContain('auto_upgrade: false');
|
|
});
|
|
|
|
test('set creates state dir if missing', () => {
|
|
const nestedDir = join(stateDir, 'nested', 'dir');
|
|
const { exitCode } = run(['set', 'foo', 'bar'], { GSTACK_STATE_DIR: nestedDir });
|
|
expect(exitCode).toBe(0);
|
|
expect(existsSync(join(nestedDir, 'config.yaml'))).toBe(true);
|
|
});
|
|
|
|
test('brain trust policy accepts local endpoint suffix', () => {
|
|
const { exitCode, stderr } = run(['set', 'brain_trust_policy@local', 'personal']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stderr).toBe('');
|
|
expect(run(['get', 'brain_trust_policy@local']).stdout).toBe('personal');
|
|
});
|
|
|
|
// ─── list ─────────────────────────────────────────────────
|
|
test('list shows all keys', () => {
|
|
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\nupdate_check: false\n');
|
|
const { exitCode, stdout } = run(['list']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('auto_upgrade: true');
|
|
expect(stdout).toContain('update_check: false');
|
|
});
|
|
|
|
test('list on missing file shows defaults, exit 0', () => {
|
|
// list prints the active-values block with defaults for unset keys.
|
|
const { exitCode, stdout } = run(['list']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('proactive:');
|
|
expect(stdout).toContain('(default)');
|
|
});
|
|
|
|
// ─── usage ────────────────────────────────────────────────
|
|
test('no args shows usage and exits 1', () => {
|
|
const { exitCode, stdout } = run([]);
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout).toContain('Usage');
|
|
});
|
|
|
|
// ─── security: input validation ─────────────────────────
|
|
test('set rejects key with regex metacharacters', () => {
|
|
const { exitCode, stderr } = run(['set', '.*', 'value']);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain('alphanumeric');
|
|
});
|
|
|
|
test('set rejects endpoint suffix with punctuation', () => {
|
|
const { exitCode, stderr } = run(['set', 'brain_trust_policy@local-dev', 'personal']);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain('endpoint-id');
|
|
});
|
|
|
|
test('set preserves value with sed special chars', () => {
|
|
run(['set', 'test_special', 'a/b&c\\d']);
|
|
const { stdout } = run(['get', 'test_special']);
|
|
expect(stdout).toBe('a/b&c\\d');
|
|
});
|
|
|
|
// ─── annotated header ──────────────────────────────────────
|
|
test('first set writes annotated header with docs', () => {
|
|
run(['set', 'telemetry', 'off']);
|
|
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
|
|
expect(content).toContain('# gstack configuration');
|
|
expect(content).toContain('edit freely');
|
|
expect(content).toContain('proactive:');
|
|
expect(content).toContain('telemetry:');
|
|
expect(content).toContain('auto_upgrade:');
|
|
expect(content).toContain('skill_prefix:');
|
|
expect(content).toContain('routing_declined:');
|
|
expect(content).toContain('codex_reviews:');
|
|
expect(content).toContain('skip_eng_review:');
|
|
});
|
|
|
|
// ─── codex_reviews (paid-calls switch: reject-on-set, preserve existing) ──
|
|
test('codex_reviews defaults to enabled', () => {
|
|
const { exitCode, stdout } = run(['get', 'codex_reviews']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toBe('enabled');
|
|
});
|
|
|
|
test('codex_reviews accepts enabled and disabled', () => {
|
|
expect(run(['set', 'codex_reviews', 'disabled']).exitCode).toBe(0);
|
|
expect(run(['get', 'codex_reviews']).stdout).toBe('disabled');
|
|
expect(run(['set', 'codex_reviews', 'enabled']).exitCode).toBe(0);
|
|
expect(run(['get', 'codex_reviews']).stdout).toBe('enabled');
|
|
});
|
|
|
|
test('codex_reviews rejects an invalid value and preserves the existing one', () => {
|
|
run(['set', 'codex_reviews', 'disabled']);
|
|
const { exitCode, stderr } = run(['set', 'codex_reviews', 'disabledd']);
|
|
expect(exitCode).not.toBe(0); // rejected, not warn-and-default
|
|
expect(stderr).toContain('not recognized');
|
|
// existing value must be untouched — a typo never silently flips paid Codex on/off
|
|
expect(run(['get', 'codex_reviews']).stdout).toBe('disabled');
|
|
});
|
|
|
|
test('header written only once, not duplicated on second set', () => {
|
|
run(['set', 'foo', 'bar']);
|
|
run(['set', 'baz', 'qux']);
|
|
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
|
|
const headerCount = (content.match(/# gstack configuration/g) || []).length;
|
|
expect(headerCount).toBe(1);
|
|
});
|
|
|
|
test('header does not break get on commented-out keys', () => {
|
|
run(['set', 'telemetry', 'community']);
|
|
// Header contains "# telemetry: anonymous" as a comment example.
|
|
// get should return the real value, not the comment.
|
|
const { stdout } = run(['get', 'telemetry']);
|
|
expect(stdout).toBe('community');
|
|
});
|
|
|
|
test('existing config file is not overwritten with header', () => {
|
|
writeFileSync(join(stateDir, 'config.yaml'), 'existing: value\n');
|
|
run(['set', 'new_key', 'new_value']);
|
|
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
|
|
expect(content).toContain('existing: value');
|
|
expect(content).not.toContain('# gstack configuration');
|
|
});
|
|
|
|
// ─── routing_declined ──────────────────────────────────────
|
|
test('routing_declined defaults to false (not set)', () => {
|
|
const { stdout } = run(['get', 'routing_declined']);
|
|
expect(stdout).toBe('false');
|
|
});
|
|
|
|
test('routing_declined can be set and read', () => {
|
|
run(['set', 'routing_declined', 'true']);
|
|
const { stdout } = run(['get', 'routing_declined']);
|
|
expect(stdout).toBe('true');
|
|
});
|
|
|
|
test('routing_declined can be reset to false', () => {
|
|
run(['set', 'routing_declined', 'true']);
|
|
run(['set', 'routing_declined', 'false']);
|
|
const { stdout } = run(['get', 'routing_declined']);
|
|
expect(stdout).toBe('false');
|
|
});
|
|
});
|