/** * 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();