Files
ECC/tests/scripts/setup.test.js
T
28e53a0bc1 feat(install): add guided multi-harness installer (#2649)
* feat(install): add guided Claude plugin setup

* fix: support Claude command shims on Windows

* feat: support safe Claude plugin scope migration

* fix(install): preserve interactive setup terminal

* fix(install): auto-migrate setup scope changes

* feat(install): add guided multi-harness installer

* fix(install): sync Yarn binary metadata

* fix(install): handle wizard EOF on Node 18

* ci: allow installer matrix tests to finish

* test(install): allow slower PowerShell delegation

* fix(install): harden guided provider reconciliation

* test(install): harden packaged and local compatibility

* chore: prepare guided installer release 2.2.0

* fix(install): report refreshed Codex marketplace state

* fix(install): verify managed content provenance

* test(install): allow empty Yarn smoke fixture

* test(install): invoke Windows package shims safely

* fix(install): close cross-platform release gaps

* fix(install): require trusted GitHub origins

* fix(install): preserve hook profile precedence

* refactor(install): centralize trusted GitHub origins

* ci: retrigger workflow run after merge of main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:39:49 -04:00

875 lines
30 KiB
JavaScript

'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const setupScript = path.join(repoRoot, 'scripts', 'setup.js');
const eccScript = path.join(repoRoot, 'scripts', 'ecc.js');
const fakeClaudeScript = path.join(repoRoot, 'tests', 'fixtures', 'fake-claude-plugin.js');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
passed += 1;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function createFixture(state = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc setup cli '));
const homeDir = path.join(root, 'home');
const configDir = path.join(root, 'config');
const projectRoot = path.join(root, 'project');
const binDir = path.join(root, 'bin');
const statePath = path.join(root, 'state.json');
const callsPath = path.join(root, 'calls.jsonl');
for (const dir of [homeDir, configDir, projectRoot, binDir]) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(statePath, `${JSON.stringify({
plugins: [],
marketplaces: [],
failures: [],
...state,
}, null, 2)}\n`);
const launcher = path.join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude');
const source = process.platform === 'win32'
? `@echo off\r\n"${process.execPath}" "${fakeClaudeScript}" %*\r\n`
: `#!/bin/sh\nexec "${process.execPath}" "${fakeClaudeScript}" "$@"\n`;
fs.writeFileSync(launcher, source);
if (process.platform !== 'win32') fs.chmodSync(launcher, 0o755);
return {
root,
homeDir,
configDir,
projectRoot,
binDir,
statePath,
callsPath,
};
}
function runSetup(fixture, args) {
return spawnSync(process.execPath, [setupScript, ...args], {
cwd: fixture.projectRoot,
env: {
...process.env,
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
CLAUDE_CONFIG_DIR: fixture.configDir,
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`,
ECC_TEST_CLAUDE_STATE: fixture.statePath,
ECC_TEST_CLAUDE_CALLS: fixture.callsPath,
},
encoding: 'utf8',
timeout: 15000,
});
}
function quoteShellArgument(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function runInteractiveEccSetup(fixture, options = {}) {
if (process.platform === 'win32') {
return null;
}
const args = options.args || ['--dry-run'];
const answers = options.answers || ['3', '3'];
const command = [
process.execPath,
eccScript,
'setup',
...args,
];
const scriptArgs = process.platform === 'darwin'
? ['-q', '-e', '/dev/null', ...command]
: [
'-q',
'-e',
'-c',
command.map(quoteShellArgument).join(' '),
'/dev/null',
];
const pseudoTerminalCommand = ['script', ...scriptArgs]
.map(quoteShellArgument)
.join(' ');
const answerCommands = answers
.map(answer => `sleep 0.5; printf '%s\\n' ${quoteShellArgument(answer)}`)
.join('; ');
return spawnSync('sh', [
'-c',
`(${answerCommands}; sleep 0.1) | ${pseudoTerminalCommand}`,
], {
cwd: fixture.projectRoot,
env: {
...process.env,
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
CLAUDE_CONFIG_DIR: fixture.configDir,
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`,
ECC_TEST_CLAUDE_STATE: fixture.statePath,
ECC_TEST_CLAUDE_CALLS: fixture.callsPath,
},
encoding: 'utf8',
timeout: 15000,
});
}
function readCalls(fixture) {
if (!fs.existsSync(fixture.callsPath)) return [];
return fs.readFileSync(fixture.callsPath, 'utf8')
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map(line => JSON.parse(line));
}
function hasMutation(fixture) {
return readCalls(fixture).some(argv => !(
argv.join(' ') === 'plugin list --json'
|| argv.join(' ') === 'plugin marketplace list --json'
));
}
const SETUP_SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+Applying ECC setup/;
function assertNoSetupSpinner(output) {
assert.doesNotMatch(output, SETUP_SPINNER_PATTERN);
}
function assertSetupSpinnerLifecycle(output, outcomePattern) {
const spinnerIndex = output.search(SETUP_SPINNER_PATTERN);
const clearIndex = output.indexOf('\x1b[2K', spinnerIndex);
const outcomeIndex = output.search(outcomePattern);
assert.ok(spinnerIndex >= 0, 'confirmed interactive apply should start the setup spinner');
assert.ok(clearIndex > spinnerIndex, 'setup spinner should clear its terminal line');
assert.ok(outcomeIndex > clearIndex, 'setup spinner should clear before the final outcome');
const visibleOutput = output
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '');
assert.match(
visibleOutput,
/\[y\/N\] (?:y|yes)\n[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+Applying ECC setup/,
'setup spinner should be the first visible status after confirmation'
);
assertNoSetupSpinner(output.slice(outcomeIndex));
}
function withFixture(state, fn) {
const fixture = createFixture(state);
try {
fn(fixture);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
}
console.log('\n=== ECC setup CLI tests ===\n');
test('fresh non-interactive plugin setup requires an explicit scope', () => {
withFixture({}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--hooks', 'standard',
'--yes',
]);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /--scope/i);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('an existing install without --scope updates its detected scope', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'project', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'project',
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--hooks', 'strict',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'updated');
assert.strictEqual(payload.scope, 'project');
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
assert.ok(readCalls(fixture).some(argv => (
JSON.stringify(argv) === JSON.stringify([
'plugin', 'update', 'ecc@ecc', '--scope', 'project',
])
)));
});
});
test('invalid plugin scopes and hook preferences are rejected before inventory', () => {
withFixture({}, fixture => {
for (const args of [
['--scope', 'global', '--hooks', 'standard'],
['--scope', 'user', '--hooks', 'aggressive'],
]) {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
...args,
'--yes',
]);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /invalid/i);
}
assert.deepStrictEqual(readCalls(fixture), []);
});
});
test('non-TTY mutation requires --yes', () => {
withFixture({}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'user',
'--hooks', 'standard',
]);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /--yes/i);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('dry-run JSON emits JSON only and reads inventory without mutation', () => {
withFixture({}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'local',
'--hooks', 'minimal',
'--dry-run',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(result.stdout.trim(), JSON.stringify(payload, null, 2));
assert.strictEqual(payload.action, 'would-install');
assert.strictEqual(payload.scope, 'local');
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('setup automatically migrates an existing install to the selected scope and hooks', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'local',
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'user',
'--hooks', 'minimal',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'migrated');
assert.strictEqual(payload.sourceScope, 'local');
assert.strictEqual(payload.scope, 'user');
assert.strictEqual(payload.hooks, 'minimal');
const calls = readCalls(fixture);
assert.ok(calls.some(argv => (
argv.join(' ') === 'plugin install ecc@ecc --scope user'
+ ' --config hooks_enabled=true --config hook_profile=minimal'
)));
assert.ok(calls.some(argv => (
argv.join(' ') === 'plugin uninstall ecc@ecc --scope local --keep-data'
)));
assert.ok(!calls.flat().includes('--prune'));
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope: 'user',
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'minimal');
});
});
test('setup resumes a safe two-scope migration without requiring --move-scope', () => {
withFixture({
plugins: [
{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' },
{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '2.0.0' },
],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'user',
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'user',
'--hooks', 'minimal',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'resumed');
assert.strictEqual(payload.sourceScope, 'local');
assert.strictEqual(payload.scope, 'user');
assert.ok(readCalls(fixture).some(argv => (
argv.join(' ') === 'plugin uninstall ecc@ecc --scope local --keep-data'
)));
});
});
test('all interrupted migration and hook combinations resume without reinstalling', () => {
const scopes = ['user', 'project', 'local'];
const hooks = ['off', 'minimal', 'standard', 'strict'];
for (const sourceScope of scopes) {
for (const destinationScope of scopes.filter(scope => scope !== sourceScope)) {
for (const hookMode of hooks) {
withFixture({
plugins: [
{ id: 'ecc@ecc', scope: sourceScope, enabled: true, version: '1.9.0' },
{ id: 'ecc@ecc', scope: destinationScope, enabled: true, version: '2.0.0' },
],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: destinationScope,
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', destinationScope,
'--hooks', hookMode,
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'resumed');
assert.strictEqual(payload.sourceScope, sourceScope);
assert.strictEqual(payload.scope, destinationScope);
assert.strictEqual(payload.hooks, hookMode);
const calls = readCalls(fixture);
assert.ok(!calls.some(argv => argv[1] === 'install'));
assert.ok(calls.some(argv => (
argv.join(' ') === `plugin uninstall ecc@ecc --scope ${sourceScope} --keep-data`
)));
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope: destinationScope,
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
const stored = settings.pluginConfigs['ecc@ecc'].options;
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
assert.strictEqual(
stored.hook_profile,
hookMode === 'off' ? 'standard' : hookMode
);
});
}
}
}
});
test('--move-scope remains explicit about its destination', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--move-scope',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /scope/i);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('destination-only --move-scope is an idempotent first call', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '2.0.0' }],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'local',
'--move-scope',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'already-migrated');
assert.strictEqual(payload.scope, 'local');
assert.strictEqual(hasMutation(fixture), false);
});
});
test('destination-only --move-scope applies explicit hook preferences', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '2.0.0' }],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'local',
'--move-scope',
'--hooks', 'strict',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'already-migrated');
assert.strictEqual(payload.preferencesUpdated, true);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
});
});
test('migration dry-run JSON exposes ordered actions without mutation', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'user',
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'project',
'--dry-run',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'would-migrate');
assert.strictEqual(payload.dryRun, true);
assert.deepStrictEqual(payload.plannedActions.slice(-4), [
['plugin', 'list', '--json'],
['plugin', 'list', '--json'],
['plugin', 'uninstall', 'ecc@ecc', '--scope', 'user', '--keep-data'],
['plugin', 'list', '--json'],
]);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('migration JSON failures retain phase, scopes, and exact recovery', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'user',
}],
failures: [{
argv: ['plugin', 'uninstall', 'ecc@ecc', '--scope', 'user', '--keep-data'],
status: 9,
stderr: 'uninstall failed',
times: 1,
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'project',
'--move-scope',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 1);
assert.strictEqual(result.stdout, '');
const payload = JSON.parse(result.stderr);
assert.strictEqual(payload.error.phase, 'source-uninstall');
assert.deepStrictEqual([...payload.error.observedScopes].sort(), ['project', 'user']);
assert.deepStrictEqual(payload.error.recovery, [
'claude plugin uninstall ecc@ecc --scope user --keep-data',
'ecc setup --mode claude-plugin --scope project --move-scope --yes',
]);
});
});
test('help explains native scope names in user-facing language', () => {
const result = spawnSync(process.execPath, [setupScript, '--help'], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /(?:user.{0,80}global|global.{0,80}user)/is);
assert.match(result.stdout, /(?:project.{0,80}shared|shared.{0,80}project)/is);
assert.match(result.stdout, /(?:local.{0,80}private|private.{0,80}local)/is);
assert.match(result.stdout, /--hooks off\|minimal\|standard\|strict/);
assert.match(result.stdout, /--move-scope/);
});
test('ecc setup delegates to the focused setup command', () => {
const result = spawnSync(process.execPath, [eccScript, 'setup', '--help'], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 15000,
});
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /ECC (guided )?setup/i);
assert.match(result.stdout, /claude-plugin/);
});
test('ecc setup preserves a real terminal for the interactive wizard', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.ifError(result.error);
assert.match(result.stdout, /Where should Claude enable ecc@ecc\?/);
assert.match(result.stdout, /How should ECC hooks run\?/);
assert.doesNotMatch(result.stdout, /Interactive setup requires a terminal/);
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
});
});
test('confirmed interactive apply starts immediately and clears the spinner on success', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['2', '2', 'y'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assertSetupSpinnerLifecycle(
`${result.stdout}${result.stderr}`,
/ECC installed ecc@ecc at project scope/
);
});
});
test('confirmed interactive apply clears and stops the spinner when apply throws', () => {
if (process.platform === 'win32') return;
withFixture({
failures: [{
argv: [
'plugin', 'marketplace', 'add',
'https://github.com/affaan-m/ECC',
'--scope', 'user',
],
status: 8,
stderr: 'injected apply failure',
times: 1,
}],
}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['1', '3', 'yes'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
assertSetupSpinnerLifecycle(
`${result.stdout}${result.stderr}`,
/Error: Claude Code command failed: injected apply failure/
);
});
});
test('all interactive scope and hook choices install and persist the selected configuration', () => {
if (process.platform === 'win32') return;
const scopes = ['user', 'project', 'local'];
const hooks = ['off', 'minimal', 'standard', 'strict'];
for (const [scopeIndex, scope] of scopes.entries()) {
for (const [hookIndex, hookMode] of hooks.entries()) {
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: [String(scopeIndex + 1), String(hookIndex + 1), 'y'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, new RegExp(`ECC installed ecc@ecc at ${scope} scope`));
assert.match(result.stdout, new RegExp(`Hook preference: ${hookMode}`));
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope,
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
const stored = settings.pluginConfigs['ecc@ecc'].options;
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
assert.strictEqual(stored.hook_profile, hookMode === 'off' ? 'standard' : hookMode);
});
}
}
});
test('all interactive choices from an existing install update or migrate to the selected configuration', () => {
if (process.platform === 'win32') return;
const scopes = ['user', 'project', 'local'];
const hooks = ['off', 'minimal', 'standard', 'strict'];
for (const [sourceIndex, sourceScope] of scopes.entries()) {
for (const [selectedIndex, selectedScope] of scopes.entries()) {
for (const [hookIndex, hookMode] of hooks.entries()) {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: sourceScope, enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: sourceScope,
}],
}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: [String(selectedIndex + 1), String(hookIndex + 1), 'y'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
const expectedAction = sourceIndex === selectedIndex ? 'updated' : 'migrated';
const expectedConfirmation = sourceIndex === selectedIndex ? 'Apply' : 'Migrate';
assert.match(
result.stdout,
new RegExp(`${expectedConfirmation} claude-plugin setup at ${selectedScope} scope`)
);
assert.match(
result.stdout,
new RegExp(`ECC ${expectedAction} ecc@ecc at ${selectedScope} scope`)
);
assert.match(result.stdout, new RegExp(`Hook preference: ${hookMode}`));
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope: selectedScope,
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
const stored = settings.pluginConfigs['ecc@ecc'].options;
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
assert.strictEqual(stored.hook_profile, hookMode === 'off' ? 'standard' : hookMode);
});
}
}
}
});
test('interactive named choices install and persist the selected configuration', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['project', 'strict', 'yes'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /ECC installed ecc@ecc at project scope/);
assert.match(result.stdout, /Hook preference: strict/);
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope: 'project',
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
const stored = settings.pluginConfigs['ecc@ecc'].options;
assert.strictEqual(stored.hooks_enabled, true);
assert.strictEqual(stored.hook_profile, 'strict');
});
});
test('invalid interactive choices explain the problem and allow a retry', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: ['--dry-run'],
answers: ['1.5', 'not-a-scope', '2', '2junk', '9', '2'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Please choose 1, 2, or 3/);
assert.match(result.stdout, /Please choose 1, 2, 3, or 4/);
assert.match(result.stdout, /ECC would-install ecc@ecc at project scope/);
assert.match(result.stdout, /Hook preference: minimal/);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('interactive cancellation after non-default choices performs no mutation', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['2', '2', 'n'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /ECC cancelled ecc@ecc at project scope/);
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
assert.strictEqual(hasMutation(fixture), false);
assert.ok(!fs.existsSync(path.join(fixture.configDir, 'settings.json')));
});
});
test('closing interactive input cancels cleanly without mutation', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['2', '\u0004'],
});
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.ifError(result.error);
assert.match(result.stdout, /cancelled/i);
assert.match(result.stdout, /no changes/i);
assert.strictEqual(hasMutation(fixture), false);
assert.ok(!fs.existsSync(path.join(fixture.configDir, 'settings.json')));
});
});
test('interactive mode flag still prompts for missing scope and hook choices', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: ['--mode', 'claude-plugin', '--dry-run'],
answers: ['3', '4'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Where should Claude enable ecc@ecc\?/);
assert.match(result.stdout, /How should ECC hooks run\?/);
assert.match(result.stdout, /ECC would-install ecc@ecc at local scope/);
assert.match(result.stdout, /Hook preference: strict/);
});
});
test('interactive defaults preserve an existing install scope and hook preference', () => {
if (process.platform === 'win32') return;
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'local',
}],
}, fixture => {
fs.writeFileSync(path.join(fixture.configDir, 'settings.json'), JSON.stringify({
pluginConfigs: {
'ecc@ecc': {
options: { hooks_enabled: true, hook_profile: 'minimal' },
},
},
}));
const result = runInteractiveEccSetup(fixture, {
args: ['--dry-run'],
answers: ['', ''],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Choose \[3\]:/);
assert.match(result.stdout, /Choose \[2\]:/);
assert.match(result.stdout, /ECC would-update ecc@ecc at local scope/);
assert.match(result.stdout, /Hook preference: minimal/);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('partial migration requires an explicit destination and preserves stored hook defaults', () => {
if (process.platform === 'win32') return;
withFixture({
plugins: [
{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' },
{ id: 'ecc@ecc', scope: 'project', enabled: true, version: '2.0.0' },
],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'user',
}],
}, fixture => {
fs.writeFileSync(path.join(fixture.configDir, 'settings.json'), JSON.stringify({
pluginConfigs: {
'ecc@ecc': {
options: { hooks_enabled: true, hook_profile: 'minimal' },
},
},
}));
const result = runInteractiveEccSetup(fixture, {
args: ['--dry-run'],
answers: ['', 'project', ''],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Choose: /);
assert.match(result.stdout, /Please choose 1, 2, or 3/);
assert.match(result.stdout, /Choose \[2\]:/);
assert.match(result.stdout, /ECC would-resume ecc@ecc at project scope/);
assert.match(result.stdout, /Previous scope: user/);
assert.match(result.stdout, /Hook preference: minimal/);
assert.strictEqual(hasMutation(fixture), false);
});
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);