Files
gstack/browse/test/gstack-config.test.ts
T

262 lines
9.4 KiB
TypeScript

/**
* Behavioral tests for the Node compatibility adapter in bin/gstack-config.
*
* 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 {
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 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',
});
return {
exitCode: result.exitCode,
stdout: result.stdout.toString().trim(),
stderr: result.stderr.toString().trim(),
};
}
function readConfig(home = stateDir) {
return JSON.parse(readFileSync(join(home, 'config.json'), 'utf8'));
}
beforeEach(() => {
stateDir = mkdtempSync(join(tmpdir(), 'gstack-config-test-'));
});
afterEach(() => {
rmSync(stateDir, { recursive: true, force: true });
});
describe('gstack-config', () => {
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');
});
});
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'],
});
});
});
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);
});
});
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');
});
});
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');
});
});
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('usage errors write stderr, not stdout', () => {
const { exitCode, stdout, stderr } = run([]);
expect(exitCode).toBe(1);
expect(stdout).toBe('');
expect(stderr).toContain('Usage: gstack-config');
});
});