mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-15 22:43:28 +08:00
Merge branch 'main' into feat/auto-update-command
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
4
tests/conftest.py
Normal file
4
tests/conftest.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
437
tests/hooks/gateguard-fact-force.test.js
Normal file
437
tests/hooks/gateguard-fact-force.test.js
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/gateguard-fact-force.js via run-with-flags.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
|
||||
const externalStateDir = process.env.GATEGUARD_STATE_DIR;
|
||||
const tmpRoot = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp';
|
||||
const stateDir = externalStateDir || fs.mkdtempSync(path.join(tmpRoot, 'gateguard-test-'));
|
||||
// Use a fixed session ID so test process and spawned hook process share the same state file
|
||||
const TEST_SESSION_ID = 'gateguard-test-session';
|
||||
const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`);
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearState() {
|
||||
try {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
fs.unlinkSync(stateFile);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` [clearState] failed to remove ${stateFile}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeExpiredState() {
|
||||
try {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const expired = {
|
||||
checked: ['some_file.js', '__bash_session__'],
|
||||
last_active: Date.now() - (31 * 60 * 1000) // 31 minutes ago
|
||||
};
|
||||
fs.writeFileSync(stateFile, JSON.stringify(expired), 'utf8');
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
function writeState(state) {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf8');
|
||||
}
|
||||
|
||||
function runHook(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [
|
||||
runner,
|
||||
'pre:edit-write:gateguard-fact-force',
|
||||
'scripts/hooks/gateguard-fact-force.js',
|
||||
'standard,strict'
|
||||
], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function runBashHook(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [
|
||||
runner,
|
||||
'pre:bash:gateguard-fact-force',
|
||||
'scripts/hooks/gateguard-fact-force.js',
|
||||
'standard,strict'
|
||||
], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function parseOutput(stdout) {
|
||||
try {
|
||||
return JSON.parse(stdout);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing gateguard-fact-force ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// --- Test 1: denies first Edit per file ---
|
||||
clearState();
|
||||
if (test('denies first Edit per file with fact-forcing message', () => {
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('import/require'));
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/app.js'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 2: allows second Edit on same file ---
|
||||
if (test('allows second Edit on same file (gate already passed)', () => {
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
// When allowed, the hook passes through the raw input (no hookSpecificOutput)
|
||||
// OR if hookSpecificOutput exists, it must not be deny
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny second edit on same file');
|
||||
} else {
|
||||
// Pass-through: output matches original input (allow)
|
||||
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 3: denies first Write per file ---
|
||||
clearState();
|
||||
if (test('denies first Write per file with fact-forcing message', () => {
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '/src/new-file.js', content: 'console.log("hello")' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('creating'));
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('call this new file'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 4: denies destructive Bash, allows retry ---
|
||||
clearState();
|
||||
if (test('denies destructive Bash commands, allows retry after facts presented', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'rm -rf /important/data' }
|
||||
};
|
||||
|
||||
// First call: should deny
|
||||
const result1 = runBashHook(input);
|
||||
assert.strictEqual(result1.code, 0, 'first call exit code should be 0');
|
||||
const output1 = parseOutput(result1.stdout);
|
||||
assert.ok(output1, 'first call should produce JSON output');
|
||||
assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('Destructive'));
|
||||
assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('rollback'));
|
||||
|
||||
// Second call (retry after facts presented): should allow
|
||||
const result2 = runBashHook(input);
|
||||
assert.strictEqual(result2.code, 0, 'second call exit code should be 0');
|
||||
const output2 = parseOutput(result2.stdout);
|
||||
assert.ok(output2, 'second call should produce valid JSON output');
|
||||
if (output2.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny destructive bash retry after facts presented');
|
||||
} else {
|
||||
assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 5: denies first routine Bash, allows second ---
|
||||
clearState();
|
||||
if (test('denies first routine Bash, allows second', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'ls -la' }
|
||||
};
|
||||
|
||||
// First call: should deny
|
||||
const result1 = runBashHook(input);
|
||||
assert.strictEqual(result1.code, 0, 'first call exit code should be 0');
|
||||
const output1 = parseOutput(result1.stdout);
|
||||
assert.ok(output1, 'first call should produce JSON output');
|
||||
assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny');
|
||||
|
||||
// Second call: should allow
|
||||
const result2 = runBashHook(input);
|
||||
assert.strictEqual(result2.code, 0, 'second call exit code should be 0');
|
||||
const output2 = parseOutput(result2.stdout);
|
||||
assert.ok(output2, 'second call should produce valid JSON output');
|
||||
if (output2.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny second routine bash');
|
||||
} else {
|
||||
assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 6: session state resets after timeout ---
|
||||
if (test('session state resets after 30-minute timeout', () => {
|
||||
writeExpiredState();
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: 'some_file.js', old_string: 'a', new_string: 'b' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output after expired state');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should deny again after session timeout (state was reset)');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 7: allows unknown tool names ---
|
||||
clearState();
|
||||
if (test('allows unknown tool names through', () => {
|
||||
const input = {
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/src/app.js' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny unknown tool');
|
||||
} else {
|
||||
assert.strictEqual(output.tool_name, 'Read', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 8: sanitizes file paths with newlines ---
|
||||
clearState();
|
||||
if (test('sanitizes file paths containing newlines', () => {
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/app.js\ninjected content', old_string: 'a', new_string: 'b' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
const reason = output.hookSpecificOutput.permissionDecisionReason;
|
||||
// The file path portion of the reason must not contain any raw newlines
|
||||
// (sanitizePath replaces \n and \r with spaces)
|
||||
const pathLine = reason.split('\n').find(l => l.includes('/src/app.js'));
|
||||
assert.ok(pathLine, 'reason should mention the file path');
|
||||
assert.ok(!pathLine.includes('\n'), 'file path line must not contain raw newlines');
|
||||
assert.ok(!reason.includes('/src/app.js\n'), 'newline after file path should be sanitized');
|
||||
assert.ok(!reason.includes('\ninjected'), 'injected content must not appear on its own line');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 9: respects ECC_DISABLED_HOOKS ---
|
||||
clearState();
|
||||
if (test('respects ECC_DISABLED_HOOKS (skips when disabled)', () => {
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/disabled.js', old_string: 'a', new_string: 'b' }
|
||||
};
|
||||
const result = runHook(input, {
|
||||
ECC_DISABLED_HOOKS: 'pre:edit-write:gateguard-fact-force'
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny when hook is disabled');
|
||||
} else {
|
||||
// When disabled, hook passes through raw input
|
||||
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 10: MultiEdit gates first unchecked file ---
|
||||
clearState();
|
||||
if (test('denies first MultiEdit with unchecked file', () => {
|
||||
const input = {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: {
|
||||
edits: [
|
||||
{ file_path: '/src/multi-a.js', old_string: 'a', new_string: 'b' },
|
||||
{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }
|
||||
]
|
||||
}
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/multi-a.js'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 11: MultiEdit allows after all files gated ---
|
||||
if (test('allows MultiEdit after all files gated', () => {
|
||||
// multi-a.js was gated in test 10; gate multi-b.js
|
||||
const input2 = {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { edits: [{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }] }
|
||||
};
|
||||
runHook(input2); // gates multi-b.js
|
||||
|
||||
// Now both files are gated — retry should allow
|
||||
const input3 = {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: {
|
||||
edits: [
|
||||
{ file_path: '/src/multi-a.js', old_string: 'a', new_string: 'b' },
|
||||
{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }
|
||||
]
|
||||
}
|
||||
};
|
||||
const result3 = runHook(input3);
|
||||
const output3 = parseOutput(result3.stdout);
|
||||
assert.ok(output3, 'should produce valid JSON');
|
||||
if (output3.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output3.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should allow MultiEdit after all files gated');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 12: reads refresh active session state ---
|
||||
clearState();
|
||||
if (test('touches last_active on read so active sessions do not age out', () => {
|
||||
const staleButActive = Date.now() - (29 * 60 * 1000);
|
||||
writeState({
|
||||
checked: ['/src/keep-alive.js'],
|
||||
last_active: staleButActive
|
||||
});
|
||||
|
||||
const before = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
assert.strictEqual(before.last_active, staleButActive, 'seed state should use the expected timestamp');
|
||||
|
||||
const result = runHook({
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' }
|
||||
});
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'already-checked file should still be allowed');
|
||||
}
|
||||
|
||||
const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
assert.ok(after.last_active > staleButActive, 'successful reads should refresh last_active');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 13: pruning preserves routine bash gate marker ---
|
||||
clearState();
|
||||
if (test('preserves __bash_session__ when pruning oversized state', () => {
|
||||
const checked = ['__bash_session__'];
|
||||
for (let i = 0; i < 80; i++) checked.push(`__destructive__${i}`);
|
||||
for (let i = 0; i < 700; i++) checked.push(`/src/file-${i}.js`);
|
||||
writeState({ checked, last_active: Date.now() });
|
||||
|
||||
runHook({
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/newly-gated.js', old_string: 'a', new_string: 'b' }
|
||||
});
|
||||
|
||||
const result = runBashHook({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'pwd' }
|
||||
});
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'routine bash marker should survive pruning');
|
||||
}
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
assert.ok(persisted.checked.includes('__bash_session__'), 'pruned state should retain __bash_session__');
|
||||
assert.ok(persisted.checked.length <= 500, 'pruned state should still honor the checked-entry cap');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Cleanup only the temp directory created by this test file.
|
||||
if (!externalStateDir) {
|
||||
try {
|
||||
if (fs.existsSync(stateDir)) {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
@@ -109,6 +111,39 @@ function waitForFile(filePath, timeoutMs = 5000) {
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${filePath}`);
|
||||
}
|
||||
|
||||
function waitForHttpReady(urlString, timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const { protocol } = new URL(urlString);
|
||||
const client = protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const attempt = () => {
|
||||
const req = client.request(urlString, { method: 'GET' }, res => {
|
||||
res.resume();
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.setTimeout(250, () => {
|
||||
req.destroy(new Error('timeout'));
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
if (Date.now() >= deadline) {
|
||||
reject(new Error(`Timed out waiting for ${urlString}: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(attempt, 25);
|
||||
});
|
||||
|
||||
req.end();
|
||||
};
|
||||
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing mcp-health-check.js ===\n');
|
||||
|
||||
@@ -329,6 +364,7 @@ async function runTests() {
|
||||
|
||||
try {
|
||||
const port = waitForFile(portFile).trim();
|
||||
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
|
||||
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
@@ -391,6 +427,7 @@ async function runTests() {
|
||||
|
||||
try {
|
||||
const port = waitForFile(portFile).trim();
|
||||
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
|
||||
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
|
||||
@@ -116,10 +116,19 @@ function runTests() {
|
||||
assert.ok(plan.operations.length > 0, 'Should include scaffold operations');
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === '.cursor'
|
||||
&& operation.strategy === 'sync-root-children'
|
||||
operation.sourceRelativePath === '.cursor/hooks.json'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
|
||||
&& operation.strategy === 'preserve-relative-path'
|
||||
)),
|
||||
'Should flatten the native cursor root'
|
||||
'Should preserve non-rule Cursor platform files'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === 'rules/common/agents.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
|
||||
&& operation.strategy === 'flatten-copy'
|
||||
)),
|
||||
'Should produce Cursor .mdc rules while preferring rules-core over duplicate platform copies'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
@@ -90,20 +90,22 @@ function runTests() {
|
||||
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.cursor'));
|
||||
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json'));
|
||||
|
||||
const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor');
|
||||
const hooksJson = plan.operations.find(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
|
||||
));
|
||||
const preserved = plan.operations.find(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
|
||||
));
|
||||
|
||||
assert.ok(flattened, 'Should include .cursor scaffold operation');
|
||||
assert.strictEqual(flattened.strategy, 'sync-root-children');
|
||||
assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor'));
|
||||
assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files');
|
||||
assert.strictEqual(hooksJson.strategy, 'preserve-relative-path');
|
||||
assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json'));
|
||||
|
||||
assert.ok(preserved, 'Should include flattened rules scaffold operations');
|
||||
assert.strictEqual(preserved.strategy, 'flatten-copy');
|
||||
assert.strictEqual(
|
||||
preserved.destinationPath,
|
||||
path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
|
||||
path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc')
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
@@ -126,16 +128,16 @@ function runTests() {
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc')
|
||||
)),
|
||||
'Should flatten common rules into namespaced files'
|
||||
'Should flatten common rules into namespaced .mdc files'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/typescript/testing.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md')
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.mdc')
|
||||
)),
|
||||
'Should flatten language rules into namespaced files'
|
||||
'Should flatten language rules into namespaced .mdc files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
@@ -143,6 +145,132 @@ function runTests() {
|
||||
)),
|
||||
'Should not preserve nested rule directories for cursor installs'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
|
||||
)),
|
||||
'Should not emit .md Cursor rule files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/README.md'
|
||||
)),
|
||||
'Should not install Cursor README docs as runtime rule files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/zh/README.md'
|
||||
)),
|
||||
'Should not flatten localized README docs into Cursor rule files'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans cursor platform rule files as .mdc and excludes rule README docs', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'cursor',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules: [
|
||||
{
|
||||
id: 'platform-configs',
|
||||
paths: ['.cursor'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-agents.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
|
||||
)),
|
||||
'Should rename Cursor platform rule files to .mdc'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.md')
|
||||
)),
|
||||
'Should not preserve .md Cursor platform rule files'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
|
||||
)),
|
||||
'Should preserve non-rule Cursor platform config files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'README.mdc')
|
||||
)),
|
||||
'Should not emit Cursor rule README docs as .mdc files'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deduplicates cursor rule destinations when rules-core and platform-configs overlap', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'cursor',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules: [
|
||||
{
|
||||
id: 'rules-core',
|
||||
paths: ['rules'],
|
||||
},
|
||||
{
|
||||
id: 'platform-configs',
|
||||
paths: ['.cursor'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commonAgentsDestinations = plan.operations.filter(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
|
||||
));
|
||||
|
||||
assert.strictEqual(commonAgentsDestinations.length, 1, 'Should keep only one common-agents.mdc operation');
|
||||
assert.strictEqual(
|
||||
normalizedRelativePath(commonAgentsDestinations[0].sourceRelativePath),
|
||||
'rules/common/agents.md',
|
||||
'Should prefer rules-core when cursor platform rules would collide'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prefers native cursor hooks when hooks-runtime and platform-configs overlap', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'cursor',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules: [
|
||||
{
|
||||
id: 'hooks-runtime',
|
||||
paths: ['hooks', 'scripts/hooks', 'scripts/lib'],
|
||||
},
|
||||
{
|
||||
id: 'platform-configs',
|
||||
paths: ['.cursor'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const hooksDestinations = plan.operations.filter(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks')
|
||||
));
|
||||
|
||||
assert.strictEqual(hooksDestinations.length, 1, 'Should keep only one .cursor/hooks scaffold operation');
|
||||
assert.strictEqual(
|
||||
normalizedRelativePath(hooksDestinations[0].sourceRelativePath),
|
||||
'.cursor/hooks',
|
||||
'Should prefer native Cursor hooks over generic hooks-runtime hooks'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans antigravity remaps for workflows, skills, and flat rules', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ const zhCnReadmePath = path.join(repoRoot, 'docs', 'zh-CN', 'README.md');
|
||||
const selectiveInstallArchitecturePath = path.join(repoRoot, 'docs', 'SELECTIVE-INSTALL-ARCHITECTURE.md');
|
||||
const opencodePackageJsonPath = path.join(repoRoot, '.opencode', 'package.json');
|
||||
const opencodePackageLockPath = path.join(repoRoot, '.opencode', 'package-lock.json');
|
||||
const opencodeHooksPluginPath = path.join(repoRoot, '.opencode', 'plugins', 'ecc-hooks.ts');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -134,6 +135,13 @@ test('docs/SELECTIVE-INSTALL-ARCHITECTURE.md repoVersion example matches package
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('.opencode/plugins/ecc-hooks.ts active plugin banner matches package.json', () => {
|
||||
const source = fs.readFileSync(opencodeHooksPluginPath, 'utf8');
|
||||
const match = source.match(/## Active Plugin: Everything Claude Code v([0-9]+\.[0-9]+\.[0-9]+)/);
|
||||
assert.ok(match, 'Expected .opencode/plugins/ecc-hooks.ts to declare an active plugin banner');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('docs/pt-BR/README.md latest release heading matches package.json', () => {
|
||||
const source = fs.readFileSync(ptBrReadmePath, 'utf8');
|
||||
assert.ok(
|
||||
|
||||
@@ -35,7 +35,7 @@ function main() {
|
||||
["package.json exposes the OpenCode build and prepack hooks", () => {
|
||||
assert.strictEqual(packageJson.scripts["build:opencode"], "node scripts/build-opencode.js")
|
||||
assert.strictEqual(packageJson.scripts.prepack, "npm run build:opencode")
|
||||
assert.ok(packageJson.files.includes(".opencode/dist/"))
|
||||
assert.ok(packageJson.files.includes(".opencode/"))
|
||||
}],
|
||||
["build script generates .opencode/dist", () => {
|
||||
const result = spawnSync("node", [buildScript], {
|
||||
|
||||
@@ -130,8 +130,11 @@ function runTests() {
|
||||
const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'README.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
@@ -304,7 +307,8 @@ function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
|
||||
const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json'));
|
||||
assert.strictEqual(state.request.profile, null);
|
||||
|
||||
@@ -110,6 +110,17 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (!powerShellCommand) {
|
||||
console.log(' - skipped help text test; PowerShell is not available in PATH');
|
||||
} else if (test('exposes the corrected Claude target help text', () => {
|
||||
const result = run(powerShellCommand, ['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(
|
||||
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
|
||||
'help text should describe the Claude target as a full ~/.claude install surface'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,15 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exposes the corrected Claude target help text', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(
|
||||
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
|
||||
'help text should describe the Claude target as a full ~/.claude install surface'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
71
tests/scripts/manual-hook-install-docs.test.js
Normal file
71
tests/scripts/manual-hook-install-docs.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Regression coverage for supported manual Claude hook installation guidance.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const README = path.join(__dirname, '..', '..', 'README.md');
|
||||
const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing manual hook install docs ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const readme = fs.readFileSync(README, 'utf8');
|
||||
const hooksReadme = fs.readFileSync(HOOKS_README, 'utf8');
|
||||
|
||||
if (test('README warns against raw hook file copying', () => {
|
||||
assert.ok(
|
||||
readme.includes('Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`'),
|
||||
'README should warn against unsupported raw hook copying'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('bash ./install.sh --target claude --modules hooks-runtime'),
|
||||
'README should document the supported Bash hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'),
|
||||
'README should document the supported PowerShell hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('%USERPROFILE%\\\\.claude'),
|
||||
'README should call out the correct Windows Claude config root'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('hooks/README mirrors supported manual install guidance', () => {
|
||||
assert.ok(
|
||||
hooksReadme.includes('do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`'),
|
||||
'hooks/README should warn against unsupported raw hook copying'
|
||||
);
|
||||
assert.ok(
|
||||
hooksReadme.includes('bash ./install.sh --target claude --modules hooks-runtime'),
|
||||
'hooks/README should document the supported Bash hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
hooksReadme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'),
|
||||
'hooks/README should document the supported PowerShell hook install path'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
150
tests/scripts/npm-publish-surface.test.js
Normal file
150
tests/scripts/npm-publish-surface.test.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tests for the npm publish surface contract.
|
||||
*/
|
||||
|
||||
const assert = require("assert")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { spawnSync } = require("child_process")
|
||||
|
||||
function runTest(name, fn) {
|
||||
try {
|
||||
fn()
|
||||
console.log(` ✓ ${name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`)
|
||||
console.error(` ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePublishPath(value) {
|
||||
return String(value).replace(/\\/g, "/").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function isCoveredByAncestor(target, roots) {
|
||||
const parts = target.split("/")
|
||||
for (let index = 1; index < parts.length; index += 1) {
|
||||
const ancestor = parts.slice(0, index).join("/")
|
||||
if (roots.has(ancestor)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function buildExpectedPublishPaths(repoRoot) {
|
||||
const modules = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "manifests", "install-modules.json"), "utf8")
|
||||
).modules
|
||||
|
||||
const extraPaths = [
|
||||
"manifests",
|
||||
"scripts/ecc.js",
|
||||
"scripts/catalog.js",
|
||||
"scripts/claw.js",
|
||||
"scripts/doctor.js",
|
||||
"scripts/status.js",
|
||||
"scripts/sessions-cli.js",
|
||||
"scripts/install-apply.js",
|
||||
"scripts/install-plan.js",
|
||||
"scripts/list-installed.js",
|
||||
"scripts/skill-create-output.js",
|
||||
"scripts/repair.js",
|
||||
"scripts/harness-audit.js",
|
||||
"scripts/session-inspect.js",
|
||||
"scripts/uninstall.js",
|
||||
"scripts/gemini-adapt-agents.js",
|
||||
"scripts/codex/merge-codex-config.js",
|
||||
"scripts/codex/merge-mcp-config.js",
|
||||
".codex-plugin",
|
||||
".mcp.json",
|
||||
"install.sh",
|
||||
"install.ps1",
|
||||
"schemas",
|
||||
"agent.yaml",
|
||||
"VERSION",
|
||||
]
|
||||
|
||||
const combined = new Set(
|
||||
[...modules.flatMap((module) => module.paths || []), ...extraPaths].map(normalizePublishPath)
|
||||
)
|
||||
|
||||
return [...combined]
|
||||
.filter((publishPath) => !isCoveredByAncestor(publishPath, combined))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("\n=== Testing npm publish surface ===\n")
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..")
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")
|
||||
)
|
||||
|
||||
const expectedPublishPaths = buildExpectedPublishPaths(repoRoot)
|
||||
const actualPublishPaths = packageJson.files.map(normalizePublishPath).sort()
|
||||
|
||||
const tests = [
|
||||
["package.json files align to the module graph and explicit runtime allowlist", () => {
|
||||
assert.deepStrictEqual(actualPublishPaths, expectedPublishPaths)
|
||||
}],
|
||||
["npm pack publishes the reduced runtime surface", () => {
|
||||
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
|
||||
|
||||
const packOutput = JSON.parse(result.stdout)
|
||||
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
|
||||
|
||||
for (const requiredPath of [
|
||||
"scripts/catalog.js",
|
||||
".gemini/GEMINI.md",
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
"schemas/install-state.schema.json",
|
||||
"skills/backend-patterns/SKILL.md",
|
||||
]) {
|
||||
assert.ok(
|
||||
packagedPaths.has(requiredPath),
|
||||
`npm pack should include ${requiredPath}`
|
||||
)
|
||||
}
|
||||
|
||||
for (const excludedPath of [
|
||||
"contexts/dev.md",
|
||||
"examples/CLAUDE.md",
|
||||
"plugins/README.md",
|
||||
"scripts/ci/catalog.js",
|
||||
"skills/skill-comply/SKILL.md",
|
||||
]) {
|
||||
assert.ok(
|
||||
!packagedPaths.has(excludedPath),
|
||||
`npm pack should not include ${excludedPath}`
|
||||
)
|
||||
}
|
||||
}],
|
||||
]
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
if (runTest(name, fn)) {
|
||||
passed += 1
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nPassed: ${passed}`)
|
||||
console.log(`Failed: ${failed}`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main()
|
||||
69
tests/test_builder.py
Normal file
69
tests/test_builder.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
from llm.core.types import LLMInput, Message, Role, ToolDefinition
|
||||
from llm.prompt import PromptBuilder, adapt_messages_for_provider
|
||||
from llm.prompt.builder import PromptConfig
|
||||
|
||||
|
||||
class TestPromptBuilder:
|
||||
def test_build_without_system(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
builder = PromptBuilder()
|
||||
result = builder.build(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].role == Role.USER
|
||||
|
||||
def test_build_with_system(self):
|
||||
messages = [
|
||||
Message(role=Role.SYSTEM, content="You are helpful."),
|
||||
Message(role=Role.USER, content="Hello"),
|
||||
]
|
||||
builder = PromptBuilder()
|
||||
result = builder.build(messages)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].role == Role.SYSTEM
|
||||
|
||||
def test_build_adds_system_from_config(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
builder = PromptBuilder(system_template="You are a pirate.")
|
||||
result = builder.build(messages)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "pirate" in result[0].content
|
||||
|
||||
def test_build_adds_system_from_config(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
builder = PromptBuilder(config=PromptConfig(system_template="You are a pirate."))
|
||||
result = builder.build(messages)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "pirate" in result[0].content
|
||||
def test_build_with_tools(self):
|
||||
messages = [Message(role=Role.USER, content="Search for something")]
|
||||
tools = [
|
||||
ToolDefinition(name="search", description="Search the web", parameters={}),
|
||||
]
|
||||
builder = PromptBuilder(include_tools_in_system=True)
|
||||
result = builder.build(messages, tools)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "search" in result[0].content
|
||||
assert "Available Tools" in result[0].content
|
||||
|
||||
|
||||
class TestAdaptMessagesForProvider:
|
||||
def test_adapt_for_claude(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
result = adapt_messages_for_provider(messages, "claude")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_adapt_for_openai(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
result = adapt_messages_for_provider(messages, "openai")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_adapt_for_ollama(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
result = adapt_messages_for_provider(messages, "ollama")
|
||||
assert len(result) == 1
|
||||
86
tests/test_executor.py
Normal file
86
tests/test_executor.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import pytest
|
||||
from llm.core.types import ToolCall, ToolDefinition, ToolResult
|
||||
from llm.tools import ToolExecutor, ToolRegistry
|
||||
|
||||
|
||||
class TestToolRegistry:
|
||||
def test_register_and_get(self):
|
||||
registry = ToolRegistry()
|
||||
|
||||
def dummy_func() -> str:
|
||||
return "result"
|
||||
|
||||
tool_def = ToolDefinition(
|
||||
name="dummy",
|
||||
description="A dummy tool",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
registry.register(tool_def, dummy_func)
|
||||
|
||||
assert registry.has("dummy") is True
|
||||
assert registry.get("dummy") is dummy_func
|
||||
assert registry.get_definition("dummy") == tool_def
|
||||
|
||||
def test_list_tools(self):
|
||||
registry = ToolRegistry()
|
||||
tool_def = ToolDefinition(name="test", description="Test", parameters={})
|
||||
registry.register(tool_def, lambda: None)
|
||||
|
||||
tools = registry.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "test"
|
||||
|
||||
|
||||
class TestToolExecutor:
|
||||
def test_execute_success(self):
|
||||
registry = ToolRegistry()
|
||||
|
||||
def search(query: str) -> str:
|
||||
return f"Results for: {query}"
|
||||
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="search",
|
||||
description="Search",
|
||||
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
),
|
||||
search,
|
||||
)
|
||||
|
||||
executor = ToolExecutor(registry)
|
||||
result = executor.execute(ToolCall(id="1", name="search", arguments={"query": "test"}))
|
||||
|
||||
assert result.tool_call_id == "1"
|
||||
assert result.content == "Results for: test"
|
||||
assert result.is_error is False
|
||||
|
||||
def test_execute_unknown_tool(self):
|
||||
registry = ToolRegistry()
|
||||
executor = ToolExecutor(registry)
|
||||
|
||||
result = executor.execute(ToolCall(id="1", name="unknown", arguments={}))
|
||||
|
||||
assert result.is_error is True
|
||||
assert "not found" in result.content
|
||||
|
||||
def test_execute_all(self):
|
||||
registry = ToolRegistry()
|
||||
|
||||
def tool1() -> str:
|
||||
return "result1"
|
||||
|
||||
def tool2() -> str:
|
||||
return "result2"
|
||||
|
||||
registry.register(ToolDefinition(name="t1", description="", parameters={}), tool1)
|
||||
registry.register(ToolDefinition(name="t2", description="", parameters={}), tool2)
|
||||
|
||||
executor = ToolExecutor(registry)
|
||||
results = executor.execute_all([
|
||||
ToolCall(id="1", name="t1", arguments={}),
|
||||
ToolCall(id="2", name="t2", arguments={}),
|
||||
])
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].content == "result1"
|
||||
assert results[1].content == "result2"
|
||||
28
tests/test_resolver.py
Normal file
28
tests/test_resolver.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import pytest
|
||||
from llm.core.types import ProviderType
|
||||
from llm.providers import ClaudeProvider, OpenAIProvider, OllamaProvider, get_provider
|
||||
|
||||
|
||||
class TestGetProvider:
|
||||
def test_get_claude_provider(self):
|
||||
provider = get_provider("claude")
|
||||
assert isinstance(provider, ClaudeProvider)
|
||||
assert provider.provider_type == ProviderType.CLAUDE
|
||||
|
||||
def test_get_openai_provider(self):
|
||||
provider = get_provider("openai")
|
||||
assert isinstance(provider, OpenAIProvider)
|
||||
assert provider.provider_type == ProviderType.OPENAI
|
||||
|
||||
def test_get_ollama_provider(self):
|
||||
provider = get_provider("ollama")
|
||||
assert isinstance(provider, OllamaProvider)
|
||||
assert provider.provider_type == ProviderType.OLLAMA
|
||||
|
||||
def test_get_provider_by_enum(self):
|
||||
provider = get_provider(ProviderType.CLAUDE)
|
||||
assert isinstance(provider, ClaudeProvider)
|
||||
|
||||
def test_invalid_provider_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown provider type"):
|
||||
get_provider("invalid")
|
||||
117
tests/test_types.py
Normal file
117
tests/test_types.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import pytest
|
||||
from llm.core.types import (
|
||||
LLMInput,
|
||||
LLMOutput,
|
||||
Message,
|
||||
ModelInfo,
|
||||
ProviderType,
|
||||
Role,
|
||||
ToolCall,
|
||||
ToolDefinition,
|
||||
ToolResult,
|
||||
)
|
||||
|
||||
|
||||
class TestRole:
|
||||
def test_role_values(self):
|
||||
assert Role.SYSTEM.value == "system"
|
||||
assert Role.USER.value == "user"
|
||||
assert Role.ASSISTANT.value == "assistant"
|
||||
assert Role.TOOL.value == "tool"
|
||||
|
||||
|
||||
class TestProviderType:
|
||||
def test_provider_values(self):
|
||||
assert ProviderType.CLAUDE.value == "claude"
|
||||
assert ProviderType.OPENAI.value == "openai"
|
||||
assert ProviderType.OLLAMA.value == "ollama"
|
||||
|
||||
|
||||
class TestMessage:
|
||||
def test_create_message(self):
|
||||
msg = Message(role=Role.USER, content="Hello")
|
||||
assert msg.role == Role.USER
|
||||
assert msg.content == "Hello"
|
||||
assert msg.name is None
|
||||
assert msg.tool_call_id is None
|
||||
|
||||
def test_message_to_dict(self):
|
||||
msg = Message(role=Role.USER, content="Hello", name="test")
|
||||
result = msg.to_dict()
|
||||
assert result["role"] == "user"
|
||||
assert result["content"] == "Hello"
|
||||
assert result["name"] == "test"
|
||||
|
||||
|
||||
class TestToolDefinition:
|
||||
def test_create_tool(self):
|
||||
tool = ToolDefinition(
|
||||
name="search",
|
||||
description="Search the web",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
assert tool.name == "search"
|
||||
assert tool.strict is True
|
||||
|
||||
def test_tool_to_dict(self):
|
||||
tool = ToolDefinition(
|
||||
name="search",
|
||||
description="Search",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
result = tool.to_dict()
|
||||
assert result["name"] == "search"
|
||||
assert result["strict"] is True
|
||||
|
||||
|
||||
class TestToolCall:
|
||||
def test_create_tool_call(self):
|
||||
tc = ToolCall(id="1", name="search", arguments={"query": "test"})
|
||||
assert tc.id == "1"
|
||||
assert tc.name == "search"
|
||||
assert tc.arguments == {"query": "test"}
|
||||
|
||||
|
||||
class TestToolResult:
|
||||
def test_create_tool_result(self):
|
||||
result = ToolResult(tool_call_id="1", content="result")
|
||||
assert result.tool_call_id == "1"
|
||||
assert result.is_error is False
|
||||
|
||||
|
||||
class TestLLMInput:
|
||||
def test_create_input(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
input_obj = LLMInput(messages=messages, temperature=0.7)
|
||||
assert len(input_obj.messages) == 1
|
||||
assert input_obj.temperature == 0.7
|
||||
|
||||
def test_input_to_dict(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
input_obj = LLMInput(messages=messages)
|
||||
result = input_obj.to_dict()
|
||||
assert "messages" in result
|
||||
assert result["temperature"] == 1.0
|
||||
|
||||
|
||||
class TestLLMOutput:
|
||||
def test_create_output(self):
|
||||
output = LLMOutput(content="Hello!")
|
||||
assert output.content == "Hello!"
|
||||
assert output.has_tool_calls is False
|
||||
|
||||
def test_output_with_tool_calls(self):
|
||||
tc = ToolCall(id="1", name="search", arguments={})
|
||||
output = LLMOutput(content="", tool_calls=[tc])
|
||||
assert output.has_tool_calls is True
|
||||
|
||||
|
||||
class TestModelInfo:
|
||||
def test_create_model_info(self):
|
||||
info = ModelInfo(
|
||||
name="gpt-4",
|
||||
provider=ProviderType.OPENAI,
|
||||
)
|
||||
assert info.name == "gpt-4"
|
||||
assert info.supports_tools is True
|
||||
assert info.supports_vision is False
|
||||
Reference in New Issue
Block a user