Files
gstack/browse/test/gstack-config.test.ts
T
Garry Tan a5833c413f v1.57.10.0 feat: Codex review default-on across review/ship/plan/docs (#1966)
* feat(config): make codex_reviews the master switch for all Codex review

Broaden the codex_reviews doc to describe it governing /review, /ship,
/document-release, plan reviews, and /autoplan. Reject invalid values on
set (preserving the existing value) so a typo can never silently flip
paid Codex calls on or off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(review): Codex review default-on across review/ship/plan/docs

Add a shared codexPreflight() helper (constants.ts) that, in one bash
block, reads codex_reviews, sources gstack-codex-probe, checks install +
auth, and echoes a single canonical mode (ready/not_installed/not_authed/
disabled). All Codex resolvers route through it.

- generateCodexPlanReview: opt-in question removed; the outside voice now
  runs automatically (default-on), falling back to a Claude subagent when
  Codex is missing/unauthed. Cross-model tension still gates on user
  approval (sovereignty preserved).
- generateAdversarialStep: probe-based availability (install AND auth),
  distinct not-installed vs not-authed guidance; 200-line structured-review
  threshold unchanged.
- generateCodexDocReview (new, wired via CODEX_DOC_REVIEW): reviews the
  release's docs against the shipped diff range, informational + an explicit
  apply-fixes decision point, never auto-edits.
- autoplan Phase 0.5 now honors codex_reviews=disabled so the switch is
  truly global.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(docs): regenerate SKILL docs + refresh ship golden

Output of gen:skill-docs for the Codex-default-on resolver/template
changes. Refreshes the factory-ship golden fixture (codex-host output
unchanged — resolvers strip for the codex host).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(infra): widen size-budget guards for default-on Codex outside-voice

The codexPreflight() block + CODEX_MODE branch prose (replacing the
smaller opt-in question) grows plan-ceo/eng/devex-review and review by
5-7% over baseline. Each bump carries a comment justifying it as
intentional capability, not slop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: guard Codex default-on + config reject-on-set

skill-validation: assert plan reviews no longer carry the opt-in question
and render the default-on outside-voice, document-release carries the doc
review, and the codex host strips all of it.

gstack-config: codex_reviews defaults to enabled, accepts enabled/disabled,
and rejects an invalid value while preserving the existing one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): align gstack-config tests with defaults-fallback behavior

Three tests (last touched v0.13.7.0) asserted get/list print empty for
unset keys, but gstack-config falls back to the documented defaults table
(get returns the default, list shows the active-values block). Update the
assertions to the real behavior and split out an unknown-key case that does
still return empty. Pre-existing red, unrelated to codex review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v1.57.10.0 feat: Codex review default-on across review/ship/plan/docs

Codex cross-model review now runs by default on /review, /ship, all four
plan reviews, /document-release, and /autoplan, governed by one master
switch (codex_reviews, default enabled). Plan-review outside voice is
default-on; /document-release gets a new Codex doc-vs-diff audit; every
call site detects install AND auth and falls back to a Claude subagent
with a clear reason. Disable everything with:
gstack-config set codex_reviews disabled

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:14:58 -07:00

229 lines
9.2 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> = {}) {
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
env: {
...process.env,
GSTACK_STATE_DIR: stateDir,
...extraEnv,
},
stdout: 'pipe',
stderr: 'pipe',
});
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 0', () => {
const { exitCode, stdout } = run(['get', 'some_unknown_key']);
expect(exitCode).toBe(0);
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', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n');
const { exitCode, stdout } = run(['get', 'nonexistent']);
expect(exitCode).toBe(0);
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);
});
// ─── 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');
});
});