mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-19 22:41:45 +02:00
Merge remote-tracking branch 'origin/main' into fix/stocktake-live-inventory
This commit is contained in:
@@ -404,6 +404,7 @@ function runHermeticPythonPrePush({
|
||||
? process.env.PATH
|
||||
: `${toBashPath(pathBin)}${path.delimiter}${process.env.PATH}`,
|
||||
HOME: process.env.HOME ?? '',
|
||||
ECC_PREPUSH_RUN_CHECKS: '1',
|
||||
ECC_SKIP_GIT_HOOKS: '0',
|
||||
ECC_SKIP_PREPUSH: '0',
|
||||
MSYS_NO_PATHCONV: '1',
|
||||
@@ -916,7 +917,13 @@ if (
|
||||
const merged = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = TOML.parse(merged);
|
||||
assert.strictEqual(parsed.mcp_servers['chrome-devtools'].command, 'npx');
|
||||
assert.deepStrictEqual(parsed.mcp_servers['chrome-devtools'].args, ['chrome-devtools-mcp@latest']);
|
||||
assert.deepStrictEqual(parsed.mcp_servers['chrome-devtools'].args, ['chrome-devtools-mcp@1.10.1']);
|
||||
const rootMcp = JSON.parse(fs.readFileSync(path.join(repoRoot, '.mcp.json'), 'utf8'));
|
||||
const rootPackages = rootMcp.mcpServers['chrome-devtools'].args.filter(arg =>
|
||||
arg.startsWith('chrome-devtools-mcp@'));
|
||||
assert.deepStrictEqual(rootPackages, parsed.mcp_servers['chrome-devtools'].args,
|
||||
'root MCP and generated Codex TOML must use the same connector pin');
|
||||
assert.deepStrictEqual(rootPackages, ['chrome-devtools-mcp@1.10.1']);
|
||||
assert.strictEqual(parsed.mcp_servers['chrome-devtools'].startup_timeout_sec, 30);
|
||||
// No retired server may be (re-)emitted — exa's url form broke Codex (#2224).
|
||||
assert.strictEqual(parsed.mcp_servers.exa, undefined);
|
||||
@@ -936,6 +943,51 @@ if (
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config preserves an existing latest entry until explicit recommendation refresh', () => {
|
||||
const tempDir = createTempDir('mcp-merge-explicit-refresh-');
|
||||
const configPath = path.join(tempDir, 'config.toml');
|
||||
const original = [
|
||||
'# User-maintained configuration',
|
||||
'[mcp_servers.chrome-devtools]',
|
||||
'command = "npx"',
|
||||
'args = ["chrome-devtools-mcp@latest", "--custom-browser-argument"]',
|
||||
'startup_timeout_sec = 75',
|
||||
'[mcp_servers.chrome-devtools.env]',
|
||||
'CUSTOM_BROWSER_SETTING = "preserve-until-explicit-refresh"',
|
||||
'',
|
||||
'[mcp_servers.user_tool]',
|
||||
'command = "custom-launcher"',
|
||||
'args = ["--user-setting"]',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, original);
|
||||
const preserved = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||
assert.strictEqual(preserved.status, 0, `${preserved.stdout}\n${preserved.stderr}`);
|
||||
assert.match(preserved.stderr, /chrome-devtools differs from ECC recommendation/);
|
||||
assert.match(preserved.stderr, /--update-mcp to refresh/);
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original,
|
||||
'normal sync must preserve existing latest and customized settings byte-for-byte');
|
||||
|
||||
const refreshed = runNode(mergeMcpConfigScript, [configPath, '--update-mcp'], deterministicPackageEnv);
|
||||
assert.strictEqual(refreshed.status, 0, `${refreshed.stdout}\n${refreshed.stderr}`);
|
||||
assert.match(refreshed.stdout, /\[update\] mcp_servers\.chrome-devtools/);
|
||||
const updated = TOML.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.deepStrictEqual(updated.mcp_servers['chrome-devtools'], {
|
||||
command: 'npx', args: ['chrome-devtools-mcp@1.10.1'], startup_timeout_sec: 30,
|
||||
}, 'explicit refresh replaces the whole recommended section, including custom subsettings');
|
||||
assert.deepStrictEqual(updated.mcp_servers.user_tool, TOML.parse(original).mcp_servers.user_tool,
|
||||
'unrelated user-managed server settings remain untouched');
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('merge-mcp-config repairs the invalid exa url entry from earlier ECC versions (#2224)', () => {
|
||||
const tempDir = createTempDir('mcp-merge-exa-repair-');
|
||||
|
||||
@@ -604,9 +604,10 @@ async function runTests() {
|
||||
if (
|
||||
await test('CLI browser opener handles spawn errors', async () => {
|
||||
const source = fs.readFileSync(SCRIPT, 'utf8');
|
||||
|
||||
assert.match(source, /child\.on\('error'/);
|
||||
assert.match(source, /child\.unref\(\)/);
|
||||
const helper = fs.readFileSync(path.join(path.dirname(SCRIPT), 'lib/platform-launch.js'), 'utf8');
|
||||
assert.match(source, /require\('\.\/lib\/platform-launch'\)/);
|
||||
assert.match(helper, /child\.on\('error'/);
|
||||
assert.match(helper, /child\.unref\(\)/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const test = require('node:test');
|
||||
|
||||
const command = path.resolve(__dirname, '../../scripts/dev/generate-skill-triggers.js');
|
||||
|
||||
test('trigger generation rejects batch sizes that cannot advance', () => {
|
||||
for (const value of ['0', '-1', 'NaN', '1.5', '9007199254740992']) {
|
||||
const result = spawnSync(process.execPath, [command, '--batch', value, '--dry-run'], {
|
||||
encoding: 'utf8', timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 1, `${value}: ${result.stderr}`);
|
||||
assert.match(result.stderr, /--batch must be a positive integer/);
|
||||
}
|
||||
});
|
||||
@@ -1104,7 +1104,7 @@ function runTests() {
|
||||
assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage);
|
||||
|
||||
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
|
||||
const boundaryPaths = [hooksPackagePath, libPackagePath];
|
||||
const boundaryPaths = [hooksPackagePath, libPackagePath].map(file => fs.realpathSync(file));
|
||||
const packageBoundaryOperations = state.operations.filter(operation => (
|
||||
boundaryPaths.includes(operation.destinationPath)
|
||||
));
|
||||
@@ -1191,6 +1191,7 @@ function runTests() {
|
||||
|
||||
applyInstallPlan({
|
||||
targetRoot: path.join(tempDir, 'installed'),
|
||||
adapter: { id: 'test-install', target: 'test-install' },
|
||||
installStatePath,
|
||||
statePreview: {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
@@ -1487,6 +1488,29 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
for (const explicitConfig of [true, false]) {
|
||||
if (test(`installs from a UTF-8 BOM config (${explicitConfig ? '--config' : 'auto-detected'})`, () => {
|
||||
const homeDir = createTempDir('install-apply-bom-home-');
|
||||
const projectDir = createTempDir('install-apply-bom-project-');
|
||||
const configPath = path.join(projectDir, 'ecc-install.json');
|
||||
const content = '\uFEFF{\r\n "version": 1,\r\n "target": "cursor",\r\n "modules": ["rules-core"]\r\n}\r\n';
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, content, 'utf8');
|
||||
const args = explicitConfig ? ['--config', configPath] : [];
|
||||
const result = run(args, { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc')));
|
||||
const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json'));
|
||||
assert.deepStrictEqual(state.request.modules, ['rules-core']);
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), content);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
if (test('preserves legacy language installs when a project config is present', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* Tests for install.sh wrapper delegation
|
||||
* Tests for install.sh wrapper delegation and owned child lifecycle.
|
||||
* --lifecycle-only runs inert Node fixtures without invoking an installer/shell.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'install.sh');
|
||||
const CHILD_LIMITS = Object.freeze({ timeout: 10000, termGrace: 100, closeGrace: 500, maxBytes: 1024 * 1024 });
|
||||
const FIXTURE_LIMITS = Object.freeze({ ...CHILD_LIMITS, maxBytes: 65536 });
|
||||
const STALL_LIMITS = Object.freeze({ ...FIXTURE_LIMITS, timeout: 500 });
|
||||
const CHILD_PATH = [path.dirname(process.execPath), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
@@ -18,45 +23,393 @@ function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
...(options.env || {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('bash', [options.scriptPath || SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
function finishCleanup(dirs, failed, primary, remove = cleanup) {
|
||||
const errors = [];
|
||||
for (const dir of dirs) {
|
||||
try { remove(dir); } catch (error) { errors.push(error); }
|
||||
}
|
||||
if (failed) {
|
||||
if (errors.length) console.error(`Fixture cleanup also failed (${errors.length} errors)`);
|
||||
throw primary; // Includes falsy thrown values; cleanup cannot replace them.
|
||||
}
|
||||
if (errors.length) throw new AggregateError(errors, 'Fixture cleanup failed');
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
async function withTempDirs(prefixes, fn) {
|
||||
const dirs = [];
|
||||
let failed = false;
|
||||
let primary;
|
||||
let value;
|
||||
try {
|
||||
fn();
|
||||
for (const prefix of prefixes) dirs.push(createTempDir(prefix));
|
||||
value = await fn(...dirs);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
primary = error;
|
||||
}
|
||||
finishCleanup(dirs, failed, primary);
|
||||
return value;
|
||||
}
|
||||
|
||||
function privateEnvironment(root, homeDir, overrides = {}) {
|
||||
const home = homeDir || path.join(root, 'home');
|
||||
const temp = path.join(root, 'tmp');
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(temp, { recursive: true });
|
||||
return {
|
||||
PATH: CHILD_PATH, HOME: home, USERPROFILE: home,
|
||||
TMPDIR: temp, TMP: temp, TEMP: temp, LANG: 'C', LC_ALL: 'C',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Each invocation owns a new POSIX group. The deadline covers headers/output
|
||||
// through 'close', even when a dead leader's descendant retains a pipe.
|
||||
function runChild(binary, args, options, limits = CHILD_LIMITS) {
|
||||
return new Promise(resolve => {
|
||||
const started = Date.now();
|
||||
let child;
|
||||
let status = null;
|
||||
let signal = null;
|
||||
let spawnError = null;
|
||||
let exited = false;
|
||||
let closed = false;
|
||||
let settled = false;
|
||||
let terminating = false;
|
||||
let hardKillSent = false;
|
||||
let timedOut = false;
|
||||
let outputLimit = false;
|
||||
let forcedPipeClose = false;
|
||||
let bytes = 0;
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
const signalErrors = [];
|
||||
const timers = [];
|
||||
const detached = process.platform !== 'win32';
|
||||
const later = (fn, ms) => { const timer = setTimeout(fn, ms); timers.push(timer); return timer; };
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
const failed = terminating || spawnError || signal || forcedPipeClose || !closed;
|
||||
const code = failed ? (status || 1) : status;
|
||||
const result = {
|
||||
code, status, signal, errorCode: spawnError && spawnError.code,
|
||||
errorMessage: spawnError && spawnError.message,
|
||||
timedOut, outputLimit, forcedPipeClose, closed, reaped: exited,
|
||||
pid: child && child.pid, signalErrors, elapsedMs: Date.now() - started,
|
||||
stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'),
|
||||
};
|
||||
result.diagnostic = JSON.stringify({
|
||||
status, signal, errorCode: result.errorCode, timedOut, outputLimit,
|
||||
forcedPipeClose, closed, reaped: exited, signalErrors,
|
||||
});
|
||||
resolve(result);
|
||||
};
|
||||
const sendSignal = name => {
|
||||
if (!child || !Number.isInteger(child.pid) || child.pid <= 0) return;
|
||||
try {
|
||||
if (detached) process.kill(-child.pid, name);
|
||||
else child.kill(name);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') signalErrors.push({ signal: name, code: error.code });
|
||||
}
|
||||
};
|
||||
const terminate = () => {
|
||||
if (terminating || settled) return;
|
||||
terminating = true;
|
||||
sendSignal('SIGTERM');
|
||||
later(() => {
|
||||
// Do not cancel escalation on leader exit or early pipe closure.
|
||||
sendSignal('SIGKILL');
|
||||
hardKillSent = true;
|
||||
if (closed) return finish();
|
||||
later(() => {
|
||||
forcedPipeClose = true;
|
||||
for (const stream of [child.stdin, child.stdout, child.stderr]) stream.destroy();
|
||||
// An OS that cannot reap after SIGKILL is a reported failure, never a
|
||||
// successful flush. Do not leave its ChildProcess handle blocking us.
|
||||
child.unref();
|
||||
finish();
|
||||
}, limits.closeGrace);
|
||||
}, limits.termGrace);
|
||||
};
|
||||
const capture = target => data => {
|
||||
const remaining = limits.maxBytes - bytes;
|
||||
const part = data.subarray(0, Math.max(0, remaining));
|
||||
if (part.length) target.push(part);
|
||||
bytes += part.length;
|
||||
if (part.length !== data.length) {
|
||||
outputLimit = true;
|
||||
terminate();
|
||||
}
|
||||
};
|
||||
try {
|
||||
child = spawn(binary, args, { ...options, shell: false, detached, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch (error) {
|
||||
spawnError = error;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
child.stdout.on('data', capture(stdout));
|
||||
child.stderr.on('data', capture(stderr));
|
||||
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
||||
stream.on('error', error => {
|
||||
if (stream === child.stdin && error.code === 'EPIPE') return;
|
||||
spawnError = spawnError || error;
|
||||
terminate();
|
||||
});
|
||||
}
|
||||
child.once('error', error => { spawnError = error; terminate(); });
|
||||
child.once('exit', (code, exitSignal) => { status = code; signal = exitSignal; exited = true; });
|
||||
child.once('close', (code, exitSignal) => {
|
||||
status = code;
|
||||
signal = exitSignal;
|
||||
closed = true;
|
||||
if (!terminating || hardKillSent) finish();
|
||||
});
|
||||
later(() => { timedOut = true; terminate(); }, limits.timeout);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
function classifyProbe(result) {
|
||||
if (result.errorCode === 'ENOENT' && !result.timedOut && !result.signal && !result.forcedPipeClose) return 'missing';
|
||||
if (result.timedOut || result.outputLimit || result.forcedPipeClose || result.errorCode || result.signal || !result.closed) {
|
||||
throw new Error(`Shell capability probe infrastructure failure: ${result.diagnostic}`);
|
||||
}
|
||||
if (result.code === 0) return 'supported';
|
||||
if (result.code === 127 && /\[\[: (?:not found|command not found)/.test(result.stderr)) return 'unsupported';
|
||||
throw new Error(`Unexpected shell capability probe failure: ${result.diagnostic}; ${result.stderr}`);
|
||||
}
|
||||
|
||||
async function findPosixOnlyShell(runner = runChild) {
|
||||
return withTempDirs(['install-sh-probe-'], async root => {
|
||||
const env = privateEnvironment(root);
|
||||
const probeScript = path.join(root, 'probe.sh');
|
||||
fs.writeFileSync(probeScript, "eval '[[ 1 == 1 ]]'\n", { flag: 'wx', mode: 0o600 });
|
||||
for (const candidate of ['dash', 'sh']) {
|
||||
console.log(` START shell capability probe: ${candidate}`);
|
||||
const result = await runner(candidate, [probeScript], { env });
|
||||
const capability = classifyProbe(result);
|
||||
console.log(` END shell capability probe: ${candidate}: ${capability}`);
|
||||
if (capability === 'unsupported') return candidate;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async function run(args = [], options = {}) {
|
||||
if (!options.scriptPath) {
|
||||
// Never let a real-source test enter install.sh's network bootstrap path.
|
||||
assert.ok(fs.statSync(path.join(path.dirname(SCRIPT), 'node_modules')).isDirectory(),
|
||||
'Installer source tests require already installed repository dependencies; no bootstrap is allowed');
|
||||
}
|
||||
return withTempDirs(['install-sh-env-'], async root => {
|
||||
const result = await runChild(options.shell || 'bash', [options.scriptPath || SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env: privateEnvironment(root, options.homeDir, options.env),
|
||||
});
|
||||
if (result.code !== 0) result.stderr += `\nChild failure: ${result.diagnostic}`;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function test(name, fn) {
|
||||
console.log(` START ${name}`);
|
||||
try {
|
||||
await fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
console.log(` Error: ${error && error.message ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
async function inertChild(runner, source, args = [], limits = FIXTURE_LIMITS) {
|
||||
return withTempDirs(['install-sh-child-'], async root => {
|
||||
const script = path.join(root, 'child.js');
|
||||
fs.writeFileSync(script, source);
|
||||
return runner(process.execPath, [script, ...args], { cwd: root, env: privateEnvironment(root) }, limits);
|
||||
});
|
||||
}
|
||||
|
||||
async function assertGone(pid) {
|
||||
assert.ok(Number.isInteger(pid) && pid > 0, 'fixture must identify its owned process');
|
||||
for (let attempt = 0; attempt < 25; attempt++) {
|
||||
try { process.kill(pid, 0); } catch (error) {
|
||||
if (error.code === 'ESRCH') return;
|
||||
throw error;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.fail(`owned fixture process ${pid} survived cleanup`);
|
||||
}
|
||||
|
||||
function lifecycleCases(runner = runChild, probe = classifyProbe, finish = finishCleanup, findProbe = findPosixOnlyShell) {
|
||||
return [
|
||||
['inert child preserves stdout, stderr, arguments and stdin EOF', async () => {
|
||||
const result = await inertChild(runner,
|
||||
"process.stdout.write('out:' + process.argv[2]); process.stderr.write('warning'); process.stdin.resume(); process.stdin.on('end', () => process.stdout.write(':EOF'));",
|
||||
['literal argument']);
|
||||
assert.strictEqual(result.code, 0, result.diagnostic);
|
||||
assert.strictEqual(result.stdout, 'out:literal argument:EOF');
|
||||
assert.strictEqual(result.stderr, 'warning');
|
||||
assert.strictEqual(result.closed, true);
|
||||
assert.strictEqual(result.reaped, true);
|
||||
}],
|
||||
['nonzero inert child preserves exact status and partial output', async () => {
|
||||
const result = await inertChild(runner, "process.stdout.write('partial'); process.stderr.write('failure'); process.exitCode = 7;");
|
||||
assert.strictEqual(result.code, 7);
|
||||
assert.strictEqual(result.status, 7);
|
||||
assert.strictEqual(result.stdout, 'partial');
|
||||
assert.strictEqual(result.stderr, 'failure');
|
||||
}],
|
||||
['missing executable remains unavailable rather than a supported capability', async () => {
|
||||
await withTempDirs(['install-sh-missing-'], async root => {
|
||||
const result = await runner(path.join(root, 'absent-binary'), [], { env: privateEnvironment(root) }, FIXTURE_LIMITS);
|
||||
assert.strictEqual(result.errorCode, 'ENOENT');
|
||||
assert.strictEqual(probe(result), 'missing');
|
||||
assert.notStrictEqual(result.code, 0);
|
||||
});
|
||||
}],
|
||||
['signal termination remains an infrastructure failure', async () => {
|
||||
const result = await inertChild(runner, "process.kill(process.pid, 'SIGTERM');");
|
||||
assert.strictEqual(result.signal, 'SIGTERM');
|
||||
assert.notStrictEqual(result.code, 0);
|
||||
assert.throws(() => probe(result), /infrastructure failure/);
|
||||
}],
|
||||
['ignored SIGTERM is escalated and the owned leader is reaped', async () => {
|
||||
const result = await inertChild(runner,
|
||||
"process.on('SIGTERM', () => {}); console.log(process.pid); setInterval(() => {}, 1000);", [], STALL_LIMITS);
|
||||
assert.strictEqual(result.timedOut, true);
|
||||
assert.strictEqual(result.signal, 'SIGKILL');
|
||||
assert.strictEqual(result.reaped, true);
|
||||
assert.strictEqual(result.forcedPipeClose, false);
|
||||
assert.notStrictEqual(result.code, 0);
|
||||
assert.ok(result.elapsedMs < 2000, result.diagnostic);
|
||||
await assertGone(Number(result.stdout.trim()));
|
||||
}],
|
||||
['leader exit cannot hide an owned descendant retaining captured pipes', async () => {
|
||||
const descendant = "process.on('SIGTERM', () => {}); process.send('ready'); process.disconnect(); setInterval(() => {}, 1000);";
|
||||
const source = "const { spawn } = require('child_process'); const child = spawn(process.execPath, ['-e', " + JSON.stringify(descendant) + "], { stdio: ['ignore', 1, 2, 'ipc'] }); child.once('message', () => { console.log(child.pid); process.exit(0); });";
|
||||
const result = await inertChild(runner, source, [], STALL_LIMITS);
|
||||
assert.strictEqual(result.status, 0, 'the leader exits successfully before the deadline');
|
||||
assert.strictEqual(result.timedOut, true);
|
||||
assert.notStrictEqual(result.code, 0);
|
||||
assert.strictEqual(result.closed, true);
|
||||
assert.strictEqual(result.forcedPipeClose, false);
|
||||
assert.ok(result.elapsedMs < 2000, result.diagnostic);
|
||||
await assertGone(Number(result.stdout.trim()));
|
||||
}],
|
||||
['captured output is capped and overflow fails', async () => {
|
||||
const result = await inertChild(runner, "process.stdout.write('x'.repeat(131072));");
|
||||
assert.strictEqual(result.outputLimit, true);
|
||||
assert.notStrictEqual(result.code, 0);
|
||||
assert.ok(Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr) <= FIXTURE_LIMITS.maxBytes);
|
||||
}],
|
||||
['a stalled capability probe fails instead of selecting or skipping a shell', async () => {
|
||||
const result = await inertChild(runner, "console.log('probe started'); setInterval(() => {}, 1000);", [], STALL_LIMITS);
|
||||
assert.strictEqual(result.timedOut, true);
|
||||
assert.ok(result.stdout.includes('probe started'));
|
||||
assert.throws(() => probe(result), /infrastructure failure/);
|
||||
}],
|
||||
['probe classification accepts only known capability outcomes', () => {
|
||||
const complete = { code: 0, closed: true, stderr: '', diagnostic: 'synthetic probe' };
|
||||
assert.strictEqual(probe(complete), 'supported');
|
||||
assert.strictEqual(probe({ ...complete, code: 127, stderr: 'dash: 1: eval: [[: not found\n' }), 'unsupported');
|
||||
assert.strictEqual(probe({ ...complete, code: 127, stderr: 'sh: [[: command not found\n' }), 'unsupported');
|
||||
for (const result of [
|
||||
{ ...complete, code: 2, stderr: 'syntax error' },
|
||||
{ ...complete, code: 127, stderr: 'other command not found' },
|
||||
{ ...complete, errorCode: 'EACCES' },
|
||||
{ ...complete, signal: 'SIGKILL' },
|
||||
{ ...complete, timedOut: true },
|
||||
{ ...complete, outputLimit: true },
|
||||
{ ...complete, forcedPipeClose: true },
|
||||
{ ...complete, closed: false },
|
||||
]) assert.throws(() => probe(result), /failure/);
|
||||
}],
|
||||
['cleanup attempts every owned root and preserves falsy primary errors', () => {
|
||||
for (const primary of [undefined, null, false, 0, '']) {
|
||||
const attempts = [];
|
||||
let threw = false;
|
||||
try {
|
||||
finish(['first', 'second'], true, primary, dir => {
|
||||
attempts.push(dir);
|
||||
if (dir === 'first') throw new Error('synthetic cleanup failure');
|
||||
});
|
||||
} catch (error) {
|
||||
threw = true;
|
||||
assert.strictEqual(error, primary);
|
||||
}
|
||||
assert.strictEqual(threw, true);
|
||||
assert.deepStrictEqual(attempts, ['first', 'second']);
|
||||
}
|
||||
}],
|
||||
['cleanup-only failures remain failures after all roots are attempted', () => {
|
||||
const attempts = [];
|
||||
assert.throws(() => finish(['first', 'second'], false, undefined, dir => {
|
||||
attempts.push(dir);
|
||||
throw new Error('synthetic cleanup failure');
|
||||
}), error => error instanceof AggregateError && error.errors.length === 2);
|
||||
assert.deepStrictEqual(attempts, ['first', 'second']);
|
||||
}],
|
||||
['shell capability probe passes a private source file as one literal argument', async () => {
|
||||
const observed = [];
|
||||
const result = await findProbe(async (binary, args, options) => {
|
||||
assert.strictEqual(args.length, 1, 'probe source must be a file argument');
|
||||
assert.ok(path.isAbsolute(args[0]));
|
||||
assert.strictEqual(fs.readFileSync(args[0], 'utf8'), "eval '[[ 1 == 1 ]]'\n");
|
||||
assert.strictEqual(fs.statSync(args[0]).mode & 0o777, 0o600);
|
||||
assert.ok(options.env.HOME.startsWith(path.dirname(args[0]) + path.sep));
|
||||
observed.push({ binary, source: args[0] });
|
||||
return binary === 'dash'
|
||||
? { code: 1, errorCode: 'ENOENT', closed: true, stderr: '' }
|
||||
: { code: 127, closed: true, stderr: 'sh: [[: not found\n', diagnostic: 'fixed probe' };
|
||||
});
|
||||
assert.strictEqual(result, 'sh');
|
||||
assert.deepStrictEqual(observed.map(item => item.binary), ['dash', 'sh']);
|
||||
assert.strictEqual(observed[0].source, observed[1].source);
|
||||
assert.strictEqual(fs.existsSync(path.dirname(observed[0].source)), false);
|
||||
}],
|
||||
['shell capability infrastructure failure stops probing and removes its source', async () => {
|
||||
const observed = [];
|
||||
await assert.rejects(findProbe(async (binary, args) => {
|
||||
assert.strictEqual(args.length, 1, 'probe source must be a file argument');
|
||||
assert.strictEqual(fs.readFileSync(args[0], 'utf8'), "eval '[[ 1 == 1 ]]'\n");
|
||||
observed.push({ binary, source: args[0] });
|
||||
return { code: 1, timedOut: true, closed: true, diagnostic: 'fixed timed-out probe' };
|
||||
}), /infrastructure failure/);
|
||||
assert.strictEqual(observed.length, 1, 'do not select another shell after infrastructure failure');
|
||||
assert.strictEqual(observed[0].binary, 'dash');
|
||||
assert.strictEqual(fs.existsSync(path.dirname(observed[0].source)), false);
|
||||
}],
|
||||
['child environment contains only private roots and explicit runtime settings', async () => {
|
||||
await withTempDirs(['install-sh-env-control-'], async root => {
|
||||
const env = privateEnvironment(root);
|
||||
assert.deepStrictEqual(Object.keys(env).sort(), ['HOME', 'LANG', 'LC_ALL', 'PATH', 'TEMP', 'TMP', 'TMPDIR', 'USERPROFILE'].sort());
|
||||
for (const key of ['HOME', 'USERPROFILE', 'TEMP', 'TMP', 'TMPDIR']) assert.ok(env[key].startsWith(root + path.sep));
|
||||
assert.strictEqual(env.PATH, CHILD_PATH);
|
||||
});
|
||||
}],
|
||||
];
|
||||
}
|
||||
|
||||
async function runLifecycleTests(setExit = true) {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
for (const [name, fn] of lifecycleCases()) {
|
||||
if (await test(name, fn)) passed++; else failed++;
|
||||
}
|
||||
if (setExit) console.log(`Lifecycle results: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (setExit) process.exitCode = failed ? 1 : 0;
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing install.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
@@ -65,15 +418,16 @@ function runTests() {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - skipped on Windows; install.ps1 covers the native wrapper path');
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(0);
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (test('delegates to the Node installer and preserves dry-run output', () => {
|
||||
const homeDir = createTempDir('install-sh-home-');
|
||||
const projectDir = createTempDir('install-sh-project-');
|
||||
if (process.argv.includes('--lifecycle-only')) return runLifecycleTests();
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', '--dry-run', 'typescript'], {
|
||||
if (await test('delegates to the Node installer and preserves dry-run output', async () => {
|
||||
await withTempDirs(['install-sh-home-', 'install-sh-project-'], async (homeDir, projectDir) => {
|
||||
|
||||
const result = await run(['--target', 'cursor', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
@@ -81,21 +435,16 @@ function runTests() {
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Dry-run install plan'));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('absolute wrapper bootstraps a fresh source while preserving the target project cwd', () => {
|
||||
const sourceDir = createTempDir('install-sh-source-');
|
||||
const projectDir = createTempDir('install-sh-target-');
|
||||
const binDir = path.join(sourceDir, 'test-bin');
|
||||
const scriptsDir = path.join(sourceDir, 'scripts');
|
||||
const npmCwdPath = path.join(sourceDir, 'npm-cwd.txt');
|
||||
const fixtureScript = path.join(sourceDir, 'install.sh');
|
||||
if (await test('absolute wrapper bootstraps a fresh source while preserving the target project cwd', async () => {
|
||||
await withTempDirs(['install-sh-source-', 'install-sh-target-'], async (sourceDir, projectDir) => {
|
||||
const binDir = path.join(sourceDir, 'test-bin');
|
||||
const scriptsDir = path.join(sourceDir, 'scripts');
|
||||
const npmCwdPath = path.join(sourceDir, 'npm-cwd.txt');
|
||||
const fixtureScript = path.join(sourceDir, 'install.sh');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
fs.copyFileSync(SCRIPT, fixtureScript);
|
||||
@@ -109,12 +458,12 @@ function runTests() {
|
||||
'console.log(JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }));\n'
|
||||
);
|
||||
|
||||
const result = run(['--target', 'antigravity', '--dry-run', 'typescript'], {
|
||||
const result = await run(['--target', 'antigravity', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
scriptPath: fixtureScript,
|
||||
env: {
|
||||
ECC_TEST_NPM_CWD: npmCwdPath,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
||||
PATH: `${binDir}${path.delimiter}${CHILD_PATH}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -124,14 +473,69 @@ function runTests() {
|
||||
assert.deepStrictEqual(payload.args, ['--target', 'antigravity', '--dry-run', 'typescript']);
|
||||
assert.strictEqual(fs.readFileSync(npmCwdPath, 'utf8').trim(), sourceDir);
|
||||
assert.ok(fs.existsSync(path.join(sourceDir, 'node_modules')));
|
||||
} finally {
|
||||
cleanup(sourceDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exposes the corrected Claude target help text', () => {
|
||||
const result = run(['--help']);
|
||||
if (await test('delegates to the Node installer when invoked via a POSIX sh wrapper', async () => {
|
||||
await withTempDirs(['install-sh-posix-source-', 'install-sh-posix-target-'], async (sourceDir, projectDir) => {
|
||||
const scriptsDir = path.join(sourceDir, 'scripts');
|
||||
const fixtureScript = path.join(sourceDir, 'install.sh');
|
||||
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(sourceDir, 'node_modules'), { recursive: true });
|
||||
fs.copyFileSync(SCRIPT, fixtureScript);
|
||||
fs.writeFileSync(
|
||||
path.join(scriptsDir, 'install-apply.js'),
|
||||
'console.log(JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }));\n'
|
||||
);
|
||||
|
||||
const result = await run(['--target', 'antigravity', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
scriptPath: fixtureScript,
|
||||
shell: 'sh',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout.trim().split('\n').at(-1));
|
||||
assert.strictEqual(payload.cwd, fs.realpathSync(projectDir));
|
||||
assert.deepStrictEqual(payload.args, ['--target', 'antigravity', '--dry-run', 'typescript']);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
const posixOnlyShell = await findPosixOnlyShell();
|
||||
if (!posixOnlyShell) {
|
||||
console.log(
|
||||
' - skipped: re-execs into bash under sh even when BASH_VERSION is spoofed in the environment ' +
|
||||
'(no shell without `[[` support was found on this system)'
|
||||
);
|
||||
} else if (await test('re-execs into bash under sh even when BASH_VERSION is spoofed in the environment', async () => {
|
||||
await withTempDirs(['install-sh-spoof-source-', 'install-sh-spoof-target-'], async (sourceDir, projectDir) => {
|
||||
const scriptsDir = path.join(sourceDir, 'scripts');
|
||||
const fixtureScript = path.join(sourceDir, 'install.sh');
|
||||
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(sourceDir, 'node_modules'), { recursive: true });
|
||||
fs.copyFileSync(SCRIPT, fixtureScript);
|
||||
fs.writeFileSync(
|
||||
path.join(scriptsDir, 'install-apply.js'),
|
||||
'console.log(JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }));\n'
|
||||
);
|
||||
|
||||
const result = await run(['--target', 'antigravity', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
scriptPath: fixtureScript,
|
||||
shell: posixOnlyShell,
|
||||
env: { BASH_VERSION: '9.9.9(1)-spoofed' },
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout.trim().split('\n').at(-1));
|
||||
assert.deepStrictEqual(payload.args, ['--target', 'antigravity', '--dry-run', 'typescript']);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('exposes the corrected Claude target help text', async () => {
|
||||
const result = await run(['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(
|
||||
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
|
||||
@@ -139,8 +543,16 @@ function runTests() {
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
const lifecycle = await runLifecycleTests(false);
|
||||
passed += lifecycle.passed;
|
||||
failed += lifecycle.failed;
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
runTests();
|
||||
if (require.main === module) {
|
||||
process.exitCode = 1; // A never-settling Promise must not silently exit successfully.
|
||||
runTests().catch(error => console.error(error && error.stack ? error.stack : String(error)));
|
||||
}
|
||||
|
||||
module.exports = { runChild, classifyProbe, finishCleanup, lifecycleCases, runLifecycleTests };
|
||||
|
||||
@@ -114,6 +114,211 @@ function extractNamedTable(content, ariaLabel) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
const README_SPONSOR_LABELS = {
|
||||
"en": {
|
||||
"active": "Partners and sponsors",
|
||||
"community": "Community sponsors:",
|
||||
"past": "Past sponsors:",
|
||||
"inactive": "inactive"
|
||||
},
|
||||
"uk": {
|
||||
"active": "\u041f\u0430\u0440\u0442\u043d\u0435\u0440\u0438 \u0442\u0430 \u0441\u043f\u043e\u043d\u0441\u043e\u0440\u0438",
|
||||
"community": "\u0421\u043f\u043e\u043d\u0441\u043e\u0440\u0438 \u0441\u043f\u0456\u043b\u044c\u043d\u043e\u0442\u0438:",
|
||||
"past": "\u041c\u0438\u043d\u0443\u043b\u0456 \u0441\u043f\u043e\u043d\u0441\u043e\u0440\u0438:",
|
||||
"inactive": "\u043d\u0435\u0430\u043a\u0442\u0438\u0432\u043d\u0438\u0439"
|
||||
}
|
||||
};
|
||||
|
||||
const CURRENT_SPONSOR_TIERS = [
|
||||
'Strategic Sponsors', 'Business Sponsors', 'Team Sponsors',
|
||||
'Pro Sponsors', 'Builder Sponsors', 'Supporters',
|
||||
];
|
||||
const SPANISH_SPONSOR_TIERS = [
|
||||
'Patrocinadores Empresariales', 'Patrocinadores Business', 'Patrocinadores Team',
|
||||
'Patrocinadores Pro', 'Patrocinadores Builder', 'Supporters',
|
||||
];
|
||||
const FORMER_SPONSOR_PATTERN = /Atlas Cloud|atlascloud|Mike Morgan|mikejmorgan-ai/i;
|
||||
const ATLAS_STATUS_NOTE = '> Sponsor status: Atlas Cloud is a past sponsor. This status change leaves the documented integration unchanged.';
|
||||
const ATLAS_GUIDE_HEADING = '# Atlas Cloud \u2014 LLM Provider Guide';
|
||||
const MIKE_LINK = '[Mike Morgan](https://github.com/mikejmorgan-ai)';
|
||||
|
||||
function requiredRegion(content, opening, closing) {
|
||||
assert.strictEqual(content.split(opening).length - 1, 1, `Expected one opening: ${opening}`);
|
||||
const start = content.indexOf(opening);
|
||||
const end = content.indexOf(closing, start + opening.length);
|
||||
assert.ok(end > start, `Missing or reversed closing: ${closing}`);
|
||||
const body = content.slice(start + opening.length, end);
|
||||
assert.doesNotMatch(body, /<p\b|<sub\b/, 'Sponsor region must close before another region starts');
|
||||
return { body, start, end: end + closing.length };
|
||||
}
|
||||
|
||||
function assertPastSponsorEntries(content, inactive, includeAtlas) {
|
||||
for (const identity of ['Mike Morgan', 'https://github.com/mikejmorgan-ai']) {
|
||||
assert.strictEqual(content.split(identity).length - 1, 1, `Expected one past ${identity}`);
|
||||
}
|
||||
assert.ok(content.includes(`Mike Morgan (${inactive})`) || content.includes(`${MIKE_LINK} (${inactive})`), 'Past Mike entry must be explicitly inactive');
|
||||
if (includeAtlas) {
|
||||
assert.strictEqual((content.match(/Atlas Cloud/g) || []).length, 1, 'Expected one past Atlas Cloud');
|
||||
assert.strictEqual(content.split('https://www.atlascloud.ai/').length - 1, 1, 'Expected one past Atlas link');
|
||||
}
|
||||
}
|
||||
|
||||
function assertReadmeSponsorStatus(content, locale) {
|
||||
const labels = README_SPONSOR_LABELS[locale];
|
||||
const active = requiredRegion(content, `<p align="center" aria-label="${labels.active}">`, '</p>');
|
||||
const community = requiredRegion(content, `<sub><strong>${labels.community}</strong>`, '</sub>');
|
||||
const past = requiredRegion(content, `<sub><strong>${labels.past}</strong>`, '</sub>');
|
||||
assert.ok(active.end < community.start && active.end < past.start, 'Current logo region must precede sponsor rows');
|
||||
assert.doesNotMatch(active.body, FORMER_SPONSOR_PATTERN);
|
||||
assert.doesNotMatch(community.body, FORMER_SPONSOR_PATTERN);
|
||||
for (const current of ['CodeRabbit', 'Greptile', 'Moonshot', 'compute.itomarkets.com', 'SerpApi']) {
|
||||
assert.ok(active.body.includes(current), `Missing current sponsor ${current}`);
|
||||
}
|
||||
for (const current of ['jasonwu513', '1anter', 'massimotodaro', 'meadmccabe']) {
|
||||
assert.ok(community.body.includes(`https://github.com/${current}`), `Missing community sponsor ${current}`);
|
||||
}
|
||||
assertPastSponsorEntries(past.body, labels.inactive, true);
|
||||
}
|
||||
|
||||
function assertSponsorRoster(content, spanish = false) {
|
||||
const headings = [...content.matchAll(/^## (.+)$/gm)];
|
||||
const sections = headings.map((heading, index) => ({
|
||||
title: heading[1],
|
||||
body: content.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? content.length),
|
||||
}));
|
||||
const pastTitle = spanish ? 'Patrocinadores anteriores' : 'Past Sponsors';
|
||||
const past = sections.filter(section => section.title === pastTitle);
|
||||
assert.strictEqual(past.length, 1, 'Expected exactly one past sponsor section');
|
||||
const tiers = spanish ? SPANISH_SPONSOR_TIERS : CURRENT_SPONSOR_TIERS;
|
||||
for (const tier of tiers) {
|
||||
const matches = sections.filter(section => section.title === tier || section.title.startsWith(`${tier} \u2014 `));
|
||||
assert.strictEqual(matches.length, 1, `Expected one current tier ${tier}`);
|
||||
assert.doesNotMatch(matches[0].body, FORMER_SPONSOR_PATTERN, `Former sponsor remains in ${tier}`);
|
||||
}
|
||||
assertPastSponsorEntries(past[0].body, spanish ? 'inactivo' : 'inactive', !spanish);
|
||||
if (spanish) assert.doesNotMatch(content, /Atlas Cloud|atlascloud/i);
|
||||
else {
|
||||
const business = sections.find(section => section.title === 'Business Sponsors').body;
|
||||
for (const current of ['CodeRabbit', 'Greptile', 'Moonshot', 'compute.itomarkets.com', 'SerpApi']) {
|
||||
assert.ok(business.includes(current), `Missing current Business sponsor ${current}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertAtlasStatusPlacement(content) {
|
||||
assert.ok(content.startsWith(`${ATLAS_GUIDE_HEADING}\n\n${ATLAS_STATUS_NOTE}\n\n`), 'Status note must immediately follow H1 and one blank line');
|
||||
}
|
||||
|
||||
function readmeSponsorFixture(locale) {
|
||||
const labels = README_SPONSOR_LABELS[locale];
|
||||
const active = `<p align="center" aria-label="${labels.active}">CodeRabbit Greptile Moonshot compute.itomarkets.com SerpApi</p>`;
|
||||
const past = `<sub><strong>${labels.past}</strong> <a href="https://www.atlascloud.ai/">Atlas Cloud</a> <a href="https://github.com/mikejmorgan-ai">Mike Morgan (${labels.inactive})</a></sub>`;
|
||||
const community = `<sub><strong>${labels.community}</strong> ${['jasonwu513', '1anter', 'massimotodaro', 'meadmccabe'].map(name => `<a href="https://github.com/${name}">${name}</a>`).join(' ')}</sub>`;
|
||||
return [active, past, community].join('\n\n');
|
||||
}
|
||||
|
||||
function sponsorRosterFixture(spanish = false) {
|
||||
const tiers = spanish ? SPANISH_SPONSOR_TIERS : CURRENT_SPONSOR_TIERS;
|
||||
const sections = tiers.map(tier => `## ${tier}\n\n${tier === 'Business Sponsors' ? 'CodeRabbit Greptile Moonshot compute.itomarkets.com SerpApi' : 'Current entries'}\n`);
|
||||
const past = spanish
|
||||
? `## Patrocinadores anteriores\n\n${MIKE_LINK} (inactivo) | 2026\n`
|
||||
: `## Past Sponsors\n\n[Atlas Cloud](https://www.atlascloud.ai/) | 2026\n${MIKE_LINK} (inactive) | 2026\n`;
|
||||
sections.splice(2, 0, past); // Current Team and later tiers occur after Past.
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function sponsorDocsTests() {
|
||||
const tests = [
|
||||
['English README keeps former sponsors out of both current regions', () => assertReadmeSponsorStatus(read('README.md'), 'en')],
|
||||
['Ukrainian README keeps former sponsors out of both current regions', () => assertReadmeSponsorStatus(read('docs/uk-UA/README.md'), 'uk')],
|
||||
['canonical roster separates past entries from every current tier', () => assertSponsorRoster(read('SPONSORS.md'))],
|
||||
['Spanish roster moves only inactive Mike out of current tiers', () => assertSponsorRoster(read('docs/es/SPONSORS.md'), true)],
|
||||
['Atlas status note immediately follows the guide heading', () => assertAtlasStatusPlacement(read('docs/ATLAS-CLOUD-GUIDE.md'))],
|
||||
];
|
||||
for (const locale of ['en', 'uk']) {
|
||||
const fixture = readmeSponsorFixture(locale);
|
||||
const labels = README_SPONSOR_LABELS[locale];
|
||||
const start = `<p align="center" aria-label="${labels.active}">`;
|
||||
const community = `<sub><strong>${labels.community}</strong>`;
|
||||
const past = `<sub><strong>${labels.past}</strong>`;
|
||||
tests.push([`${locale} fixture allows past before or after community`, () => {
|
||||
assertReadmeSponsorStatus(fixture, locale);
|
||||
const [active, pastRow, communityRow] = fixture.split('\n\n');
|
||||
assertReadmeSponsorStatus([active, communityRow, pastRow].join('\n\n'), locale);
|
||||
}]);
|
||||
const mutations = [
|
||||
['missing active marker', fixture.replace(start, '<p>')],
|
||||
['missing closing marker', fixture.replace('</p>', '')],
|
||||
['reversed closing marker', `</p>${fixture.replace('</p>', '')}`],
|
||||
['duplicate active marker', `${fixture}\n${start}Extra</p>`],
|
||||
['duplicate community marker', `${fixture}\n${community}Extra</sub>`],
|
||||
['duplicate past marker', `${fixture}\n${past}Extra</sub>`],
|
||||
['missing community marker', fixture.replace(community, '<sub>')],
|
||||
['missing past marker', fixture.replace(past, '<sub>')],
|
||||
['Atlas in current logos', fixture.replace(start, `${start}Atlas Cloud `)],
|
||||
['Mike in current logos', fixture.replace(start, `${start}mikejmorgan-ai `)],
|
||||
['Mike in current community', fixture.replace(community, `${community}Mike Morgan `)],
|
||||
['Atlas in current community', fixture.replace(community, `${community}atlascloud `)],
|
||||
['missing past Mike', fixture.replace('Mike Morgan', 'Former sponsor')],
|
||||
['missing past status', fixture.replace(` (${labels.inactive})`, '')],
|
||||
['missing past Atlas', fixture.replace('Atlas Cloud', 'Former organization')],
|
||||
['past before active closes', fixture.replace('</p>', '').replace(past, `${past}</p>`)],
|
||||
];
|
||||
for (const [name, mutated] of mutations) {
|
||||
tests.push([`${locale} sponsor checker rejects ${name}`, () => {
|
||||
assertReadmeSponsorStatus(fixture, locale);
|
||||
assert.notStrictEqual(mutated, fixture);
|
||||
assert.throws(() => assertReadmeSponsorStatus(mutated, locale));
|
||||
}]);
|
||||
}
|
||||
}
|
||||
for (const spanish of [false, true]) {
|
||||
const locale = spanish ? 'es' : 'en';
|
||||
const fixture = sponsorRosterFixture(spanish);
|
||||
const tiers = spanish ? SPANISH_SPONSOR_TIERS : CURRENT_SPONSOR_TIERS;
|
||||
const past = spanish ? 'Patrocinadores anteriores' : 'Past Sponsors';
|
||||
tests.push([`${locale} roster fixture accepts current tiers on both sides of Past`, () => assertSponsorRoster(fixture, spanish)]);
|
||||
for (const tier of tiers) {
|
||||
for (const identity of ['Atlas Cloud', 'mikejmorgan-ai']) {
|
||||
tests.push([`${locale} roster rejects ${identity} in ${tier}`, () => {
|
||||
assertSponsorRoster(fixture, spanish);
|
||||
const mutated = fixture.replace(`## ${tier}\n`, `## ${tier}\n${identity}\n`);
|
||||
assert.notStrictEqual(mutated, fixture);
|
||||
assert.throws(() => assertSponsorRoster(mutated, spanish));
|
||||
}]);
|
||||
}
|
||||
}
|
||||
for (const [name, mutated] of [
|
||||
['missing past section', fixture.replace(`## ${past}`, '## Archive')],
|
||||
['duplicate past section', `${fixture}\n## ${past}\n`],
|
||||
['missing current tier', fixture.replace(`## ${tiers[2]}`, '## Other')],
|
||||
['duplicate current tier', `${fixture}\n## ${tiers[2]}\n`],
|
||||
['missing past Mike', fixture.replace('Mike Morgan', 'Former sponsor')],
|
||||
['missing inactive status', fixture.replace(spanish ? '(inactivo)' : '(inactive)', '')],
|
||||
]) {
|
||||
tests.push([`${locale} roster checker rejects ${name}`, () => {
|
||||
assertSponsorRoster(fixture, spanish);
|
||||
assert.notStrictEqual(mutated, fixture);
|
||||
assert.throws(() => assertSponsorRoster(mutated, spanish));
|
||||
}]);
|
||||
}
|
||||
}
|
||||
const guide = `${ATLAS_GUIDE_HEADING}\n\n${ATLAS_STATUS_NOTE}\n\nProvider documentation.\n`;
|
||||
tests.push(['guide fixture accepts the immediate undated status note', () => assertAtlasStatusPlacement(guide)]);
|
||||
for (const [name, mutated] of [
|
||||
['content between heading and note', guide.replace(`\n\n${ATLAS_STATUS_NOTE}`, `\n\nUnrelated content.\n\n${ATLAS_STATUS_NOTE}`)],
|
||||
['note after provider description', `${ATLAS_GUIDE_HEADING}\n\nProvider documentation.\n\n${ATLAS_STATUS_NOTE}\n\n`],
|
||||
['missing heading', guide.replace(ATLAS_GUIDE_HEADING, '')],
|
||||
['unsupported dated note', guide.replace('Sponsor status:', 'Sponsor status (2026-09-10):')],
|
||||
]) {
|
||||
tests.push([`guide checker rejects ${name}`, () => {
|
||||
assertAtlasStatusPlacement(guide);
|
||||
assert.throws(() => assertAtlasStatusPlacement(mutated));
|
||||
}]);
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('\n=== Testing Ito compute-sponsor surface ===\n');
|
||||
|
||||
@@ -459,7 +664,12 @@ function main() {
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
const docsOnly = process.argv.includes('--sponsor-docs-only');
|
||||
const docsTests = sponsorDocsTests();
|
||||
const selectedTests = docsOnly ? docsTests : [...tests, ...docsTests];
|
||||
if (docsOnly) console.log(`Sponsor docs only: ${docsTests.length} pure cases; ${tests.length} unrelated callbacks omitted.`);
|
||||
|
||||
for (const [name, fn] of selectedTests) {
|
||||
if (runTest(name, fn)) {
|
||||
passed += 1;
|
||||
} else {
|
||||
|
||||
@@ -51,6 +51,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/profile.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/dashboard-web.js",
|
||||
"scripts/discussion-audit.js",
|
||||
@@ -108,6 +109,9 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"docs/COMMAND-AGENT-MAP.md",
|
||||
"docs/ROADMAP.md",
|
||||
"docs/design/ecc-memory-vault.md",
|
||||
"docs/design/context-profiles.md",
|
||||
"docs/design/context-carriers.md",
|
||||
"docs/design/context-profile-delivery.md",
|
||||
"assets/images/sponsors",
|
||||
]
|
||||
const exclusionPaths = [
|
||||
@@ -185,6 +189,29 @@ function main() {
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/profile.js",
|
||||
"scripts/lib/context-profiles.js",
|
||||
"scripts/lib/context-pack-registry.js",
|
||||
"scripts/lib/context-profile-support.js",
|
||||
"scripts/lib/context-carriers.js",
|
||||
"scripts/lib/context-selection.js",
|
||||
"scripts/lib/context-profile-commands.js",
|
||||
"scripts/lib/context-profile-launch.js",
|
||||
"scripts/lib/context-profile-proposal.js",
|
||||
"scripts/lib/context-profile-native.js",
|
||||
"scripts/lib/context-profile-native-discovery.js",
|
||||
"scripts/lib/context-profile-native-executable.js",
|
||||
"scripts/lib/context-profile-store.js",
|
||||
"scripts/lib/context-profile-store-fs.js",
|
||||
"schemas/context-profile.schema.json",
|
||||
"schemas/context-pack-registry.schema.json",
|
||||
"schemas/context-carrier.schema.json",
|
||||
"manifests/context-profiles/lean@1.json",
|
||||
"manifests/context-profiles/full@1.json",
|
||||
"manifests/context-packs/skill-registry@1.json",
|
||||
"docs/design/context-profiles.md",
|
||||
"docs/design/context-carriers.md",
|
||||
"docs/design/context-profile-delivery.md",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/feedback.js",
|
||||
"scripts/ito.js",
|
||||
|
||||
+1315
-387
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const test = require('node:test');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
function withReadOnlyCli(fn) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-carrier-cli-'));
|
||||
const user = path.join(root, 'user');
|
||||
const workspace = path.join(root, 'workspace');
|
||||
fs.mkdirSync(user);
|
||||
fs.mkdirSync(workspace);
|
||||
fs.writeFileSync(path.join(user, 'settings.json'), '{"existing":true}\n');
|
||||
try {
|
||||
fn(args => spawnSync(process.execPath, [path.join(ROOT, 'scripts/ecc.js'), 'profile', ...args], {
|
||||
cwd: workspace, encoding: 'utf8', timeout: 30000, maxBuffer: 4 * 1024 * 1024,
|
||||
env: {
|
||||
PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
HOME: user, USERPROFILE: user, CODEX_HOME: path.join(user, '.codex'),
|
||||
CLAUDE_CONFIG_DIR: path.join(user, '.claude'),
|
||||
XDG_CONFIG_HOME: path.join(user, 'config'), XDG_STATE_HOME: path.join(user, 'state'),
|
||||
...(process.env.NODE_V8_COVERAGE ? { NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE } : {}),
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
try {
|
||||
assert.deepEqual(fs.readdirSync(root).sort(), ['user', 'workspace']);
|
||||
assert.deepEqual(fs.readdirSync(user), ['settings.json']);
|
||||
assert.equal(fs.readFileSync(path.join(user, 'settings.json'), 'utf8'), '{"existing":true}\n');
|
||||
assert.deepEqual(fs.readdirSync(workspace), []);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
}
|
||||
|
||||
function payload(result) {
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const value = JSON.parse(result.stdout);
|
||||
assert.equal(value.status, 'warning');
|
||||
assert.equal(value.activation, 'unobserved');
|
||||
assert.equal(value.carrier.active, false);
|
||||
assert.equal(value.carrier.nativeSupport, 'unobserved');
|
||||
return value;
|
||||
}
|
||||
|
||||
test('profile help exposes carrier planning without a write command', () => withReadOnlyCli(run => {
|
||||
const result = run(['--help']);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /ecc profile carrier/);
|
||||
assert.match(result.stdout, /read-only/i);
|
||||
}));
|
||||
|
||||
test('default carrier preview is a deterministic Lean Codex proposal', () => withReadOnlyCli(run => {
|
||||
const first = payload(run(['carrier', '--json']));
|
||||
assert.deepEqual(payload(run(['carrier', '--json'])), first);
|
||||
assert.equal(first.carrier.target, 'codex');
|
||||
assert.equal(first.carrier.profileId, 'lean@1');
|
||||
assert.equal(first.carrier.selectionMode, 'auto');
|
||||
assert.equal(first.carrier.selectedIds.length, 3);
|
||||
assert.equal(first.carrier.status, 'planned');
|
||||
assert.equal(first.artifacts[0].digest, first.carrier.carrierDigest);
|
||||
assert.ok(!JSON.stringify(first).includes(ROOT));
|
||||
}));
|
||||
|
||||
test('Full carrier keeps explicit exclusions and manual intent', () => withReadOnlyCli(run => {
|
||||
const { carrier } = payload(run(['carrier', 'full@1', '--selection', 'manual',
|
||||
'--exclude', 'skill:python-patterns', '--json']));
|
||||
assert.equal(carrier.selectionMode, 'manual');
|
||||
assert.ok(carrier.excludedIds.includes('skill:python-patterns'));
|
||||
assert.ok(carrier.files.every(file => file.skillId !== 'skill:python-patterns'));
|
||||
}));
|
||||
|
||||
test('every implemented layout remains explicitly native-unobserved', () => withReadOnlyCli(run => {
|
||||
for (const target of ['claude', 'codex', 'cursor', 'opencode', 'pi']) {
|
||||
const { carrier } = payload(run(['carrier', '--target', target, '--json']));
|
||||
assert.equal(carrier.target, target);
|
||||
assert.equal(carrier.status, 'planned');
|
||||
assert.ok(carrier.files.length > 0);
|
||||
}
|
||||
}));
|
||||
|
||||
test('recognized unsupported target returns inventory without generated files', () => withReadOnlyCli(run => {
|
||||
const { carrier } = payload(run(['carrier', '--target', 'kimi', '--json']));
|
||||
assert.equal(carrier.status, 'unsupported');
|
||||
assert.deepEqual(carrier.files, []);
|
||||
assert.equal(carrier.selectedIds.length, 3);
|
||||
}));
|
||||
|
||||
test('carrier text and dry-run output preserve read-only and unobserved boundaries', () => withReadOnlyCli(run => {
|
||||
const result = run(['carrier', '--dry-run']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /carrier/i);
|
||||
assert.match(result.stdout, /unobserved/i);
|
||||
assert.match(result.stdout, /planned|proposed/i);
|
||||
const expected = payload(run(['carrier', 'lean@1', '--target', 'codex', '--json']));
|
||||
for (const args of [
|
||||
['--dry-run', 'carrier', 'lean@1', '--target', 'codex', '--json'],
|
||||
['carrier', 'lean@1', '--target', '--dry-run', 'codex', '--json'],
|
||||
]) assert.deepEqual(payload(run(args)), expected);
|
||||
}));
|
||||
|
||||
test('carrier rejects unknown targets, write destinations, and hook flags', () => withReadOnlyCli(run => {
|
||||
for (const args of [['--target', 'unknown'], ['--output', 'user'], ['--hooks', 'strict']]) {
|
||||
const result = run(['carrier', ...args, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const error = JSON.parse(result.stdout);
|
||||
assert.equal(error.status, 'error');
|
||||
assert.doesNotMatch(error.summary, /Cannot find module|Require stack/);
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const test = require('node:test');
|
||||
const CLI = path.resolve(__dirname, '../../scripts/ecc.js');
|
||||
const task = { sessionId: 'stdin-session', taskId: 'stdin-task', revision: 1,
|
||||
phase: 'implement', explicitIds: ['skill:python-patterns'] };
|
||||
function invoke(args, input) {
|
||||
return spawnSync(process.execPath, [CLI, 'profile', ...args, '--json'], {
|
||||
input, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 });
|
||||
}
|
||||
|
||||
test('resolve accepts bounded task JSON on stdin without creating task files', () => {
|
||||
const result = invoke(['resolve', '--task-input', '-', '--load'], JSON.stringify(task));
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout).selection.loadedIds, ['skill:python-patterns']);
|
||||
});
|
||||
|
||||
test('stdin task JSON rejects overflow, malformed UTF-8, NUL, and invalid JSON', () => {
|
||||
for (const [input, message] of [[Buffer.alloc(65537, 32), /65536/], [Buffer.from([0xff]), /UTF-8/],
|
||||
['\0', /UTF-8/], ['{', /JSON/]]) {
|
||||
const result = invoke(['resolve', '--task-input', '-'], input);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).summary, message);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed task JSON never echoes private input through the CLI envelope', () => {
|
||||
const secret = 'PRIVATE_TASK_SENTINEL';
|
||||
const result = invoke(['resolve', '--task-input', '-'], `{"task":"${secret}"`);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).summary, /valid JSON/);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, new RegExp(secret));
|
||||
});
|
||||
|
||||
test('start requires both roots, rejects authority flags, and dry-run never prepares a native home', () => {
|
||||
const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-start-cli-'));
|
||||
try {
|
||||
const stateRoot = path.join(parent, 'state');
|
||||
const nativeRoot = path.join(parent, 'native');
|
||||
for (const [args, message] of [[[], /requires --state-root/],
|
||||
[['--state-root', stateRoot], /requires --native-root/],
|
||||
[['--state-root', stateRoot, '--native-root', nativeRoot, '--dangerously-bypass-approvals-and-sandbox'], /Unknown argument/]]) {
|
||||
const result = invoke(['start', ...args]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).summary, message);
|
||||
}
|
||||
assert.equal(invoke(['set', 'lean', '--state-root', stateRoot]).status, 0);
|
||||
const jsonStart = invoke(['start', '--state-root', stateRoot, '--native-root', nativeRoot]);
|
||||
assert.equal(jsonStart.status, 1);
|
||||
assert.match(JSON.parse(jsonStart.stdout).summary, /--json requires --dry-run/);
|
||||
const result = invoke(['start', '--state-root', stateRoot, '--native-root', nativeRoot, '--dry-run']);
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
assert.equal(JSON.parse(result.stdout).interactive.status, 'proposed');
|
||||
assert.equal(fs.existsSync(nativeRoot), false);
|
||||
} finally { fs.rmSync(parent, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const test = require('node:test');
|
||||
const { withFixture } = require('../lib/helpers/context-fixture');
|
||||
const CLI = path.resolve(__dirname, '../../scripts/ecc.js');
|
||||
const PROFILE_CLI = path.resolve(__dirname, '../../scripts/profile.js');
|
||||
|
||||
function cliFixture(run) {
|
||||
const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-selection-cli-'));
|
||||
const stateRoot = path.join(root, 'managed');
|
||||
const input = path.join(root, 'task.json');
|
||||
const setTask = values => fs.writeFileSync(input, JSON.stringify({ sessionId: 'test', taskId: 'test',
|
||||
revision: 1, phase: 'implement', query: 'Explain Python lists', ...values }));
|
||||
setTask({ proposedIds: ['skill:python-patterns'] });
|
||||
const invoke = (args, { preload, env = {} } = {}) => {
|
||||
const entry = preload ? ['--require', preload, PROFILE_CLI] : [CLI, 'profile'];
|
||||
const child = spawnSync(process.execPath, [...entry, ...args, '--json'], {
|
||||
cwd: root, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024,
|
||||
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE, ...env },
|
||||
});
|
||||
assert.ok(child.stdout, child.stderr || child.error?.message);
|
||||
return { code: child.status, response: JSON.parse(child.stdout) };
|
||||
};
|
||||
try { return run({ root, stateRoot, input, setTask, invoke }); }
|
||||
finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
function providerFixture(root) {
|
||||
const preload = path.join(root, 'provider-preload.cjs');
|
||||
const sentinel = path.join(root, 'provider-executed');
|
||||
fs.writeFileSync(preload, `
|
||||
const cp = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const original = cp.spawnSync;
|
||||
cp.spawnSync = (command, ...args) => {
|
||||
if (command !== 'codex' && command !== 'claude') return original(command, ...args);
|
||||
fs.writeFileSync(process.env.ECC_TEST_PROVIDER_SENTINEL, 'executed');
|
||||
return { status: Number(process.env.ECC_TEST_PROVIDER_STATUS || 0),
|
||||
stdout: 'fixture output', stderr: 'fixture provider failure' };
|
||||
};
|
||||
`);
|
||||
return { preload, sentinel, env: { ECC_TEST_PROVIDER_SENTINEL: sentinel } };
|
||||
}
|
||||
|
||||
test('CLI resolves and loads explicit task context with JSON output', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-selection-cli-'));
|
||||
try {
|
||||
const input = path.join(root, 'task.json');
|
||||
fs.writeFileSync(input, JSON.stringify({ sessionId: 'test', taskId: 'test', revision: 1, phase: 'implement',
|
||||
explicitIds: ['skill:python-patterns'] }));
|
||||
const args = ['profile', 'resolve', '--task-input', input, '--load', '--json'];
|
||||
const run = extra => spawnSync(process.execPath, [CLI, ...extra, ...args], {
|
||||
cwd: root, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024,
|
||||
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE },
|
||||
});
|
||||
const result = run([]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout).selection.loadedIds, ['skill:python-patterns']);
|
||||
const preview = run(['--dry-run']);
|
||||
assert.equal(preview.status, 0, preview.stderr || preview.stdout);
|
||||
assert.deepEqual(JSON.parse(preview.stdout).selection.loadedIds, []);
|
||||
assert.deepEqual(fs.readdirSync(root), ['task.json']);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('resolver rejects unknown flags before reading task input', () => {
|
||||
const result = spawnSync(process.execPath, [CLI, 'profile', 'resolve', '--task-input', 'missing', '--hooks', 'full', '--json'], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).summary, /Unknown argument/);
|
||||
});
|
||||
|
||||
test('saved mode and exclusions govern resolution and survive mode changes', () => cliFixture(({ stateRoot, input, setTask, invoke }) => {
|
||||
const setup = invoke(['set', 'lean', '--state-root', stateRoot, '--selection', 'suggest',
|
||||
'--include', 'skill:python-patterns', '--exclude', 'skill:python-testing']);
|
||||
assert.equal(setup.code, 0, setup.response.summary);
|
||||
const resolveArgs = ['resolve', '--state-root', stateRoot, '--task-input', input, '--load'];
|
||||
const suggestion = invoke(resolveArgs);
|
||||
assert.equal(suggestion.code, 0, suggestion.response.summary);
|
||||
assert.equal(suggestion.response.selection.selectionMode, 'suggest');
|
||||
assert.deepEqual(suggestion.response.selection.selectedIds, ['skill:python-patterns']);
|
||||
assert.deepEqual(suggestion.response.selection.loadedIds, []);
|
||||
|
||||
const preview = invoke(['mode', 'manual', '--state-root', stateRoot, '--dry-run']);
|
||||
assert.equal(preview.code, 0, preview.response.summary);
|
||||
assert.equal(preview.response.store.status, 'proposed');
|
||||
assert.equal(invoke(['status', '--state-root', stateRoot]).response.store.selectionMode, 'suggest');
|
||||
const changed = invoke(['mode', 'manual', '--state-root', stateRoot, '--expected-revision', '1']);
|
||||
assert.equal(changed.code, 0, changed.response.summary);
|
||||
assert.deepEqual(changed.response.store.include, ['skill:python-patterns']);
|
||||
assert.deepEqual(changed.response.store.exclude, ['skill:python-testing']);
|
||||
const manual = invoke(resolveArgs);
|
||||
assert.equal(manual.code, 0, manual.response.summary);
|
||||
assert.deepEqual(manual.response.selection.selectedIds, []);
|
||||
assert.equal(manual.response.selection.selectionMode, 'manual');
|
||||
|
||||
setTask({ explicitIds: ['skill:python-testing'] });
|
||||
const excluded = invoke(resolveArgs);
|
||||
assert.equal(excluded.code, 1);
|
||||
assert.match(excluded.response.summary, /excluded/);
|
||||
setTask({ proposedIds: ['skill:python-patterns'] });
|
||||
assert.equal(invoke(['mode', 'auto', '--state-root', stateRoot]).code, 0);
|
||||
const automatic = invoke(resolveArgs);
|
||||
assert.equal(automatic.code, 0, automatic.response.summary);
|
||||
assert.deepEqual(automatic.response.selection.loadedIds, ['skill:python-patterns']);
|
||||
}));
|
||||
|
||||
test('stored resolution and launch reject every configuration override before reading input', () => cliFixture(({ stateRoot, invoke }) => {
|
||||
for (const command of ['resolve', 'run']) {
|
||||
for (const override of [['full'], ['--target', 'claude'], ['--selection', 'manual'],
|
||||
['--include', 'skill:python-patterns'], ['--exclude', 'skill:python-testing']]) {
|
||||
const result = invoke([command, '--state-root', stateRoot, '--task-input', 'missing', ...override]);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.response.summary, /cannot override/);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
test('launch dry runs never load bodies or execute a provider', () => cliFixture(({ root, input, invoke }) => {
|
||||
const provider = providerFixture(root);
|
||||
for (const dry of [{ args: ['--dry-run'], env: {} }, { args: [], env: { ECC_DRY_RUN: '1' } }]) {
|
||||
const result = invoke(['run', '--task-input', input, ...dry.args],
|
||||
{ preload: provider.preload, env: { ...provider.env, ...dry.env } });
|
||||
assert.equal(result.code, 0, result.response.summary);
|
||||
assert.equal(result.response.launch.status, 'proposed');
|
||||
assert.deepEqual(result.response.launch.selection.loadedIds, []);
|
||||
assert.equal(fs.existsSync(provider.sentinel), false);
|
||||
}
|
||||
}));
|
||||
|
||||
test('provider exit failures produce a failed CLI result and preserve the native exit code', () => cliFixture(({ root, input, invoke }) => {
|
||||
const provider = providerFixture(root);
|
||||
const result = invoke(['run', '--task-input', input], { preload: provider.preload,
|
||||
env: { ...provider.env, ECC_TEST_PROVIDER_STATUS: '23' } });
|
||||
assert.equal(fs.readFileSync(provider.sentinel, 'utf8'), 'executed');
|
||||
assert.equal(result.code, 1);
|
||||
assert.equal(result.response.status, 'error');
|
||||
assert.equal(result.response.launch.status, 'failed');
|
||||
assert.equal(result.response.launch.exitCode, 23);
|
||||
assert.equal(result.response.launch.taskSuccess, 'unverified');
|
||||
assert.match(result.response.launch.error, /fixture provider failure/);
|
||||
}));
|
||||
|
||||
test('unsupported targets and stale selection digests fail before provider execution', () => cliFixture(({ root, input, invoke }) => {
|
||||
const provider = providerFixture(root);
|
||||
for (const args of [['--target', 'pi'], ['--expected-digest', '0'.repeat(64)]]) {
|
||||
const result = invoke(['run', '--task-input', input, ...args], provider);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.response.summary, /Unsupported|stale/);
|
||||
assert.equal(fs.existsSync(provider.sentinel), false);
|
||||
}
|
||||
}));
|
||||
|
||||
test('unconfigured and source-stale stores cannot resolve task context', () => cliFixture(({ stateRoot, input, invoke }) => {
|
||||
const args = ['resolve', '--state-root', stateRoot, '--task-input', input, '--load'];
|
||||
const absent = invoke(args);
|
||||
assert.equal(absent.code, 1);
|
||||
assert.match(absent.response.summary, /Configure or recover/);
|
||||
withFixture(repoRoot => require('../../scripts/lib/context-profile-store').applyStore({ repoRoot, stateRoot }));
|
||||
const stale = invoke(args);
|
||||
assert.equal(stale.code, 1);
|
||||
assert.match(stale.response.summary, /source is stale/);
|
||||
}));
|
||||
|
||||
test('malformed operation flags and stale write preconditions fail without creating a store', () => cliFixture(({ stateRoot, invoke }) => {
|
||||
for (const [args, message] of [
|
||||
[['mode', 'unknown', '--state-root', stateRoot], /Choose mode/],
|
||||
[['set', 'lean', '--state-root'], /Missing value/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--state-root', stateRoot], /Duplicate argument/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--task-input', 'missing'], /unavailable/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--expected-revision', '01'], /nonnegative integer/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--expected-revision', '1'], /revision changed/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--expected-digest', 'bad'], /Invalid expected/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--expected-digest', '0'.repeat(64)], /digest changed/],
|
||||
[['run', '--task-input', 'missing', '--load'], /Unknown argument/],
|
||||
]) {
|
||||
const result = invoke(args);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.response.summary, message);
|
||||
assert.equal(fs.existsSync(stateRoot), false);
|
||||
}
|
||||
}));
|
||||
|
||||
test('native command routing rejects missing roots and unsupported flags before provider or filesystem work', () => cliFixture(({ root, stateRoot, invoke }) => {
|
||||
const nativeRoot = path.join(root, 'native');
|
||||
for (const command of ['prepare-native', 'native-status', 'native-rollback', 'native-recover']) {
|
||||
for (const [args, pattern] of [
|
||||
[[], /requires --state-root/],
|
||||
[['--state-root', stateRoot], /requires --native-root/],
|
||||
[['--state-root', stateRoot, '--native-root', nativeRoot, '--target', 'codex'], /unavailable/],
|
||||
[['--state-root', stateRoot, '--native-root', nativeRoot, '--expected-revision', '1e2'], /nonnegative integer/],
|
||||
]) {
|
||||
const result = invoke([command, ...args]);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.response.summary, pattern);
|
||||
}
|
||||
}
|
||||
const orphan = invoke(['run', '--native-root', nativeRoot, '--task-input', 'missing']);
|
||||
assert.equal(orphan.code, 1);
|
||||
assert.match(orphan.response.summary, /--native-root requires --state-root/);
|
||||
const invalidResolve = invoke(['resolve', '--state-root', stateRoot, '--native-root', nativeRoot, '--task-input', 'missing']);
|
||||
assert.equal(invalidResolve.code, 1);
|
||||
assert.match(invalidResolve.response.summary, /--native-root is unavailable/);
|
||||
assert.equal(fs.existsSync(stateRoot), false);
|
||||
assert.equal(fs.existsSync(nativeRoot), false);
|
||||
}));
|
||||
@@ -0,0 +1,206 @@
|
||||
/** Read-only context profile journeys, exercised through the shipped CLI. */
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const CLI = path.join(ROOT, 'scripts/ecc.js');
|
||||
const PROFILE = path.join(ROOT, 'scripts/profile.js');
|
||||
|
||||
function snapshot(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
|
||||
.flatMap(entry => {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) return [`${entry.name}:link:${fs.readlinkSync(file)}`];
|
||||
return entry.isDirectory()
|
||||
? snapshot(file).map(item => `${entry.name}/${item}`)
|
||||
: [`${entry.name}:${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')}`];
|
||||
});
|
||||
}
|
||||
|
||||
function withFixture(fn) {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-profile-cli-'));
|
||||
let before;
|
||||
try {
|
||||
const userDirectory = path.join(fixture, 'user');
|
||||
const workspace = path.join(fixture, 'workspace');
|
||||
fs.mkdirSync(userDirectory);
|
||||
fs.mkdirSync(workspace);
|
||||
fs.writeFileSync(path.join(userDirectory, 'settings.json'), '{"keep":"user preference"}\n');
|
||||
fs.writeFileSync(path.join(workspace, 'owned.txt'), 'existing user work\n');
|
||||
before = snapshot(fixture);
|
||||
const run = (args, direct = false) => spawnSync(process.execPath,
|
||||
[direct ? PROFILE : CLI, ...(direct ? [] : ['profile']), ...args], {
|
||||
cwd: workspace, encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024,
|
||||
env: {
|
||||
PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
HOME: userDirectory, USERPROFILE: userDirectory,
|
||||
XDG_CONFIG_HOME: path.join(userDirectory, 'config'),
|
||||
XDG_STATE_HOME: path.join(userDirectory, 'state'),
|
||||
CLAUDE_CONFIG_DIR: path.join(userDirectory, '.claude'),
|
||||
CODEX_HOME: path.join(userDirectory, '.codex'),
|
||||
...(process.env.NODE_V8_COVERAGE ? { NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE } : {}),
|
||||
},
|
||||
});
|
||||
fn(run);
|
||||
} finally {
|
||||
try {
|
||||
if (before) assert.deepStrictEqual(snapshot(fixture), before,
|
||||
'inspection must preserve user and workspace files, including failure paths');
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function success(result) {
|
||||
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.ok(['success', 'warning'].includes(payload.status));
|
||||
assert.strictEqual(typeof payload.summary, 'string');
|
||||
assert.ok(Array.isArray(payload.next_actions));
|
||||
assert.ok(Array.isArray(payload.artifacts));
|
||||
return payload;
|
||||
}
|
||||
|
||||
const tests = [
|
||||
['the dispatcher exposes read-only profile help', () => withFixture(run => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /read-only/i);
|
||||
for (const command of ['show', 'preview', 'explain']) assert.ok(result.stdout.includes(command));
|
||||
})],
|
||||
['show lists versioned definitions without claiming an active installation', () => withFixture(run => {
|
||||
const result = success(run(['show', '--json']));
|
||||
assert.deepStrictEqual(result.profiles.map(profile => profile.id).sort(), ['full@1', 'lean@1']);
|
||||
assert.strictEqual(result.activation, 'unobserved');
|
||||
})],
|
||||
['show reads one profile definition through the direct packaged entrypoint', () => withFixture(run => {
|
||||
const result = success(run(['show', 'lean@1', '--json'], true));
|
||||
assert.strictEqual(result.profile.id, 'lean@1');
|
||||
})],
|
||||
['Lean preview is deterministic and reports no observed activation', () => withFixture(run => {
|
||||
const args = ['preview', 'lean@1', '--target', 'codex', '--selection', 'auto', '--json'];
|
||||
const first = run(args);
|
||||
const payload = success(first);
|
||||
assert.deepStrictEqual(JSON.parse(run(args).stdout), payload);
|
||||
assert.strictEqual(payload.activation, 'unobserved');
|
||||
assert.ok(payload.plan);
|
||||
assert.ok(!first.stdout.includes(ROOT), 'portable output must omit local checkout path');
|
||||
})],
|
||||
['Full remains inspectable with explicit manual selection', () => withFixture(run => {
|
||||
assert.ok(success(run(['preview', 'full@1', '--target', 'claude', '--selection', 'manual', '--json'])).plan);
|
||||
})],
|
||||
['explicit includes and exclusions remain inspection only', () => withFixture(run => {
|
||||
success(run(['preview', '--target', 'codex', '--include', 'skill:security-review',
|
||||
'--exclude', 'skill:python-patterns', '--json']));
|
||||
})],
|
||||
['exact-ID explanation includes an entry and never invokes the skill', () => withFixture(run => {
|
||||
const payload = success(run(['explain', 'skill:security-review', '--target', 'codex', '--json']));
|
||||
assert.strictEqual(payload.entry.id, 'skill:security-review');
|
||||
assert.strictEqual(payload.activation, 'unobserved');
|
||||
})],
|
||||
['text output identifies estimates and unobserved runtime state', () => withFixture(run => {
|
||||
const result = run(['preview', '--target', 'codex']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /estimate/i);
|
||||
assert.match(result.stdout, /unobserved/i);
|
||||
})],
|
||||
['text error output renders terminal controls inert', () => withFixture(run => {
|
||||
const control = String.fromCharCode(27);
|
||||
const result = run(['explain', `skill:unknown${control}]52;c;example${String.fromCharCode(7)}`]);
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.ok(!result.stderr.includes(control), 'terminal escape must not reach the text output');
|
||||
assert.match(result.stderr, /\\u001b/);
|
||||
})],
|
||||
['global dry-run remains compatible with profile inspection', () => withFixture(run => {
|
||||
success(run(['preview', '--target', 'codex', '--dry-run', '--json']));
|
||||
})],
|
||||
['global dry-run is ignored at every argument position without changing parsed controls', () => {
|
||||
const { parseArgs } = require('../../scripts/profile');
|
||||
for (const args of [
|
||||
['show', 'lean@1', '--json'],
|
||||
['preview', 'lean@1', '--target', 'codex', '--selection', 'auto',
|
||||
'--include', 'skill:security-review', '--exclude', 'skill:python-patterns', '--json'],
|
||||
['explain', 'skill:ecc-guide', '--target', 'codex', '--json'],
|
||||
]) {
|
||||
const expected = parseArgs(args);
|
||||
for (let index = 0; index <= args.length; index++) {
|
||||
const invocation = [...args.slice(0, index), '--dry-run', ...args.slice(index)];
|
||||
const before = [...invocation];
|
||||
assert.deepStrictEqual(parseArgs(invocation), expected, invocation.join(' '));
|
||||
assert.deepStrictEqual(invocation, before, 'parsing must preserve caller arguments');
|
||||
}
|
||||
assert.deepStrictEqual(parseArgs(['--dry-run', ...args, '--dry-run']), expected);
|
||||
}
|
||||
}],
|
||||
['package includes the direct profile entrypoint and public schemas', () => {
|
||||
const { files } = require('../../package.json');
|
||||
assert.ok(files.includes('scripts/profile.js'));
|
||||
assert.ok(files.includes('schemas/'));
|
||||
assert.ok(files.includes('manifests/'));
|
||||
}],
|
||||
];
|
||||
|
||||
for (const args of [
|
||||
['show', 'lean@1', '--json'],
|
||||
['preview', 'lean@1', '--target', 'codex', '--selection', 'auto', '--json'],
|
||||
['explain', 'skill:ecc-guide', '--target', 'codex', '--json'],
|
||||
]) {
|
||||
tests.push([`leading global dry-run preserves ${args[0]} through both CLI entrypoints`, () => withFixture(run => {
|
||||
const expected = success(run(args));
|
||||
for (const direct of [false, true]) {
|
||||
const observed = success(run(['--dry-run', ...args], direct));
|
||||
assert.deepStrictEqual(observed, expected);
|
||||
assert.strictEqual(observed.activation, 'unobserved');
|
||||
}
|
||||
})]);
|
||||
}
|
||||
|
||||
for (const args of [
|
||||
['use', 'lean@1'],
|
||||
['--dry-run', 'use', 'lean@1'],
|
||||
['show', 'unknown@1'],
|
||||
['preview', '--target', 'unknown-host'],
|
||||
['preview', '--selection', 'eager'],
|
||||
['preview', '--target'],
|
||||
['preview', '--target', '--json'],
|
||||
['preview', '--target', 'codex', '--target', 'claude'],
|
||||
['preview', '--include', 'skill:missing-workflow'],
|
||||
['preview', '--include', '../../outside'],
|
||||
['preview', '--hooks', 'strict'],
|
||||
['--dry-run', 'preview', '--hooks', 'strict'],
|
||||
['show', '--include', 'skill:security-review'],
|
||||
['explain'],
|
||||
['explain', 'skill:missing-workflow'],
|
||||
['explain', 'skill:ecc-guide', 'extra'],
|
||||
]) {
|
||||
tests.push([`rejects unsupported or malformed input: ${args.join(' ')}`, () => withFixture(run => {
|
||||
const result = run([...args, '--json']);
|
||||
assert.notStrictEqual(result.status, 0);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.status, 'error');
|
||||
assert.doesNotMatch(payload.summary, /Cannot find module|Require stack/,
|
||||
'validation must fail for the request, not a missing implementation');
|
||||
assert.ok(payload.next_actions.length > 0);
|
||||
assert.strictEqual(payload.activation, 'unobserved');
|
||||
})]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
let passed = 0;
|
||||
for (const [name, test] of tests) {
|
||||
try { test(); passed++; console.log(` PASS ${name}`); }
|
||||
catch (error) { console.error(` FAIL ${name}: ${error.message}`); }
|
||||
}
|
||||
console.log(`\nPassed: ${passed}\nFailed: ${tests.length - passed}`);
|
||||
process.exitCode = passed === tests.length ? 0 : 1;
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
module.exports = { main };
|
||||
@@ -134,12 +134,22 @@ function runTests() {
|
||||
'release.sh should detect metadata that already declares the requested version'
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('echo " git tag \\"v$VERSION\\""') &&
|
||||
source.includes('echo " git tag -s \\"v$VERSION\\" -m \\"Release v$VERSION\\""') &&
|
||||
source.includes('echo " git push origin \\"v$VERSION\\""'),
|
||||
'same-version guidance should point maintainers to the tag-driven publish path'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release signs an annotated tag and stops before push if signing fails', () => {
|
||||
assert.match(source, /^set -euo pipefail$/m);
|
||||
const tagCommand = 'git tag -s "v$VERSION" -m "Release v$VERSION"';
|
||||
const tagIndex = source.indexOf('\n' + tagCommand + '\n');
|
||||
const pushIndex = source.indexOf('\ngit push origin main "v$VERSION"');
|
||||
assert.ok(tagIndex > source.indexOf('git commit -m'), 'sign after the release commit');
|
||||
assert.ok(pushIndex > tagIndex, 'push only after successful signing');
|
||||
assert.doesNotMatch(source.slice(tagIndex, pushIndex), /\|\||set \+e/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release workflows mark prerelease tags as GitHub prereleases', () => {
|
||||
assert.ok(
|
||||
releaseWorkflowSource.includes('prerelease: ${{ contains(github.ref_name, \'-\') }}'),
|
||||
|
||||
+43
-12
@@ -78,6 +78,35 @@ function runSetup(fixture, args, options = {}) {
|
||||
function quoteShellArgument(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// Answer only after the PTY displays a prompt. Fixed-delay pipes can deliver
|
||||
// blank defaults and EOF before the wizard creates its readline interface.
|
||||
function driveInteractiveTerminal() {
|
||||
const { spawn } = require('child_process');
|
||||
const { pseudoTerminalCommand, answers } = JSON.parse(process.argv[1]);
|
||||
// Node pipes are sockets on macOS; script requires a real pipe for stdin.
|
||||
const child = spawn('sh', ['-c', `cat | ${pseudoTerminalCommand}`], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let pending = '';
|
||||
let answerIndex = 0;
|
||||
child.stdout.on('data', chunk => {
|
||||
process.stdout.write(chunk);
|
||||
pending += chunk.toString('utf8');
|
||||
const prompt = /Choose(?: \[\d+\])?: |\[y\/N\] /.exec(pending);
|
||||
if (!prompt) return;
|
||||
pending = pending.slice(prompt.index + prompt[0].length);
|
||||
if (answerIndex >= answers.length) { child.stdin.end(); return; }
|
||||
const answer = answers[answerIndex++];
|
||||
child.stdin.write(answer === '\u0004' ? answer : `${answer}\n`);
|
||||
if (answerIndex === answers.length) child.stdin.end();
|
||||
});
|
||||
child.stderr.on('data', chunk => process.stderr.write(chunk));
|
||||
child.stdin.on('error', error => {
|
||||
if (error.code !== 'EPIPE') { process.stderr.write(error.message); process.exitCode = 1; }
|
||||
});
|
||||
child.on('error', error => { process.stderr.write(error.message); process.exitCode = 1; });
|
||||
child.on('close', code => { process.exitCode = code ?? 1; });
|
||||
}
|
||||
|
||||
function runInteractiveEccSetup(fixture, options = {}) {
|
||||
if (process.platform === 'win32') {
|
||||
return null;
|
||||
@@ -85,12 +114,18 @@ function runInteractiveEccSetup(fixture, options = {}) {
|
||||
|
||||
const args = options.args || ['--dry-run'];
|
||||
const answers = options.answers || ['3', '3'];
|
||||
const command = [
|
||||
const setupCommand = [
|
||||
process.execPath,
|
||||
eccScript,
|
||||
'setup',
|
||||
...args,
|
||||
];
|
||||
const command = options.delayedStartup
|
||||
? [process.execPath, '-e', `setTimeout(() => {
|
||||
const result = require('child_process').spawnSync(process.argv[1], process.argv.slice(2), { stdio: 'inherit' });
|
||||
process.exitCode = result.status ?? 1;
|
||||
}, 1250);`, ...setupCommand]
|
||||
: setupCommand;
|
||||
const scriptArgs = process.platform === 'darwin'
|
||||
? ['-q', '-e', '/dev/null', ...command]
|
||||
: [
|
||||
@@ -100,16 +135,11 @@ function runInteractiveEccSetup(fixture, options = {}) {
|
||||
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}`,
|
||||
const pseudoTerminalCommand = ['script', ...scriptArgs].map(quoteShellArgument).join(' ');
|
||||
return spawnSync(process.execPath, [
|
||||
'-e',
|
||||
`(${driveInteractiveTerminal.toString()})();`,
|
||||
JSON.stringify({ pseudoTerminalCommand, answers }),
|
||||
], {
|
||||
cwd: fixture.projectRoot,
|
||||
env: {
|
||||
@@ -386,8 +416,8 @@ test('setup automatically migrates an existing install to the selected scope and
|
||||
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.every(argv => !argv.includes('--config')));
|
||||
assert.ok(calls.some(argv => (
|
||||
argv.join(' ') === 'plugin uninstall ecc@ecc --scope local --keep-data'
|
||||
)));
|
||||
@@ -914,6 +944,7 @@ test('interactive defaults preserve an existing install scope and hook preferenc
|
||||
const result = runInteractiveEccSetup(fixture, {
|
||||
args: ['--dry-run'],
|
||||
answers: ['', ''],
|
||||
delayedStartup: true,
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
|
||||
Reference in New Issue
Block a user