mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
- gstack-settings-hook-schema-aware: 16 new cases — identity re-point (tag
restore), foreign-basename rejection, mixed-entry per-item safety for
add-event/remove-source/--all, prune-stale modes incl. bash-prefix +
Windows-backslash + spaced-path idempotence, duplicate collapse preferring
the tagged twin, plan_tune_hooks:no split, backup-on-change no-churn,
fail-closed corrupt-JSON for every mutator, stale-lock takeover,
fresh-foreign-lock skip, two-writer concurrency smoke, and an INCIDENT
FACSIMILE replaying the exact 2026-08-17 production damage (6/3/2 entries,
mixed tags, live-ephemeral Stop) healing to 2/1/1 canonical.
- NEW setup-hook-canonical-paths: static tripwires — canonical-only resolver
(no $SOURCE_GSTACK_DIR anywhere in it), heal-before-guards ordering,
unsuppressed heal output, ${VAR:-0} counter idiom, shared-prelude
concatenation at every bun call site, KNOWN_HOOKS completeness vs setup's
registrations, uninstall cleanup-before-deletion ordering, defect-class
warning present.
- setup-plan-tune-hooks-noninteractive: PT_EXPLICIT pins + `gstack-config
has` provenance + has-subcommand behavior (env-resolution, malformed keys).
- auq-error-fallback-hook: registration + both-teardown wiring (previously
untested).
- uninstall: behavioral ordering test running the INSTALLED copy from inside
the root it deletes.
- setup-windows-fallback / gstack-config-key-locale: pins updated for the new
HOOK_CMD shape and the third C-locale validator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
193 lines
8.2 KiB
TypeScript
193 lines
8.2 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { execSync } from 'child_process';
|
|
|
|
// Regression guard for the conductor/workspace setup hang:
|
|
// `./setup` used a blocking `read -r` to ask "Install both hooks now? [y/N]".
|
|
// When setup runs under a forwarded/automated TTY (conductor workspace setup,
|
|
// CI with a pty) the read blocked forever. The fix moves the decision into
|
|
// flags + env + saved config with a non-blocking, time-bounded prompt fallback.
|
|
//
|
|
// These are static + binary-level assertions (free, <1s) — they lock in the
|
|
// contract without running the full (environment-mutating) setup script.
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const SETUP = path.join(ROOT, 'setup');
|
|
const GSTACK_CONFIG = path.join(ROOT, 'bin', 'gstack-config');
|
|
|
|
const setupSrc = fs.readFileSync(SETUP, 'utf-8');
|
|
|
|
describe('setup: plan-tune hooks are non-interactive-safe', () => {
|
|
test('exposes --plan-tune-hooks / --no-plan-tune-hooks / =value flags', () => {
|
|
expect(setupSrc).toContain('--plan-tune-hooks)');
|
|
expect(setupSrc).toContain('--no-plan-tune-hooks)');
|
|
expect(setupSrc).toContain('--plan-tune-hooks=*)');
|
|
});
|
|
|
|
test('resolution falls through env then saved config', () => {
|
|
expect(setupSrc).toContain('GSTACK_PLAN_TUNE_HOOKS');
|
|
expect(setupSrc).toContain('get plan_tune_hooks');
|
|
});
|
|
|
|
test('explicit yes/no decisions never reach a prompt', () => {
|
|
// The yes/no branches must short-circuit before the interactive branch.
|
|
const yesIdx = setupSrc.indexOf('PT_DECISION" = "yes"');
|
|
const noIdx = setupSrc.indexOf('PT_DECISION" = "no"');
|
|
const promptIdx = setupSrc.indexOf('Install both hooks now?');
|
|
expect(yesIdx).toBeGreaterThan(-1);
|
|
expect(noIdx).toBeGreaterThan(-1);
|
|
expect(yesIdx).toBeLessThan(promptIdx);
|
|
expect(noIdx).toBeLessThan(promptIdx);
|
|
});
|
|
|
|
test('the interactive prompt is time-bounded (cannot hang)', () => {
|
|
// No bare blocking read for the plan-tune reply.
|
|
expect(setupSrc).not.toMatch(/read -r PLAN_TUNE_INSTALL_REPLY\b/);
|
|
// It must use a timed read from the controlling tty with an empty fallback.
|
|
// The timeout may be a literal or a named variable (e.g. "$_PT_PROMPT_TIMEOUT").
|
|
expect(setupSrc).toMatch(/read -t (?:\d+|"?\$\{?\w+\}?"?) -r PLAN_TUNE_INSTALL_REPLY <\/dev\/tty/);
|
|
});
|
|
|
|
test('interactive prompt is gated on a real TTY and non-quiet', () => {
|
|
// The prompt branch requires both stdin+stdout TTYs and not --quiet.
|
|
expect(setupSrc).toMatch(/\[ "\$QUIET" -ne 1 \] && \[ -t 0 \] && \[ -t 1 \]/);
|
|
});
|
|
|
|
test('decision input is normalized (lowercase + whitespace-stripped)', () => {
|
|
// "YES" / " yes" from a flag/env must not silently downgrade to skip.
|
|
expect(setupSrc).toMatch(/tr '\[:upper:\]' '\[:lower:\]'/);
|
|
expect(setupSrc).toMatch(/PT_DECISION=\$\(printf .* tr/);
|
|
});
|
|
});
|
|
|
|
describe('dev-setup: never silently mutates global settings.json', () => {
|
|
const DEV_SETUP = path.join(ROOT, 'bin', 'dev-setup');
|
|
const devSetupSrc = fs.readFileSync(DEV_SETUP, 'utf-8');
|
|
|
|
test('runs setup with stdin detached AND --plan-tune-hooks=prompt pin', () => {
|
|
// stdin alone only suppresses the prompt branch; the flag (highest
|
|
// precedence) is what stops a saved `plan_tune_hooks: yes` / env opt-in
|
|
// from rewriting global hooks to the ephemeral worktree path.
|
|
expect(devSetupSrc).toMatch(/setup" --plan-tune-hooks=prompt <\/dev\/null/);
|
|
});
|
|
});
|
|
|
|
describe('setup: PT_EXPLICIT provenance (Conductor auto-opt-in respects explicit decisions)', () => {
|
|
// The phantom-hooks root cause (Bug A): the Conductor auto-opt-in upgraded
|
|
// PT_DECISION "prompt" → "yes" even when "prompt" came from dev-setup's
|
|
// EXPLICIT --plan-tune-hooks=prompt flag, so every new Conductor workspace
|
|
// installed hooks pointing at its ephemeral worktree.
|
|
|
|
test('flag and env set PT_EXPLICIT=1', () => {
|
|
expect(setupSrc).toContain('PT_EXPLICIT=1');
|
|
const flagIdx = setupSrc.indexOf('PT_DECISION="$PLAN_TUNE_HOOKS_MODE"');
|
|
const explicitIdx = setupSrc.indexOf('PT_EXPLICIT=1', flagIdx);
|
|
expect(flagIdx).toBeGreaterThan(-1);
|
|
expect(explicitIdx).toBeGreaterThan(flagIdx);
|
|
});
|
|
|
|
test('the Conductor auto-opt-in fires only on the true silent fall-through', () => {
|
|
expect(setupSrc).toMatch(
|
|
/\[ "\$PT_DECISION" = "prompt" \] && \[ "\$PT_EXPLICIT" -eq 0 \] && \{ \[ -n "\$\{CONDUCTOR_WORKSPACE_PATH:-\}" \] \|\| \[ -n "\$\{CONDUCTOR_PORT:-\}" \]; \}/,
|
|
);
|
|
});
|
|
|
|
test('config provenance uses gstack-config has (env-resolution-safe), never a hardcoded config grep', () => {
|
|
// `gstack-config get` returns the default "prompt" for absent keys, so
|
|
// key PRESENCE must come from `has`, which resolves GSTACK_STATE_ROOT /
|
|
// GSTACK_HOME / GSTACK_STATE_DIR the same way `get` does. A hardcoded
|
|
// grep of ~/.gstack/config.yaml misclassifies under env overrides.
|
|
expect(setupSrc).toMatch(/"\$GSTACK_CONFIG" has plan_tune_hooks/);
|
|
expect(setupSrc).not.toMatch(/grep -q ["']\^plan_tune_hooks:/);
|
|
});
|
|
});
|
|
|
|
describe('gstack-config: has subcommand (key-presence provenance)', () => {
|
|
let tmpHome2: string;
|
|
let env2: NodeJS.ProcessEnv;
|
|
|
|
beforeAll(() => {
|
|
tmpHome2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-has-'));
|
|
env2 = { ...process.env, GSTACK_STATE_ROOT: tmpHome2 };
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpHome2, { recursive: true, force: true });
|
|
});
|
|
|
|
function has(key: string): number {
|
|
try {
|
|
execSync(`${GSTACK_CONFIG} has '${key}'`, { encoding: 'utf-8', env: env2 });
|
|
return 0;
|
|
} catch (e: any) {
|
|
return e.status ?? 1;
|
|
}
|
|
}
|
|
|
|
test('absent key exits nonzero even though get returns the default', () => {
|
|
expect(has('plan_tune_hooks')).not.toBe(0);
|
|
const got = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, { encoding: 'utf-8', env: env2 }).trim();
|
|
expect(got).toBe('prompt'); // default — indistinguishable from a saved value via get
|
|
});
|
|
|
|
test('present key exits 0 through the same STATE_DIR resolution as get', () => {
|
|
execSync(`${GSTACK_CONFIG} set plan_tune_hooks no`, { encoding: 'utf-8', env: env2 });
|
|
expect(has('plan_tune_hooks')).toBe(0);
|
|
// GSTACK_STATE_ROOT was the writer — a hardcoded ~/.gstack grep would miss it.
|
|
});
|
|
|
|
test('rejects malformed keys', () => {
|
|
expect(has('bad key$(touch /tmp/pwned)')).not.toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('gstack-config: plan_tune_hooks key', () => {
|
|
// Isolate state: gstack-config reads $GSTACK_HOME/config.yaml. Point it at a
|
|
// fresh temp dir so `get` returns the built-in default rather than whatever
|
|
// the host machine has in ~/.gstack/config.yaml (which would make the
|
|
// default-value assertion non-deterministic).
|
|
let tmpHome: string;
|
|
let env: NodeJS.ProcessEnv;
|
|
|
|
beforeAll(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-test-'));
|
|
env = { ...process.env, GSTACK_HOME: tmpHome };
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
test('default is "prompt"', () => {
|
|
const out = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, {
|
|
encoding: 'utf-8',
|
|
env,
|
|
}).trim();
|
|
expect(out).toBe('prompt');
|
|
});
|
|
|
|
test('appears in defaults and list output', () => {
|
|
const defaults = execSync(`${GSTACK_CONFIG} defaults`, { encoding: 'utf-8', env });
|
|
expect(defaults).toContain('plan_tune_hooks');
|
|
const list = execSync(`${GSTACK_CONFIG} list`, { encoding: 'utf-8', env });
|
|
expect(list).toContain('plan_tune_hooks');
|
|
});
|
|
|
|
test('accepts valid values (round-trips yes/no/prompt)', () => {
|
|
for (const v of ['yes', 'no', 'prompt']) {
|
|
execSync(`${GSTACK_CONFIG} set plan_tune_hooks ${v}`, { encoding: 'utf-8', env });
|
|
const got = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, { encoding: 'utf-8', env }).trim();
|
|
expect(got).toBe(v);
|
|
}
|
|
});
|
|
|
|
test('rejects out-of-domain values (warns + falls back to prompt)', () => {
|
|
const res = execSync(`${GSTACK_CONFIG} set plan_tune_hooks maybe 2>&1`, { encoding: 'utf-8', env });
|
|
expect(res.toLowerCase()).toContain('not recognized');
|
|
const got = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, { encoding: 'utf-8', env }).trim();
|
|
expect(got).toBe('prompt');
|
|
});
|
|
});
|