177 lines
7.4 KiB
JavaScript
177 lines
7.4 KiB
JavaScript
// Unit tests for the argv parser embedded in bin/install.js.
|
|
// We don't import parseArgs (it's not exported) — instead we shell out to the
|
|
// installer with --help / --list / unknown flags and assert the framing.
|
|
// For deeper coverage of flag-resolution semantics, exec --dry-run --list and
|
|
// check the rendered defaults.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js');
|
|
|
|
function run(...args) {
|
|
return spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8' });
|
|
}
|
|
|
|
test('--help prints usage and exits 0', () => {
|
|
const r = run('--help');
|
|
assert.equal(r.status, 0);
|
|
assert.match(r.stdout, /USAGE/);
|
|
assert.match(r.stdout, /--with-hooks/);
|
|
});
|
|
|
|
test('--list prints provider matrix', () => {
|
|
const r = run('--list');
|
|
assert.equal(r.status, 0);
|
|
assert.match(r.stdout, /caveman provider matrix/);
|
|
assert.match(r.stdout, /claude\b/);
|
|
assert.match(r.stdout, /gemini\b/);
|
|
assert.match(r.stdout, /antigravity\b.*\(soft\)/);
|
|
});
|
|
|
|
test('unknown flag exits 2 with error', () => {
|
|
const r = run('--bogus');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /unknown flag/);
|
|
});
|
|
|
|
test('--all + --minimal mutually exclusive', () => {
|
|
const r = run('--all', '--minimal');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /mutually exclusive/);
|
|
});
|
|
|
|
test('--only without arg fails', () => {
|
|
const r = run('--only');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /--only requires an argument/);
|
|
});
|
|
|
|
test('--config-dir without arg fails', () => {
|
|
const r = run('--config-dir');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /--config-dir requires a path/);
|
|
});
|
|
|
|
test('--config-dir followed by another flag fails', () => {
|
|
const r = run('--config-dir', '--all');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /--config-dir requires a path/);
|
|
});
|
|
|
|
test('aider alias rewrites to aider-desk in dry-run output', () => {
|
|
const r = run('--dry-run', '--only', 'aider', '--non-interactive', '--config-dir', '/tmp/__cm_alias_test');
|
|
// No detection means no install lines, but the script should not crash.
|
|
assert.equal(r.status, 0);
|
|
});
|
|
|
|
test('--only with unknown agent id exits 2', () => {
|
|
const r = run('--only', 'definitely-not-an-agent', '--non-interactive');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /unknown agent: definitely-not-an-agent/);
|
|
assert.match(r.stderr, /caveman --list/);
|
|
});
|
|
|
|
test('--only known id passes argv validation', () => {
|
|
// Dry-run + --only claude exits 0 even if the claude binary isn't on PATH.
|
|
const r = run('--dry-run', '--only', 'claude', '--non-interactive', '--config-dir', '/tmp/__cm_only_test');
|
|
assert.equal(r.status, 0);
|
|
});
|
|
|
|
test('--config-dir expands ~ to home directory', async () => {
|
|
// Pass `~/cm-test-…` and assert the dry-run plan resolves it relative to $HOME.
|
|
// Use a unique suffix so the assertion is unambiguous.
|
|
const suffix = `cm-test-${process.pid}`;
|
|
// --with-hooks: since #392/#393 the hooks plan (which echoes the resolved
|
|
// config-dir) is only emitted when the plugin install fails OR hooks are
|
|
// forced. Force it so the path-expansion assertion below has something to
|
|
// match even when the caveman plugin is already installed.
|
|
const r = run('--dry-run', '--only', 'claude', '--with-hooks', '--non-interactive', '--config-dir', `~/${suffix}`);
|
|
assert.equal(r.status, 0);
|
|
// If the literal `~` had survived, we'd see `~/cm-test-…/hooks` in the plan.
|
|
// The fix expands it in parseArgs, so we expect the absolute home path.
|
|
assert.doesNotMatch(r.stdout, /~\/cm-test-/);
|
|
// The plan only includes the hooks dir if claude is detected. Skip the
|
|
// positive assertion when claude isn't on PATH on the runner.
|
|
if (/Claude Code detected/.test(r.stdout)) {
|
|
const { homedir } = await import('node:os');
|
|
assert.match(r.stdout, new RegExp(homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/' + suffix));
|
|
}
|
|
});
|
|
|
|
test('bare -- (POSIX end-of-options) is accepted and ignored', () => {
|
|
// Regression: npx forwarded `--` from `curl|bash -- --only openclaw` to the
|
|
// package, and parseArgs rejected it as an unknown flag. Now we accept it.
|
|
const r = run('--', '--only', 'claude', '--non-interactive', '--dry-run', '--config-dir', '/tmp/__cm_dashdash');
|
|
assert.equal(r.status, 0);
|
|
});
|
|
|
|
test('bare --with-mcp-shrink (no upstream) exits 2 with hint', () => {
|
|
// Regression for issue where --with-mcp-shrink registered a stub MCP entry
|
|
// that crashed on every Claude Code startup. caveman-shrink is a proxy and
|
|
// requires an upstream command — we now refuse the bare flag (#474).
|
|
const r = run('--with-mcp-shrink', '--non-interactive', '--dry-run');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /requires an upstream command/);
|
|
assert.match(r.stderr, /server-filesystem/);
|
|
});
|
|
|
|
test('--with-mcp-shrink followed by another flag (no value) exits 2', () => {
|
|
// The next-token form must distinguish "no value" from "value happens to
|
|
// start with --". A user typing `--with-mcp-shrink --dry-run` clearly
|
|
// forgot the upstream; refuse.
|
|
const r = run('--with-mcp-shrink', '--dry-run', '--non-interactive');
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /requires an upstream command/);
|
|
});
|
|
|
|
test('--with-mcp-shrink="<cmd>" registers wrapping that upstream', () => {
|
|
const r = run(
|
|
'--with-mcp-shrink=npx @modelcontextprotocol/server-filesystem /tmp',
|
|
'--only', 'claude', '--dry-run', '--non-interactive',
|
|
'--config-dir', '/tmp/__cm_shrink_test'
|
|
);
|
|
assert.equal(r.status, 0);
|
|
// Dry-run only emits the planned `claude mcp add` line when claude is on
|
|
// PATH (installMcpShrink probes `claude mcp --help` first). Assert the
|
|
// wrapping content only when that line is actually present.
|
|
if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) {
|
|
assert.match(r.stdout, /claude mcp add caveman-shrink .* npx -y caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/);
|
|
}
|
|
});
|
|
|
|
test('--with-mcp-shrink "<cmd>" (space-separated) also accepted', () => {
|
|
const r = run(
|
|
'--with-mcp-shrink', 'npx @modelcontextprotocol/server-filesystem /tmp',
|
|
'--only', 'claude', '--dry-run', '--non-interactive',
|
|
'--config-dir', '/tmp/__cm_shrink_space'
|
|
);
|
|
assert.equal(r.status, 0);
|
|
if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) {
|
|
assert.match(r.stdout, /caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/);
|
|
}
|
|
});
|
|
|
|
test('--all does NOT auto-enable mcp-shrink (no sensible default upstream)', () => {
|
|
const r = run('--all', '--only', 'claude', '--dry-run', '--non-interactive', '--config-dir', '/tmp/__cm_all_no_shrink');
|
|
assert.equal(r.status, 0);
|
|
// Whether or not claude is on PATH, the wiring banner should not appear
|
|
// because withMcpShrink stays false under --all alone.
|
|
assert.doesNotMatch(r.stdout, /wiring caveman-shrink MCP proxy/);
|
|
});
|
|
|
|
test('--help discloses --config-dir scope', () => {
|
|
const r = run('--help');
|
|
assert.equal(r.status, 0);
|
|
// Disclosure: --config-dir does NOT scope third-party CLI invocations.
|
|
// Help text wraps mid-phrase, so collapse whitespace before matching.
|
|
const collapsed = r.stdout.replace(/\s+/g, ' ');
|
|
assert.match(collapsed, /Does NOT scope/);
|
|
assert.match(collapsed, /XDG_CONFIG_HOME/);
|
|
assert.match(collapsed, /OPENCLAW_WORKSPACE/);
|
|
});
|