|
| 1 | +#!/usr/bin/env node |
| 2 | +// Tests for degraded sibling loading in the hook entrypoints. |
| 3 | +// Covers issue #848: caveman-activate.js, caveman-mode-tracker.js and |
| 4 | +// caveman-stats.js required './caveman-config' at module top level with no |
| 5 | +// guard, so an install that is missing that one file produced an uncaught |
| 6 | +// MODULE_NOT_FOUND — a raw Node stack trace and exit 1 on EVERY session start |
| 7 | +// and EVERY prompt, reported by Claude Code as an opaque |
| 8 | +// "SessionStart:startup hook error ... node:internal/modules/cjs/loader". |
| 9 | +// Same failure class as #801 (installer copy list omitting caveman-parse.js). |
| 10 | +// |
| 11 | +// Run: node tests/test_hook_missing_sibling.js |
| 12 | + |
| 13 | +const path = require('path'); |
| 14 | +const os = require('os'); |
| 15 | +const fs = require('fs'); |
| 16 | +const assert = require('assert'); |
| 17 | +const { spawnSync } = require('child_process'); |
| 18 | + |
| 19 | +const HOOKS_DIR = path.resolve(__dirname, '..', 'src', 'hooks'); |
| 20 | +const CLEAN_EXIT = 0; |
| 21 | + |
| 22 | +let passed = 0; |
| 23 | +let failed = 0; |
| 24 | + |
| 25 | +function test(name, fn) { |
| 26 | + try { |
| 27 | + fn(); |
| 28 | + passed++; |
| 29 | + console.log(` ✓ ${name}`); |
| 30 | + } catch (e) { |
| 31 | + failed++; |
| 32 | + console.error(` ✗ ${name}`); |
| 33 | + console.error(` ${e.message}`); |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +console.log('caveman hooks: missing/broken sibling degradation\n'); |
| 38 | + |
| 39 | +// A hook dir holding real copies of every hook file, minus `omit`, plus any |
| 40 | +// `overrides` written verbatim. Copying rather than symlinking keeps require |
| 41 | +// resolution inside the temp dir, so the real src/hooks siblings can never |
| 42 | +// satisfy a require the test is trying to break. |
| 43 | +function makeHookDir(omit, overrides) { |
| 44 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-sibling-')); |
| 45 | + for (const name of fs.readdirSync(HOOKS_DIR)) { |
| 46 | + if (name === omit) continue; |
| 47 | + const src = path.join(HOOKS_DIR, name); |
| 48 | + if (fs.statSync(src).isFile()) fs.copyFileSync(src, path.join(dir, name)); |
| 49 | + } |
| 50 | + for (const [name, body] of Object.entries(overrides || {})) { |
| 51 | + fs.writeFileSync(path.join(dir, name), body); |
| 52 | + } |
| 53 | + return dir; |
| 54 | +} |
| 55 | + |
| 56 | +function run(hookDir, hookName, payload) { |
| 57 | + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-sibling-cfg-')); |
| 58 | + const res = spawnSync(process.execPath, [path.join(hookDir, hookName)], { |
| 59 | + input: payload === undefined ? '' : JSON.stringify(payload), |
| 60 | + // A stale CAVEMAN_DEFAULT_MODE in the developer's shell would otherwise |
| 61 | + // change which mode the degraded path reports. |
| 62 | + env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CAVEMAN_DEFAULT_MODE: '' }, |
| 63 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 64 | + encoding: 'utf8', |
| 65 | + }); |
| 66 | + res.configDir = configDir; |
| 67 | + return res; |
| 68 | +} |
| 69 | + |
| 70 | +function cleanup(...dirs) { |
| 71 | + for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); |
| 72 | +} |
| 73 | + |
| 74 | +// The exact shape that reached users: an unhandled require failure prints the |
| 75 | +// loader frame plus a "Require stack:" block. Neither may ever appear again. |
| 76 | +function assertNoStackTrace(res) { |
| 77 | + const err = res.stderr || ''; |
| 78 | + assert.ok(!/Require stack:/.test(err), `hook leaked a require stack:\n${err.trim()}`); |
| 79 | + assert.ok( |
| 80 | + !/node:internal\/modules\/cjs\/loader/.test(err), |
| 81 | + `hook leaked a module-loader stack trace:\n${err.trim()}` |
| 82 | + ); |
| 83 | +} |
| 84 | + |
| 85 | +// ---------- SessionStart (caveman-activate.js) ---------- |
| 86 | + |
| 87 | +test('activate: missing caveman-config.js still emits the ruleset and exits 0', () => { |
| 88 | + const dir = makeHookDir('caveman-config.js'); |
| 89 | + const res = run(dir, 'caveman-activate.js', { source: 'startup' }); |
| 90 | + try { |
| 91 | + assert.strictEqual( |
| 92 | + res.status, |
| 93 | + CLEAN_EXIT, |
| 94 | + `expected clean exit, got status=${res.status}\nstderr: ${(res.stderr || '').trim()}` |
| 95 | + ); |
| 96 | + assertNoStackTrace(res); |
| 97 | + assert.match( |
| 98 | + res.stdout || '', |
| 99 | + /CAVEMAN MODE ACTIVE/, |
| 100 | + 'degraded SessionStart must still inject the fallback ruleset' |
| 101 | + ); |
| 102 | + assert.match( |
| 103 | + res.stderr || '', |
| 104 | + /caveman-config\.js is missing from .*— the install is incomplete/, |
| 105 | + 'stderr must name the missing file and the remedy' |
| 106 | + ); |
| 107 | + } finally { |
| 108 | + cleanup(dir, res.configDir); |
| 109 | + } |
| 110 | +}); |
| 111 | + |
| 112 | +test('activate: a sibling that throws at load is reported as a load error, not a missing file', () => { |
| 113 | + const dir = makeHookDir(null, { |
| 114 | + 'caveman-config.js': 'throw new Error("boom from inside caveman-config");\n', |
| 115 | + }); |
| 116 | + const res = run(dir, 'caveman-activate.js', { source: 'startup' }); |
| 117 | + try { |
| 118 | + assert.strictEqual(res.status, CLEAN_EXIT, `expected clean exit, got status=${res.status}`); |
| 119 | + assertNoStackTrace(res); |
| 120 | + assert.match(res.stdout || '', /CAVEMAN MODE ACTIVE/); |
| 121 | + assert.match( |
| 122 | + res.stderr || '', |
| 123 | + /could not load caveman-config\.js — boom from inside caveman-config/, |
| 124 | + 'a broken sibling must not be misreported as an incomplete install' |
| 125 | + ); |
| 126 | + } finally { |
| 127 | + cleanup(dir, res.configDir); |
| 128 | + } |
| 129 | +}); |
| 130 | + |
| 131 | +test('activate: a MODULE_NOT_FOUND raised *inside* the sibling is not misreported as missing', () => { |
| 132 | + const dir = makeHookDir(null, { |
| 133 | + 'caveman-config.js': "require('./definitely-not-a-real-module');\n", |
| 134 | + }); |
| 135 | + const res = run(dir, 'caveman-activate.js', { source: 'startup' }); |
| 136 | + try { |
| 137 | + assert.strictEqual(res.status, CLEAN_EXIT, `expected clean exit, got status=${res.status}`); |
| 138 | + assertNoStackTrace(res); |
| 139 | + assert.match( |
| 140 | + res.stderr || '', |
| 141 | + /could not load caveman-config\.js — Cannot find module '\.\/definitely-not-a-real-module'/, |
| 142 | + 'nested resolution failures must surface the inner module, not "install is incomplete"' |
| 143 | + ); |
| 144 | + } finally { |
| 145 | + cleanup(dir, res.configDir); |
| 146 | + } |
| 147 | +}); |
| 148 | + |
| 149 | +test('activate: control — complete hook dir writes the flag and emits no warning', () => { |
| 150 | + const dir = makeHookDir(null); |
| 151 | + const res = run(dir, 'caveman-activate.js', { source: 'startup' }); |
| 152 | + try { |
| 153 | + assert.strictEqual(res.status, CLEAN_EXIT); |
| 154 | + assert.match(res.stdout || '', /CAVEMAN MODE ACTIVE/); |
| 155 | + assert.strictEqual( |
| 156 | + fs.readFileSync(path.join(res.configDir, '.caveman-active'), 'utf8'), |
| 157 | + 'full', |
| 158 | + 'the non-degraded path must still persist the mode flag' |
| 159 | + ); |
| 160 | + assert.ok( |
| 161 | + !/install is incomplete|could not load/.test(res.stderr || ''), |
| 162 | + `healthy install must not warn:\n${(res.stderr || '').trim()}` |
| 163 | + ); |
| 164 | + } finally { |
| 165 | + cleanup(dir, res.configDir); |
| 166 | + } |
| 167 | +}); |
| 168 | + |
| 169 | +// ---------- UserPromptSubmit (caveman-mode-tracker.js) ---------- |
| 170 | + |
| 171 | +for (const omitted of ['caveman-config.js', 'caveman-parse.js']) { |
| 172 | + test(`mode-tracker: missing ${omitted} exits 0 and emits nothing`, () => { |
| 173 | + const dir = makeHookDir(omitted); |
| 174 | + const res = run(dir, 'caveman-mode-tracker.js', { prompt: 'fix the auth bug' }); |
| 175 | + try { |
| 176 | + assert.strictEqual( |
| 177 | + res.status, |
| 178 | + CLEAN_EXIT, |
| 179 | + `expected clean exit, got status=${res.status}\nstderr: ${(res.stderr || '').trim()}` |
| 180 | + ); |
| 181 | + assertNoStackTrace(res); |
| 182 | + assert.strictEqual( |
| 183 | + (res.stdout || '').trim(), |
| 184 | + '', |
| 185 | + 'a degraded tracker must inject nothing into the prompt' |
| 186 | + ); |
| 187 | + assert.match(res.stderr || '', /install is incomplete|could not load/); |
| 188 | + } finally { |
| 189 | + cleanup(dir, res.configDir); |
| 190 | + } |
| 191 | + }); |
| 192 | +} |
| 193 | + |
| 194 | +test('mode-tracker: control — complete hook dir still reinforces an active mode', () => { |
| 195 | + const dir = makeHookDir(null); |
| 196 | + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-sibling-cfg-')); |
| 197 | + try { |
| 198 | + fs.writeFileSync(path.join(configDir, '.caveman-active'), 'full'); |
| 199 | + const res = spawnSync(process.execPath, [path.join(dir, 'caveman-mode-tracker.js')], { |
| 200 | + input: JSON.stringify({ prompt: 'fix the auth bug' }), |
| 201 | + env: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, |
| 202 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 203 | + encoding: 'utf8', |
| 204 | + }); |
| 205 | + assert.strictEqual(res.status, CLEAN_EXIT); |
| 206 | + assert.match(res.stdout || '', /CAVEMAN MODE ACTIVE \(full\)/); |
| 207 | + } finally { |
| 208 | + cleanup(dir, configDir); |
| 209 | + } |
| 210 | +}); |
| 211 | + |
| 212 | +// ---------- /caveman-stats subprocess (caveman-stats.js) ---------- |
| 213 | + |
| 214 | +test('stats: missing caveman-config.js prints one actionable line, no stack trace', () => { |
| 215 | + const dir = makeHookDir('caveman-config.js'); |
| 216 | + const res = run(dir, 'caveman-stats.js'); |
| 217 | + try { |
| 218 | + assertNoStackTrace(res); |
| 219 | + assert.notStrictEqual(res.status, CLEAN_EXIT, 'stats must report that it did not run'); |
| 220 | + assert.match( |
| 221 | + res.stderr || '', |
| 222 | + /caveman-stats: caveman-config\.js is missing from .*— the install is incomplete/ |
| 223 | + ); |
| 224 | + } finally { |
| 225 | + cleanup(dir, res.configDir); |
| 226 | + } |
| 227 | +}); |
| 228 | + |
| 229 | +console.log(`\n${passed} passed, ${failed} failed`); |
| 230 | +process.exit(failed === 0 ? 0 : 1); |
0 commit comments