TEST_ENV_BASE blanks session-identity variables but none of the three that
decide WHERE a child process writes: CLAUDE_CONFIG_DIR, GSD_RUNTIME and
CODEX_HOME. The config-home resolver is env-first (runtime-homes.cts, the
dot-home case consults the env var before the home-derived fallback), so an
ambient CLAUDE_CONFIG_DIR in the developer's shell beats a call site that
sandboxes only HOME. The suite then writes into the developer's real config
directory -- including a registered skill under <configDir>/skills/ whose
body carries behavioural directives that load into later sessions.
Blank all three alongside the session-identity vars. `...env` still spreads
last, so the five call sites that already constrain these locally keep
winning with their explicit values.
TEST_ENV_BASE is re-declared in nine files, so the three lines are added
nine times rather than once. Consolidating the nine into a single exported
constant -- and fixing the TERM_SESSION / TERM_SESSION_ID drift between the
copies -- is deliberately left out of this change; see the PR body.
One call site needed adjusting. capability-state.test.cjs's
`capability state --runtime claude` CLI test passed no env at all and
compared the CHILD's resolved config dir against the PARENT process's
getGlobalConfigDir('claude'). That agreed only because the child inherited
the developer's ambient CLAUDE_CONFIG_DIR -- i.e. it passed *because of*
the leak. It now redirects both runtime homes into the sandbox and asserts
against values the test controls, so it is hermetic with the variable set
or unset.
Regression case folded into the owning module's test file rather than a new
bug-NNNN file, per scripts/lint-regression-test-names.cjs. It sets the
variable on the PARENT process, which is the actual vector; setting it in
the per-call env argument would exercise a path that was never broken.
252 lines
10 KiB
JavaScript
252 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* E2E capability-wiring tests for the assumption-delta checkpoint (#1561).
|
|
*
|
|
* Drives the real `loop render-hooks plan:pre` CLI subprocess against temp
|
|
* projects with different config values and asserts on the typed envelope's
|
|
* activeHooks — proving the capability contribution activates/deactivates by
|
|
* config (acceptance criteria #4 non-blocking advisory + #6 capability hook).
|
|
*
|
|
* CONTENT/E2E only: every test drives a real CLI subprocess. No readFileSync
|
|
* source-grep. Genuine assertions: each case asserts the SPECIFIC differing
|
|
* value (capId presence/absence), not a count (plan:pre carries other
|
|
* default-on contributions owned by other capabilities).
|
|
*/
|
|
|
|
const { describe, test, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
const TEST_ENV_BASE = {
|
|
GSD_SESSION_KEY: '',
|
|
CODEX_THREAD_ID: '',
|
|
CLAUDE_SESSION_ID: '',
|
|
CLAUDE_CODE_SSE_PORT: '',
|
|
OPENCODE_SESSION_ID: '',
|
|
GEMINI_SESSION_ID: '',
|
|
CURSOR_SESSION_ID: '',
|
|
WINDSURF_SESSION_ID: '',
|
|
TERM_SESSION_ID: '',
|
|
WT_SESSION: '',
|
|
TMUX_PANE: '',
|
|
ZELLIJ_SESSION_NAME: '',
|
|
TTY: '',
|
|
SSH_TTY: '',
|
|
// Config-LOCATION vars. Distinct in kind from the session-identity vars
|
|
// above: these decide WHERE a child writes, so leaving them ambient lets a
|
|
// test that sandboxes HOME still escape into the developer's real config dir.
|
|
CLAUDE_CONFIG_DIR: '',
|
|
GSD_RUNTIME: '',
|
|
CODEX_HOME: '',
|
|
};
|
|
|
|
function runTools(args, cwd) {
|
|
const argv = Array.isArray(args)
|
|
? args
|
|
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
|
.map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
|
|
|
|
try {
|
|
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, ...TEST_ENV_BASE },
|
|
timeout: 60000,
|
|
});
|
|
return { success: true, output: stdout.trim(), exitCode: 0, error: '' };
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
output: err.stdout?.toString().trim() || '',
|
|
error: err.stderr?.toString().trim() || err.message,
|
|
exitCode: err.status ?? 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
function makeProject(config) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-adelta-'));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify(config),
|
|
'utf8'
|
|
);
|
|
return tmpDir;
|
|
}
|
|
|
|
function planPreHooks(cwd) {
|
|
const result = runTools('loop render-hooks plan:pre --raw', cwd);
|
|
assert.ok(result.success, `render-hooks plan:pre should succeed. stderr: ${result.error}`);
|
|
const envelope = JSON.parse(result.output);
|
|
assert.strictEqual(envelope.point, 'plan:pre', 'point field must be plan:pre');
|
|
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
|
return envelope;
|
|
}
|
|
|
|
function findCap(envelope, capId) {
|
|
return envelope.activeHooks.find((h) => h.capId === capId) || null;
|
|
}
|
|
|
|
// ─── ROADMAP fixture for the scan query ──────────────────────────────────────
|
|
const ROADMAP = [
|
|
'# Roadmap',
|
|
'',
|
|
'## v1.0.0',
|
|
'',
|
|
'### Phase 1: Add a second auth method alongside passwords',
|
|
'**Goal:** Users can authenticate via SSO in addition to passwords',
|
|
'**Success Criteria**:',
|
|
'1. SSO login works',
|
|
'2. Password login still works',
|
|
'',
|
|
'### Phase 2: Refactor the parser for readability',
|
|
'**Goal:** Smaller functions, no behavior change',
|
|
'**Success Criteria**:',
|
|
'1. All existing tests still pass',
|
|
'',
|
|
].join('\n');
|
|
|
|
function makeRoadmapProject() {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-adelta-rm-'));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP, 'utf8');
|
|
return tmpDir;
|
|
}
|
|
|
|
function scanQuery(cwd, phase, extra) {
|
|
const argv = ['query', 'assumption-delta', 'scan', String(phase)];
|
|
if (extra) argv.push(...extra);
|
|
return runTools(argv, cwd);
|
|
}
|
|
|
|
describe('assumption-delta scan query — phase-section detection (#1561)', () => {
|
|
let tmpDir;
|
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
|
|
|
test('phase with a pluralization signal → detected:true', () => {
|
|
tmpDir = makeRoadmapProject();
|
|
const r = scanQuery(tmpDir, 1, ['--json']);
|
|
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
|
const parsed = JSON.parse(r.output);
|
|
assert.strictEqual(parsed.detected, true);
|
|
assert.ok(parsed.signals.some((s) => s.kind === 'pluralization'), 'phase 1 must trip pluralization');
|
|
});
|
|
|
|
test('phase with no signal → detected:false (low false-positive)', () => {
|
|
tmpDir = makeRoadmapProject();
|
|
const r = scanQuery(tmpDir, 2, ['--json']);
|
|
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
|
const parsed = JSON.parse(r.output);
|
|
assert.strictEqual(parsed.detected, false);
|
|
assert.deepStrictEqual(parsed.signals, []);
|
|
});
|
|
|
|
test('unknown phase → detected:false, no throw (graceful)', () => {
|
|
tmpDir = makeRoadmapProject();
|
|
const r = scanQuery(tmpDir, 999, ['--json']);
|
|
assert.ok(r.success, `scan should succeed on unknown phase. stderr: ${r.error}`);
|
|
assert.strictEqual(JSON.parse(r.output).detected, false);
|
|
});
|
|
|
|
test('--terms override narrows the vocabulary (custom cue fires, default cue does not)', () => {
|
|
tmpDir = makeRoadmapProject();
|
|
// phase 1 trips "second" by default; override to "xyzzy" → must NOT fire
|
|
const r = scanQuery(tmpDir, 1, ['--json', '--terms', 'xyzzy']);
|
|
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
|
assert.strictEqual(JSON.parse(r.output).detected, false);
|
|
});
|
|
|
|
test('missing phase arg → non-zero exit (usage error)', () => {
|
|
tmpDir = makeRoadmapProject();
|
|
const r = runTools(['query', 'assumption-delta', 'scan'], tmpDir);
|
|
assert.notStrictEqual(r.exitCode, 0, 'scan with no phase must exit non-zero');
|
|
});
|
|
|
|
// ── Hardening (Codex Step-4 review): malformed args ──────────────────────
|
|
test('flag-shaped phase (scan --json) → non-zero exit, not treated as phase', () => {
|
|
tmpDir = makeRoadmapProject();
|
|
const r = runTools(['query', 'assumption-delta', 'scan', '--json'], tmpDir);
|
|
assert.notStrictEqual(r.exitCode, 0, '"--json" must not be accepted as a phase number');
|
|
});
|
|
|
|
test('empty --terms restores curated defaults (detected, not disabled)', () => {
|
|
tmpDir = makeRoadmapProject();
|
|
const r = scanQuery(tmpDir, 1, ['--terms', '', '--json']);
|
|
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
|
const parsed = JSON.parse(r.output);
|
|
assert.strictEqual(parsed.detected, true, 'phase 1 must still trip defaults under empty --terms');
|
|
assert.ok(parsed.terms.pluralization.includes('second'));
|
|
});
|
|
|
|
test('flag-shaped --terms value (--terms --json) falls back to defaults, not treated as a term', () => {
|
|
tmpDir = makeRoadmapProject();
|
|
const r = scanQuery(tmpDir, 1, ['--terms', '--json']);
|
|
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
|
const parsed = JSON.parse(r.output);
|
|
// 'json' must NOT have been consumed as a pluralization cue; defaults apply.
|
|
assert.ok(!parsed.terms.pluralization.includes('json'), '"--json" must not become a trigger term');
|
|
assert.ok(parsed.terms.pluralization.includes('second'), 'defaults restored');
|
|
});
|
|
});
|
|
|
|
describe('assumption-delta capability — plan:pre render-hooks wiring (#1561)', () => {
|
|
let tmpDir;
|
|
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
|
|
|
test('default config (no toggle): assumption-delta contribution is ACTIVE', () => {
|
|
tmpDir = makeProject({});
|
|
const env = planPreHooks(tmpDir);
|
|
const hook = findCap(env, 'assumption-delta');
|
|
assert.ok(hook, 'assumption-delta contribution must be active under default config');
|
|
assert.strictEqual(hook.kind, 'contribution', 'must be a non-blocking contribution, not a gate');
|
|
assert.strictEqual(hook.into, 'planner', 'contribution injects into the planner role');
|
|
assert.strictEqual(hook.when, 'workflow.assumption_delta', 'when must gate on workflow.assumption_delta');
|
|
assert.strictEqual(hook.onError, 'skip', 'onError must be skip (advisory, never halts)');
|
|
// The fragment body must be inlined so the planner receives concrete prose.
|
|
const frag = hook.fragment && hook.fragment.inline ? hook.fragment.inline : hook.fragment;
|
|
assert.ok(typeof frag === 'string' && frag.length > 0, 'fragment must be inlined as non-empty text');
|
|
assert.ok(
|
|
/Assumption-Delta Architecture Checkpoint/i.test(frag),
|
|
'inlined fragment must carry the checkpoint heading'
|
|
);
|
|
});
|
|
|
|
test('workflow.assumption_delta=true: contribution is ACTIVE', () => {
|
|
tmpDir = makeProject({ workflow: { assumption_delta: true } });
|
|
const env = planPreHooks(tmpDir);
|
|
assert.ok(findCap(env, 'assumption-delta'), 'must be active when explicitly true');
|
|
});
|
|
|
|
test('workflow.assumption_delta=false: contribution is INACTIVE (absent from activeHooks)', () => {
|
|
tmpDir = makeProject({ workflow: { assumption_delta: false } });
|
|
const env = planPreHooks(tmpDir);
|
|
assert.strictEqual(
|
|
findCap(env, 'assumption-delta'),
|
|
null,
|
|
'must NOT appear in activeHooks when disabled — other default-on plan:pre hooks may still be present'
|
|
);
|
|
});
|
|
|
|
test('non-blocking guarantee: no assumption-delta entry is ever a blocking gate', () => {
|
|
// Advisory contract (acceptance #4): the checkpoint informs; it never blocks.
|
|
// Across both on/off states the capability must never surface as kind=gate
|
|
// with blocking=true.
|
|
tmpDir = makeProject({ workflow: { assumption_delta: true } });
|
|
const env = planPreHooks(tmpDir);
|
|
const gates = env.activeHooks.filter((h) => h.kind === 'gate');
|
|
assert.ok(
|
|
gates.every((g) => g.capId !== 'assumption-delta'),
|
|
'assumption-delta must never register a blocking gate at plan:pre'
|
|
);
|
|
});
|
|
});
|