Files
caveman/tests/test_mode_tracker_stdin.js
Julius BrusseeandClaude Fable 5 7693a046ee fix(hooks): shared parser, envelope unwrap, resume-safe SessionStart
- extract mode parsing into src/hooks/caveman-parse.js, consumed by both
  the Claude tracker and the opencode plugin — fixes the three #602 drifts
  (brevity triggers missing, bogus level overwrote flag with default,
  independent modes unreachable via expanded templates)
- unwrap Claude Code's <command-name>/<command-args> slash envelope: real
  slash-UI /caveman <level> and /caveman off were silent no-ops (#537);
  foreign envelopes skip natural-language detection entirely
- SessionStart branches on payload source: startup resets to configured
  default, resume/clear/compact preserve a valid existing flag (#691)
- scheduled-task prompts (<scheduled-task marker) skip flag mutation and
  reinforcement so unattended runs aren't hijacked
- per-turn reinforcement honors repo-local defaultMode off via
  getDefaultMode(cwd) gate — read-only, never deletes the shared flag
  (#634; rejects #532's cross-session flag deletion)
- reinforcement anchor shrunk ~57%, opencode line kept identical (#660)
- statusline setup nudge shown once, gated by .caveman-nudge-shown (#661)
- /caveman-stats delivered via hookSpecificOutput.additionalContext so the
  macOS desktop app renders it (#618)
- safeWriteFlag: retry rename on Windows sharing violations, always unlink
  temp in finally — no more .caveman-active.<pid>.<ts> litter (#511 #578)
- statusline.sh exits 0 on empty suffix file — non-zero exit was hiding
  the whole status bar (#711)
- cavecrew-model-overrides resolves plugin root across layouts; env model
  overrides were a silent no-op (#645)
- opencode dev-tree loader: base require on the loaded file so
  caveman-parse's relative require resolves in both layouts

Supersedes PRs #623 #674 #700 #691 #634 #660 #661 #692 #632 #622 #657
#578 #511 #645 #590 #498 #501 with local implementations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:00:43 +02:00

260 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
// Tests for the stdin 'error' handler in caveman-mode-tracker.js.
// Covers issue #538: an abnormal stdin close (broken pipe, parent crash) emits
// an 'error' event on process.stdin; without a listener Node throws it as an
// uncaught exception and the hook exits non-zero — a spurious hook failure.
//
// Run: node tests/test_mode_tracker_stdin.js
const path = require('path');
const os = require('os');
const fs = require('fs');
const assert = require('assert');
const { spawnSync } = require('child_process');
const HOOK_PATH = path.resolve(__dirname, '..', 'src', 'hooks', 'caveman-mode-tracker.js');
const CLEAN_EXIT = 0;
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(` ✓ ${name}`);
} catch (e) {
failed++;
console.error(` ✗ ${name}`);
console.error(` ${e.message}`);
}
}
console.log('caveman-mode-tracker stdin error handling\n');
// Load the REAL hook in a child, then emit an 'error' on process.stdin to
// simulate an abnormal close. stdin is left open (never closed) so the only
// event that fires is the injected 'error' — isolating the handler under test.
function runWithStdinError() {
const harness =
`require(${JSON.stringify(HOOK_PATH)});` +
`setImmediate(() => process.stdin.emit('error', new Error('EPIPE (simulated)')));`;
return spawnSync(process.execPath, ['-e', harness], {
stdio: ['pipe', 'ignore', 'pipe'],
encoding: 'utf8',
});
}
test('stdin "error" event does not crash the hook (exit 0)', () => {
const res = runWithStdinError();
assert.strictEqual(
res.status,
CLEAN_EXIT,
`expected clean exit on stdin error, got status=${res.status} signal=${res.signal}\n` +
`stderr: ${(res.stderr || '').trim()}`
);
assert.ok(
!/Unhandled 'error' event/.test(res.stderr || ''),
`hook leaked an uncaught stdin error:\n${(res.stderr || '').trim()}`
);
});
// Regression guard: the new listener must not disturb the normal path — a valid
// prompt piped on stdin, then a clean EOF, still exits 0.
test('normal stdin (valid JSON + clean EOF) still exits 0', () => {
const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-stdin-'));
try {
const res = spawnSync(process.execPath, [HOOK_PATH], {
input: JSON.stringify({ prompt: 'hello there' }),
env: { ...process.env, CLAUDE_CONFIG_DIR: tmpConfig },
stdio: ['pipe', 'ignore', 'pipe'],
encoding: 'utf8',
});
assert.strictEqual(
res.status,
CLEAN_EXIT,
`expected clean exit on normal input, got status=${res.status}\n` +
`stderr: ${(res.stderr || '').trim()}`
);
} finally {
fs.rmSync(tmpConfig, { recursive: true, force: true });
}
});
// ---------- helpers for the tests below ----------
function makeConfigDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-'));
}
function send(configDir, payload) {
return spawnSync(process.execPath, [HOOK_PATH], {
input: JSON.stringify(payload),
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf8',
});
}
function flagValue(configDir) {
const p = path.join(configDir, '.caveman-active');
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
}
function envelope(name, args, newlines) {
const sep = newlines ? '\n' : '';
return (
`<command-message>${name.replace(/^\//, '')}</command-message>${sep}` +
`<command-name>${name}</command-name>${sep}` +
`<command-args>${args}</command-args>`
);
}
function makeSession(configDir, lines) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-sess-'));
const sessFile = path.join(dir, 's.jsonl');
fs.writeFileSync(sessFile, lines.map(l => JSON.stringify(l)).join('\n'));
return sessFile;
}
// ---------- #537: slash-command envelope unwrap ----------
test('envelope one-line form switches level', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
const r = send(cfg, { prompt: envelope('/caveman', 'lite', false) });
assert.strictEqual(flagValue(cfg), 'lite');
assert.match(r.stdout, /CAVEMAN MODE ACTIVE \(lite\)/);
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
test('envelope newline-separated form switches level', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
send(cfg, { prompt: envelope('/caveman', 'ultra', true) });
assert.strictEqual(flagValue(cfg), 'ultra');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
test('envelope "/caveman off" deactivates', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
send(cfg, { prompt: envelope('/caveman', 'off', true) });
assert.strictEqual(flagValue(cfg), null);
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
test('foreign command envelope is left untouched (no NL misfire on its args)', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
send(cfg, { prompt: envelope('/commit', 'fix the caveman parser', true) });
assert.strictEqual(flagValue(cfg), 'full', 'foreign envelope must not touch the flag');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
// ---------- bogus level must never fall through to the default ----------
test('bogus /caveman level leaves the flag unchanged', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'ultra');
send(cfg, { prompt: '/caveman not-a-real-level' });
assert.strictEqual(flagValue(cfg), 'ultra');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
// ---------- brevity trigger (#602 drift also fixed in opencode) ----------
test('brevity trigger ("be brief") activates caveman at the default mode', () => {
const cfg = makeConfigDir();
try {
send(cfg, { prompt: 'be brief' });
assert.strictEqual(flagValue(cfg), 'full');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
// ---------- scheduled-task guard ----------
test('scheduled-task prompt emits nothing while caveman active (control: normal prompt is reinforced)', () => {
const cfg = makeConfigDir();
try {
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
const scheduled = send(cfg, {
prompt: '<scheduled-task name="trigger-runner" file="/x/SKILL.md">\nAutomated run.',
});
assert.strictEqual(scheduled.status, CLEAN_EXIT);
assert.strictEqual((scheduled.stdout || '').trim(), '', 'scheduled-task run must emit no reinforcement');
assert.strictEqual(flagValue(cfg), 'full', 'scheduled-task run must not mutate the flag');
const normal = send(cfg, { prompt: 'fix the auth bug' });
assert.ok(/CAVEMAN MODE ACTIVE/.test(normal.stdout || ''), 'control prompt should be reinforced');
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
// ---------- #634: repo-local defaultMode "off" gates reinforcement only ----------
test('defaultMode off (via cwd-scoped repo config) suppresses reinforcement but leaves the flag alone', () => {
const cfg = makeConfigDir();
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-repo-'));
try {
fs.writeFileSync(path.join(repoDir, '.caveman.json'), JSON.stringify({ defaultMode: 'off' }));
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
const gated = send(cfg, { prompt: 'fix the auth bug', cwd: repoDir });
assert.strictEqual((gated.stdout || '').trim(), '', 'reinforcement must be suppressed');
assert.strictEqual(flagValue(cfg), 'full', 'gating must never touch the flag file');
// Control: same flag, no cwd override — reinforcement fires normally.
const ungated = send(cfg, { prompt: 'fix the auth bug' });
assert.ok(/CAVEMAN MODE ACTIVE/.test(ungated.stdout || ''));
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
fs.rmSync(repoDir, { recursive: true, force: true });
}
});
// ---------- #618: stats delivery via additionalContext, not decision:block ----------
test('/caveman-stats emits hookSpecificOutput.additionalContext, not decision:block', () => {
const cfg = makeConfigDir();
try {
const sess = makeSession(cfg, [
{ type: 'assistant', message: { usage: { output_tokens: 350 } } },
]);
fs.writeFileSync(path.join(cfg, '.caveman-active'), 'full');
const r = send(cfg, { prompt: '/caveman-stats', transcript_path: sess });
const parsed = JSON.parse(r.stdout);
assert.strictEqual(parsed.decision, undefined, 'old decision:block shape must be gone');
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
assert.ok(
/print this stats block verbatim/i.test(parsed.hookSpecificOutput.additionalContext),
'additionalContext must instruct the model to relay the block verbatim'
);
assert.match(parsed.hookSpecificOutput.additionalContext, /Saved 650 output tokens|Caveman Stats/);
} finally {
fs.rmSync(cfg, { recursive: true, force: true });
}
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);