mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-21 06:52:24 +02:00
* feat(install): add guided Claude plugin setup * fix: support Claude command shims on Windows * feat: support safe Claude plugin scope migration * fix(install): preserve interactive setup terminal * fix(install): auto-migrate setup scope changes * feat(install): add guided multi-harness installer * fix(install): sync Yarn binary metadata * fix(install): handle wizard EOF on Node 18 * ci: allow installer matrix tests to finish * test(install): allow slower PowerShell delegation * fix(install): harden guided provider reconciliation * test(install): harden packaged and local compatibility * chore: prepare guided installer release 2.2.0 * fix(install): report refreshed Codex marketplace state * fix(install): verify managed content provenance * test(install): allow empty Yarn smoke fixture * test(install): invoke Windows package shims safely * fix(install): close cross-platform release gaps * fix(install): require trusted GitHub origins * fix(install): preserve hook profile precedence * refactor(install): centralize trusted GitHub origins * ci: retrigger workflow run after merge of main Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
486 lines
17 KiB
JavaScript
486 lines
17 KiB
JavaScript
/**
|
|
* Contract tests for the consolidated PostToolUse dispatchers.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const repoRoot = path.join(__dirname, '..', '..');
|
|
const hooksPath = path.join(repoRoot, 'hooks', 'hooks.json');
|
|
const dispatcherPath = path.join(repoRoot, 'scripts', 'hooks', 'posttooluse-dispatcher.js');
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function runDispatcher(mode, toolName, env = {}) {
|
|
const raw = JSON.stringify({
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: toolName,
|
|
tool_input: toolName === 'Bash' ? { command: 'true' } : { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
|
|
tool_response: {}
|
|
});
|
|
|
|
return spawnSync(process.execPath, [dispatcherPath, mode], {
|
|
cwd: repoRoot,
|
|
input: raw,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_PLUGIN_ROOT: repoRoot,
|
|
ECC_PLUGIN_ROOT: repoRoot,
|
|
...env
|
|
},
|
|
timeout: 10000
|
|
});
|
|
}
|
|
|
|
function previewedIds(stderr) {
|
|
return [...String(stderr).matchAll(/Hook "([^"]+)"/g)].map(match => match[1]);
|
|
}
|
|
|
|
function runConfiguredCommand(entry, raw, env = {}) {
|
|
return spawnSync(entry.hooks[0].command, {
|
|
shell: true,
|
|
cwd: repoRoot,
|
|
input: raw,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_PLUGIN_ROOT: repoRoot,
|
|
ECC_PLUGIN_ROOT: repoRoot,
|
|
...env
|
|
},
|
|
timeout: 10000
|
|
});
|
|
}
|
|
|
|
function runTests() {
|
|
console.log('\n=== PostToolUse dispatcher tests ===\n');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
if (
|
|
test('hooks.json exposes one sync and one async PostToolUse entry', () => {
|
|
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
|
|
assert.strictEqual(entries.length, 2, 'PostToolUse should launch at most two commands');
|
|
assert.deepStrictEqual(
|
|
entries.map(entry => entry.id),
|
|
['post:dispatcher:sync', 'post:dispatcher:async']
|
|
);
|
|
assert.ok(entries.every(entry => entry.matcher === '*'));
|
|
assert.strictEqual(entries[0].hooks[0].async, undefined);
|
|
assert.strictEqual(entries[1].hooks[0].async, true);
|
|
assert.ok(entries[0].hooks[0].command.includes('posttooluse-dispatcher.js'));
|
|
assert.ok(entries[0].hooks[0].command.endsWith('" sync'));
|
|
assert.ok(entries[1].hooks[0].command.includes('posttooluse-dispatcher.js'));
|
|
assert.ok(entries[1].hooks[0].command.endsWith('" async'));
|
|
assert.ok(entries.every(entry => entry.hooks[0].command.includes('resolve-ecc-root')));
|
|
assert.ok(
|
|
entries.every(entry => !entry.hooks[0].command.includes('plugin-hook-bootstrap.js')),
|
|
'PostToolUse dispatchers should not spawn a second Node bootstrap process'
|
|
);
|
|
assert.ok(entries[1].hooks[0].timeout >= 30);
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('dry-run selects the original IDs by tool and phase', () => {
|
|
const cases = [
|
|
{
|
|
tool: 'Edit',
|
|
sync: [
|
|
'post:edit:design-quality-check',
|
|
'post:edit:accumulator',
|
|
'post:edit:console-warn',
|
|
'post:governance-capture',
|
|
'post:session-activity-tracker',
|
|
'post:ecc-metrics-bridge',
|
|
'post:ecc-context-monitor'
|
|
],
|
|
async: ['post:quality-gate', 'post:observe:continuous-learning']
|
|
},
|
|
{
|
|
tool: 'Write',
|
|
sync: ['post:edit:design-quality-check', 'post:edit:accumulator', 'post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
|
|
async: ['post:quality-gate', 'post:observe:continuous-learning']
|
|
},
|
|
{
|
|
tool: 'Bash',
|
|
sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
|
|
async: ['post:bash:dispatcher', 'post:observe:continuous-learning']
|
|
},
|
|
{
|
|
tool: 'Read',
|
|
sync: ['post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
|
|
async: ['post:observe:continuous-learning']
|
|
}
|
|
];
|
|
|
|
for (const expected of cases) {
|
|
const sync = runDispatcher('sync', expected.tool, { ECC_DRY_RUN: '1' });
|
|
const asyncResult = runDispatcher('async', expected.tool, { ECC_DRY_RUN: '1' });
|
|
assert.strictEqual(sync.status, 0, sync.stderr);
|
|
assert.strictEqual(asyncResult.status, 0, asyncResult.stderr);
|
|
assert.deepStrictEqual(previewedIds(sync.stderr), expected.sync, `${expected.tool} sync IDs`);
|
|
assert.deepStrictEqual(previewedIds(asyncResult.stderr), expected.async, `${expected.tool} async IDs`);
|
|
assert.strictEqual(sync.stdout, '');
|
|
assert.strictEqual(asyncResult.stdout, '');
|
|
}
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('actual hooks.json commands preserve Edit dry-run output and IDs', () => {
|
|
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
|
|
const raw = JSON.stringify({
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
|
|
tool_response: {}
|
|
});
|
|
const results = entries.map(entry => runConfiguredCommand(entry, raw, { ECC_DRY_RUN: '1' }));
|
|
|
|
for (const result of results) {
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, raw, 'configured command should preserve pass-through output');
|
|
}
|
|
const ids = results.flatMap(result => previewedIds(result.stderr));
|
|
assert.deepStrictEqual(ids, [
|
|
'post:edit:design-quality-check',
|
|
'post:edit:accumulator',
|
|
'post:edit:console-warn',
|
|
'post:governance-capture',
|
|
'post:session-activity-tracker',
|
|
'post:ecc-metrics-bridge',
|
|
'post:ecc-context-monitor',
|
|
'post:quality-gate',
|
|
'post:observe:continuous-learning'
|
|
]);
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('actual hooks.json commands never echo truncated oversized input', () => {
|
|
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
|
|
const values = ['x'.repeat(1024 * 1024 + 1024), 'é'.repeat(600000), '\u{1F600}'.repeat(300000)];
|
|
|
|
for (const value of values) {
|
|
const raw = JSON.stringify({
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Read',
|
|
tool_input: { value },
|
|
tool_response: {}
|
|
});
|
|
assert.ok(Buffer.byteLength(raw, 'utf8') > 1024 * 1024);
|
|
|
|
for (const entry of entries) {
|
|
const result = runConfiguredCommand(entry, raw, { ECC_DRY_RUN: '1' });
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, '', `${entry.id} should suppress truncated pass-through`);
|
|
assert.ok(result.stderr.includes('stdin exceeded'), `${entry.id} should report truncation`);
|
|
}
|
|
}
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('profiles and disabled IDs remain scoped to each original hook', () => {
|
|
const minimalSync = runDispatcher('sync', 'Edit', {
|
|
ECC_DRY_RUN: '1',
|
|
ECC_HOOK_PROFILE: 'minimal'
|
|
});
|
|
assert.strictEqual(minimalSync.status, 0, minimalSync.stderr);
|
|
assert.deepStrictEqual(previewedIds(minimalSync.stderr), ['post:ecc-metrics-bridge']);
|
|
|
|
const minimalAsync = runDispatcher('async', 'Bash', {
|
|
ECC_DRY_RUN: '1',
|
|
ECC_HOOK_PROFILE: 'minimal'
|
|
});
|
|
assert.strictEqual(minimalAsync.status, 0, minimalAsync.stderr);
|
|
assert.deepStrictEqual(previewedIds(minimalAsync.stderr), ['post:bash:dispatcher'], 'bash dispatcher phase must stay reachable in minimal profile like main; its sub-hooks gate themselves');
|
|
|
|
const disabled = runDispatcher('sync', 'Edit', {
|
|
ECC_DRY_RUN: '1',
|
|
ECC_DISABLED_HOOKS: 'post:edit:accumulator'
|
|
});
|
|
assert.strictEqual(disabled.status, 0, disabled.stderr);
|
|
const ids = previewedIds(disabled.stderr);
|
|
assert.ok(!ids.includes('post:edit:accumulator'));
|
|
assert.ok(ids.includes('post:edit:design-quality-check'));
|
|
assert.ok(ids.includes('post:ecc-context-monitor'));
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('Claude plugin hooks_enabled=false suppresses both dispatcher phases', () => {
|
|
for (const mode of ['sync', 'async']) {
|
|
const result = runDispatcher(mode, 'Edit', {
|
|
ECC_DRY_RUN: '1',
|
|
ECC_HOOKS_ENABLED: undefined,
|
|
CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: 'false'
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(
|
|
previewedIds(result.stderr),
|
|
[],
|
|
`${mode} dispatcher must not select child hooks when plugin hooks are off`
|
|
);
|
|
}
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('public dispatcher IDs disable their complete phase', () => {
|
|
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
|
|
const raw = JSON.stringify({
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
|
|
tool_response: {}
|
|
});
|
|
|
|
for (const entry of entries) {
|
|
const result = runConfiguredCommand(entry, raw, {
|
|
ECC_DRY_RUN: '1',
|
|
ECC_DISABLED_HOOKS: entry.id
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(previewedIds(result.stderr), [], `${entry.id} should disable all child hooks`);
|
|
assert.strictEqual(result.stdout, raw);
|
|
}
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('dry-run has no PostToolUse side effects', () => {
|
|
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-posttooluse-dry-run-'));
|
|
try {
|
|
const result = runDispatcher('sync', 'Edit', {
|
|
ECC_DRY_RUN: '1',
|
|
HOME: homeDir,
|
|
USERPROFILE: homeDir,
|
|
CLAUDE_SESSION_ID: 'dry-run-session'
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(fs.readdirSync(homeDir), []);
|
|
} finally {
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
}
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('dispatcher isolates failures and preserves explicit output and exit status', () => {
|
|
assert.ok(fs.existsSync(dispatcherPath), 'dispatcher module should exist');
|
|
const { resolveMainStdout, runHooks } = require(dispatcherPath);
|
|
const calls = [];
|
|
const raw = JSON.stringify({ tool_name: 'Read' });
|
|
const explicitOutput = JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'PostToolUse',
|
|
additionalContext: 'context warning'
|
|
}
|
|
});
|
|
const hooks = [
|
|
{
|
|
id: 'post:test:first',
|
|
matcher: '*',
|
|
profiles: 'standard,strict',
|
|
run: input => {
|
|
calls.push(['first', input]);
|
|
return input;
|
|
}
|
|
},
|
|
{
|
|
id: 'post:test:broken',
|
|
matcher: '*',
|
|
profiles: 'standard,strict',
|
|
run: () => {
|
|
throw new Error('boom');
|
|
}
|
|
},
|
|
{ id: 'post:test:nonzero', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) },
|
|
{
|
|
id: 'post:test:last',
|
|
matcher: '*',
|
|
profiles: 'standard,strict',
|
|
run: input => {
|
|
calls.push(['last', input]);
|
|
return { stdout: explicitOutput, stderr: 'last warning' };
|
|
}
|
|
}
|
|
];
|
|
|
|
const result = runHooks(raw, hooks, { toolName: 'Read', env: { ECC_HOOK_PROFILE: 'standard' } });
|
|
assert.deepStrictEqual(
|
|
calls,
|
|
[
|
|
['first', raw],
|
|
['last', raw]
|
|
],
|
|
'each hook should receive the original input'
|
|
);
|
|
assert.strictEqual(result.stdout, explicitOutput);
|
|
assert.ok(result.stderr.includes('post:test:broken'));
|
|
assert.ok(result.stderr.includes('boom'));
|
|
assert.ok(result.stderr.includes('post:test:nonzero'));
|
|
assert.ok(result.stderr.indexOf('post:test:broken') < result.stderr.indexOf('last warning'));
|
|
assert.strictEqual(result.exitCode, 7, 'explicit child exit codes should be preserved');
|
|
assert.strictEqual(resolveMainStdout(raw, { stdout: '', exitCode: 7 }, { passthrough: true, truncated: false }), '', 'nonzero results should not restore raw input');
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('failing hook exit code propagates to the real dispatcher process status', () => {
|
|
const script = [
|
|
`const dispatcher = require(${JSON.stringify(dispatcherPath)});`,
|
|
'dispatcher.SYNC_HOOKS.length = 0;',
|
|
"dispatcher.SYNC_HOOKS.push({ id: 'post:test:fail', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) });",
|
|
"process.argv[2] = 'sync';",
|
|
'dispatcher.cli();'
|
|
].join('');
|
|
const result = spawnSync(process.execPath, ['-e', script], {
|
|
cwd: repoRoot,
|
|
input: JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: {}, tool_response: {} }),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot, ECC_POSTTOOLUSE_PASSTHROUGH: '1' },
|
|
timeout: 10000
|
|
});
|
|
assert.strictEqual(result.status, 7, 'OS-level exit status should reflect the failing hook');
|
|
assert.ok(result.stderr.includes('post:test:fail exited with code 7'), result.stderr);
|
|
assert.strictEqual(result.stdout, '', 'failed runs must not restore pass-through output');
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('console warning hook is safe to require in-process', () => {
|
|
const script = [`const hook = require(${JSON.stringify(path.join(repoRoot, 'scripts', 'hooks', 'post-edit-console-warn.js'))});`, "if (typeof hook.run !== 'function') process.exit(2);"].join(
|
|
''
|
|
);
|
|
const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 5000 });
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, '');
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('empty and malformed input fail open', () => {
|
|
for (const input of ['', '{not-json']) {
|
|
const result = spawnSync(process.execPath, [dispatcherPath, 'sync'], {
|
|
cwd: repoRoot,
|
|
input,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot },
|
|
timeout: 10000
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
}
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('multiple additionalContext outputs merge; raw stdout conflicts warn', () => {
|
|
const { mergeHookStdout, runHooks } = require(dispatcherPath);
|
|
const envelope = context =>
|
|
JSON.stringify({
|
|
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context }
|
|
});
|
|
const contextHook = (id, context) => ({
|
|
id,
|
|
matcher: '*',
|
|
profiles: 'standard,strict',
|
|
run: () => ({ additionalContext: context })
|
|
});
|
|
|
|
const merged = runHooks(JSON.stringify({ tool_name: 'Read' }), [contextHook('post:test:one', 'first warning'), contextHook('post:test:two', 'second warning')], {
|
|
toolName: 'Read',
|
|
env: { ECC_HOOK_PROFILE: 'standard' }
|
|
});
|
|
assert.strictEqual(merged.stdout, envelope('first warning\nsecond warning'), 'context envelopes should merge into one');
|
|
assert.ok(!merged.stderr.includes('dropped'), merged.stderr);
|
|
|
|
const conflicting = mergeHookStdout([
|
|
{ id: 'post:test:raw', stdout: 'plain output' },
|
|
{ id: 'post:test:ctx', stdout: envelope('kept warning') }
|
|
]);
|
|
assert.strictEqual(conflicting.stdout, envelope('kept warning'), 'last output should win when raw stdout cannot merge');
|
|
assert.ok(conflicting.warning.includes('post:test:raw'), 'dropped hook IDs should be named');
|
|
assert.ok(conflicting.warning.includes('post:test:ctx'));
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
if (
|
|
test('requiring the dispatcher module never dispatches; hooks.json calls cli()', () => {
|
|
const raw = JSON.stringify({
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Read',
|
|
tool_input: {},
|
|
tool_response: {}
|
|
});
|
|
const result = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(dispatcherPath)})`], {
|
|
cwd: repoRoot,
|
|
input: raw,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot, ECC_POSTTOOLUSE_PASSTHROUGH: '1' },
|
|
timeout: 10000
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, '', 'require() alone must not run main() or echo stdin');
|
|
|
|
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
|
|
assert.ok(
|
|
entries.every(entry => entry.hooks[0].command.includes('require(s).cli()')),
|
|
'hooks.json must invoke the explicit cli() entrypoint'
|
|
);
|
|
})
|
|
)
|
|
passed++;
|
|
else failed++;
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
runTests();
|