557 lines
27 KiB
JavaScript
557 lines
27 KiB
JavaScript
// 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, /<!-- caveman-begin -->/, 'SOUL.md missing begin marker');
|
|
assert.match(soulRaw, /<!-- caveman-end -->/, '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(/<!-- caveman-begin -->/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-begin -->/, '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-begin -->/, 'caveman block survived uninstall');
|
|
assert.doesNotMatch(soulAfter, /<!-- caveman-end -->/, '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, /<!-- caveman-begin -->/, 'fenced block missing after migration');
|
|
assert.match(after, /<!-- caveman-end -->/, '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('<!-- caveman-begin -->'));
|
|
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 });
|
|
}
|
|
});
|