'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; })();