mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
test(installer): add fresh-install e2e test against temp CLAUDE_CONFIG_DIR
Real install harness — writes hooks, merges settings.json, asserts on-disk state. Catches regressions a dry-run can't see (missing hooks, malformed settings, broken statusline). Five cases: - Fresh install populates hooks dir + wires SessionStart/UserPromptSubmit - Idempotent install does not duplicate hook entries - Uninstall strips caveman hooks, preserves user-authored ones - Install tolerates JSONC settings.json (#249 regression guard) - Lib-level addCommandHook idempotency (always runs, no claude CLI) Tests requiring `claude` on PATH skip cleanly with a clear reason. Uninstall test strips claude/gemini from PATH so the user's real plugin/extension state is never touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b67c404e80
commit
a2dca8194d
@@ -0,0 +1,261 @@
|
||||
// End-to-end: real fresh install against an isolated $CLAUDE_CONFIG_DIR.
|
||||
//
|
||||
// Unlike e2e.dryrun.test.mjs (which only verifies the planned output), this
|
||||
// suite actually writes hooks, merges settings.json, and asserts the on-disk
|
||||
// state. It catches regressions in the install pipeline that a dry-run can't
|
||||
// see — missing hook files, malformed settings entries, broken statusline
|
||||
// wiring, idempotency bugs, JSONC-tolerance regressions (#249-class).
|
||||
//
|
||||
// Limitations:
|
||||
// - The Claude Code provider only triggers when `claude` is on PATH. Tests
|
||||
// that depend on it skip cleanly when missing (most CI runners and dev
|
||||
// boxes won't have it). Tests that don't need `claude` (idempotence,
|
||||
// JSONC tolerance) always run.
|
||||
// - The installer's uninstall path also calls `claude plugin uninstall` and
|
||||
// `gemini extensions uninstall` against whatever binary is on PATH. We
|
||||
// strip those out of PATH in the uninstall test so the user's real
|
||||
// plugin/extension state is never touched.
|
||||
// - The plugin install step makes a network call (clones the marketplace).
|
||||
// We tolerate failure there — only the hook/settings assertions matter
|
||||
// because the hooks-installer runs regardless of plugin-install status.
|
||||
// - Each fresh-install case spawns a real `claude plugin install` (~300MB
|
||||
// of git clone). Run the test runner with `--test-concurrency=1` to
|
||||
// avoid OOM on memory-constrained CI runners.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
||||
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const requireCjs = createRequire(import.meta.url);
|
||||
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js'));
|
||||
|
||||
function freshTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-freshinstall-'));
|
||||
}
|
||||
|
||||
function pathWithout(binNames) {
|
||||
// Walk every PATH entry; drop any that contains one of the named binaries.
|
||||
// Cross-platform: works on macOS/Linux (`:` sep) and Windows (`;` sep).
|
||||
const sep = process.platform === 'win32' ? ';' : ':';
|
||||
const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
|
||||
const want = new Set(binNames);
|
||||
return (process.env.PATH || '')
|
||||
.split(sep)
|
||||
.filter(dir => {
|
||||
if (!dir) return false;
|
||||
for (const b of want) {
|
||||
for (const ext of exts) {
|
||||
try { if (fs.existsSync(path.join(dir, b + ext))) return false; } catch (_) {}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.join(sep);
|
||||
}
|
||||
|
||||
function runInstaller(args, configDir, extraEnv = {}) {
|
||||
return spawnSync('node', [INSTALLER, ...args, '--config-dir', configDir, '--non-interactive', '--no-mcp-shrink'], {
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, NO_COLOR: '1', ...extraEnv },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function hasClaudeCli() {
|
||||
// We can't import bin/install.js's hasCmd directly (CJS, not exported), but
|
||||
// a plain `command -v` / `where` shell-out is equivalent for this purpose.
|
||||
if (process.platform === 'win32') {
|
||||
return spawnSync('where', ['claude'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
return spawnSync('sh', ['-c', 'command -v claude'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
|
||||
const STATUSLINE_FILE = process.platform === 'win32'
|
||||
? 'caveman-statusline.ps1'
|
||||
: 'caveman-statusline.sh';
|
||||
|
||||
function getStatuslineCommand(settings) {
|
||||
if (!settings.statusLine) return '';
|
||||
return typeof settings.statusLine === 'string'
|
||||
? settings.statusLine
|
||||
: (settings.statusLine.command || '');
|
||||
}
|
||||
|
||||
function cavemanHookCommands(settings, event, marker) {
|
||||
return (settings.hooks?.[event] || [])
|
||||
.flatMap(e => (Array.isArray(e?.hooks) ? e.hooks : []))
|
||||
.filter(h => h && typeof h.command === 'string' && h.command.includes(marker));
|
||||
}
|
||||
|
||||
// ── Test: fresh install populates expected files ───────────────────────────
|
||||
test('fresh install populates hooks dir and settings.json (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH; the claude provider is the only path that wires hooks' }, () => {
|
||||
const dir = freshTmpDir();
|
||||
try {
|
||||
const r = runInstaller(['--only', 'claude'], dir);
|
||||
// The plugin install step may fail (network, auth) but the hooks step
|
||||
// runs regardless. We only require the hooks-side state.
|
||||
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
|
||||
|
||||
const hooks = path.join(dir, 'hooks');
|
||||
assert.ok(fs.existsSync(path.join(hooks, 'caveman-activate.js')), 'caveman-activate.js missing');
|
||||
assert.ok(fs.existsSync(path.join(hooks, 'caveman-mode-tracker.js')), 'caveman-mode-tracker.js missing');
|
||||
assert.ok(fs.existsSync(path.join(hooks, 'caveman-config.js')), 'caveman-config.js missing');
|
||||
assert.ok(fs.existsSync(path.join(hooks, 'package.json')), 'hooks/package.json (CJS marker) missing');
|
||||
assert.ok(fs.existsSync(path.join(hooks, STATUSLINE_FILE)), `${STATUSLINE_FILE} missing`);
|
||||
|
||||
// Settings merged correctly.
|
||||
const settingsPath = path.join(dir, 'settings.json');
|
||||
assert.ok(fs.existsSync(settingsPath), 'settings.json missing');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
|
||||
assert.ok(SETTINGS.hasCavemanHook(settings, 'SessionStart', 'caveman-activate'),
|
||||
'SessionStart hook missing or wrong marker');
|
||||
assert.ok(SETTINGS.hasCavemanHook(settings, 'UserPromptSubmit', 'caveman-mode-tracker'),
|
||||
'UserPromptSubmit hook missing or wrong marker');
|
||||
assert.ok(settings.statusLine, 'statusLine not set');
|
||||
assert.match(getStatuslineCommand(settings), /caveman-statusline/,
|
||||
'statusLine command does not reference caveman');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: idempotent install (run twice, no duplication) ───────────────────
|
||||
test('idempotent install does not duplicate hook entries (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => {
|
||||
const dir = freshTmpDir();
|
||||
try {
|
||||
const r1 = runInstaller(['--only', 'claude'], dir);
|
||||
assert.notEqual(r1.status, 2, `first install argv error: ${r1.stderr}`);
|
||||
const r2 = runInstaller(['--only', 'claude'], dir);
|
||||
assert.notEqual(r2.status, 2, `second install argv error: ${r2.stderr}`);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'));
|
||||
|
||||
const sessStart = cavemanHookCommands(settings, 'SessionStart', 'caveman-activate');
|
||||
assert.equal(sessStart.length, 1, `expected 1 SessionStart caveman hook, got ${sessStart.length}`);
|
||||
|
||||
const ups = cavemanHookCommands(settings, 'UserPromptSubmit', 'caveman-mode-tracker');
|
||||
assert.equal(ups.length, 1, `expected 1 UserPromptSubmit caveman hook, got ${ups.length}`);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: uninstall removes hooks, preserves unrelated entries ─────────────
|
||||
test('uninstall strips caveman hooks but preserves user-authored ones (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH; uninstall test depends on a prior real install' }, () => {
|
||||
const dir = freshTmpDir();
|
||||
try {
|
||||
// Seed user's existing settings so we can verify they survive.
|
||||
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({
|
||||
model: 'opus',
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: 'echo user-owned-hook' }] }],
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const r1 = runInstaller(['--only', 'claude'], dir);
|
||||
assert.notEqual(r1.status, 2, `install argv error: ${r1.stderr}`);
|
||||
|
||||
// Strip claude/gemini from PATH for uninstall so we don't touch the user's
|
||||
// real plugin/extension state — only file/settings cleanup runs.
|
||||
const cleanPath = pathWithout(['claude', 'gemini']);
|
||||
const r2 = runInstaller(['--uninstall'], dir, { PATH: cleanPath });
|
||||
assert.notEqual(r2.status, 2, `uninstall argv error: ${r2.stderr}`);
|
||||
|
||||
// Hook scripts deleted.
|
||||
const hooks = path.join(dir, 'hooks');
|
||||
if (fs.existsSync(hooks)) {
|
||||
for (const f of ['caveman-activate.js', 'caveman-mode-tracker.js', 'caveman-config.js', STATUSLINE_FILE]) {
|
||||
assert.equal(fs.existsSync(path.join(hooks, f)), false, `${f} should be removed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Settings cleaned up.
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'));
|
||||
// No remaining caveman-marked hooks anywhere.
|
||||
for (const ev of Object.keys(settings.hooks || {})) {
|
||||
const arr = settings.hooks[ev] || [];
|
||||
for (const e of arr) {
|
||||
for (const h of (e.hooks || [])) {
|
||||
assert.doesNotMatch(h.command || '', /caveman/, `${ev} still has caveman hook: ${h.command}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// User's pre-existing hook preserved.
|
||||
const preservedUser = cavemanHookCommands(settings, 'SessionStart', 'user-owned-hook').length > 0;
|
||||
assert.ok(preservedUser, 'user-authored SessionStart hook was wiped during uninstall');
|
||||
|
||||
// Statusline pointing at caveman should be removed.
|
||||
assert.doesNotMatch(getStatuslineCommand(settings), /caveman-statusline/,
|
||||
'caveman statusline survived uninstall');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: settings.json with JSONC comments doesn't crash (#249) ───────────
|
||||
// Regression guard: the installer used to crash here because JSON.parse can't
|
||||
// eat // or /* */. bin/lib/settings.js now strips them before merging.
|
||||
test('install tolerates JSONC settings.json (comments + trailing commas)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => {
|
||||
const dir = freshTmpDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'settings.json'),
|
||||
`// user wrote this by hand
|
||||
{
|
||||
/* keep it simple */
|
||||
"model": "opus",
|
||||
"hooks": {},
|
||||
}
|
||||
`);
|
||||
|
||||
const r = runInstaller(['--only', 'claude'], dir);
|
||||
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
|
||||
|
||||
// After install, settings.json must be strict-JSON parseable.
|
||||
const raw = fs.readFileSync(path.join(dir, 'settings.json'), 'utf8');
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => { parsed = JSON.parse(raw); }, 'settings.json must round-trip as strict JSON');
|
||||
|
||||
// User's `model` key must survive the merge.
|
||||
assert.equal(parsed.model, 'opus', 'user-authored model setting was dropped');
|
||||
|
||||
// Caveman hooks must be wired.
|
||||
assert.ok(SETTINGS.hasCavemanHook(parsed, 'SessionStart', 'caveman-activate'));
|
||||
assert.ok(SETTINGS.hasCavemanHook(parsed, 'UserPromptSubmit', 'caveman-mode-tracker'));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: idempotent re-add at the lib level (always runs, no claude needed)
|
||||
// This guards the addCommandHook idempotency promise without spawning a real
|
||||
// install — even on machines with no `claude` CLI we want this assertion.
|
||||
test('lib settings.addCommandHook is idempotent across two synthetic install passes', () => {
|
||||
const dir = freshTmpDir();
|
||||
const settingsPath = path.join(dir, 'settings.json');
|
||||
try {
|
||||
const settings = SETTINGS.readSettings(settingsPath);
|
||||
SETTINGS.addCommandHook(settings, 'SessionStart', {
|
||||
command: '"/usr/bin/node" "/abs/hooks/caveman-activate.js"',
|
||||
marker: 'caveman-activate',
|
||||
});
|
||||
SETTINGS.addCommandHook(settings, 'SessionStart', {
|
||||
command: '"/usr/bin/node" "/different/hooks/caveman-activate.js"',
|
||||
marker: 'caveman-activate',
|
||||
});
|
||||
SETTINGS.validateHookFields(settings);
|
||||
SETTINGS.writeSettings(settingsPath, settings);
|
||||
|
||||
const round = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
assert.equal(round.hooks.SessionStart.length, 1, 'addCommandHook duplicated entry');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user