Files
ECC/tests/scripts/install-guided.test.js
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

367 lines
13 KiB
JavaScript

'use strict';
const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const {
collectInteractiveOptions,
main,
parseArgs,
validateExecutionMode,
} = require('../../scripts/install-guided');
const {
normalizeGuidedInstallRequest,
} = require('../../scripts/lib/multi-harness-setup');
const repoRoot = path.join(__dirname, '..', '..');
const guidedPtyFixture = path.join(repoRoot, 'tests', 'fixtures', 'run-guided-install-pty.js');
let passed = 0;
let failed = 0;
async function test(name, fn) {
try {
await fn();
console.log(` ✓ ${name}`);
passed += 1;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function fakeTerminal(answers) {
const queue = [...answers];
let prompts = [];
return {
async question(prompt = '') {
prompts = [...prompts, prompt];
if (queue.length === 0) throw new Error('No fake answer available');
return queue.shift();
},
close() {},
get prompts() { return [...prompts]; },
};
}
function capture(isTTY = true) {
let value = '';
return {
isTTY,
write(chunk) { value += chunk; },
read() { return value; },
};
}
function quoteShellArgument(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function runGuidedPtyFixture(answers) {
if (process.platform === 'win32') return null;
const command = [process.execPath, guidedPtyFixture];
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.35; printf '%s\\n' ${quoteShellArgument(answer)}`)
.join('; ');
return spawnSync('sh', ['-c', `(${answerCommands}; sleep 0.1) | ${pseudoTerminalCommand}`], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 15000,
});
}
(async () => {
console.log('\n=== Guided multi-harness CLI tests ===\n');
await test('parses repeatable harness flags and provider-specific choices', () => {
assert.deepStrictEqual(parseArgs([
'--harness', 'kimi', '--harness', 'claude,codex',
'--claude-scope', 'local', '--claude-hooks', 'minimal',
'--profile', 'developer', '--yes', '--dry-run', '--json',
]), {
allHarnesses: false,
claudeHooks: 'minimal',
claudeScope: 'local',
dryRun: true,
harnesses: ['kimi', 'claude,codex'],
help: false,
json: true,
profile: 'developer',
yes: true,
});
assert.throws(() => parseArgs(['--harness']), /Missing value.*--harness/);
assert.throws(() => parseArgs(['--nope']), /Unknown argument/);
assert.throws(
() => parseArgs(['--all-harnesses', '--harness', 'claude']),
/mutually exclusive/i
);
});
await test('supports every non-empty Claude, Codex, and Kimi selection combination', () => {
const combinations = [
['claude'], ['codex'], ['kimi'],
['claude', 'codex'], ['claude', 'kimi'], ['codex', 'kimi'],
['claude', 'codex', 'kimi'],
];
for (const harnesses of combinations) {
const parsed = parseArgs(harnesses.flatMap(id => ['--harness', id]));
assert.deepStrictEqual(parsed.harnesses, harnesses);
const request = normalizeGuidedInstallRequest({
...parsed,
claudeHooks: harnesses.includes('claude') ? 'standard' : undefined,
claudeScope: harnesses.includes('claude') ? 'user' : undefined,
profile: harnesses.includes('kimi') ? 'core' : undefined,
});
assert.deepStrictEqual(request.harnesses, harnesses);
}
});
await test('interactive selection reprompts and only asks relevant provider questions', async () => {
const output = capture();
const result = await collectInteractiveOptions(parseArgs([]), {
output,
terminal: fakeTerminal(['bogus', '1,3', '3', '2', '4']),
});
assert.deepStrictEqual(result.harnesses, ['claude', 'kimi']);
assert.strictEqual(result.claudeScope, 'local');
assert.strictEqual(result.claudeHooks, 'minimal');
assert.strictEqual(result.profile, 'security');
assert.match(output.read(), /Please choose/i);
assert.doesNotMatch(output.read(), /Codex.*scope/i);
});
await test('interactive prompts keep spacing, recommended defaults, and one visible confirmation', async () => {
const output = capture();
const terminal = fakeTerminal(['all', '1', '3', '2']);
const options = await collectInteractiveOptions(parseArgs([]), { output, terminal });
assert.deepStrictEqual(options.harnesses, ['claude', 'codex', 'kimi']);
assert.match(output.read(), /Advanced adapters[^\n]+\.\n\n\nWhere should Claude/);
assert.deepStrictEqual(terminal.prompts, [
'Choose one or more (for example 1,3 or all): ',
'Choose [Recommended: user] (one option only): ',
'Choose [Recommended: standard] (one option only): ',
'Choose [Recommended: core] (one option only): ',
]);
const confirmationOutput = capture();
const confirmationTerminal = fakeTerminal(['y']);
const code = await main([
'--harness', 'codex',
], {
applyPlan: async () => ({ status: 'complete', completed: [{ id: 'codex' }] }),
createPlan: async request => ({
request,
harnesses: [{ id: 'codex', channel: 'native-plugin', preview: {} }],
}),
interactive: true,
output: confirmationOutput,
terminal: confirmationTerminal,
showWelcome: () => {},
startSpinner: () => ({ stop() {} }),
});
assert.strictEqual(code, 0);
assert.deepStrictEqual(
confirmationTerminal.prompts,
['Apply ECC to these harnesses? [y/N]: ']
);
});
await test('real PTY shows every all-harness question and applies after visible yes', () => {
const result = runGuidedPtyFixture(['all', '1', '3', '2', 'y']);
if (result === null) return;
assert.strictEqual(result.status, 0, result.stderr);
const visible = `${result.stdout}${result.stderr}`
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '');
const orderedPrompts = [
'Choose one or more (for example 1,3 or all):',
'Choose [Recommended: user] (one option only):',
'Choose [Recommended: standard] (one option only):',
'Choose [Recommended: core] (one option only):',
'Apply ECC to these harnesses? [y/N]:',
'PTY_WELCOME_SHOWN',
];
let previousIndex = -1;
for (const prompt of orderedPrompts) {
const promptIndex = visible.indexOf(prompt);
assert.ok(promptIndex > previousIndex, `missing or out-of-order PTY prompt: ${prompt}`);
previousIndex = promptIndex;
}
assert.doesNotMatch(visible, /install cancelled/i);
});
await test('non-interactive and JSON modes require complete explicit choices', () => {
assert.throws(
() => validateExecutionMode(parseArgs([]), false),
/--harness/i
);
assert.throws(
() => validateExecutionMode(parseArgs(['--harness', 'claude', '--json']), true),
/Claude.*scope.*hooks/i
);
assert.throws(
() => validateExecutionMode(parseArgs([
'--harness', 'claude', '--claude-scope', 'user', '--claude-hooks', 'standard', '--json',
]), true),
/--yes/i
);
});
await test('runs one preflight, one confirmation, and one apply for all selected harnesses', async () => {
const output = capture();
const terminal = fakeTerminal(['y']);
const events = [];
const code = await main([
'--harness', 'claude', '--harness', 'codex', '--harness', 'kimi',
'--claude-scope', 'user', '--claude-hooks', 'standard', '--profile', 'core',
], {
applyPlan: async plan => { events.push('apply'); return { status: 'complete', completed: plan.harnesses }; },
createPlan: async request => {
events.push('preflight');
return {
request,
harnesses: request.harnesses.map(id => ({ id, channel: id === 'kimi' ? 'managed-project' : 'native-plugin', preview: {} })),
};
},
interactive: true,
output,
terminal,
showWelcome: () => events.push('welcome'),
startSpinner: () => ({ stop: () => events.push('spinner:stop') }),
});
assert.strictEqual(code, 0);
assert.deepStrictEqual(events, ['preflight', 'apply', 'spinner:stop', 'welcome']);
assert.strictEqual(
terminal.prompts.filter(prompt => /Apply ECC to these harnesses\?/.test(prompt)).length,
1
);
});
await test('cancellation and dry-run perform no mutation or welcome', async () => {
for (const dryRun of [false, true]) {
const output = capture();
let applyCalls = 0;
let welcomeCalls = 0;
const args = [
'--harness', 'codex',
...(dryRun ? ['--dry-run'] : []),
];
const code = await main(args, {
applyPlan: async () => { applyCalls += 1; },
createPlan: async request => ({ request, harnesses: [{ id: 'codex', channel: 'native-plugin', preview: {} }] }),
interactive: true,
output,
terminal: fakeTerminal(dryRun ? [] : ['n']),
showWelcome: () => { welcomeCalls += 1; },
});
assert.strictEqual(code, 0);
assert.strictEqual(applyCalls, 0);
assert.strictEqual(welcomeCalls, 0);
}
});
await test('JSON mode emits one clean result document', async () => {
const output = capture(true);
const code = await main(['--harness', 'codex', '--yes', '--json'], {
applyPlan: async () => ({ status: 'complete', completed: [{ id: 'codex' }], retryHarnesses: [] }),
createPlan: async request => ({ request, harnesses: [{ id: 'codex', channel: 'native-plugin', preview: {} }] }),
interactive: true,
output,
showWelcome: () => { throw new Error('welcome must be suppressed'); },
});
assert.strictEqual(code, 0);
const value = JSON.parse(output.read());
assert.strictEqual(value.result.status, 'complete');
});
await test('help and failed apply paths are actionable', async () => {
const helpOutput = capture();
assert.strictEqual(await main(['--help'], { output: helpOutput }), 0);
assert.match(helpOutput.read(), /Advanced managed adapters/);
const output = capture();
const errorOutput = capture();
const code = await main(['--harness', 'codex', '--yes'], {
applyPlan: async () => ({
status: 'failed',
completed: [],
failure: { id: 'codex', message: 'verification failed' },
retryHarnesses: ['codex'],
}),
createPlan: async request => ({ request, harnesses: [{ id: 'codex', channel: 'native-plugin', preview: {} }] }),
errorOutput,
interactive: false,
output,
});
assert.strictEqual(code, 1);
assert.match(
errorOutput.read(),
/Retry with: ecc-universal install --guided --harness codex/
);
const jsonError = capture();
assert.strictEqual(await main(['--json'], {
errorOutput: jsonError,
interactive: false,
output: capture(false),
}), 1);
assert.strictEqual(JSON.parse(jsonError.read()).error.code, 'GUIDED_INSTALL_FAILED');
});
await test('retry command preserves unfinished provider-specific choices', async () => {
const output = capture(false);
const errorOutput = capture(false);
const code = await main([
'--harness', 'claude', '--harness', 'kimi',
'--claude-scope', 'local', '--claude-hooks', 'strict',
'--profile', 'developer', '--yes',
], {
applyPlan: async () => ({
status: 'failed',
completed: [],
failure: { id: 'claude', message: 'verification failed' },
retryHarnesses: ['claude', 'kimi'],
}),
createPlan: async request => ({
request,
harnesses: [
{ id: 'claude', channel: 'native-plugin', preview: {} },
{ id: 'kimi', channel: 'managed-project', preview: {} },
],
}),
errorOutput,
interactive: false,
output,
});
assert.strictEqual(code, 1);
assert.match(
errorOutput.read(),
/Retry with: ecc-universal install --guided --harness claude --harness kimi --claude-scope local --claude-hooks strict --profile developer/
);
});
await test('human-facing parser errors never echo terminal control bytes', async () => {
const errorOutput = capture();
const code = await main(['--harness', 'codex\u001b[31m'], {
errorOutput,
interactive: false,
output: capture(false),
});
assert.strictEqual(code, 1);
assert.ok(!errorOutput.read().includes('\u001b'));
assert.doesNotMatch(errorOutput.read(), /\[31m/);
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
})();