// 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. // Since #392/#393, default install wires standalone hooks ONLY when the // plugin install fails (to avoid double-firing), so these tests pass // --with-hooks to force the standalone wiring path deterministically. // - 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', '--with-hooks'], dir); // The plugin install step may fail (network, auth); --with-hooks forces // the standalone hook wiring 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', '--with-hooks'], dir); assert.notEqual(r1.status, 2, `first install argv error: ${r1.stderr}`); const r2 = runInstaller(['--only', 'claude', '--with-hooks'], 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', '--with-hooks'], 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', '--with-hooks'], 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 }); } }); // ── Tests: OpenClaw workspace install (always run, no real OpenClaw needed) // installOpenclaw writes plain files into a workspace dir we point at via // OPENCLAW_WORKSPACE — no network, no external CLI, no plugin install. Safe // to run on every CI box. const SKILL_BODY_SRC = path.join(REPO_ROOT, 'skills', 'caveman', 'SKILL.md'); test('openclaw install writes skill folder + SOUL.md bootstrap', () => { const dir = freshTmpDir(); const ws = path.join(dir, 'ws'); fs.mkdirSync(ws); try { const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }, encoding: 'utf8', }); assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`); // 1. Skill body written with merged frontmatter. const skillFile = path.join(ws, 'skills', 'caveman', 'SKILL.md'); assert.ok(fs.existsSync(skillFile), 'skill SKILL.md missing'); const skillRaw = fs.readFileSync(skillFile, 'utf8'); assert.match(skillRaw, /^---\n/, 'skill missing frontmatter'); assert.match(skillRaw, /\nversion:\s*\d+\.\d+\.\d+/, 'skill missing version frontmatter'); assert.match(skillRaw, /\nalways:\s*true/, 'skill missing always: true frontmatter'); // Body after the merged frontmatter must match the source body. const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); const srcRaw = fs.readFileSync(SKILL_BODY_SRC, 'utf8'); const srcBody = helper.splitFrontmatter(srcRaw).body; const installedBody = helper.splitFrontmatter(skillRaw).body; assert.equal(installedBody, srcBody, 'installed skill body diverged from source'); // 2. SOUL.md has marker block. const soul = path.join(ws, 'SOUL.md'); assert.ok(fs.existsSync(soul), 'SOUL.md missing'); const soulRaw = fs.readFileSync(soul, 'utf8'); assert.match(soulRaw, //, 'SOUL.md missing begin marker'); assert.match(soulRaw, //, 'SOUL.md missing end marker'); assert.match(soulRaw, /Respond terse like smart caveman/, 'SOUL.md missing sentinel'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('openclaw install is idempotent: skill frontmatter not double-prepended, SOUL.md has one marker block', () => { const dir = freshTmpDir(); const ws = path.join(dir, 'ws'); fs.mkdirSync(ws); try { const env = { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }; const args = ['--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir]; spawnSync('node', [INSTALLER, ...args], { env, encoding: 'utf8' }); spawnSync('node', [INSTALLER, ...args], { env, encoding: 'utf8' }); const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8'); // version key should appear exactly once (idempotent merge). const versionMatches = skillRaw.match(/^version:/gm) || []; assert.equal(versionMatches.length, 1, `expected 1 version key after re-run, got ${versionMatches.length}`); const alwaysMatches = skillRaw.match(/^always:/gm) || []; assert.equal(alwaysMatches.length, 1, `expected 1 always key after re-run, got ${alwaysMatches.length}`); const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8'); const beginMatches = soulRaw.match(//g) || []; assert.equal(beginMatches.length, 1, `expected 1 marker block after re-run, got ${beginMatches.length}`); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('openclaw install preserves user content in SOUL.md (append, not overwrite)', () => { const dir = freshTmpDir(); const ws = path.join(dir, 'ws'); fs.mkdirSync(ws); const userContent = '# my workspace\n\nfoo bar baz\n'; fs.writeFileSync(path.join(ws, 'SOUL.md'), userContent); try { spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }, encoding: 'utf8', }); const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8'); assert.match(soulRaw, /# my workspace/, 'user heading wiped during install'); assert.match(soulRaw, /foo bar baz/, 'user content wiped during install'); assert.match(soulRaw, //, 'caveman block not appended'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('openclaw uninstall removes skill folder + strips SOUL.md block, preserving user content', () => { const dir = freshTmpDir(); const ws = path.join(dir, 'ws'); fs.mkdirSync(ws); const userContent = '# my workspace\n\nfoo bar baz\n'; fs.writeFileSync(path.join(ws, 'SOUL.md'), userContent); try { const env = { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }; spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { env, encoding: 'utf8' }); // Strip claude/gemini from PATH so uninstall doesn't touch real plugins. const cleanPath = pathWithout(['claude', 'gemini']); const r = spawnSync('node', [INSTALLER, '--uninstall', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { env: { ...env, PATH: cleanPath }, encoding: 'utf8', }); assert.notEqual(r.status, 2, `uninstall argv error: ${r.stderr}`); assert.equal(fs.existsSync(path.join(ws, 'skills', 'caveman')), false, 'skill folder should be removed'); const soulAfter = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8'); assert.doesNotMatch(soulAfter, //, 'caveman block survived uninstall'); assert.doesNotMatch(soulAfter, //, 'caveman end marker survived uninstall'); assert.match(soulAfter, /# my workspace/, 'user heading wiped during uninstall'); assert.match(soulAfter, /foo bar baz/, 'user content wiped during uninstall'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('caveman-init.js --only openclaw routes through the same helper', () => { const dir = freshTmpDir(); const ws = path.join(dir, 'ws'); fs.mkdirSync(ws); try { const initScript = path.join(REPO_ROOT, 'src', 'tools', 'caveman-init.js'); const r = spawnSync('node', [initScript, dir, '--only', 'openclaw'], { env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }, encoding: 'utf8', }); assert.equal(r.status, 0, `caveman-init failed: ${r.stderr || r.stdout}`); assert.ok(fs.existsSync(path.join(ws, 'skills', 'caveman', 'SKILL.md')), 'skill missing via init route'); assert.ok(fs.existsSync(path.join(ws, 'SOUL.md')), 'SOUL.md missing via init route'); const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8'); assert.match(soulRaw, /Respond terse like smart caveman/, 'sentinel missing via init route'); } 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 }); } }); // ── Test: --force migrates a mixed legacy AGENTS.md instead of wiping it (#594) // The old code replaced the whole file with the fenced block whenever the // legacy un-fenced sentinel was present — and the installer's own hint told // users with mixed files to run exactly that. User content must survive. test('opencode: --force on legacy AGENTS.md preserves user content and takes a backup', () => { const dir = freshTmpDir(); const xdg = path.join(dir, 'xdg'); const ocDir = path.join(xdg, 'opencode'); fs.mkdirSync(ocDir, { recursive: true }); const agentsMd = path.join(ocDir, 'AGENTS.md'); const legacyBody = fs.readFileSync( path.join(REPO_ROOT, 'src', 'rules', 'caveman-activate.md'), 'utf8').trimEnd() + '\n'; const userRules = '# My precious user rules\n\nAlways use tabs.\n'; fs.writeFileSync(agentsMd, userRules + '\n' + legacyBody); try { const r = spawnSync('node', [INSTALLER, '--only', 'opencode', '--force', '--non-interactive', '--no-mcp-shrink', '--config-dir', path.join(dir, 'claude')], { env: { ...process.env, XDG_CONFIG_HOME: xdg, NO_COLOR: '1' }, encoding: 'utf8', }); assert.notEqual(r.status, 2, `installer argv error: ${r.stderr}`); const after = fs.readFileSync(agentsMd, 'utf8'); assert.match(after, /My precious user rules/, 'user heading wiped by --force migration'); assert.match(after, /Always use tabs\./, 'user rule wiped by --force migration'); assert.match(after, //, 'fenced block missing after migration'); assert.match(after, //, 'fence end missing after migration'); // Legacy un-fenced copy must be gone: sentinel appears only inside the fence. const beforeFence = after.slice(0, after.indexOf('')); assert.doesNotMatch(beforeFence, /Respond terse like smart caveman/, 'legacy un-fenced block still present above the fence'); assert.ok(fs.existsSync(agentsMd + '.bak'), 'backup missing after --force migration'); assert.match(fs.readFileSync(agentsMd + '.bak', 'utf8'), /My precious user rules/, 'backup does not contain the original content'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); // ── Tests: SOUL.md marker damage tolerance (#596) ────────────────────────── // A stray/truncated marker used to chain into data loss: append added a // second block, then strip cut from the FIRST begin to the FIRST end — // spanning all user content in between. These drive the helper directly. test('openclaw: truncated begin marker does not eat user content (issue #596 chain)', () => { const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); const dir = freshTmpDir(); const soul = path.join(dir, 'SOUL.md'); try { // Begin marker with no end (interrupted write), then user content. fs.writeFileSync(soul, helper.MARK_BEGIN + '\n\nUSER IMPORTANT CONTENT\n'); const snippet = helper.loadBootstrapSnippet(REPO_ROOT); const a = helper.appendBootstrapToSoul(soul, snippet); assert.equal(a.changed, true); const afterAppend = fs.readFileSync(soul, 'utf8'); assert.match(afterAppend, /USER IMPORTANT CONTENT/, 'user content lost during repair-append'); assert.equal(afterAppend.split(helper.MARK_BEGIN).length - 1, 1, 'repair must leave exactly one begin marker'); const s = helper.stripBootstrapFromSoul(soul); assert.equal(s.changed, true); assert.equal(s.removed, undefined, 'file with user content must not be deleted'); const afterStrip = fs.readFileSync(soul, 'utf8'); assert.match(afterStrip, /USER IMPORTANT CONTENT/, 'user content deleted by strip — the #596 data loss'); assert.doesNotMatch(afterStrip, /caveman-begin/, 'marker survived strip'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('openclaw: strip removes multiple blocks pairwise, keeping user content between them', () => { const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); const dir = freshTmpDir(); const soul = path.join(dir, 'SOUL.md'); try { const block = helper.MARK_BEGIN + '\nrules v1\n' + helper.MARK_END; fs.writeFileSync(soul, block + '\n\nUSER KEEP ME\n\n' + block + '\n'); const s = helper.stripBootstrapFromSoul(soul); assert.equal(s.changed, true); const after = fs.readFileSync(soul, 'utf8'); assert.match(after, /USER KEEP ME/, 'user content between blocks deleted'); assert.doesNotMatch(after, /caveman-(begin|end)/, 'markers survived'); assert.doesNotMatch(after, /rules v1/, 'block bodies survived'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('openclaw: orphan end marker stripped without touching content', () => { const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); const dir = freshTmpDir(); const soul = path.join(dir, 'SOUL.md'); try { fs.writeFileSync(soul, 'before\n' + helper.MARK_END + '\nafter\n'); const s = helper.stripBootstrapFromSoul(soul); assert.equal(s.changed, true); const after = fs.readFileSync(soul, 'utf8'); assert.match(after, /before/); assert.match(after, /after/); assert.doesNotMatch(after, /caveman-end/); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('openclaw: append on a well-formed block stays a no-op', () => { const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); const dir = freshTmpDir(); const soul = path.join(dir, 'SOUL.md'); try { const snippet = helper.loadBootstrapSnippet(REPO_ROOT); helper.appendBootstrapToSoul(soul, snippet); const first = fs.readFileSync(soul, 'utf8'); const again = helper.appendBootstrapToSoul(soul, snippet); assert.equal(again.changed, false); assert.equal(fs.readFileSync(soul, 'utf8'), first, 'no-op append must not modify the file'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); // ── Test: missing `claude` CLI must be a FAILURE, not silent success (#592) // spawnSync reports ENOENT as { status: null, error }; the old // `(r.status || 0) === 0` coerced that to success, so the installer printed // "installed: claude", skipped the standalone-hook fallback, and left the // machine with nothing installed. Always runs: an empty PATH guarantees the // claude lookup fails even on machines that do have the CLI. test('missing claude CLI: reports failure and falls back to standalone hook wiring', () => { const dir = freshTmpDir(); const emptyBin = path.join(dir, 'empty-bin'); fs.mkdirSync(emptyBin); const configDir = path.join(dir, 'claude-config'); try { // process.execPath instead of 'node': the stripped PATH must not break // the test's own ability to launch the installer. const r = spawnSync(process.execPath, [ INSTALLER, '--only', 'claude', '--skip-skills', '--config-dir', configDir, '--non-interactive', '--no-mcp-shrink', ], { env: { ...process.env, PATH: emptyBin, CLAUDE_CONFIG_DIR: configDir, NO_COLOR: '1' }, encoding: 'utf8', }); const out = (r.stdout || '') + (r.stderr || ''); assert.match(out, /plugin install did not succeed; falling back to standalone wiring/, `fallback to standalone hooks did not trigger:\n${out}`); assert.match(out, /claude plugin install failed/, 'claude was not reported as failed'); assert.ok(!/• claude\n/.test(out), 'claude must not be listed as installed'); assert.ok(fs.existsSync(path.join(configDir, 'hooks', 'caveman-activate.js')), 'standalone hooks were not written'); const settings = JSON.parse(fs.readFileSync(path.join(configDir, 'settings.json'), 'utf8')); assert.ok(settings.hooks && settings.hooks.SessionStart, 'SessionStart hook not wired'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } });