Files
ECC/tests/scripts/install-guided.test.js
T
Affaan Mustafa f0378ccdb6 fix(tests): feed guided install PTY answers only after each prompt
The real-PTY test piped answers on fixed sleeps, typing them ahead of
readline. Under CI load the first answer could land before the interface
listened, shifting every later answer onto the wrong question: the
ubuntu-latest Node 18.x npm job installed Claude only, exited 0, and never
printed the Kimi profile prompt while the sibling yarn, pnpm, and bun jobs
on the same Node version passed. Answer each prompt once it appears on
screen instead; spawned stdio goes through cat because the macOS script(1)
refuses a socket stdin.
2026-09-18 18:47:05 -04:00

424 lines
15 KiB
JavaScript

'use strict';
const assert = require('assert');
const path = require('path');
const { spawn } = require('child_process');
const {
collectInteractiveOptions,
main,
parseArgs,
printPlan,
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 stripPtyControlBytes(value) {
return value
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '');
}
function runGuidedPtyFixture(exchanges) {
if (process.platform === 'win32') return Promise.resolve(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'];
return new Promise((resolve, reject) => {
// Answers go through cat so script reads a plain pipe: spawned stdio is
// a socketpair, and the macOS script(1) refuses a socket stdin.
const feeder = `cat | ${['script', ...scriptArgs].map(quoteShellArgument).join(' ')}`;
const child = spawn('sh', ['-c', feeder], { cwd: repoRoot });
let stdout = '';
let stderr = '';
let sent = 0;
let settled = false;
const finish = callback => {
if (settled) return;
settled = true;
clearTimeout(timer);
callback();
};
const timer = setTimeout(() => {
child.kill('SIGKILL');
finish(() => reject(new Error('guided PTY fixture timed out')));
}, 15000);
const feed = () => {
// Answer only once the matching prompt is on screen. Fixed sleeps
// typed answers ahead of readline; under CI load the first answer
// could land before the interface listened, shifting every later
// answer onto the wrong question (ubuntu Node 18 npm job).
const visible = stripPtyControlBytes(stdout + stderr);
while (sent < exchanges.length && visible.includes(exchanges[sent].expect)) {
child.stdin.write(`${exchanges[sent].send}\n`);
sent += 1;
}
if (sent === exchanges.length) child.stdin.end();
};
child.stdout.on('data', data => { stdout += data; feed(); });
child.stderr.on('data', data => { stderr += data; feed(); });
child.on('error', error => finish(() => reject(error)));
child.on('close', (status, signal) => finish(() => resolve({ status, signal, stdout, stderr })));
});
}
(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', async () => {
const result = await runGuidedPtyFixture([
{ expect: 'Choose one or more (for example 1,3 or all):', send: 'all' },
{ expect: 'Choose [Recommended: user] (one option only):', send: '1' },
{ expect: 'Choose [Recommended: standard] (one option only):', send: '3' },
{ expect: 'Choose [Recommended: core] (one option only):', send: '2' },
{ expect: 'Apply ECC to these harnesses? [y/N]:', send: 'y' },
]);
if (result === null) return;
assert.strictEqual(result.status, 0, result.stderr);
const visible = stripPtyControlBytes(`${result.stdout}${result.stderr}`);
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/);
});
await test('printPlan discloses hook capabilities for non-off Claude hook profiles', async () => {
let written = '';
const output = { write: chunk => { written += chunk; } };
printPlan({
harnesses: [{ id: 'claude', channel: 'native-plugin' }],
request: { harnesses: ['claude'], claudeHooks: 'standard' },
}, output);
assert.ok(written.includes("hook profile 'standard'"));
assert.ok(written.includes('modify project source files'));
assert.ok(written.includes("--claude-hooks off"));
});
await test('printPlan omits the hook disclosure when Claude hooks are off', async () => {
let written = '';
const output = { write: chunk => { written += chunk; } };
printPlan({
harnesses: [{ id: 'claude', channel: 'native-plugin' }],
request: { harnesses: ['claude'], claudeHooks: 'off' },
}, output);
assert.ok(!written.includes('enables automation'));
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
})();