Compare commits

...

2 Commits

Author SHA1 Message Date
Affaan Mustafa
015b00b8fc test: stabilize mcp health crash probes 2026-04-29 18:29:02 -04:00
Affaan Mustafa
51511461f6 test: cover pre-bash commit quality edges 2026-04-29 18:28:56 -04:00
3 changed files with 203 additions and 7 deletions

View File

@@ -422,7 +422,7 @@ async function runTests() {
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
ECC_MCP_HEALTH_TIMEOUT_MS: '1000'
}
);
@@ -458,7 +458,7 @@ async function runTests() {
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_HEALTH_FAIL_OPEN: '1',
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
ECC_MCP_HEALTH_TIMEOUT_MS: '1000'
}
);
@@ -490,7 +490,7 @@ async function runTests() {
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
ECC_MCP_HEALTH_TIMEOUT_MS: '1000'
}
);
const missingCommand = runHook(
@@ -499,7 +499,7 @@ async function runTests() {
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
ECC_MCP_HEALTH_TIMEOUT_MS: '1000'
}
);
@@ -597,7 +597,7 @@ async function runTests() {
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}`,
ECC_MCP_HEALTH_TIMEOUT_MS: '100',
ECC_MCP_HEALTH_TIMEOUT_MS: '1000',
ECC_MCP_HEALTH_BACKOFF_MS: '10'
}
);
@@ -660,7 +660,7 @@ async function runTests() {
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_RECONNECT_COMMAND: `node ${JSON.stringify(reconnectScript)}`,
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
ECC_MCP_HEALTH_TIMEOUT_MS: '1000'
}
);

View File

@@ -40,6 +40,48 @@ function inTempRepo(fn) {
}
}
function captureConsoleError(fn) {
const previousError = console.error;
const lines = [];
console.error = (...args) => {
lines.push(args.join(' '));
};
try {
const result = fn();
return { result, stderr: lines.join('\n') };
} finally {
console.error = previousError;
}
}
function writeAndStage(repoDir, relativePath, content) {
const filePath = path.join(repoDir, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
spawnSync('git', ['add', relativePath], { cwd: repoDir, stdio: 'pipe', encoding: 'utf8' });
}
function withEnv(overrides, fn) {
const previous = {};
for (const key of Object.keys(overrides)) {
previous[key] = process.env[key];
process.env[key] = overrides[key];
}
try {
return fn();
} finally {
for (const key of Object.keys(overrides)) {
if (typeof previous[key] === 'string') {
process.env[key] = previous[key];
} else {
delete process.env[key];
}
}
}
}
let passed = 0;
let failed = 0;
@@ -77,5 +119,159 @@ if (test('evaluate inspects staged snapshot instead of newer working tree conten
});
})) passed++; else failed++;
if (test('passes through non-commit amend malformed JSON and run wrapper paths', () => {
const readInput = JSON.stringify({ tool_input: { command: 'git status --short' } });
assert.deepStrictEqual(hook.evaluate(readInput), { output: readInput, exitCode: 0 });
const amendInput = JSON.stringify({ tool_input: { command: 'git commit --amend -m "fix: update"' } });
assert.deepStrictEqual(hook.evaluate(amendInput), { output: amendInput, exitCode: 0 });
const malformed = 'not json {{{';
const malformedResult = captureConsoleError(() => hook.run(malformed));
assert.deepStrictEqual(malformedResult.result, { stdout: malformed, exitCode: 0 });
assert.ok(malformedResult.stderr.includes('[Hook] Error:'), 'should log JSON parse errors without blocking');
})) passed++; else failed++;
if (test('allows git commit when no files are staged', () => {
inTempRepo(() => {
const input = JSON.stringify({ tool_input: { command: 'git commit -m "fix: no staged files"' } });
const { result, stderr } = captureConsoleError(() => hook.evaluate(input));
assert.strictEqual(result.output, input);
assert.strictEqual(result.exitCode, 0);
assert.ok(stderr.includes('No staged files found'), `expected no-staged warning, got: ${stderr}`);
});
})) passed++; else failed++;
if (test('allows warning-only issues while reporting console TODO and message warnings', () => {
inTempRepo(repoDir => {
writeAndStage(repoDir, 'index.js', [
'console.log("debug only");',
'// TODO: clean this up',
'// TODO: tracked in issue #123',
'// console.log("commented out");',
'* console.log("doc comment");',
'const ok = true;',
''
].join('\n'));
const input = JSON.stringify({
tool_input: {
command: 'git commit -m "fix: Uppercase subject."'
}
});
const { result, stderr } = captureConsoleError(() => hook.evaluate(input));
assert.strictEqual(result.output, input);
assert.strictEqual(result.exitCode, 0, 'warning-only issues should not block');
assert.ok(stderr.includes('WARNING Line 1'), `expected console warning, got: ${stderr}`);
assert.ok(stderr.includes('INFO Line 2'), `expected TODO info warning, got: ${stderr}`);
assert.ok(stderr.includes('Subject should start with lowercase'), `expected capitalization warning, got: ${stderr}`);
assert.ok(stderr.includes('should not end with a period'), `expected punctuation warning, got: ${stderr}`);
assert.ok(stderr.includes('Warnings found'), `expected warning summary, got: ${stderr}`);
});
})) passed++; else failed++;
if (test('reports invalid and long commit messages without blocking when files are clean', () => {
inTempRepo(repoDir => {
writeAndStage(repoDir, 'index.js', 'const clean = true;\n');
const longMessage = `Bad message ${'x'.repeat(80)}`;
const input = JSON.stringify({
tool_input: {
command: `git commit --message="${longMessage}"`
}
});
const { result, stderr } = captureConsoleError(() => hook.evaluate(input));
assert.strictEqual(result.output, input);
assert.strictEqual(result.exitCode, 0);
assert.ok(stderr.includes('does not follow conventional commit format'), `expected format warning, got: ${stderr}`);
assert.ok(stderr.includes('Commit message too long'), `expected length warning, got: ${stderr}`);
});
})) passed++; else failed++;
if (test('blocks commits with staged secret patterns across checkable files', () => {
inTempRepo(repoDir => {
writeAndStage(repoDir, 'index.js', [
"const openai = 'sk-abcdefghijklmnopqrstuvwxyz';",
"const token = 'ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ';",
''
].join('\n'));
writeAndStage(repoDir, 'app.py', [
'aws = "AKIAABCDEFGHIJKLMNOP"',
'api_key = "secret-value"',
''
].join('\n'));
const input = JSON.stringify({ tool_input: { command: 'git commit -m "fix: block secrets"' } });
const { result, stderr } = captureConsoleError(() => hook.evaluate(input));
assert.strictEqual(result.output, input);
assert.strictEqual(result.exitCode, 2);
assert.ok(stderr.includes('Potential OpenAI API key'), `expected OpenAI secret warning, got: ${stderr}`);
assert.ok(stderr.includes('Potential GitHub PAT'), `expected GitHub PAT warning, got: ${stderr}`);
assert.ok(stderr.includes('Potential AWS Access Key'), `expected AWS key warning, got: ${stderr}`);
assert.ok(stderr.includes('Potential API key'), `expected generic API key warning, got: ${stderr}`);
});
})) passed++; else failed++;
if (test('reports eslint pylint and golint failures from staged files', () => {
inTempRepo(repoDir => {
writeAndStage(repoDir, 'index.js', 'const lint = true;\n');
writeAndStage(repoDir, 'app.py', 'print("lint")\n');
writeAndStage(repoDir, 'main.go', 'package main\n');
const eslintPath = path.join(repoDir, 'node_modules', '.bin', process.platform === 'win32' ? 'eslint.cmd' : 'eslint');
fs.mkdirSync(path.dirname(eslintPath), { recursive: true });
fs.writeFileSync(eslintPath, '#!/bin/sh\necho "eslint failed"\nexit 1\n', 'utf8');
fs.chmodSync(eslintPath, 0o755);
const binDir = path.join(repoDir, 'fake-bin');
fs.mkdirSync(binDir, { recursive: true });
const pylintPath = path.join(binDir, 'pylint');
const golintPath = path.join(binDir, 'golint');
fs.writeFileSync(pylintPath, '#!/bin/sh\necho "pylint failed"\nexit 1\n', 'utf8');
fs.writeFileSync(golintPath, '#!/bin/sh\necho "main.go:1: lint failed"\nexit 0\n', 'utf8');
fs.chmodSync(pylintPath, 0o755);
fs.chmodSync(golintPath, 0o755);
withEnv({ PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` }, () => {
const input = JSON.stringify({ tool_input: { command: 'git commit -m "fix: lint failures"' } });
const { result, stderr } = captureConsoleError(() => hook.evaluate(input));
assert.strictEqual(result.output, input);
assert.strictEqual(result.exitCode, 2);
assert.ok(stderr.includes('ESLint Issues'), `expected ESLint output, got: ${stderr}`);
assert.ok(stderr.includes('eslint failed'), `expected ESLint failure text, got: ${stderr}`);
assert.ok(stderr.includes('Pylint Issues'), `expected Pylint output, got: ${stderr}`);
assert.ok(stderr.includes('pylint failed'), `expected Pylint failure text, got: ${stderr}`);
assert.ok(stderr.includes('golint Issues'), `expected golint output, got: ${stderr}`);
assert.ok(stderr.includes('main.go:1: lint failed'), `expected golint failure text, got: ${stderr}`);
});
});
})) passed++; else failed++;
if (test('stdin entry point truncates oversized input and preserves pass-through output', () => {
const oversized = JSON.stringify({
tool_input: {
command: 'git status',
filler: 'x'.repeat(1024 * 1024 + 1024)
}
});
const result = spawnSync('node', [path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-commit-quality.js')], {
input: oversized,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000
});
assert.strictEqual(result.status, 0);
assert.ok(result.stdout.length > 0, 'expected truncated payload to pass through');
assert.ok(result.stdout.length <= 1024 * 1024, 'expected stdout to stay within hook input limit');
assert.strictEqual(result.stdout, oversized.slice(0, result.stdout.length));
assert.ok(result.stderr.includes('[Hook] Error:'), 'truncated JSON should be logged and allowed');
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -577,7 +577,7 @@ async function runTests() {
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
ECC_MCP_HEALTH_TIMEOUT_MS: '1000'
}
);