add gstack 2 parity and lifecycle gates

This commit is contained in:
Sinabina
2026-07-17 11:08:32 -07:00
parent b6572ebbb7
commit 9919c4cdd3
212 changed files with 29098 additions and 3845 deletions
+214 -181
View File
@@ -1,26 +1,39 @@
/**
* Tests for bin/gstack-config bash script.
* Behavioral tests for the Node compatibility adapter in bin/gstack-config.
*
* Uses Bun.spawnSync to invoke the script with temp dirs and
* GSTACK_STATE_DIR env override for full isolation.
* config.json is the sole writable authority. A legacy config.yaml remains
* read-only migration input, and every mutation must first claim GSTACK_HOME.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs';
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config');
const NODE = Bun.which('node') ?? 'node';
let stateDir: string;
function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
env: {
...process.env,
GSTACK_STATE_DIR: stateDir,
...extraEnv,
},
function environment(home = stateDir) {
return {
...process.env,
GSTACK_HOME: home,
GSTACK_STATE_ROOT: home,
GSTACK_STATE_DIR: home,
};
}
function run(args: string[] = [], home = stateDir) {
const result = Bun.spawnSync([NODE, SCRIPT, ...args], {
env: environment(home),
stdout: 'pipe',
stderr: 'pipe',
});
@@ -31,6 +44,10 @@ function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
};
}
function readConfig(home = stateDir) {
return JSON.parse(readFileSync(join(home, 'config.json'), 'utf8'));
}
beforeEach(() => {
stateDir = mkdtempSync(join(tmpdir(), 'gstack-config-test-'));
});
@@ -40,189 +57,205 @@ afterEach(() => {
});
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');
describe('defaults, get, and list', () => {
test('defaults prints the compatibility defaults without claiming the home', () => {
const { exitCode, stdout, stderr } = run(['defaults']);
expect(exitCode).toBe(0);
expect(stderr).toBe('');
expect(stdout).toContain('auto_upgrade: false');
expect(stdout).toContain('codex_reviews: enabled');
expect(stdout).toContain('proactive: true');
expect(stdout).toContain('routing_declined: false');
expect(readdirSync(stateDir)).toEqual([]);
});
test('get returns a documented default and an empty unknown value', () => {
expect(run(['get', 'auto_upgrade'])).toMatchObject({
exitCode: 0,
stdout: 'false',
stderr: '',
});
expect(run(['get', 'some_unknown_key'])).toMatchObject({
exitCode: 0,
stdout: '',
stderr: '',
});
expect(readdirSync(stateDir)).toEqual([]);
});
test('list merges and flattens stored JSON over compatibility defaults', () => {
expect(run(['set', 'telemetry', 'community']).exitCode).toBe(0);
const { exitCode, stdout, stderr } = run(['list']);
expect(exitCode).toBe(0);
expect(stderr).toBe('');
expect(stdout).toContain('network.mode: off');
expect(stdout).toContain('proactive: true');
expect(stdout).toContain('telemetry: community');
});
});
test('get unknown key on missing file returns empty, exit 0', () => {
const { exitCode, stdout } = run(['get', 'some_unknown_key']);
expect(exitCode).toBe(0);
expect(stdout).toBe('');
describe('legacy YAML migration input', () => {
test('get falls back to YAML and returns the last matching value', () => {
writeFileSync(
join(stateDir, 'config.yaml'),
'telemetry: off\ntelemetry: "community" # latest choice\n',
);
expect(run(['get', 'telemetry'])).toMatchObject({
exitCode: 0,
stdout: 'community',
});
expect(existsSync(join(stateDir, 'config.json'))).toBe(false);
});
test('list reads legacy values without mutating the YAML-only home', () => {
const yaml = 'auto_upgrade: true\nupdate_check: false\n';
writeFileSync(join(stateDir, 'config.yaml'), yaml);
const { exitCode, stdout } = run(['list']);
expect(exitCode).toBe(0);
expect(stdout).toContain('auto_upgrade: true');
expect(stdout).toContain('update_check: false');
expect(readFileSync(join(stateDir, 'config.yaml'), 'utf8')).toBe(yaml);
expect(existsSync(join(stateDir, 'config.json'))).toBe(false);
});
test('set adopts recognized legacy state, preserves YAML, and gives JSON authority', () => {
const yaml = 'telemetry: community\nproactive: false\n';
writeFileSync(join(stateDir, 'config.yaml'), yaml);
expect(run(['set', 'telemetry', 'off']).exitCode).toBe(0);
expect(readFileSync(join(stateDir, 'config.yaml'), 'utf8')).toBe(yaml);
expect(readConfig()).toMatchObject({ telemetry: 'off', proactive: false });
expect(run(['get', 'telemetry']).stdout).toBe('off');
expect(JSON.parse(readFileSync(join(stateDir, '.gstack-managed-home.json'), 'utf8'))).toMatchObject({
kind: 'gstack-managed-home',
home: stateDir,
adoptedLegacy: true,
preexistingTopLevel: ['config.yaml'],
});
});
});
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');
describe('JSON writes and managed-home ownership', () => {
test('first set claims the home and atomically commits valid config.json', () => {
expect(run(['set', 'auto_upgrade', 'true'])).toMatchObject({
exitCode: 0,
stdout: '',
stderr: '',
});
expect(readConfig()).toMatchObject({
schemaVersion: 2,
auto_upgrade: true,
});
expect(JSON.parse(readFileSync(join(stateDir, '.gstack-managed-home.json'), 'utf8'))).toMatchObject({
kind: 'gstack-managed-home',
home: stateDir,
});
expect(existsSync(join(stateDir, 'secrets.json'))).toBe(true);
expect(existsSync(join(stateDir, 'config.yaml'))).toBe(false);
expect(readdirSync(stateDir).some((name) => /\.tmp-|\.replace-/.test(name))).toBe(false);
});
test('subsequent sets replace values without losing unrelated JSON state', () => {
expect(run(['set', 'first_setting', 'first-value']).exitCode).toBe(0);
expect(run(['set', 'auto_upgrade', 'true']).exitCode).toBe(0);
expect(run(['set', 'first_setting', 'replacement']).exitCode).toBe(0);
expect(readConfig()).toMatchObject({
first_setting: 'replacement',
auto_upgrade: true,
network: { mode: 'off', consent: false, selection: null },
});
expect(readdirSync(stateDir).some((name) => /\.tmp-|\.replace-/.test(name))).toBe(false);
});
test('set creates and claims a nested GSTACK_HOME', () => {
const nested = join(stateDir, 'nested', 'state');
expect(run(['set', 'telemetry', 'anonymous'], nested).exitCode).toBe(0);
expect(readConfig(nested).telemetry).toBe('anonymous');
expect(JSON.parse(readFileSync(join(nested, '.gstack-managed-home.json'), 'utf8'))).toMatchObject({
kind: 'gstack-managed-home',
home: nested,
});
});
test('set refuses to claim an unrelated non-empty directory', () => {
writeFileSync(join(stateDir, 'user-file.txt'), 'keep me\n');
const { exitCode, stderr } = run(['set', 'telemetry', 'off']);
expect(exitCode).toBe(1);
expect(stderr).toContain('Refusing to claim a non-empty directory as managed home');
expect(readFileSync(join(stateDir, 'user-file.txt'), 'utf8')).toBe('keep me\n');
expect(existsSync(join(stateDir, 'config.json'))).toBe(false);
});
});
test('get missing key returns empty', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n');
const { exitCode, stdout } = run(['get', 'nonexistent']);
expect(exitCode).toBe(0);
expect(stdout).toBe('');
describe('key and value validation', () => {
test('set rejects keys with metacharacters before writing state', () => {
const { exitCode, stderr } = run(['set', '.*', 'value']);
expect(exitCode).toBe(1);
expect(stderr).toContain('alphanumeric');
expect(readdirSync(stateDir)).toEqual([]);
});
test('set preserves string values containing former sed metacharacters', () => {
expect(run(['set', 'test_special', 'a/b&c\\d']).exitCode).toBe(0);
expect(run(['get', 'test_special']).stdout).toBe('a/b&c\\d');
expect(readConfig().test_special).toBe('a/b&c\\d');
});
test('closed-domain values warn and store their safe fallback', () => {
const { exitCode, stderr } = run(['set', 'artifacts_sync_mode', 'bogus']);
expect(exitCode).toBe(0);
expect(stderr).toContain('not recognized');
expect(stderr).toContain('Using off');
expect(run(['get', 'artifacts_sync_mode']).stdout).toBe('off');
});
});
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');
describe('codex_reviews', () => {
test('defaults to enabled and accepts both supported values', () => {
expect(run(['get', 'codex_reviews']).stdout).toBe('enabled');
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('rejects an invalid value and preserves the existing choice', () => {
expect(run(['set', 'codex_reviews', 'disabled']).exitCode).toBe(0);
const { exitCode, stderr } = run(['set', 'codex_reviews', 'disabledd']);
expect(exitCode).toBe(1);
expect(stderr).toContain('not recognized');
expect(run(['get', 'codex_reviews']).stdout).toBe('disabled');
expect(readConfig().codex_reviews).toBe('disabled');
});
});
// ─── 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');
describe('routing_declined', () => {
test('defaults false and round-trips true then false', () => {
expect(run(['get', 'routing_declined']).stdout).toBe('false');
expect(run(['set', 'routing_declined', 'true']).exitCode).toBe(0);
expect(run(['get', 'routing_declined']).stdout).toBe('true');
expect(run(['set', 'routing_declined', 'false']).exitCode).toBe(0);
expect(run(['get', 'routing_declined']).stdout).toBe('false');
expect(readConfig().routing_declined).toBe(false);
});
});
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('usage errors write stderr, not stdout', () => {
const { exitCode, stdout, stderr } = run([]);
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);
});
// ─── 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 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');
expect(stdout).toBe('');
expect(stderr).toContain('Usage: gstack-config');
});
});