mirror of https://github.com/garrytan/gstack.git
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
/**
|
|
* GSTACK_STATE_ROOT override — verifies the 3 plan-tune bins honor
|
|
* GSTACK_STATE_ROOT as a higher-priority override over GSTACK_HOME.
|
|
*
|
|
* Surfaced by plan-tune cathedral D16 (Codex outside voice): tests can't
|
|
* isolate from real ~/.gstack today because the bins ignore STATE_ROOT.
|
|
* Without this override, the cathedral's E2E + integration tests would
|
|
* silently pollute the user's real profile.
|
|
*
|
|
* Contract:
|
|
* - GSTACK_STATE_ROOT set → bins write under STATE_ROOT (HOME ignored).
|
|
* - Only GSTACK_HOME set → bins write under HOME (existing behavior).
|
|
* - Neither set → falls back to $HOME/.gstack (existing behavior).
|
|
* - Both set → STATE_ROOT wins.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN_LOG = path.join(ROOT, 'bin', 'gstack-question-log');
|
|
const BIN_PREF = path.join(ROOT, 'bin', 'gstack-question-preference');
|
|
const BIN_DEV = path.join(ROOT, 'bin', 'gstack-developer-profile');
|
|
|
|
let stateRoot: string;
|
|
let homeRoot: string;
|
|
|
|
beforeEach(() => {
|
|
stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-state-'));
|
|
homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(stateRoot, { recursive: true, force: true });
|
|
fs.rmSync(homeRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function runBin(
|
|
bin: string,
|
|
args: string[],
|
|
env: Record<string, string | undefined>,
|
|
): { stdout: string; stderr: string; status: number } {
|
|
const cleaned: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries({ ...process.env, ...env })) {
|
|
if (v !== undefined) cleaned[k] = v;
|
|
}
|
|
// Strip these from process.env so the override matrix is clean.
|
|
if (env.GSTACK_STATE_ROOT === undefined) delete cleaned.GSTACK_STATE_ROOT;
|
|
if (env.GSTACK_HOME === undefined) delete cleaned.GSTACK_HOME;
|
|
const res = spawnSync(bin, args, {
|
|
env: cleaned,
|
|
encoding: 'utf-8',
|
|
cwd: ROOT,
|
|
});
|
|
return {
|
|
stdout: res.stdout ?? '',
|
|
stderr: res.stderr ?? '',
|
|
status: res.status ?? -1,
|
|
};
|
|
}
|
|
|
|
const SAMPLE_LOG = {
|
|
skill: 'plan-tune',
|
|
question_id: 'state-root-test',
|
|
question_summary: 'Test STATE_ROOT honoring',
|
|
category: 'clarification',
|
|
door_type: 'two-way',
|
|
options_count: 2,
|
|
user_choice: 'a',
|
|
recommended: 'a',
|
|
session_id: 'state-root-test-session',
|
|
};
|
|
|
|
describe('gstack-question-log honors GSTACK_STATE_ROOT', () => {
|
|
test('STATE_ROOT set, HOME unset → writes under STATE_ROOT', () => {
|
|
const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], {
|
|
GSTACK_STATE_ROOT: stateRoot,
|
|
GSTACK_HOME: undefined,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
// The slug is derived from cwd; just check at least one log file exists.
|
|
const projectDirs = fs.readdirSync(path.join(stateRoot, 'projects'));
|
|
expect(projectDirs.length).toBeGreaterThanOrEqual(1);
|
|
const logPath = path.join(stateRoot, 'projects', projectDirs[0], 'question-log.jsonl');
|
|
expect(fs.existsSync(logPath)).toBe(true);
|
|
});
|
|
|
|
test('STATE_ROOT wins over HOME when both set', () => {
|
|
const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], {
|
|
GSTACK_STATE_ROOT: stateRoot,
|
|
GSTACK_HOME: homeRoot,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
// STATE_ROOT must have the file.
|
|
const stateProjects = fs.readdirSync(path.join(stateRoot, 'projects'));
|
|
expect(stateProjects.length).toBeGreaterThanOrEqual(1);
|
|
// HOME must NOT have a projects dir (or it must be empty).
|
|
const homeProjectsPath = path.join(homeRoot, 'projects');
|
|
if (fs.existsSync(homeProjectsPath)) {
|
|
const homeProjects = fs.readdirSync(homeProjectsPath);
|
|
expect(homeProjects.length).toBe(0);
|
|
}
|
|
});
|
|
|
|
test('only HOME set → preserves existing behavior (writes under HOME)', () => {
|
|
const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], {
|
|
GSTACK_STATE_ROOT: undefined,
|
|
GSTACK_HOME: homeRoot,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
const homeProjects = fs.readdirSync(path.join(homeRoot, 'projects'));
|
|
expect(homeProjects.length).toBeGreaterThanOrEqual(1);
|
|
// STATE_ROOT must NOT have anything.
|
|
const stateProjectsPath = path.join(stateRoot, 'projects');
|
|
if (fs.existsSync(stateProjectsPath)) {
|
|
expect(fs.readdirSync(stateProjectsPath).length).toBe(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gstack-question-preference honors GSTACK_STATE_ROOT', () => {
|
|
test('STATE_ROOT set → preferences file lives under STATE_ROOT', () => {
|
|
const write = runBin(
|
|
BIN_PREF,
|
|
[
|
|
'--write',
|
|
JSON.stringify({
|
|
question_id: 'state-root-pref-test',
|
|
preference: 'never-ask',
|
|
source: 'plan-tune',
|
|
}),
|
|
],
|
|
{ GSTACK_STATE_ROOT: stateRoot, GSTACK_HOME: undefined },
|
|
);
|
|
expect(write.status).toBe(0);
|
|
const projectDirs = fs.readdirSync(path.join(stateRoot, 'projects'));
|
|
expect(projectDirs.length).toBeGreaterThanOrEqual(1);
|
|
const prefPath = path.join(stateRoot, 'projects', projectDirs[0], 'question-preferences.json');
|
|
expect(fs.existsSync(prefPath)).toBe(true);
|
|
const prefs = JSON.parse(fs.readFileSync(prefPath, 'utf-8'));
|
|
expect(prefs['state-root-pref-test']).toBe('never-ask');
|
|
});
|
|
});
|
|
|
|
describe('gstack-developer-profile honors GSTACK_STATE_ROOT', () => {
|
|
test('STATE_ROOT set → profile file lives under STATE_ROOT, not HOME', () => {
|
|
// --read creates a stub profile if missing.
|
|
const r = runBin(BIN_DEV, ['--read'], {
|
|
GSTACK_STATE_ROOT: stateRoot,
|
|
GSTACK_HOME: homeRoot,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
expect(fs.existsSync(path.join(stateRoot, 'developer-profile.json'))).toBe(true);
|
|
expect(fs.existsSync(path.join(homeRoot, 'developer-profile.json'))).toBe(false);
|
|
});
|
|
});
|