wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation
This commit is contained in:
2671
.agent/knowledge/everything_claude/tests/ci/validators.test.js
Normal file
2671
.agent/knowledge/everything_claude/tests/ci/validators.test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Tests for `.codex/config.toml` reference defaults.
|
||||
*
|
||||
* Run with: node tests/codex-config.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const configPath = path.join(repoRoot, '.codex', 'config.toml');
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
const codexAgentsDir = path.join(repoRoot, '.codex', 'agents');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
test('reference config does not pin a top-level model', () => {
|
||||
assert.ok(!/^model\s*=/m.test(config), 'Expected `.codex/config.toml` to inherit the CLI default model');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('reference config does not pin a top-level model provider', () => {
|
||||
assert.ok(
|
||||
!/^model_provider\s*=/m.test(config),
|
||||
'Expected `.codex/config.toml` to inherit the CLI default provider',
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('sample Codex role configs do not use o4-mini', () => {
|
||||
const roleFiles = fs.readdirSync(codexAgentsDir).filter(file => file.endsWith('.toml'));
|
||||
assert.ok(roleFiles.length > 0, 'Expected sample role config files under `.codex/agents`');
|
||||
|
||||
for (const roleFile of roleFiles) {
|
||||
const rolePath = path.join(codexAgentsDir, roleFile);
|
||||
const roleConfig = fs.readFileSync(rolePath, 'utf8');
|
||||
assert.ok(
|
||||
!/^model\s*=\s*"o4-mini"$/m.test(roleConfig),
|
||||
`Expected sample role config to avoid o4-mini: ${roleFile}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/auto-tmux-dev.js
|
||||
*
|
||||
* Tests dev server command transformation for tmux wrapping.
|
||||
*
|
||||
* Run with: node tests/hooks/auto-tmux-dev.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'auto-tmux-dev.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(input) {
|
||||
const result = spawnSync('node', [script], {
|
||||
encoding: 'utf8',
|
||||
input: typeof input === 'string' ? input : JSON.stringify(input),
|
||||
timeout: 10000,
|
||||
});
|
||||
return {
|
||||
code: result.status || 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing auto-tmux-dev.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Check if tmux is available for conditional tests
|
||||
const tmuxAvailable = spawnSync('which', ['tmux'], { encoding: 'utf8' }).status === 0;
|
||||
|
||||
console.log('Dev server detection:');
|
||||
|
||||
if (test('transforms npm run dev command', () => {
|
||||
const result = runScript({ tool_input: { command: 'npm run dev' } });
|
||||
assert.strictEqual(result.code, 0);
|
||||
const output = JSON.parse(result.stdout);
|
||||
if (process.platform !== 'win32' && tmuxAvailable) {
|
||||
assert.ok(output.tool_input.command.includes('tmux'), 'Should contain tmux');
|
||||
assert.ok(output.tool_input.command.includes('npm run dev'), 'Should contain original command');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('transforms pnpm dev command', () => {
|
||||
const result = runScript({ tool_input: { command: 'pnpm dev' } });
|
||||
assert.strictEqual(result.code, 0);
|
||||
const output = JSON.parse(result.stdout);
|
||||
if (process.platform !== 'win32' && tmuxAvailable) {
|
||||
assert.ok(output.tool_input.command.includes('tmux'));
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('transforms yarn dev command', () => {
|
||||
const result = runScript({ tool_input: { command: 'yarn dev' } });
|
||||
assert.strictEqual(result.code, 0);
|
||||
const output = JSON.parse(result.stdout);
|
||||
if (process.platform !== 'win32' && tmuxAvailable) {
|
||||
assert.ok(output.tool_input.command.includes('tmux'));
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('transforms bun run dev command', () => {
|
||||
const result = runScript({ tool_input: { command: 'bun run dev' } });
|
||||
assert.strictEqual(result.code, 0);
|
||||
const output = JSON.parse(result.stdout);
|
||||
if (process.platform !== 'win32' && tmuxAvailable) {
|
||||
assert.ok(output.tool_input.command.includes('tmux'));
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nNon-dev commands (pass-through):');
|
||||
|
||||
if (test('does not transform npm install', () => {
|
||||
const input = { tool_input: { command: 'npm install' } };
|
||||
const result = runScript(input);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.strictEqual(output.tool_input.command, 'npm install');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not transform npm test', () => {
|
||||
const input = { tool_input: { command: 'npm test' } };
|
||||
const result = runScript(input);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.strictEqual(output.tool_input.command, 'npm test');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not transform npm run build', () => {
|
||||
const input = { tool_input: { command: 'npm run build' } };
|
||||
const result = runScript(input);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.strictEqual(output.tool_input.command, 'npm run build');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not transform npm run develop (partial match)', () => {
|
||||
const input = { tool_input: { command: 'npm run develop' } };
|
||||
const result = runScript(input);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.strictEqual(output.tool_input.command, 'npm run develop');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nEdge cases:');
|
||||
|
||||
if (test('handles empty input gracefully', () => {
|
||||
const result = runScript('{}');
|
||||
assert.strictEqual(result.code, 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles invalid JSON gracefully', () => {
|
||||
const result = runScript('not json');
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.strictEqual(result.stdout, 'not json');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('passes through missing command field', () => {
|
||||
const input = { tool_input: {} };
|
||||
const result = runScript(input);
|
||||
assert.strictEqual(result.code, 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/check-hook-enabled.js
|
||||
*
|
||||
* Tests the CLI wrapper around isHookEnabled.
|
||||
*
|
||||
* Run with: node tests/hooks/check-hook-enabled.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'check-hook-enabled.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(args = [], envOverrides = {}) {
|
||||
const env = { ...process.env, ...envOverrides };
|
||||
// Remove potentially interfering env vars unless explicitly set
|
||||
if (!envOverrides.ECC_HOOK_PROFILE) delete env.ECC_HOOK_PROFILE;
|
||||
if (!envOverrides.ECC_DISABLED_HOOKS) delete env.ECC_DISABLED_HOOKS;
|
||||
|
||||
const result = spawnSync('node', [script, ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
env,
|
||||
});
|
||||
return {
|
||||
code: result.status || 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing check-hook-enabled.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
console.log('No arguments:');
|
||||
|
||||
if (test('returns yes when no hookId provided', () => {
|
||||
const result = runScript([]);
|
||||
assert.strictEqual(result.stdout, 'yes');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nDefault profile (standard):');
|
||||
|
||||
if (test('returns yes for hook with default profiles', () => {
|
||||
const result = runScript(['my-hook']);
|
||||
assert.strictEqual(result.stdout, 'yes');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns yes for hook with standard,strict profiles', () => {
|
||||
const result = runScript(['my-hook', 'standard,strict']);
|
||||
assert.strictEqual(result.stdout, 'yes');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns no for hook with only strict profile', () => {
|
||||
const result = runScript(['my-hook', 'strict']);
|
||||
assert.strictEqual(result.stdout, 'no');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns no for hook with only minimal profile', () => {
|
||||
const result = runScript(['my-hook', 'minimal']);
|
||||
assert.strictEqual(result.stdout, 'no');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nDisabled hooks:');
|
||||
|
||||
if (test('returns no when hook is disabled via env', () => {
|
||||
const result = runScript(['my-hook'], { ECC_DISABLED_HOOKS: 'my-hook' });
|
||||
assert.strictEqual(result.stdout, 'no');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns yes when different hook is disabled', () => {
|
||||
const result = runScript(['my-hook'], { ECC_DISABLED_HOOKS: 'other-hook' });
|
||||
assert.strictEqual(result.stdout, 'yes');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nProfile overrides:');
|
||||
|
||||
if (test('returns yes for strict profile with strict-only hook', () => {
|
||||
const result = runScript(['my-hook', 'strict'], { ECC_HOOK_PROFILE: 'strict' });
|
||||
assert.strictEqual(result.stdout, 'yes');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns yes for minimal profile with minimal-only hook', () => {
|
||||
const result = runScript(['my-hook', 'minimal'], { ECC_HOOK_PROFILE: 'minimal' });
|
||||
assert.strictEqual(result.stdout, 'yes');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/config-protection.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');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runHook(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [runner, 'pre:config-protection', 'scripts/hooks/config-protection.js', 'standard,strict'], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function runCustomHook(pluginRoot, hookId, relScriptPath, input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [runner, hookId, relScriptPath, 'standard,strict'], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing config-protection ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('blocks protected config file edits through run-with-flags', () => {
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: '.eslintrc.js',
|
||||
content: 'module.exports = {};'
|
||||
}
|
||||
};
|
||||
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked');
|
||||
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
||||
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('passes through safe file edits unchanged', () => {
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: 'src/index.js',
|
||||
content: 'console.log("ok");'
|
||||
}
|
||||
};
|
||||
|
||||
const rawInput = JSON.stringify(input);
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
|
||||
assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
|
||||
assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks truncated protected config payloads instead of failing open', () => {
|
||||
const rawInput = JSON.stringify({
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: '.eslintrc.js',
|
||||
content: 'x'.repeat(1024 * 1024 + 2048)
|
||||
}
|
||||
});
|
||||
|
||||
const result = runHook(rawInput);
|
||||
assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked');
|
||||
assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input');
|
||||
assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('legacy hooks do not echo raw input when they fail without stdout', () => {
|
||||
const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`);
|
||||
const scriptDir = path.join(pluginRoot, 'scripts', 'hooks');
|
||||
const scriptPath = path.join(scriptDir, 'legacy-block.js');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(scriptDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
'#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n'
|
||||
);
|
||||
|
||||
const rawInput = JSON.stringify({
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: '.eslintrc.js',
|
||||
content: 'module.exports = {};'
|
||||
}
|
||||
});
|
||||
|
||||
const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput);
|
||||
assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate');
|
||||
assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough');
|
||||
assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`);
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Tests for cost-tracker.js hook
|
||||
*
|
||||
* Run with: node tests/hooks/cost-tracker.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'cost-tracker.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'cost-tracker-test-'));
|
||||
}
|
||||
|
||||
function withTempHome(homeDir) {
|
||||
return {
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
};
|
||||
}
|
||||
|
||||
function runScript(input, envOverrides = {}) {
|
||||
const inputStr = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [script], {
|
||||
encoding: 'utf8',
|
||||
input: inputStr,
|
||||
timeout: 10000,
|
||||
env: { ...process.env, ...envOverrides },
|
||||
});
|
||||
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing cost-tracker.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// 1. Passes through input on stdout
|
||||
(test('passes through input on stdout', () => {
|
||||
const input = {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
usage: { input_tokens: 100, output_tokens: 50 },
|
||||
};
|
||||
const inputStr = JSON.stringify(input);
|
||||
const result = runScript(input);
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input');
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 2. Creates metrics file when given valid usage data
|
||||
(test('creates metrics file when given valid usage data', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
usage: { input_tokens: 1000, output_tokens: 500 },
|
||||
};
|
||||
const result = runScript(input, withTempHome(tmpHome));
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||
assert.ok(fs.existsSync(metricsFile), `Expected metrics file to exist at ${metricsFile}`);
|
||||
|
||||
const content = fs.readFileSync(metricsFile, 'utf8').trim();
|
||||
const row = JSON.parse(content);
|
||||
assert.strictEqual(row.input_tokens, 1000, 'Expected input_tokens to be 1000');
|
||||
assert.strictEqual(row.output_tokens, 500, 'Expected output_tokens to be 500');
|
||||
assert.ok(row.timestamp, 'Expected timestamp to be present');
|
||||
assert.ok(typeof row.estimated_cost_usd === 'number', 'Expected estimated_cost_usd to be a number');
|
||||
assert.ok(row.estimated_cost_usd > 0, 'Expected estimated_cost_usd to be positive');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 3. Handles empty input gracefully
|
||||
(test('handles empty input gracefully', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const result = runScript('', withTempHome(tmpHome));
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
// stdout should be empty since input was empty
|
||||
assert.strictEqual(result.stdout, '', 'Expected empty stdout for empty input');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 4. Handles invalid JSON gracefully
|
||||
(test('handles invalid JSON gracefully', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const invalidInput = 'not valid json {{{';
|
||||
const result = runScript(invalidInput, withTempHome(tmpHome));
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
// Should still pass through the raw input on stdout
|
||||
assert.strictEqual(result.stdout, invalidInput, 'Expected stdout to contain original invalid input');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 5. Handles missing usage fields gracefully
|
||||
(test('handles missing usage fields gracefully', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = { model: 'claude-sonnet-4-20250514' };
|
||||
const inputStr = JSON.stringify(input);
|
||||
const result = runScript(input, withTempHome(tmpHome));
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input');
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||
assert.ok(fs.existsSync(metricsFile), 'Expected metrics file to exist even with missing usage');
|
||||
|
||||
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||
assert.strictEqual(row.input_tokens, 0, 'Expected input_tokens to be 0 when missing');
|
||||
assert.strictEqual(row.output_tokens, 0, 'Expected output_tokens to be 0 when missing');
|
||||
assert.strictEqual(row.estimated_cost_usd, 0, 'Expected estimated_cost_usd to be 0 when no tokens');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Tests for worktree project-ID mismatch fix
|
||||
*
|
||||
* Validates that detect-project.sh uses -e (not -d) for .git existence
|
||||
* checks, so that git worktrees (where .git is a file) are detected
|
||||
* correctly.
|
||||
*
|
||||
* Run with: node tests/hooks/detect-project-worktree.test.js
|
||||
*/
|
||||
|
||||
|
||||
// Skip on Windows — these tests invoke bash scripts directly
|
||||
if (process.platform === 'win32') {
|
||||
console.log('Skipping bash-dependent worktree tests on Windows\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { execFileSync, execSync } = require('child_process');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-worktree-test-'));
|
||||
}
|
||||
|
||||
function cleanupDir(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
function toBashPath(filePath) {
|
||||
if (process.platform !== 'win32') {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return String(filePath)
|
||||
.replace(/^([A-Za-z]):/, (_, driveLetter) => `/${driveLetter.toLowerCase()}`)
|
||||
.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function runBash(command, options = {}) {
|
||||
return execFileSync('bash', ['-lc', command], options).toString().trim();
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const detectProjectPath = path.join(
|
||||
repoRoot,
|
||||
'skills',
|
||||
'continuous-learning-v2',
|
||||
'scripts',
|
||||
'detect-project.sh'
|
||||
);
|
||||
|
||||
console.log('\n=== Worktree Project-ID Mismatch Tests ===\n');
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Group 1: Content checks on detect-project.sh
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('--- Content checks on detect-project.sh ---');
|
||||
|
||||
test('uses -e (not -d) for .git existence check', () => {
|
||||
const content = fs.readFileSync(detectProjectPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('[ -e "${project_root}/.git" ]'),
|
||||
'detect-project.sh should use -e for .git check'
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('[ -d "${project_root}/.git" ]'),
|
||||
'detect-project.sh should NOT use -d for .git check'
|
||||
);
|
||||
});
|
||||
|
||||
test('has command -v git fallback check', () => {
|
||||
const content = fs.readFileSync(detectProjectPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('command -v git'),
|
||||
'detect-project.sh should check for git availability with command -v'
|
||||
);
|
||||
});
|
||||
|
||||
test('uses git -C for safe directory operations', () => {
|
||||
const content = fs.readFileSync(detectProjectPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('git -C'),
|
||||
'detect-project.sh should use git -C for directory-scoped operations'
|
||||
);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Group 2: Behavior test — -e vs -d
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n--- Behavior test: -e vs -d ---');
|
||||
|
||||
const behaviorDir = createTempDir();
|
||||
|
||||
test('[ -d ] returns true for .git directory', () => {
|
||||
const dir = path.join(behaviorDir, 'test-d-dir');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, '.git'));
|
||||
const result = runBash(`[ -d "${toBashPath(path.join(dir, '.git'))}" ] && echo yes || echo no`);
|
||||
assert.strictEqual(result, 'yes');
|
||||
});
|
||||
|
||||
test('[ -d ] returns false for .git file', () => {
|
||||
const dir = path.join(behaviorDir, 'test-d-file');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.git'), 'gitdir: /some/path\n');
|
||||
const result = runBash(`[ -d "${toBashPath(path.join(dir, '.git'))}" ] && echo yes || echo no`);
|
||||
assert.strictEqual(result, 'no');
|
||||
});
|
||||
|
||||
test('[ -e ] returns true for .git directory', () => {
|
||||
const dir = path.join(behaviorDir, 'test-e-dir');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, '.git'));
|
||||
const result = runBash(`[ -e "${toBashPath(path.join(dir, '.git'))}" ] && echo yes || echo no`);
|
||||
assert.strictEqual(result, 'yes');
|
||||
});
|
||||
|
||||
test('[ -e ] returns true for .git file', () => {
|
||||
const dir = path.join(behaviorDir, 'test-e-file');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.git'), 'gitdir: /some/path\n');
|
||||
const result = runBash(`[ -e "${toBashPath(path.join(dir, '.git'))}" ] && echo yes || echo no`);
|
||||
assert.strictEqual(result, 'yes');
|
||||
});
|
||||
|
||||
test('[ -e ] returns false when .git does not exist', () => {
|
||||
const dir = path.join(behaviorDir, 'test-e-none');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const result = runBash(`[ -e "${toBashPath(path.join(dir, '.git'))}" ] && echo yes || echo no`);
|
||||
assert.strictEqual(result, 'no');
|
||||
});
|
||||
|
||||
cleanupDir(behaviorDir);
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Group 3: E2E test — detect-project.sh with worktree .git file
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n--- E2E: detect-project.sh with worktree .git file ---');
|
||||
|
||||
test('detect-project.sh sets PROJECT_NAME and non-global PROJECT_ID for worktree', () => {
|
||||
const testDir = createTempDir();
|
||||
|
||||
try {
|
||||
// Create a "main" repo with git init so we have real git structures
|
||||
const mainRepo = path.join(testDir, 'main-repo');
|
||||
fs.mkdirSync(mainRepo, { recursive: true });
|
||||
execSync('git init', { cwd: mainRepo, stdio: 'pipe' });
|
||||
execSync('git commit --allow-empty -m "init"', {
|
||||
cwd: mainRepo,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@test.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@test.com'
|
||||
}
|
||||
});
|
||||
|
||||
// Create a worktree-like directory with .git as a file
|
||||
const worktreeDir = path.join(testDir, 'my-worktree');
|
||||
fs.mkdirSync(worktreeDir, { recursive: true });
|
||||
|
||||
// Set up the worktree directory structure in the main repo
|
||||
const worktreesDir = path.join(mainRepo, '.git', 'worktrees', 'my-worktree');
|
||||
fs.mkdirSync(worktreesDir, { recursive: true });
|
||||
|
||||
// Create the gitdir file and commondir in the worktree metadata
|
||||
const mainGitDir = path.join(mainRepo, '.git');
|
||||
fs.writeFileSync(
|
||||
path.join(worktreesDir, 'commondir'),
|
||||
'../..\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(worktreesDir, 'HEAD'),
|
||||
fs.readFileSync(path.join(mainGitDir, 'HEAD'), 'utf8')
|
||||
);
|
||||
|
||||
// Write .git file in the worktree directory (this is what git worktree creates)
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeDir, '.git'),
|
||||
`gitdir: ${worktreesDir}\n`
|
||||
);
|
||||
|
||||
// Source detect-project.sh from the worktree directory and capture results
|
||||
const script = `
|
||||
export CLAUDE_PROJECT_DIR="${toBashPath(worktreeDir)}"
|
||||
export HOME="${toBashPath(testDir)}"
|
||||
source "${toBashPath(detectProjectPath)}"
|
||||
echo "PROJECT_NAME=\${PROJECT_NAME}"
|
||||
echo "PROJECT_ID=\${PROJECT_ID}"
|
||||
`;
|
||||
|
||||
const result = execFileSync('bash', ['-lc', script], {
|
||||
cwd: worktreeDir,
|
||||
timeout: 10000,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: toBashPath(testDir),
|
||||
USERPROFILE: testDir,
|
||||
CLAUDE_PROJECT_DIR: toBashPath(worktreeDir)
|
||||
}
|
||||
}).toString();
|
||||
|
||||
const lines = result.trim().split('\n');
|
||||
const vars = {};
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(PROJECT_NAME|PROJECT_ID)=(.*)$/);
|
||||
if (match) {
|
||||
vars[match[1]] = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
vars.PROJECT_NAME && vars.PROJECT_NAME.length > 0,
|
||||
`PROJECT_NAME should be set, got: "${vars.PROJECT_NAME || ''}"`
|
||||
);
|
||||
assert.ok(
|
||||
vars.PROJECT_ID && vars.PROJECT_ID !== 'global',
|
||||
`PROJECT_ID should not be "global", got: "${vars.PROJECT_ID || ''}"`
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(testDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Summary
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${passed + failed}\n`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'doc-file-warning.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(input) {
|
||||
const result = spawnSync('node', [script], {
|
||||
encoding: 'utf8',
|
||||
input: JSON.stringify(input),
|
||||
timeout: 10000,
|
||||
});
|
||||
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing doc-file-warning.js ===\n');
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// 1. Allowed standard doc files - no warning in stderr
|
||||
const standardFiles = [
|
||||
'README.md',
|
||||
'CLAUDE.md',
|
||||
'AGENTS.md',
|
||||
'CONTRIBUTING.md',
|
||||
'CHANGELOG.md',
|
||||
'LICENSE.md',
|
||||
'SKILL.md',
|
||||
'MEMORY.md',
|
||||
'WORKLOG.md',
|
||||
];
|
||||
for (const file of standardFiles) {
|
||||
(test(`allows standard doc file: ${file}`, () => {
|
||||
const { code, stderr } = runScript({ tool_input: { file_path: file } });
|
||||
assert.strictEqual(code, 0, `expected exit code 0, got ${code}`);
|
||||
assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
}
|
||||
|
||||
// 2. Allowed directory paths - no warning
|
||||
const allowedDirPaths = [
|
||||
'docs/foo.md',
|
||||
'docs/guide/setup.md',
|
||||
'skills/bar.md',
|
||||
'skills/testing/tdd.md',
|
||||
'.history/session.md',
|
||||
'memory/patterns.md',
|
||||
'.claude/commands/deploy.md',
|
||||
'.claude/plans/roadmap.md',
|
||||
'.claude/projects/myproject.md',
|
||||
];
|
||||
for (const file of allowedDirPaths) {
|
||||
(test(`allows directory path: ${file}`, () => {
|
||||
const { code, stderr } = runScript({ tool_input: { file_path: file } });
|
||||
assert.strictEqual(code, 0, `expected exit code 0, got ${code}`);
|
||||
assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
}
|
||||
|
||||
// 3. Allowed .plan.md files - no warning
|
||||
(test('allows .plan.md files', () => {
|
||||
const { code, stderr } = runScript({ tool_input: { file_path: 'feature.plan.md' } });
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(stderr, '', `expected no warning for .plan.md, got: ${stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('allows nested .plan.md files', () => {
|
||||
const { code, stderr } = runScript({ tool_input: { file_path: 'src/refactor.plan.md' } });
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(stderr, '', `expected no warning for nested .plan.md, got: ${stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 4. Non-md/txt files always pass - no warning
|
||||
const nonDocFiles = ['foo.js', 'app.py', 'styles.css', 'data.json', 'image.png'];
|
||||
for (const file of nonDocFiles) {
|
||||
(test(`allows non-doc file: ${file}`, () => {
|
||||
const { code, stderr } = runScript({ tool_input: { file_path: file } });
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
}
|
||||
|
||||
// 5. Non-standard doc files - warning in stderr
|
||||
const nonStandardFiles = ['random-notes.md', 'TODO.md', 'notes.txt', 'scratch.md', 'ideas.txt'];
|
||||
for (const file of nonStandardFiles) {
|
||||
(test(`warns on non-standard doc file: ${file}`, () => {
|
||||
const { code, stderr } = runScript({ tool_input: { file_path: file } });
|
||||
assert.strictEqual(code, 0, 'should still exit 0 (warn only)');
|
||||
assert.ok(stderr.includes('WARNING'), `expected warning in stderr for ${file}, got: ${stderr}`);
|
||||
assert.ok(stderr.includes(file), `expected file path in stderr for ${file}`);
|
||||
}) ? passed++ : failed++);
|
||||
}
|
||||
|
||||
// 6. Invalid/empty input - passes through without error
|
||||
(test('handles empty object input without error', () => {
|
||||
const { code, stderr } = runScript({});
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(stderr, '', `expected no warning for empty input, got: ${stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('handles missing file_path without error', () => {
|
||||
const { code, stderr } = runScript({ tool_input: {} });
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(stderr, '', `expected no warning for missing file_path, got: ${stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('handles empty file_path without error', () => {
|
||||
const { code, stderr } = runScript({ tool_input: { file_path: '' } });
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(stderr, '', `expected no warning for empty file_path, got: ${stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 7. Stdout always contains the original input (pass-through)
|
||||
(test('passes through input to stdout for allowed file', () => {
|
||||
const input = { tool_input: { file_path: 'README.md' } };
|
||||
const { stdout } = runScript(input);
|
||||
assert.strictEqual(stdout, JSON.stringify(input));
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('passes through input to stdout for warned file', () => {
|
||||
const input = { tool_input: { file_path: 'random-notes.md' } };
|
||||
const { stdout } = runScript(input);
|
||||
assert.strictEqual(stdout, JSON.stringify(input));
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('passes through input to stdout for empty input', () => {
|
||||
const input = {};
|
||||
const { stdout } = runScript(input);
|
||||
assert.strictEqual(stdout, JSON.stringify(input));
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/evaluate-session.js
|
||||
*
|
||||
* Tests the session evaluation threshold logic, config loading,
|
||||
* and stdin parsing. Uses temporary JSONL transcript files.
|
||||
*
|
||||
* Run with: node tests/hooks/evaluate-session.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const evaluateScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'evaluate-session.js');
|
||||
|
||||
// Test helpers
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTestDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'eval-session-test-'));
|
||||
}
|
||||
|
||||
function cleanupTestDir(testDir) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSONL transcript file with N user messages.
|
||||
* Each line is a JSON object with `"type":"user"`.
|
||||
*/
|
||||
function createTranscript(dir, messageCount) {
|
||||
const filePath = path.join(dir, 'transcript.jsonl');
|
||||
const lines = [];
|
||||
for (let i = 0; i < messageCount; i++) {
|
||||
lines.push(JSON.stringify({ type: 'user', content: `Message ${i + 1}` }));
|
||||
// Intersperse assistant messages to be realistic
|
||||
lines.push(JSON.stringify({ type: 'assistant', content: `Response ${i + 1}` }));
|
||||
}
|
||||
fs.writeFileSync(filePath, lines.join('\n') + '\n');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run evaluate-session.js with stdin providing the transcript_path.
|
||||
* Uses spawnSync to capture both stdout and stderr regardless of exit code.
|
||||
* Returns { code, stdout, stderr }.
|
||||
*/
|
||||
function runEvaluate(stdinJson) {
|
||||
const result = spawnSync('node', [evaluateScript], {
|
||||
encoding: 'utf8',
|
||||
input: JSON.stringify(stdinJson),
|
||||
timeout: 10000,
|
||||
});
|
||||
return {
|
||||
code: result.status || 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing evaluate-session.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Threshold boundary tests (default minSessionLength = 10)
|
||||
console.log('Threshold boundary (default min=10):');
|
||||
|
||||
if (test('skips session with 9 user messages (below threshold)', () => {
|
||||
const testDir = createTestDir();
|
||||
const transcript = createTranscript(testDir, 9);
|
||||
const result = runEvaluate({ transcript_path: transcript });
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
// "too short" message should appear in stderr (log goes to stderr)
|
||||
assert.ok(
|
||||
result.stderr.includes('too short') || result.stderr.includes('9 messages'),
|
||||
'Should indicate session too short'
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('evaluates session with exactly 10 user messages (at threshold)', () => {
|
||||
const testDir = createTestDir();
|
||||
const transcript = createTranscript(testDir, 10);
|
||||
const result = runEvaluate({ transcript_path: transcript });
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
// Should NOT say "too short" — should say "evaluate for extractable patterns"
|
||||
assert.ok(!result.stderr.includes('too short'), 'Should NOT say too short at threshold');
|
||||
assert.ok(
|
||||
result.stderr.includes('10 messages') || result.stderr.includes('evaluate'),
|
||||
'Should indicate evaluation'
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('evaluates session with 11 user messages (above threshold)', () => {
|
||||
const testDir = createTestDir();
|
||||
const transcript = createTranscript(testDir, 11);
|
||||
const result = runEvaluate({ transcript_path: transcript });
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(!result.stderr.includes('too short'), 'Should NOT say too short');
|
||||
assert.ok(result.stderr.includes('evaluate'), 'Should trigger evaluation');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Edge cases
|
||||
console.log('\nEdge cases:');
|
||||
|
||||
if (test('exits 0 with missing transcript_path', () => {
|
||||
const result = runEvaluate({});
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 gracefully');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exits 0 with non-existent transcript file', () => {
|
||||
const result = runEvaluate({ transcript_path: '/nonexistent/path/transcript.jsonl' });
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 gracefully');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exits 0 with invalid stdin JSON', () => {
|
||||
// Pass raw string instead of JSON
|
||||
const result = spawnSync('node', [evaluateScript], {
|
||||
encoding: 'utf8',
|
||||
input: 'not valid json at all',
|
||||
timeout: 10000,
|
||||
});
|
||||
assert.strictEqual(result.status, 0, 'Should exit 0 even on bad stdin');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('skips empty transcript file (0 user messages)', () => {
|
||||
const testDir = createTestDir();
|
||||
const filePath = path.join(testDir, 'empty.jsonl');
|
||||
fs.writeFileSync(filePath, '');
|
||||
const result = runEvaluate({ transcript_path: filePath });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// 0 < 10, so should be "too short"
|
||||
assert.ok(
|
||||
result.stderr.includes('too short') || result.stderr.includes('0 messages'),
|
||||
'Empty transcript should be too short'
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('counts only user messages (ignores assistant messages)', () => {
|
||||
const testDir = createTestDir();
|
||||
const filePath = path.join(testDir, 'mixed.jsonl');
|
||||
// 5 user messages + 50 assistant messages — should still be "too short"
|
||||
const lines = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
lines.push(JSON.stringify({ type: 'user', content: `msg ${i}` }));
|
||||
}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
lines.push(JSON.stringify({ type: 'assistant', content: `resp ${i}` }));
|
||||
}
|
||||
fs.writeFileSync(filePath, lines.join('\n') + '\n');
|
||||
|
||||
const result = runEvaluate({ transcript_path: filePath });
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(
|
||||
result.stderr.includes('too short') || result.stderr.includes('5 messages'),
|
||||
'Should count only user messages'
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 28: config file parsing ──
|
||||
console.log('\nConfig file parsing:');
|
||||
|
||||
if (test('uses custom min_session_length from config file', () => {
|
||||
const testDir = createTestDir();
|
||||
// Create a config that sets min_session_length to 3
|
||||
const configDir = path.join(testDir, 'skills', 'continuous-learning');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({
|
||||
min_session_length: 3
|
||||
}));
|
||||
|
||||
// Create 4 user messages (above threshold of 3, but below default of 10)
|
||||
const transcript = createTranscript(testDir, 4);
|
||||
|
||||
// Run the script from the testDir so it finds config relative to script location
|
||||
// The config path is: path.join(__dirname, '..', '..', 'skills', 'continuous-learning', 'config.json')
|
||||
// __dirname = scripts/hooks, so config = repo_root/skills/continuous-learning/config.json
|
||||
// We can't easily change __dirname, so we test that the REAL config path doesn't interfere
|
||||
// Instead, test that 4 messages with default threshold (10) is indeed too short
|
||||
const result = runEvaluate({ transcript_path: transcript });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// With default min=10, 4 messages should be too short
|
||||
assert.ok(
|
||||
result.stderr.includes('too short') || result.stderr.includes('4 messages'),
|
||||
'With default config, 4 messages should be too short'
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles transcript with only assistant messages (0 user match)', () => {
|
||||
const testDir = createTestDir();
|
||||
const filePath = path.join(testDir, 'assistant-only.jsonl');
|
||||
const lines = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
lines.push(JSON.stringify({ type: 'assistant', content: `response ${i}` }));
|
||||
}
|
||||
fs.writeFileSync(filePath, lines.join('\n') + '\n');
|
||||
|
||||
const result = runEvaluate({ transcript_path: filePath });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// countInFile looks for /"type"\s*:\s*"user"/ — no matches
|
||||
assert.ok(
|
||||
result.stderr.includes('too short') || result.stderr.includes('0 messages'),
|
||||
'Should report too short with 0 user messages'
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles transcript with malformed JSON lines (still counts valid ones)', () => {
|
||||
const testDir = createTestDir();
|
||||
const filePath = path.join(testDir, 'mixed.jsonl');
|
||||
// 12 valid user lines + 5 invalid lines
|
||||
const lines = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
lines.push(JSON.stringify({ type: 'user', content: `msg ${i}` }));
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
lines.push('not valid json {{{');
|
||||
}
|
||||
fs.writeFileSync(filePath, lines.join('\n') + '\n');
|
||||
|
||||
const result = runEvaluate({ transcript_path: filePath });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// countInFile uses regex matching, not JSON parsing — counts all lines matching /"type"\s*:\s*"user"/
|
||||
// 12 user messages >= 10 threshold → should evaluate
|
||||
assert.ok(
|
||||
result.stderr.includes('evaluate') && result.stderr.includes('12 messages'),
|
||||
'Should evaluate session with 12 valid user messages'
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles empty stdin (no input) gracefully', () => {
|
||||
const result = spawnSync('node', [evaluateScript], {
|
||||
encoding: 'utf8',
|
||||
input: '',
|
||||
timeout: 10000,
|
||||
});
|
||||
// Empty stdin → JSON.parse('') throws → fallback to env var (unset) → null → exit 0
|
||||
assert.strictEqual(result.status, 0, 'Should exit 0 on empty stdin');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 53: env var fallback path ──
|
||||
console.log('\nRound 53: CLAUDE_TRANSCRIPT_PATH fallback:');
|
||||
|
||||
if (test('falls back to CLAUDE_TRANSCRIPT_PATH env var when stdin is invalid JSON', () => {
|
||||
const testDir = createTestDir();
|
||||
const transcript = createTranscript(testDir, 15);
|
||||
|
||||
const result = spawnSync('node', [evaluateScript], {
|
||||
encoding: 'utf8',
|
||||
input: 'invalid json {{{',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, CLAUDE_TRANSCRIPT_PATH: transcript }
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, 'Should exit 0');
|
||||
assert.ok(
|
||||
result.stderr.includes('15 messages'),
|
||||
'Should evaluate using env var fallback path'
|
||||
);
|
||||
assert.ok(
|
||||
result.stderr.includes('evaluate'),
|
||||
'Should indicate session evaluation'
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 65: regex whitespace tolerance in countInFile ──
|
||||
console.log('\nRound 65: regex whitespace tolerance around colon:');
|
||||
|
||||
if (test('counts user messages when JSON has spaces around colon ("type" : "user")', () => {
|
||||
const testDir = createTestDir();
|
||||
const filePath = path.join(testDir, 'spaced.jsonl');
|
||||
// Manually write JSON with spaces around the colon — NOT JSON.stringify
|
||||
// The regex /"type"\s*:\s*"user"/g should match these
|
||||
const lines = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
lines.push(`{"type" : "user", "content": "msg ${i}"}`);
|
||||
lines.push(`{"type" : "assistant", "content": "resp ${i}"}`);
|
||||
}
|
||||
fs.writeFileSync(filePath, lines.join('\n') + '\n');
|
||||
|
||||
const result = runEvaluate({ transcript_path: filePath });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// 12 user messages >= 10 threshold → should evaluate (not "too short")
|
||||
assert.ok(!result.stderr.includes('too short'),
|
||||
'Should NOT say too short for 12 spaced-colon user messages');
|
||||
assert.ok(
|
||||
result.stderr.includes('12 messages') || result.stderr.includes('evaluate'),
|
||||
`Should evaluate session with spaced-colon JSON. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 85: config file parse error (corrupt JSON) ──
|
||||
console.log('\nRound 85: config parse error catch block:');
|
||||
|
||||
if (test('falls back to defaults when config file contains invalid JSON', () => {
|
||||
// The evaluate-session.js script reads config from:
|
||||
// path.join(__dirname, '..', '..', 'skills', 'continuous-learning', 'config.json')
|
||||
// where __dirname = scripts/hooks/ → config = repo_root/skills/continuous-learning/config.json
|
||||
const configPath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning', 'config.json');
|
||||
let originalContent = null;
|
||||
try {
|
||||
originalContent = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
// Config file may not exist — that's fine
|
||||
}
|
||||
|
||||
try {
|
||||
// Write corrupt JSON to the config file
|
||||
fs.writeFileSync(configPath, 'NOT VALID JSON {{{ corrupt data !!!', 'utf8');
|
||||
|
||||
// Create a transcript with 12 user messages (above default threshold of 10)
|
||||
const testDir = createTestDir();
|
||||
const transcript = createTranscript(testDir, 12);
|
||||
const result = runEvaluate({ transcript_path: transcript });
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 despite corrupt config');
|
||||
// With corrupt config, defaults apply: min_session_length = 10
|
||||
// 12 >= 10 → should evaluate (not "too short")
|
||||
assert.ok(!result.stderr.includes('too short'),
|
||||
`Should NOT say too short — corrupt config falls back to default min=10. Got: ${result.stderr}`);
|
||||
assert.ok(
|
||||
result.stderr.includes('12 messages') || result.stderr.includes('evaluate'),
|
||||
`Should evaluate with 12 messages using default threshold. Got: ${result.stderr}`
|
||||
);
|
||||
// The catch block logs "Failed to parse config" — verify that log message
|
||||
assert.ok(result.stderr.includes('Failed to parse config'),
|
||||
`Should log config parse error. Got: ${result.stderr}`);
|
||||
|
||||
cleanupTestDir(testDir);
|
||||
} finally {
|
||||
// Restore original config file
|
||||
if (originalContent !== null) {
|
||||
fs.writeFileSync(configPath, originalContent, 'utf8');
|
||||
} else {
|
||||
// Config didn't exist before — remove the corrupt one we created
|
||||
try { fs.unlinkSync(configPath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 86: config learned_skills_path override with ~ expansion ──
|
||||
console.log('\nRound 86: config learned_skills_path override:');
|
||||
|
||||
if (test('uses learned_skills_path from config with ~ expansion', () => {
|
||||
// evaluate-session.js lines 69-72:
|
||||
// if (config.learned_skills_path) {
|
||||
// learnedSkillsPath = config.learned_skills_path.replace(/^~/, require('os').homedir());
|
||||
// }
|
||||
// This branch was never tested — only the parse error (Round 85) and default path.
|
||||
const configPath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning', 'config.json');
|
||||
let originalContent = null;
|
||||
try {
|
||||
originalContent = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
// Config file may not exist
|
||||
}
|
||||
|
||||
try {
|
||||
// Write config with a custom learned_skills_path using ~ prefix
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
min_session_length: 10,
|
||||
learned_skills_path: '~/custom-learned-skills-dir'
|
||||
}));
|
||||
|
||||
// Create a transcript with 12 user messages (above threshold)
|
||||
const testDir = createTestDir();
|
||||
const transcript = createTranscript(testDir, 12);
|
||||
const result = runEvaluate({ transcript_path: transcript });
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
// The script logs "Save learned skills to: <path>" where <path> should
|
||||
// be the expanded home directory, NOT the literal "~"
|
||||
assert.ok(!result.stderr.includes('~/custom-learned-skills-dir'),
|
||||
'Should NOT contain literal ~ in output (should be expanded)');
|
||||
assert.ok(result.stderr.includes('custom-learned-skills-dir'),
|
||||
`Should reference the custom learned skills dir. Got: ${result.stderr}`);
|
||||
// The ~ should have been replaced with os.homedir()
|
||||
assert.ok(result.stderr.includes(os.homedir()),
|
||||
`Should contain expanded home directory. Got: ${result.stderr}`);
|
||||
|
||||
cleanupTestDir(testDir);
|
||||
} finally {
|
||||
// Restore original config file
|
||||
if (originalContent !== null) {
|
||||
fs.writeFileSync(configPath, originalContent, 'utf8');
|
||||
} else {
|
||||
try { fs.unlinkSync(configPath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Tests for governance event capture hook.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const {
|
||||
detectSecrets,
|
||||
detectApprovalRequired,
|
||||
detectSensitivePath,
|
||||
analyzeForGovernanceEvents,
|
||||
run,
|
||||
} = require('../../scripts/hooks/governance-capture');
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing governance-capture ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// ── detectSecrets ──────────────────────────────────────────
|
||||
|
||||
if (await test('detectSecrets finds AWS access keys', async () => {
|
||||
const findings = detectSecrets('my key is AKIAIOSFODNN7EXAMPLE');
|
||||
assert.ok(findings.length > 0);
|
||||
assert.ok(findings.some(f => f.name === 'aws_key'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectSecrets finds generic secrets', async () => {
|
||||
const findings = detectSecrets('api_key = "sk-proj-abcdefghij1234567890"');
|
||||
assert.ok(findings.length > 0);
|
||||
assert.ok(findings.some(f => f.name === 'generic_secret'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectSecrets finds private keys', async () => {
|
||||
const findings = detectSecrets('-----BEGIN RSA PRIVATE KEY-----\nMIIE...');
|
||||
assert.ok(findings.length > 0);
|
||||
assert.ok(findings.some(f => f.name === 'private_key'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectSecrets finds GitHub tokens', async () => {
|
||||
const findings = detectSecrets('token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij');
|
||||
assert.ok(findings.length > 0);
|
||||
assert.ok(findings.some(f => f.name === 'github_token'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectSecrets returns empty array for clean text', async () => {
|
||||
const findings = detectSecrets('This is a normal log message with no secrets.');
|
||||
assert.strictEqual(findings.length, 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectSecrets handles null and undefined', async () => {
|
||||
assert.deepStrictEqual(detectSecrets(null), []);
|
||||
assert.deepStrictEqual(detectSecrets(undefined), []);
|
||||
assert.deepStrictEqual(detectSecrets(''), []);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
// ── detectApprovalRequired ─────────────────────────────────
|
||||
|
||||
if (await test('detectApprovalRequired flags force push', async () => {
|
||||
const findings = detectApprovalRequired('git push origin main --force');
|
||||
assert.ok(findings.length > 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectApprovalRequired flags hard reset', async () => {
|
||||
const findings = detectApprovalRequired('git reset --hard HEAD~3');
|
||||
assert.ok(findings.length > 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectApprovalRequired flags rm -rf', async () => {
|
||||
const findings = detectApprovalRequired('rm -rf /tmp/important');
|
||||
assert.ok(findings.length > 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectApprovalRequired flags DROP TABLE', async () => {
|
||||
const findings = detectApprovalRequired('DROP TABLE users');
|
||||
assert.ok(findings.length > 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectApprovalRequired allows safe commands', async () => {
|
||||
const findings = detectApprovalRequired('git status');
|
||||
assert.strictEqual(findings.length, 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectApprovalRequired handles null', async () => {
|
||||
assert.deepStrictEqual(detectApprovalRequired(null), []);
|
||||
assert.deepStrictEqual(detectApprovalRequired(''), []);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
// ── detectSensitivePath ────────────────────────────────────
|
||||
|
||||
if (await test('detectSensitivePath identifies .env files', async () => {
|
||||
assert.ok(detectSensitivePath('.env'));
|
||||
assert.ok(detectSensitivePath('.env.local'));
|
||||
assert.ok(detectSensitivePath('/project/.env.production'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectSensitivePath identifies credential files', async () => {
|
||||
assert.ok(detectSensitivePath('credentials.json'));
|
||||
assert.ok(detectSensitivePath('/home/user/.ssh/id_rsa'));
|
||||
assert.ok(detectSensitivePath('server.key'));
|
||||
assert.ok(detectSensitivePath('cert.pem'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectSensitivePath returns false for normal files', async () => {
|
||||
assert.ok(!detectSensitivePath('index.js'));
|
||||
assert.ok(!detectSensitivePath('README.md'));
|
||||
assert.ok(!detectSensitivePath('package.json'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectSensitivePath handles null', async () => {
|
||||
assert.ok(!detectSensitivePath(null));
|
||||
assert.ok(!detectSensitivePath(''));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
// ── analyzeForGovernanceEvents ─────────────────────────────
|
||||
|
||||
if (await test('analyzeForGovernanceEvents detects secrets in tool input', async () => {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: '/tmp/config.js',
|
||||
content: 'const key = "AKIAIOSFODNN7EXAMPLE";',
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(events.length > 0);
|
||||
const secretEvent = events.find(e => e.eventType === 'secret_detected');
|
||||
assert.ok(secretEvent);
|
||||
assert.strictEqual(secretEvent.payload.severity, 'critical');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('analyzeForGovernanceEvents detects approval-required commands', async () => {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Bash',
|
||||
tool_input: {
|
||||
command: 'git push origin main --force',
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(events.length > 0);
|
||||
const approvalEvent = events.find(e => e.eventType === 'approval_requested');
|
||||
assert.ok(approvalEvent);
|
||||
assert.strictEqual(approvalEvent.payload.severity, 'high');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('approval events fingerprint commands instead of storing raw command text', async () => {
|
||||
const command = 'git push origin main --force';
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command },
|
||||
});
|
||||
|
||||
const approvalEvent = events.find(e => e.eventType === 'approval_requested');
|
||||
assert.ok(approvalEvent);
|
||||
assert.strictEqual(approvalEvent.payload.commandName, 'git');
|
||||
assert.ok(/^[a-f0-9]{12}$/.test(approvalEvent.payload.commandFingerprint), 'Expected short command fingerprint');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(approvalEvent.payload, 'command'), 'Should not store raw command text');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('security findings fingerprint elevated commands instead of storing raw command text', async () => {
|
||||
const command = 'sudo chmod 600 ~/.ssh/id_rsa';
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command },
|
||||
}, {
|
||||
hookPhase: 'post',
|
||||
});
|
||||
|
||||
const securityEvent = events.find(e => e.eventType === 'security_finding');
|
||||
assert.ok(securityEvent);
|
||||
assert.strictEqual(securityEvent.payload.commandName, 'sudo');
|
||||
assert.ok(/^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint), 'Expected short command fingerprint');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'), 'Should not store raw command text');
|
||||
})) passed += 1; else failed += 1;
|
||||
if (await test('analyzeForGovernanceEvents detects sensitive file access', async () => {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: '/project/.env.production',
|
||||
old_string: 'DB_URL=old',
|
||||
new_string: 'DB_URL=new',
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(events.length > 0);
|
||||
const policyEvent = events.find(e => e.eventType === 'policy_violation');
|
||||
assert.ok(policyEvent);
|
||||
assert.strictEqual(policyEvent.payload.reason, 'sensitive_file_access');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('analyzeForGovernanceEvents detects elevated privilege commands', async () => {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'sudo rm -rf /etc/something' },
|
||||
}, {
|
||||
hookPhase: 'post',
|
||||
});
|
||||
|
||||
const securityEvent = events.find(e => e.eventType === 'security_finding');
|
||||
assert.ok(securityEvent);
|
||||
assert.strictEqual(securityEvent.payload.reason, 'elevated_privilege_command');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('analyzeForGovernanceEvents returns empty for clean inputs', async () => {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/project/src/index.js' },
|
||||
});
|
||||
assert.strictEqual(events.length, 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('analyzeForGovernanceEvents populates session ID from context', async () => {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: '/project/.env',
|
||||
content: 'DB_URL=test',
|
||||
},
|
||||
}, {
|
||||
sessionId: 'test-session-123',
|
||||
});
|
||||
|
||||
assert.ok(events.length > 0);
|
||||
assert.strictEqual(events[0].sessionId, 'test-session-123');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('analyzeForGovernanceEvents generates unique event IDs', async () => {
|
||||
const events1 = analyzeForGovernanceEvents({
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '.env', content: '' },
|
||||
});
|
||||
const events2 = analyzeForGovernanceEvents({
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '.env.local', content: '' },
|
||||
});
|
||||
|
||||
if (events1.length > 0 && events2.length > 0) {
|
||||
assert.notStrictEqual(events1[0].id, events2[0].id);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
// ── run() function ─────────────────────────────────────────
|
||||
|
||||
if (await test('run() passes through input when feature flag is off', async () => {
|
||||
const original = process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
delete process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
|
||||
try {
|
||||
const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'git push --force' } });
|
||||
const result = run(input);
|
||||
assert.strictEqual(result, input);
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = original;
|
||||
}
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('run() passes through input when feature flag is on', async () => {
|
||||
const original = process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = '1';
|
||||
|
||||
try {
|
||||
const input = JSON.stringify({ tool_name: 'Read', tool_input: { file_path: 'index.js' } });
|
||||
const result = run(input);
|
||||
assert.strictEqual(result, input);
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = original;
|
||||
} else {
|
||||
delete process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
}
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('run() handles invalid JSON gracefully', async () => {
|
||||
const original = process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = '1';
|
||||
|
||||
try {
|
||||
const result = run('not valid json');
|
||||
assert.strictEqual(result, 'not valid json');
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = original;
|
||||
} else {
|
||||
delete process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
}
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('run() emits hook_input_truncated event without logging raw command text', async () => {
|
||||
const original = process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
const originalHookEvent = process.env.CLAUDE_HOOK_EVENT_NAME;
|
||||
const originalWrite = process.stderr.write;
|
||||
const stderr = [];
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = '1';
|
||||
process.env.CLAUDE_HOOK_EVENT_NAME = 'PreToolUse';
|
||||
process.stderr.write = (chunk, encoding, callback) => {
|
||||
stderr.push(String(chunk));
|
||||
if (typeof encoding === 'function') encoding();
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'rm -rf /tmp/important' } });
|
||||
const result = run(input, { truncated: true, maxStdin: 1024 });
|
||||
assert.strictEqual(result, input);
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
if (original !== undefined) {
|
||||
process.env.ECC_GOVERNANCE_CAPTURE = original;
|
||||
} else {
|
||||
delete process.env.ECC_GOVERNANCE_CAPTURE;
|
||||
}
|
||||
if (originalHookEvent !== undefined) {
|
||||
process.env.CLAUDE_HOOK_EVENT_NAME = originalHookEvent;
|
||||
} else {
|
||||
delete process.env.CLAUDE_HOOK_EVENT_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
const combined = stderr.join('');
|
||||
assert.ok(combined.includes('"eventType":"hook_input_truncated"'), 'Should emit truncation event');
|
||||
assert.ok(combined.includes('"sizeLimitBytes":1024'), 'Should record the truncation limit');
|
||||
assert.ok(!combined.includes('rm -rf /tmp/important'), 'Should not leak raw command text to governance logs');
|
||||
})) passed += 1; else failed += 1;
|
||||
if (await test('run() can detect multiple event types in one input', async () => {
|
||||
// Bash command with force push AND secret in command
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Bash',
|
||||
tool_input: {
|
||||
command: 'API_KEY="AKIAIOSFODNN7EXAMPLE" git push --force',
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypes = events.map(e => e.eventType);
|
||||
assert.ok(eventTypes.includes('secret_detected'));
|
||||
assert.ok(eventTypes.includes('approval_requested'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Tests for scripts/lib/hook-flags.js
|
||||
*
|
||||
* Run with: node tests/hooks/hook-flags.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
// Import the module
|
||||
const {
|
||||
VALID_PROFILES,
|
||||
normalizeId,
|
||||
getHookProfile,
|
||||
getDisabledHookIds,
|
||||
parseProfiles,
|
||||
isHookEnabled,
|
||||
} = require('../../scripts/lib/hook-flags');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to save and restore env vars
|
||||
function withEnv(vars, fn) {
|
||||
const saved = {};
|
||||
for (const key of Object.keys(vars)) {
|
||||
saved[key] = process.env[key];
|
||||
if (vars[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = vars[key];
|
||||
}
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(saved)) {
|
||||
if (saved[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = saved[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test suite
|
||||
function runTests() {
|
||||
console.log('\n=== Testing hook-flags.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// VALID_PROFILES tests
|
||||
console.log('VALID_PROFILES:');
|
||||
|
||||
if (test('is a Set', () => {
|
||||
assert.ok(VALID_PROFILES instanceof Set);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('contains minimal, standard, strict', () => {
|
||||
assert.ok(VALID_PROFILES.has('minimal'));
|
||||
assert.ok(VALID_PROFILES.has('standard'));
|
||||
assert.ok(VALID_PROFILES.has('strict'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('contains exactly 3 profiles', () => {
|
||||
assert.strictEqual(VALID_PROFILES.size, 3);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// normalizeId tests
|
||||
console.log('\nnormalizeId:');
|
||||
|
||||
if (test('returns empty string for null', () => {
|
||||
assert.strictEqual(normalizeId(null), '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns empty string for undefined', () => {
|
||||
assert.strictEqual(normalizeId(undefined), '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns empty string for empty string', () => {
|
||||
assert.strictEqual(normalizeId(''), '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('trims whitespace', () => {
|
||||
assert.strictEqual(normalizeId(' hello '), 'hello');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('converts to lowercase', () => {
|
||||
assert.strictEqual(normalizeId('MyHook'), 'myhook');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles mixed case with whitespace', () => {
|
||||
assert.strictEqual(normalizeId(' My-Hook-ID '), 'my-hook-id');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('converts numbers to string', () => {
|
||||
assert.strictEqual(normalizeId(123), '123');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns empty string for whitespace-only input', () => {
|
||||
assert.strictEqual(normalizeId(' '), '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// getHookProfile tests
|
||||
console.log('\ngetHookProfile:');
|
||||
|
||||
if (test('defaults to standard when env var not set', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: undefined }, () => {
|
||||
assert.strictEqual(getHookProfile(), 'standard');
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns minimal when set to minimal', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'minimal' }, () => {
|
||||
assert.strictEqual(getHookProfile(), 'minimal');
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns standard when set to standard', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'standard' }, () => {
|
||||
assert.strictEqual(getHookProfile(), 'standard');
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns strict when set to strict', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'strict' }, () => {
|
||||
assert.strictEqual(getHookProfile(), 'strict');
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('is case-insensitive', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'STRICT' }, () => {
|
||||
assert.strictEqual(getHookProfile(), 'strict');
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('trims whitespace from env var', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: ' minimal ' }, () => {
|
||||
assert.strictEqual(getHookProfile(), 'minimal');
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('defaults to standard for invalid value', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'invalid' }, () => {
|
||||
assert.strictEqual(getHookProfile(), 'standard');
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('defaults to standard for empty string', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: '' }, () => {
|
||||
assert.strictEqual(getHookProfile(), 'standard');
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
// getDisabledHookIds tests
|
||||
console.log('\ngetDisabledHookIds:');
|
||||
|
||||
if (test('returns empty Set when env var not set', () => {
|
||||
withEnv({ ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
const result = getDisabledHookIds();
|
||||
assert.ok(result instanceof Set);
|
||||
assert.strictEqual(result.size, 0);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns empty Set for empty string', () => {
|
||||
withEnv({ ECC_DISABLED_HOOKS: '' }, () => {
|
||||
assert.strictEqual(getDisabledHookIds().size, 0);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns empty Set for whitespace-only string', () => {
|
||||
withEnv({ ECC_DISABLED_HOOKS: ' ' }, () => {
|
||||
assert.strictEqual(getDisabledHookIds().size, 0);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses single hook id', () => {
|
||||
withEnv({ ECC_DISABLED_HOOKS: 'my-hook' }, () => {
|
||||
const result = getDisabledHookIds();
|
||||
assert.strictEqual(result.size, 1);
|
||||
assert.ok(result.has('my-hook'));
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses multiple comma-separated hook ids', () => {
|
||||
withEnv({ ECC_DISABLED_HOOKS: 'hook-a,hook-b,hook-c' }, () => {
|
||||
const result = getDisabledHookIds();
|
||||
assert.strictEqual(result.size, 3);
|
||||
assert.ok(result.has('hook-a'));
|
||||
assert.ok(result.has('hook-b'));
|
||||
assert.ok(result.has('hook-c'));
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('trims whitespace around hook ids', () => {
|
||||
withEnv({ ECC_DISABLED_HOOKS: ' hook-a , hook-b ' }, () => {
|
||||
const result = getDisabledHookIds();
|
||||
assert.strictEqual(result.size, 2);
|
||||
assert.ok(result.has('hook-a'));
|
||||
assert.ok(result.has('hook-b'));
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('normalizes hook ids to lowercase', () => {
|
||||
withEnv({ ECC_DISABLED_HOOKS: 'MyHook,ANOTHER' }, () => {
|
||||
const result = getDisabledHookIds();
|
||||
assert.ok(result.has('myhook'));
|
||||
assert.ok(result.has('another'));
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filters out empty entries from trailing commas', () => {
|
||||
withEnv({ ECC_DISABLED_HOOKS: 'hook-a,,hook-b,' }, () => {
|
||||
const result = getDisabledHookIds();
|
||||
assert.strictEqual(result.size, 2);
|
||||
assert.ok(result.has('hook-a'));
|
||||
assert.ok(result.has('hook-b'));
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
// parseProfiles tests
|
||||
console.log('\nparseProfiles:');
|
||||
|
||||
if (test('returns fallback for null input', () => {
|
||||
const result = parseProfiles(null);
|
||||
assert.deepStrictEqual(result, ['standard', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns fallback for undefined input', () => {
|
||||
const result = parseProfiles(undefined);
|
||||
assert.deepStrictEqual(result, ['standard', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uses custom fallback when provided', () => {
|
||||
const result = parseProfiles(null, ['minimal']);
|
||||
assert.deepStrictEqual(result, ['minimal']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses comma-separated string', () => {
|
||||
const result = parseProfiles('minimal,strict');
|
||||
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses single string value', () => {
|
||||
const result = parseProfiles('strict');
|
||||
assert.deepStrictEqual(result, ['strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses array of profiles', () => {
|
||||
const result = parseProfiles(['minimal', 'standard']);
|
||||
assert.deepStrictEqual(result, ['minimal', 'standard']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filters invalid profiles from string', () => {
|
||||
const result = parseProfiles('minimal,invalid,strict');
|
||||
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filters invalid profiles from array', () => {
|
||||
const result = parseProfiles(['minimal', 'bogus', 'strict']);
|
||||
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns fallback when all string values are invalid', () => {
|
||||
const result = parseProfiles('invalid,bogus');
|
||||
assert.deepStrictEqual(result, ['standard', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns fallback when all array values are invalid', () => {
|
||||
const result = parseProfiles(['invalid', 'bogus']);
|
||||
assert.deepStrictEqual(result, ['standard', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('is case-insensitive for string input', () => {
|
||||
const result = parseProfiles('MINIMAL,STRICT');
|
||||
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('is case-insensitive for array input', () => {
|
||||
const result = parseProfiles(['MINIMAL', 'STRICT']);
|
||||
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('trims whitespace in string input', () => {
|
||||
const result = parseProfiles(' minimal , strict ');
|
||||
assert.deepStrictEqual(result, ['minimal', 'strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles null values in array', () => {
|
||||
const result = parseProfiles([null, 'strict']);
|
||||
assert.deepStrictEqual(result, ['strict']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// isHookEnabled tests
|
||||
console.log('\nisHookEnabled:');
|
||||
|
||||
if (test('returns true by default for a hook (standard profile)', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook'), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns true for empty hookId', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled(''), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns true for null hookId', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled(null), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns false when hook is in disabled list', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'my-hook' }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook'), false);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('disabled check is case-insensitive', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'MY-HOOK' }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook'), false);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns true when hook is not in disabled list', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'other-hook' }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook'), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns false when current profile is not in allowed profiles', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook', { profiles: 'strict' }), false);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns true when current profile is in allowed profiles', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook', { profiles: 'standard,strict' }), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns true when current profile matches single allowed profile', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook', { profiles: 'minimal' }), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('disabled hooks take precedence over profile match', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: 'my-hook' }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook', { profiles: 'strict' }), false);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uses default profiles (standard, strict) when none specified', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook'), false);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('allows standard profile by default', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'standard', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook'), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('allows strict profile by default', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook'), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('accepts array profiles option', () => {
|
||||
withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => {
|
||||
assert.strictEqual(isHookEnabled('my-hook', { profiles: ['minimal', 'standard'] }), true);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
5301
.agent/knowledge/everything_claude/tests/hooks/hooks.test.js
Normal file
5301
.agent/knowledge/everything_claude/tests/hooks/hooks.test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/mcp-health-check.js
|
||||
*
|
||||
* Run with: node tests/hooks/mcp-health-check.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'mcp-health-check.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function asyncTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-mcp-health-'));
|
||||
}
|
||||
|
||||
function cleanupTempDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeConfig(configPath, body) {
|
||||
fs.writeFileSync(configPath, JSON.stringify(body, null, 2));
|
||||
}
|
||||
|
||||
function readState(statePath) {
|
||||
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
}
|
||||
|
||||
function createCommandConfig(scriptPath) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [scriptPath]
|
||||
};
|
||||
}
|
||||
|
||||
function runHook(input, env = {}) {
|
||||
const result = spawnSync('node', [script], {
|
||||
input: JSON.stringify(input),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: result.status || 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function runRawHook(rawInput, env = {}) {
|
||||
const result = spawnSync('node', [script], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: result.status || 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing mcp-health-check.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('passes through non-MCP tools untouched', () => {
|
||||
const result = runHook(
|
||||
{ tool_name: 'Read', tool_input: { file_path: 'README.md' } },
|
||||
{ CLAUDE_HOOK_EVENT_NAME: 'PreToolUse' }
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Expected non-MCP tool to pass through');
|
||||
assert.strictEqual(result.stderr, '', 'Expected no stderr for non-MCP tool');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks truncated MCP hook input by default', () => {
|
||||
const rawInput = JSON.stringify({ tool_name: 'mcp__flaky__search', tool_input: {} });
|
||||
const result = runRawHook(rawInput, {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_HOOK_INPUT_TRUNCATED: '1',
|
||||
ECC_HOOK_INPUT_MAX_BYTES: '512'
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 2, 'Expected truncated MCP input to block by default');
|
||||
assert.strictEqual(result.stdout, rawInput, 'Expected raw input passthrough on stdout');
|
||||
assert.ok(result.stderr.includes('Hook input exceeded 512 bytes'), `Expected size warning, got: ${result.stderr}`);
|
||||
assert.ok(/blocking search/i.test(result.stderr), `Expected blocking message, got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
if (await asyncTest('marks healthy command MCP servers and allows the tool call', async () => {
|
||||
const tempDir = createTempDir();
|
||||
const configPath = path.join(tempDir, 'claude.json');
|
||||
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||
const serverScript = path.join(tempDir, 'healthy-server.js');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(serverScript, "setInterval(() => {}, 1000);\n");
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
mock: createCommandConfig(serverScript)
|
||||
}
|
||||
});
|
||||
|
||||
const input = { tool_name: 'mcp__mock__list_items', tool_input: {} };
|
||||
const result = runHook(input, {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_MCP_CONFIG_PATH: configPath,
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, `Expected healthy server to pass, got ${result.code}`);
|
||||
assert.strictEqual(result.stdout.trim(), JSON.stringify(input), 'Expected original JSON on stdout');
|
||||
|
||||
const state = readState(statePath);
|
||||
assert.strictEqual(state.servers.mock.status, 'healthy', 'Expected mock server to be marked healthy');
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('blocks unhealthy command MCP servers and records backoff state', async () => {
|
||||
const tempDir = createTempDir();
|
||||
const configPath = path.join(tempDir, 'claude.json');
|
||||
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||
const serverScript = path.join(tempDir, 'unhealthy-server.js');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(serverScript, "process.exit(1);\n");
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
flaky: createCommandConfig(serverScript)
|
||||
}
|
||||
});
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'mcp__flaky__search', tool_input: {} },
|
||||
{
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_MCP_CONFIG_PATH: configPath,
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 2, 'Expected unhealthy server to block the MCP tool');
|
||||
assert.ok(result.stderr.includes('Blocking search'), `Expected blocking message, got: ${result.stderr}`);
|
||||
|
||||
const state = readState(statePath);
|
||||
assert.strictEqual(state.servers.flaky.status, 'unhealthy', 'Expected flaky server to be marked unhealthy');
|
||||
assert.ok(state.servers.flaky.nextRetryAt > state.servers.flaky.checkedAt, 'Expected retry backoff to be recorded');
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('fail-open mode warns but does not block unhealthy MCP servers', async () => {
|
||||
const tempDir = createTempDir();
|
||||
const configPath = path.join(tempDir, 'claude.json');
|
||||
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||
const serverScript = path.join(tempDir, 'relaxed-server.js');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(serverScript, "process.exit(1);\n");
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
relaxed: createCommandConfig(serverScript)
|
||||
}
|
||||
});
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'mcp__relaxed__list', tool_input: {} },
|
||||
{
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_MCP_CONFIG_PATH: configPath,
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_HEALTH_FAIL_OPEN: '1',
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Expected fail-open mode to allow execution');
|
||||
assert.ok(result.stderr.includes('Blocking list') || result.stderr.includes('fall back'), 'Expected warning output in fail-open mode');
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('post-failure reconnect command restores server health when a reprobe succeeds', async () => {
|
||||
const tempDir = createTempDir();
|
||||
const configPath = path.join(tempDir, 'claude.json');
|
||||
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||
const switchFile = path.join(tempDir, 'server-mode.txt');
|
||||
const reconnectFile = path.join(tempDir, 'reconnected.txt');
|
||||
const probeScript = path.join(tempDir, 'probe-server.js');
|
||||
|
||||
fs.writeFileSync(switchFile, 'down');
|
||||
fs.writeFileSync(
|
||||
probeScript,
|
||||
[
|
||||
"const fs = require('fs');",
|
||||
`const mode = fs.readFileSync(${JSON.stringify(switchFile)}, 'utf8').trim();`,
|
||||
"if (mode === 'up') { setInterval(() => {}, 1000); } else { console.error('401 Unauthorized'); process.exit(1); }"
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const reconnectScript = path.join(tempDir, 'reconnect.js');
|
||||
fs.writeFileSync(
|
||||
reconnectScript,
|
||||
[
|
||||
"const fs = require('fs');",
|
||||
`fs.writeFileSync(${JSON.stringify(switchFile)}, 'up');`,
|
||||
`fs.writeFileSync(${JSON.stringify(reconnectFile)}, 'done');`
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
try {
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
authy: createCommandConfig(probeScript)
|
||||
}
|
||||
});
|
||||
|
||||
const result = runHook(
|
||||
{
|
||||
tool_name: 'mcp__authy__messages',
|
||||
tool_input: {},
|
||||
error: '401 Unauthorized'
|
||||
},
|
||||
{
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure',
|
||||
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'
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Expected failure hook to remain non-blocking');
|
||||
assert.ok(result.stderr.includes('reported 401'), `Expected reconnect log, got: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('connection restored'), `Expected restored log, got: ${result.stderr}`);
|
||||
assert.ok(fs.existsSync(reconnectFile), 'Expected reconnect command to run');
|
||||
|
||||
const state = readState(statePath);
|
||||
assert.strictEqual(state.servers.authy.status, 'healthy', 'Expected authy server to be restored after reconnect');
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Tests for observer memory explosion fix (#521)
|
||||
*
|
||||
* Validates three fixes:
|
||||
* 1. SIGUSR1 throttling in observe.sh (signal counter)
|
||||
* 2. Tail-based sampling in observer-loop.sh (not loading entire file)
|
||||
* 3. Re-entrancy guard + cooldown in observer-loop.sh on_usr1()
|
||||
*
|
||||
* Run with: node tests/hooks/observer-memory.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-observer-test-'));
|
||||
}
|
||||
|
||||
function cleanupDir(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const observeShPath = path.join(repoRoot, 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh');
|
||||
const observerLoopPath = path.join(repoRoot, 'skills', 'continuous-learning-v2', 'agents', 'observer-loop.sh');
|
||||
|
||||
console.log('\n=== Observer Memory Fix Tests (#521) ===\n');
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Test group 1: observe.sh SIGUSR1 throttling
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('--- observe.sh signal throttling ---');
|
||||
|
||||
test('observe.sh contains SIGNAL_EVERY_N throttle variable', () => {
|
||||
const content = fs.readFileSync(observeShPath, 'utf8');
|
||||
assert.ok(content.includes('SIGNAL_EVERY_N'), 'observe.sh should define SIGNAL_EVERY_N for throttling');
|
||||
});
|
||||
|
||||
test('observe.sh uses a counter file instead of signaling every call', () => {
|
||||
const content = fs.readFileSync(observeShPath, 'utf8');
|
||||
assert.ok(content.includes('.observer-signal-counter'), 'observe.sh should use a signal counter file');
|
||||
});
|
||||
|
||||
test('observe.sh only signals when counter reaches threshold', () => {
|
||||
const content = fs.readFileSync(observeShPath, 'utf8');
|
||||
assert.ok(content.includes('should_signal=0'), 'observe.sh should default should_signal to 0');
|
||||
assert.ok(content.includes('should_signal=1'), 'observe.sh should set should_signal=1 when threshold reached');
|
||||
assert.ok(content.includes('if [ "$should_signal" -eq 1 ]'), 'observe.sh should gate kill -USR1 behind should_signal check');
|
||||
});
|
||||
|
||||
test('observe.sh default throttle is 20 observations per signal', () => {
|
||||
const content = fs.readFileSync(observeShPath, 'utf8');
|
||||
assert.ok(content.includes('ECC_OBSERVER_SIGNAL_EVERY_N:-20'), 'Default signal frequency should be every 20 observations');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Test group 2: observer-loop.sh re-entrancy guard
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n--- observer-loop.sh re-entrancy guard ---');
|
||||
|
||||
test('observer-loop.sh defines ANALYZING guard variable', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('ANALYZING=0'), 'observer-loop.sh should initialize ANALYZING=0');
|
||||
});
|
||||
|
||||
test('on_usr1 checks ANALYZING before starting analysis', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('if [ "$ANALYZING" -eq 1 ]'), 'on_usr1 should check ANALYZING flag');
|
||||
assert.ok(content.includes('Analysis already in progress, skipping signal'), 'on_usr1 should log when skipping due to re-entrancy');
|
||||
});
|
||||
|
||||
test('on_usr1 sets ANALYZING=1 before and ANALYZING=0 after analysis', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
// Check that ANALYZING=1 is set before analyze_observations
|
||||
const analyzeCall = content.indexOf('ANALYZING=1');
|
||||
const analyzeObsCall = content.indexOf('analyze_observations', analyzeCall);
|
||||
const analyzeReset = content.indexOf('ANALYZING=0', analyzeObsCall);
|
||||
assert.ok(analyzeCall > 0, 'ANALYZING=1 should be set');
|
||||
assert.ok(analyzeObsCall > analyzeCall, 'analyze_observations should be called after ANALYZING=1');
|
||||
assert.ok(analyzeReset > analyzeObsCall, 'ANALYZING=0 should follow analyze_observations');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Test group 3: observer-loop.sh cooldown throttle
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n--- observer-loop.sh cooldown throttle ---');
|
||||
|
||||
test('observer-loop.sh defines ANALYSIS_COOLDOWN', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('ANALYSIS_COOLDOWN'), 'observer-loop.sh should define ANALYSIS_COOLDOWN');
|
||||
});
|
||||
|
||||
test('on_usr1 enforces cooldown between analyses', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('LAST_ANALYSIS_EPOCH'), 'Should track last analysis time');
|
||||
assert.ok(content.includes('Analysis cooldown active'), 'Should log when cooldown prevents analysis');
|
||||
});
|
||||
|
||||
test('default cooldown is 60 seconds', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('ECC_OBSERVER_ANALYSIS_COOLDOWN:-60'), 'Default cooldown should be 60 seconds');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Test group 4: Tail-based sampling (no full file load)
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n--- observer-loop.sh tail-based sampling ---');
|
||||
|
||||
test('analyze_observations uses tail to sample recent observations', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('tail -n "$MAX_ANALYSIS_LINES"'), 'Should use tail to limit observations sent to LLM');
|
||||
});
|
||||
|
||||
test('default max analysis lines is 500', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('ECC_OBSERVER_MAX_ANALYSIS_LINES:-500'), 'Default should sample last 500 lines');
|
||||
});
|
||||
|
||||
test('analysis temp file is created and cleaned up', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('ecc-observer-analysis'), 'Should create a temp analysis file');
|
||||
assert.ok(content.includes('rm -f "$prompt_file" "$analysis_file"'), 'Should clean up both prompt and analysis temp files');
|
||||
});
|
||||
|
||||
test('observer-loop uses project-local temp directory for analysis artifacts', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('observer_tmp_dir="${PROJECT_DIR}/.observer-tmp"'), 'Should keep observer temp files inside the project');
|
||||
assert.ok(content.includes('mktemp "${observer_tmp_dir}/ecc-observer-analysis.'), 'Analysis temp file should use the project temp dir');
|
||||
assert.ok(content.includes('mktemp "${observer_tmp_dir}/ecc-observer-prompt.'), 'Prompt temp file should use the project temp dir');
|
||||
});
|
||||
|
||||
test('observer-loop prompt requires direct instinct writes without asking permission', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
const heredocStart = content.indexOf('cat > "$prompt_file" <<PROMPT');
|
||||
const heredocEnd = content.indexOf('\nPROMPT', heredocStart + 1);
|
||||
assert.ok(heredocStart > 0, 'Should find prompt heredoc start');
|
||||
assert.ok(heredocEnd > heredocStart, 'Should find prompt heredoc end');
|
||||
const promptSection = content.substring(heredocStart, heredocEnd);
|
||||
assert.ok(promptSection.includes('MUST write an instinct file directly'), 'Prompt should require direct file creation');
|
||||
assert.ok(promptSection.includes('Do NOT ask for permission'), 'Prompt should forbid permission-seeking');
|
||||
assert.ok(promptSection.includes('write or update the instinct file in this run'), 'Prompt should require same-run writes');
|
||||
});
|
||||
test('prompt references analysis_file not full OBSERVATIONS_FILE', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
// The prompt heredoc should reference analysis_file for the Read instruction.
|
||||
// Find the section between the heredoc open and close markers.
|
||||
const heredocStart = content.indexOf('cat > "$prompt_file" <<PROMPT');
|
||||
const heredocEnd = content.indexOf('\nPROMPT', heredocStart + 1);
|
||||
assert.ok(heredocStart > 0, 'Should find prompt heredoc start');
|
||||
assert.ok(heredocEnd > heredocStart, 'Should find prompt heredoc end');
|
||||
const promptSection = content.substring(heredocStart, heredocEnd);
|
||||
assert.ok(promptSection.includes('${analysis_file}'), 'Prompt should point Claude at the sampled analysis file, not the full observations file');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Test group 5: Signal counter file simulation
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n--- Signal counter file behavior ---');
|
||||
|
||||
test('counter file increments and resets correctly', () => {
|
||||
const testDir = createTempDir();
|
||||
const counterFile = path.join(testDir, '.observer-signal-counter');
|
||||
|
||||
// Simulate 20 calls - first 19 should not signal, 20th should
|
||||
const signalEveryN = 20;
|
||||
let signalCount = 0;
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
let shouldSignal = false;
|
||||
if (fs.existsSync(counterFile)) {
|
||||
let counter = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10) || 0;
|
||||
counter++;
|
||||
if (counter >= signalEveryN) {
|
||||
shouldSignal = true;
|
||||
counter = 0;
|
||||
}
|
||||
fs.writeFileSync(counterFile, String(counter));
|
||||
} else {
|
||||
fs.writeFileSync(counterFile, '1');
|
||||
}
|
||||
if (shouldSignal) signalCount++;
|
||||
}
|
||||
|
||||
// 40 calls with threshold 20 should signal exactly 2 times
|
||||
// (at call 20 and call 40)
|
||||
assert.strictEqual(signalCount, 2, `Expected 2 signals over 40 calls, got ${signalCount}`);
|
||||
|
||||
cleanupDir(testDir);
|
||||
});
|
||||
|
||||
test('counter file handles missing/corrupt file gracefully', () => {
|
||||
const testDir = createTempDir();
|
||||
const counterFile = path.join(testDir, '.observer-signal-counter');
|
||||
|
||||
// Write corrupt content
|
||||
fs.writeFileSync(counterFile, 'not-a-number');
|
||||
const counter = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10) || 0;
|
||||
assert.strictEqual(counter, 0, 'Corrupt counter should default to 0');
|
||||
|
||||
cleanupDir(testDir);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Test group 6: End-to-end observe.sh signal throttle (shell)
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n--- observe.sh end-to-end throttle (shell execution) ---');
|
||||
|
||||
test('observe.sh creates counter file and increments on each call', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
// This test runs observe.sh with minimal input to verify counter behavior.
|
||||
// We need python3, bash, and a valid project dir to test the full flow.
|
||||
// We use ECC_SKIP_OBSERVE=0 and minimal JSON so observe.sh processes but
|
||||
// exits before signaling (no observer PID running).
|
||||
|
||||
const testDir = createTempDir();
|
||||
const projectDir = path.join(testDir, 'project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
// Create a minimal detect-project.sh that sets required vars
|
||||
const skillRoot = path.join(testDir, 'skill');
|
||||
const scriptsDir = path.join(skillRoot, 'scripts');
|
||||
const hooksDir = path.join(skillRoot, 'hooks');
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
// Minimal detect-project.sh stub
|
||||
fs.writeFileSync(
|
||||
path.join(scriptsDir, 'detect-project.sh'),
|
||||
[
|
||||
'#!/bin/bash',
|
||||
`PROJECT_ID="test-project"`,
|
||||
`PROJECT_NAME="test-project"`,
|
||||
`PROJECT_ROOT="${projectDir}"`,
|
||||
`PROJECT_DIR="${projectDir}"`,
|
||||
`CLV2_PYTHON_CMD="${process.platform === 'win32' ? 'python' : 'python3'}"`,
|
||||
''
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
// Copy observe.sh but patch SKILL_ROOT to our test dir
|
||||
let observeContent = fs.readFileSync(observeShPath, 'utf8');
|
||||
observeContent = observeContent.replace('SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"', `SKILL_ROOT="${skillRoot}"`);
|
||||
const testObserve = path.join(hooksDir, 'observe.sh');
|
||||
fs.writeFileSync(testObserve, observeContent, { mode: 0o755 });
|
||||
|
||||
const hookInput = JSON.stringify({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/tmp/test.txt' },
|
||||
session_id: 'test-session',
|
||||
cwd: projectDir
|
||||
});
|
||||
|
||||
// Run observe.sh twice
|
||||
for (let i = 0; i < 2; i++) {
|
||||
spawnSync('bash', [testObserve, 'post'], {
|
||||
input: hookInput,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: testDir,
|
||||
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
ECC_SKIP_OBSERVE: '0',
|
||||
CLAUDE_PROJECT_DIR: projectDir
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
}
|
||||
|
||||
const counterFile = path.join(projectDir, '.observer-signal-counter');
|
||||
if (fs.existsSync(counterFile)) {
|
||||
const val = fs.readFileSync(counterFile, 'utf8').trim();
|
||||
const counterVal = parseInt(val, 10);
|
||||
assert.ok(counterVal >= 1 && counterVal <= 2, `Counter should be 1 or 2 after 2 calls, got ${counterVal}`);
|
||||
} else {
|
||||
// If python3 is not available the hook exits early - that is acceptable
|
||||
const hasPython = spawnSync('python3', ['--version']).status === 0;
|
||||
if (hasPython) {
|
||||
assert.fail('Counter file should exist after running observe.sh');
|
||||
}
|
||||
}
|
||||
|
||||
cleanupDir(testDir);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Test group 7: Observer Haiku invocation flags
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n--- Observer Haiku invocation flags ---');
|
||||
|
||||
test('claude invocation includes --allowedTools flag', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('--allowedTools'), 'observer-loop.sh should include --allowedTools flag in claude invocation');
|
||||
});
|
||||
|
||||
test('allowedTools includes Read permission', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
const match = content.match(/--allowedTools\s+"([^"]+)"/);
|
||||
assert.ok(match, 'Should find --allowedTools with quoted value');
|
||||
assert.ok(match[1].includes('Read'), `allowedTools should include Read, got: ${match[1]}`);
|
||||
});
|
||||
|
||||
test('allowedTools includes Write permission', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
const match = content.match(/--allowedTools\s+"([^"]+)"/);
|
||||
assert.ok(match, 'Should find --allowedTools with quoted value');
|
||||
assert.ok(match[1].includes('Write'), `allowedTools should include Write, got: ${match[1]}`);
|
||||
});
|
||||
|
||||
test('claude invocation still includes ECC_SKIP_OBSERVE and ECC_HOOK_PROFILE guards', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
// Find the claude execution line(s)
|
||||
const lines = content.split('\n');
|
||||
const claudeLine = lines.find(l => l.includes('claude --model haiku'));
|
||||
assert.ok(claudeLine, 'Should find claude --model haiku invocation line');
|
||||
// The env vars are on the same line as the claude command
|
||||
const claudeLineIndex = lines.indexOf(claudeLine);
|
||||
const fullCommand = lines.slice(Math.max(0, claudeLineIndex - 1), claudeLineIndex + 3).join(' ');
|
||||
assert.ok(fullCommand.includes('ECC_SKIP_OBSERVE=1'), 'claude invocation should include ECC_SKIP_OBSERVE=1 guard');
|
||||
assert.ok(fullCommand.includes('ECC_HOOK_PROFILE=minimal'), 'claude invocation should include ECC_HOOK_PROFILE=minimal guard');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Summary
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${passed + failed}\n`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Tests for post-bash-build-complete.js and post-bash-pr-created.js
|
||||
*
|
||||
* Run with: node tests/hooks/post-bash-hooks.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const buildCompleteScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-build-complete.js');
|
||||
const prCreatedScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-pr-created.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(scriptPath, input) {
|
||||
return spawnSync('node', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
input,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// ── post-bash-build-complete.js ──────────────────────────────────
|
||||
|
||||
console.log('\nPost-Bash Build Complete Hook Tests');
|
||||
console.log('====================================\n');
|
||||
|
||||
console.log('Build command detection:');
|
||||
|
||||
if (test('stderr contains "Build completed" for npm run build command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'npm run build' } });
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stderr contains "Build completed" for pnpm build command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'pnpm build' } });
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stderr contains "Build completed" for yarn build command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'yarn build' } });
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nNon-build command detection:');
|
||||
|
||||
if (test('no stderr message for npm test command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'npm test' } });
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('no stderr message for ls command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'ls -la' } });
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('no stderr message for git status command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'git status' } });
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nStdout pass-through:');
|
||||
|
||||
if (test('stdout passes through input for build command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'npm run build' } });
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stdout passes through input for non-build command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'npm test' } });
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stdout passes through input for invalid JSON', () => {
|
||||
const input = 'not valid json';
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stdout passes through empty input', () => {
|
||||
const input = '';
|
||||
const result = runScript(buildCompleteScript, input);
|
||||
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── post-bash-pr-created.js ──────────────────────────────────────
|
||||
|
||||
console.log('\n\nPost-Bash PR Created Hook Tests');
|
||||
console.log('================================\n');
|
||||
|
||||
console.log('PR creation detection:');
|
||||
|
||||
if (test('stderr contains PR URL when gh pr create output has PR URL', () => {
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'gh pr create --title "Fix bug" --body "desc"' },
|
||||
tool_output: { output: 'https://github.com/owner/repo/pull/42\n' }
|
||||
});
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.ok(result.stderr.includes('https://github.com/owner/repo/pull/42'), `stderr should contain PR URL, got: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('[Hook] PR created:'), 'stderr should contain PR created message');
|
||||
assert.ok(result.stderr.includes('gh pr review 42'), 'stderr should contain review command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stderr contains correct repo in review command', () => {
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'gh pr create' },
|
||||
tool_output: { output: 'Created PR\nhttps://github.com/my-org/my-repo/pull/123\nDone' }
|
||||
});
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.ok(result.stderr.includes('--repo my-org/my-repo'), `stderr should contain correct repo, got: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('gh pr review 123'), 'stderr should contain correct PR number');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nNon-PR command detection:');
|
||||
|
||||
if (test('no stderr about PR for non-gh command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'npm test' } });
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-PR command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('no stderr about PR for gh issue command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'gh issue list' } });
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.strictEqual(result.stderr, '', 'stderr should be empty for non-PR create command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('no stderr about PR for gh pr create without PR URL in output', () => {
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'gh pr create' },
|
||||
tool_output: { output: 'Error: could not create PR' }
|
||||
});
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.strictEqual(result.stderr, '', 'stderr should be empty when no PR URL in output');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('no stderr about PR for gh pr list command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'gh pr list' } });
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.status, 0, 'Should exit with code 0');
|
||||
assert.strictEqual(result.stderr, '', 'stderr should be empty for gh pr list');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nStdout pass-through:');
|
||||
|
||||
if (test('stdout passes through input for PR create command', () => {
|
||||
const input = JSON.stringify({
|
||||
tool_input: { command: 'gh pr create' },
|
||||
tool_output: { output: 'https://github.com/owner/repo/pull/1' }
|
||||
});
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stdout passes through input for non-PR command', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'echo hello' } });
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stdout passes through input for invalid JSON', () => {
|
||||
const input = 'not valid json';
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stdout passes through empty input', () => {
|
||||
const input = '';
|
||||
const result = runScript(prCreatedScript, input);
|
||||
assert.strictEqual(result.stdout, input, 'stdout should be the original input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for pre-bash-dev-server-block.js hook
|
||||
*
|
||||
* Run with: node tests/hooks/pre-bash-dev-server-block.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-dev-server-block.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(command) {
|
||||
const input = { tool_input: { command } };
|
||||
const result = spawnSync('node', [script], {
|
||||
encoding: 'utf8',
|
||||
input: JSON.stringify(input),
|
||||
timeout: 10000,
|
||||
});
|
||||
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing pre-bash-dev-server-block.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// --- Blocking tests (non-Windows only) ---
|
||||
|
||||
if (!isWindows) {
|
||||
(test('blocks npm run dev (exit code 2, stderr contains BLOCKED)', () => {
|
||||
const result = runScript('npm run dev');
|
||||
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||
assert.ok(result.stderr.includes('BLOCKED'), `Expected stderr to contain BLOCKED, got: ${result.stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('blocks pnpm dev (exit code 2)', () => {
|
||||
const result = runScript('pnpm dev');
|
||||
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('blocks yarn dev (exit code 2)', () => {
|
||||
const result = runScript('yarn dev');
|
||||
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('blocks bun run dev (exit code 2)', () => {
|
||||
const result = runScript('bun run dev');
|
||||
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||
}) ? passed++ : failed++);
|
||||
} else {
|
||||
console.log(' (skipping blocking tests on Windows)\n');
|
||||
}
|
||||
|
||||
// --- Allow tests ---
|
||||
|
||||
(test('allows tmux-wrapped npm run dev (exit code 0)', () => {
|
||||
const result = runScript('tmux new-session -d -s dev "npm run dev"');
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('allows npm install (exit code 0)', () => {
|
||||
const result = runScript('npm install');
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('allows npm test (exit code 0)', () => {
|
||||
const result = runScript('npm test');
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('allows npm run build (exit code 0)', () => {
|
||||
const result = runScript('npm run build');
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
(test('empty/invalid input passes through (exit code 0)', () => {
|
||||
const result = spawnSync('node', [script], {
|
||||
encoding: 'utf8',
|
||||
input: '',
|
||||
timeout: 10000,
|
||||
});
|
||||
assert.strictEqual(result.status || 0, 0, `Expected exit code 0, got ${result.status}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('stdout contains original input on pass-through', () => {
|
||||
const input = { tool_input: { command: 'npm install' } };
|
||||
const inputStr = JSON.stringify(input);
|
||||
const result = spawnSync('node', [script], {
|
||||
encoding: 'utf8',
|
||||
input: inputStr,
|
||||
timeout: 10000,
|
||||
});
|
||||
assert.strictEqual(result.status || 0, 0);
|
||||
assert.strictEqual(result.stdout.trim(), inputStr, `Expected stdout to contain original input`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// --- Summary ---
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for pre-bash-git-push-reminder.js and pre-bash-tmux-reminder.js hooks
|
||||
*
|
||||
* Run with: node tests/hooks/pre-bash-reminders.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const gitPushScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-git-push-reminder.js');
|
||||
const tmuxScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-tmux-reminder.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(scriptPath, command, envOverrides = {}) {
|
||||
const input = { tool_input: { command } };
|
||||
const inputStr = JSON.stringify(input);
|
||||
const result = spawnSync('node', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
input: inputStr,
|
||||
timeout: 10000,
|
||||
env: { ...process.env, ...envOverrides },
|
||||
});
|
||||
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '', inputStr };
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing pre-bash-git-push-reminder.js & pre-bash-tmux-reminder.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// --- git-push-reminder tests ---
|
||||
|
||||
console.log(' git-push-reminder:');
|
||||
|
||||
(test('git push triggers stderr warning', () => {
|
||||
const result = runScript(gitPushScript, 'git push origin main');
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
assert.ok(result.stderr.includes('[Hook]'), `Expected stderr to contain [Hook], got: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('Review changes before push'), `Expected stderr to mention review`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('git status has no warning', () => {
|
||||
const result = runScript(gitPushScript, 'git status');
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
assert.strictEqual(result.stderr, '', `Expected no stderr, got: ${result.stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('git push always passes through input on stdout', () => {
|
||||
const result = runScript(gitPushScript, 'git push');
|
||||
assert.strictEqual(result.stdout, result.inputStr, 'Expected stdout to match original input');
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// --- tmux-reminder tests (non-Windows only) ---
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
if (!isWindows) {
|
||||
console.log('\n tmux-reminder:');
|
||||
|
||||
(test('npm install triggers tmux suggestion', () => {
|
||||
const result = runScript(tmuxScript, 'npm install', { TMUX: '' });
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
assert.ok(result.stderr.includes('[Hook]'), `Expected stderr to contain [Hook], got: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('tmux'), `Expected stderr to mention tmux`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('npm test triggers tmux suggestion', () => {
|
||||
const result = runScript(tmuxScript, 'npm test', { TMUX: '' });
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
assert.ok(result.stderr.includes('tmux'), `Expected stderr to mention tmux`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('regular command like ls has no tmux suggestion', () => {
|
||||
const result = runScript(tmuxScript, 'ls -la', { TMUX: '' });
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
assert.strictEqual(result.stderr, '', `Expected no stderr for ls, got: ${result.stderr}`);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('tmux reminder always passes through input on stdout', () => {
|
||||
const result = runScript(tmuxScript, 'npm install', { TMUX: '' });
|
||||
assert.strictEqual(result.stdout, result.inputStr, 'Expected stdout to match original input');
|
||||
}) ? passed++ : failed++);
|
||||
} else {
|
||||
console.log('\n (skipping tmux-reminder tests on Windows)\n');
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/quality-gate.js
|
||||
*
|
||||
* Run with: node tests/hooks/quality-gate.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
const qualityGate = require('../../scripts/hooks/quality-gate');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
console.log('\nQuality Gate Hook Tests');
|
||||
console.log('========================\n');
|
||||
|
||||
// --- run() returns original input for valid JSON ---
|
||||
|
||||
console.log('run() pass-through behavior:');
|
||||
|
||||
if (test('returns original input for valid JSON with file_path', () => {
|
||||
const input = JSON.stringify({ tool_input: { file_path: '/tmp/nonexistent-file.js' } });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns original input for valid JSON without file_path', () => {
|
||||
const input = JSON.stringify({ tool_input: { command: 'ls' } });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns original input for valid JSON with nested structure', () => {
|
||||
const input = JSON.stringify({ tool_input: { file_path: '/some/path.ts', content: 'hello' }, other: [1, 2, 3] });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- run() returns original input for invalid JSON ---
|
||||
|
||||
console.log('\nInvalid JSON handling:');
|
||||
|
||||
if (test('returns original input for invalid JSON (no crash)', () => {
|
||||
const input = 'this is not json at all {{{';
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns original input for partial JSON', () => {
|
||||
const input = '{"tool_input": {';
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns original input for JSON with trailing garbage', () => {
|
||||
const input = '{"tool_input": {}}extra';
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- run() returns original input when file does not exist ---
|
||||
|
||||
console.log('\nNon-existent file handling:');
|
||||
|
||||
if (test('returns original input when file_path points to non-existent file', () => {
|
||||
const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.js' } });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns original input when file_path is a non-existent .py file', () => {
|
||||
const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.py' } });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns original input when file_path is a non-existent .go file', () => {
|
||||
const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.go' } });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- run() returns original input for empty input ---
|
||||
|
||||
console.log('\nEmpty input handling:');
|
||||
|
||||
if (test('returns original input for empty string', () => {
|
||||
const input = '';
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return empty string unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns original input for whitespace-only string', () => {
|
||||
const input = ' ';
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return whitespace string unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- run() handles missing tool_input gracefully ---
|
||||
|
||||
console.log('\nMissing tool_input handling:');
|
||||
|
||||
if (test('handles missing tool_input gracefully', () => {
|
||||
const input = JSON.stringify({ something_else: 'value' });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles null tool_input gracefully', () => {
|
||||
const input = JSON.stringify({ tool_input: null });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles tool_input with empty file_path', () => {
|
||||
const input = JSON.stringify({ tool_input: { file_path: '' } });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles empty JSON object', () => {
|
||||
const input = JSON.stringify({});
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- run() with a real file (but no formatter installed) ---
|
||||
|
||||
console.log('\nReal file without formatter:');
|
||||
|
||||
if (test('returns original input for existing file with no formatter configured', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `quality-gate-test-${Date.now()}.js`);
|
||||
fs.writeFileSync(tmpFile, 'const x = 1;\n');
|
||||
try {
|
||||
const input = JSON.stringify({ tool_input: { file_path: tmpFile } });
|
||||
const result = qualityGate.run(input);
|
||||
assert.strictEqual(result, input, 'Should return original input unchanged');
|
||||
} finally {
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/suggest-compact.js
|
||||
*
|
||||
* Tests the tool-call counter, threshold logic, interval suggestions,
|
||||
* and environment variable handling.
|
||||
*
|
||||
* Run with: node tests/hooks/suggest-compact.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const compactScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'suggest-compact.js');
|
||||
|
||||
// Test helpers
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (_err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${_err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run suggest-compact.js with optional env overrides.
|
||||
* Returns { code, stdout, stderr }.
|
||||
*/
|
||||
function runCompact(envOverrides = {}) {
|
||||
const env = { ...process.env, ...envOverrides };
|
||||
const result = spawnSync('node', [compactScript], {
|
||||
encoding: 'utf8',
|
||||
input: '{}',
|
||||
timeout: 10000,
|
||||
env,
|
||||
});
|
||||
return {
|
||||
code: result.status || 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the counter file path for a given session ID.
|
||||
*/
|
||||
function getCounterFilePath(sessionId) {
|
||||
return path.join(os.tmpdir(), `claude-tool-count-${sessionId}`);
|
||||
}
|
||||
|
||||
let counterContextSeq = 0;
|
||||
|
||||
function createCounterContext(prefix = 'test-compact') {
|
||||
counterContextSeq += 1;
|
||||
const sessionId = `${prefix}-${Date.now()}-${counterContextSeq}`;
|
||||
const counterFile = getCounterFilePath(sessionId);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
counterFile,
|
||||
cleanup() {
|
||||
try {
|
||||
fs.unlinkSync(counterFile);
|
||||
} catch (_err) {
|
||||
// Ignore missing temp files between runs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing suggest-compact.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Basic functionality
|
||||
console.log('Basic counter functionality:');
|
||||
|
||||
if (test('creates counter file on first run', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
assert.ok(fs.existsSync(counterFile), 'Counter file should be created');
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Counter should be 1 after first run');
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('increments counter on subsequent runs', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 3, 'Counter should be 3 after three runs');
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// Threshold suggestion
|
||||
console.log('\nThreshold suggestion:');
|
||||
|
||||
if (test('suggests compact at threshold (COMPACT_THRESHOLD=3)', () => {
|
||||
const { sessionId, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
// Run 3 times with threshold=3
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
assert.ok(
|
||||
result.stderr.includes('3 tool calls reached') || result.stderr.includes('consider /compact'),
|
||||
`Should suggest compact at threshold. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('does NOT suggest compact before threshold', () => {
|
||||
const { sessionId, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' });
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' });
|
||||
assert.ok(
|
||||
!result.stderr.includes('StrategicCompact'),
|
||||
'Should NOT suggest compact before threshold'
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// Interval suggestion (every 25 calls after threshold)
|
||||
console.log('\nInterval suggestion:');
|
||||
|
||||
if (test('suggests at threshold + 25 interval', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
// Set counter to threshold+24 (so next run = threshold+25)
|
||||
// threshold=3, so we need count=28 → 25 calls past threshold
|
||||
// Write 27 to the counter file, next run will be 28 = 3 + 25
|
||||
fs.writeFileSync(counterFile, '27');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
// count=28, threshold=3, 28-3=25, 25 % 25 === 0 → should suggest
|
||||
assert.ok(
|
||||
result.stderr.includes('28 tool calls') || result.stderr.includes('checkpoint'),
|
||||
`Should suggest at threshold+25 interval. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// Environment variable handling
|
||||
console.log('\nEnvironment variable handling:');
|
||||
|
||||
if (test('uses default threshold (50) when COMPACT_THRESHOLD is not set', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
// Write counter to 49, next run will be 50 = default threshold
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
// Remove COMPACT_THRESHOLD from env
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should use default threshold of 50. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('ignores invalid COMPACT_THRESHOLD (negative)', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '-5' });
|
||||
// Invalid threshold falls back to 50
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should fallback to 50 for negative threshold. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('ignores non-numeric COMPACT_THRESHOLD', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: 'abc' });
|
||||
// NaN falls back to 50
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should fallback to 50 for non-numeric threshold. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// Corrupted counter file
|
||||
console.log('\nCorrupted counter file:');
|
||||
|
||||
if (test('resets counter on corrupted file content', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, 'not-a-number');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// Corrupted file → parsed is NaN → falls back to count=1
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Should reset to 1 on corrupted file');
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('resets counter on extremely large value', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
// Value > 1000000 should be clamped
|
||||
fs.writeFileSync(counterFile, '9999999');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0);
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Should reset to 1 for value > 1000000');
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('handles empty counter file', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0);
|
||||
// Empty file → bytesRead=0 → count starts at 1
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Should start at 1 for empty file');
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// Session isolation
|
||||
console.log('\nSession isolation:');
|
||||
|
||||
if (test('uses separate counter files per session ID', () => {
|
||||
const sessionA = `compact-a-${Date.now()}`;
|
||||
const sessionB = `compact-b-${Date.now()}`;
|
||||
const fileA = getCounterFilePath(sessionA);
|
||||
const fileB = getCounterFilePath(sessionB);
|
||||
try {
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionA });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionA });
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionB });
|
||||
const countA = parseInt(fs.readFileSync(fileA, 'utf8').trim(), 10);
|
||||
const countB = parseInt(fs.readFileSync(fileB, 'utf8').trim(), 10);
|
||||
assert.strictEqual(countA, 2, 'Session A should have count 2');
|
||||
assert.strictEqual(countB, 1, 'Session B should have count 1');
|
||||
} finally {
|
||||
try { fs.unlinkSync(fileA); } catch (_err) { /* ignore */ }
|
||||
try { fs.unlinkSync(fileB); } catch (_err) { /* ignore */ }
|
||||
}
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// Always exits 0
|
||||
console.log('\nExit code:');
|
||||
|
||||
if (test('always exits 0 (never blocks Claude)', () => {
|
||||
const { sessionId, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
assert.strictEqual(result.code, 0, 'Should always exit 0');
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// ── Round 29: threshold boundary values ──
|
||||
console.log('\nThreshold boundary values:');
|
||||
|
||||
if (test('rejects COMPACT_THRESHOLD=0 (falls back to 50)', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '0' });
|
||||
// 0 is invalid (must be > 0), falls back to 50, count becomes 50 → should suggest
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should fallback to 50 for threshold=0. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('accepts COMPACT_THRESHOLD=10000 (boundary max)', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '9999');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '10000' });
|
||||
// count becomes 10000, threshold=10000 → should suggest
|
||||
assert.ok(
|
||||
result.stderr.includes('10000 tool calls reached'),
|
||||
`Should accept threshold=10000. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('rejects COMPACT_THRESHOLD=10001 (falls back to 50)', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '10001' });
|
||||
// 10001 > 10000, invalid, falls back to 50, count becomes 50 → should suggest
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls reached'),
|
||||
`Should fallback to 50 for threshold=10001. Got stderr: ${result.stderr}`
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('rejects float COMPACT_THRESHOLD (e.g. 3.5)', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3.5' });
|
||||
// parseInt('3.5') = 3, which is valid (> 0 && <= 10000)
|
||||
// count becomes 50, threshold=3, 50-3=47, 47%25≠0 and 50≠3 → no suggestion
|
||||
assert.strictEqual(result.code, 0);
|
||||
// No suggestion expected (50 !== 3, and (50-3) % 25 !== 0)
|
||||
assert.ok(
|
||||
!result.stderr.includes('StrategicCompact'),
|
||||
'Float threshold should be parseInt-ed to 3, no suggestion at count=50'
|
||||
);
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('counter value at exact boundary 1000000 is valid', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '999999');
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
|
||||
// 999999 is valid (> 0, <= 1000000), count becomes 1000000
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1000000, 'Counter at 1000000 boundary should be valid');
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
if (test('counter value at 1000001 is clamped (reset to 1)', () => {
|
||||
const { sessionId, counterFile, cleanup } = createCounterContext();
|
||||
cleanup();
|
||||
fs.writeFileSync(counterFile, '1000001');
|
||||
runCompact({ CLAUDE_SESSION_ID: sessionId });
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Counter > 1000000 should be reset to 1');
|
||||
cleanup();
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// ── Round 64: default session ID fallback ──
|
||||
console.log('\nDefault session ID fallback (Round 64):');
|
||||
|
||||
if (test('uses "default" session ID when CLAUDE_SESSION_ID is empty', () => {
|
||||
const defaultCounterFile = getCounterFilePath('default');
|
||||
try { fs.unlinkSync(defaultCounterFile); } catch (_err) { /* ignore */ }
|
||||
try {
|
||||
// Pass empty CLAUDE_SESSION_ID — falsy, so script uses 'default'
|
||||
const env = { ...process.env, CLAUDE_SESSION_ID: '' };
|
||||
const result = spawnSync('node', [compactScript], {
|
||||
encoding: 'utf8',
|
||||
input: '{}',
|
||||
timeout: 10000,
|
||||
env,
|
||||
});
|
||||
assert.strictEqual(result.status || 0, 0, 'Should exit 0');
|
||||
assert.ok(fs.existsSync(defaultCounterFile), 'Counter file should use "default" session ID');
|
||||
const count = parseInt(fs.readFileSync(defaultCounterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, 'Counter should be 1 for first run with default session');
|
||||
} finally {
|
||||
try { fs.unlinkSync(defaultCounterFile); } catch (_err) { /* ignore */ }
|
||||
}
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`
|
||||
Results: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,775 @@
|
||||
/**
|
||||
* Integration tests for hook scripts
|
||||
*
|
||||
* Tests hook behavior in realistic scenarios with proper input/output handling.
|
||||
*
|
||||
* Run with: node tests/integration/hooks.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawn } = require('child_process');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
|
||||
// Test helper
|
||||
function _test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Async test helper
|
||||
async function asyncTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a hook script with simulated Claude Code input
|
||||
* @param {string} scriptPath - Path to the hook script
|
||||
* @param {object} input - Hook input object (will be JSON stringified)
|
||||
* @param {object} env - Environment variables
|
||||
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
|
||||
*/
|
||||
function runHookWithInput(scriptPath, input = {}, env = {}, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('node', [scriptPath], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', data => stdout += data);
|
||||
proc.stderr.on('data', data => stderr += data);
|
||||
|
||||
// Ignore EPIPE/EOF errors (process may exit before we finish writing)
|
||||
// Windows uses EOF instead of EPIPE for closed pipe writes
|
||||
proc.stdin.on('error', (err) => {
|
||||
if (err.code !== 'EPIPE' && err.code !== 'EOF') {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Send JSON input on stdin (simulating Claude Code hook invocation)
|
||||
if (input && Object.keys(input).length > 0) {
|
||||
proc.stdin.write(JSON.stringify(input));
|
||||
}
|
||||
proc.stdin.end();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
reject(new Error(`Hook timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
proc.on('close', code => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
|
||||
proc.on('error', err => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionStartPayload(stdout) {
|
||||
assert.ok(stdout.trim(), 'Expected SessionStart hook to emit stdout payload');
|
||||
const payload = JSON.parse(stdout);
|
||||
assert.strictEqual(payload.hookSpecificOutput?.hookEventName, 'SessionStart');
|
||||
assert.strictEqual(typeof payload.hookSpecificOutput?.additionalContext, 'string');
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a hook command string exactly as declared in hooks.json.
|
||||
* Supports wrapped node script commands and shell wrappers.
|
||||
* @param {string} command - Hook command from hooks.json
|
||||
* @param {object} input - Hook input object
|
||||
* @param {object} env - Environment variables
|
||||
*/
|
||||
function runHookCommand(command, input = {}, env = {}, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const mergedEnv = { ...process.env, CLAUDE_PLUGIN_ROOT: REPO_ROOT, ...env };
|
||||
const resolvedCommand = command.replace(
|
||||
/\$\{([A-Z_][A-Z0-9_]*)\}/g,
|
||||
(_, name) => String(mergedEnv[name] || '')
|
||||
);
|
||||
|
||||
const nodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/);
|
||||
const useDirectNodeSpawn = Boolean(nodeMatch);
|
||||
const shell = isWindows ? 'cmd' : 'bash';
|
||||
const shellArgs = isWindows ? ['/d', '/s', '/c', resolvedCommand] : ['-lc', resolvedCommand];
|
||||
const nodeArgs = nodeMatch
|
||||
? [
|
||||
nodeMatch[1],
|
||||
...Array.from(
|
||||
nodeMatch[2].matchAll(/"([^"]*)"|(\S+)/g),
|
||||
m => m[1] !== undefined ? m[1] : m[2]
|
||||
)
|
||||
]
|
||||
: [];
|
||||
|
||||
const proc = useDirectNodeSpawn
|
||||
? spawn('node', nodeArgs, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
: spawn(shell, shellArgs, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timer;
|
||||
|
||||
proc.stdout.on('data', data => stdout += data);
|
||||
proc.stderr.on('data', data => stderr += data);
|
||||
|
||||
// Ignore EPIPE/EOF errors (process may exit before we finish writing)
|
||||
proc.stdin.on('error', (err) => {
|
||||
if (err.code !== 'EPIPE' && err.code !== 'EOF') {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
if (input && Object.keys(input).length > 0) {
|
||||
proc.stdin.write(JSON.stringify(input));
|
||||
}
|
||||
proc.stdin.end();
|
||||
|
||||
timer = setTimeout(() => {
|
||||
proc.kill(isWindows ? undefined : 'SIGKILL');
|
||||
reject(new Error(`Hook command timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
proc.on('close', code => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
|
||||
proc.on('error', err => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create a temporary test directory
|
||||
function createTestDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hook-integration-test-'));
|
||||
}
|
||||
|
||||
// Clean up test directory
|
||||
function cleanupTestDir(testDir) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function getHookCommandByDescription(hooks, lifecycle, descriptionText) {
|
||||
const hookGroup = hooks.hooks[lifecycle]?.find(
|
||||
entry => entry.description && entry.description.includes(descriptionText)
|
||||
);
|
||||
|
||||
assert.ok(hookGroup, `Expected ${lifecycle} hook matching "${descriptionText}"`);
|
||||
assert.ok(hookGroup.hooks?.[0]?.command, `Expected ${lifecycle} hook command for "${descriptionText}"`);
|
||||
return hookGroup.hooks[0].command;
|
||||
}
|
||||
|
||||
// Test suite
|
||||
async function runTests() {
|
||||
console.log('\n=== Hook Integration Tests ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const scriptsDir = path.join(__dirname, '..', '..', 'scripts', 'hooks');
|
||||
const hooksJsonPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
|
||||
const hooks = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
|
||||
// ==========================================
|
||||
// Input Format Tests
|
||||
// ==========================================
|
||||
console.log('Hook Input Format Handling:');
|
||||
|
||||
if (await asyncTest('hooks handle empty stdin gracefully', async () => {
|
||||
const result = await runHookWithInput(path.join(scriptsDir, 'session-start.js'), {});
|
||||
assert.strictEqual(result.code, 0, `Should exit 0, got ${result.code}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('hooks handle malformed JSON input', async () => {
|
||||
const proc = spawn('node', [path.join(scriptsDir, 'session-start.js')], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let code = null;
|
||||
proc.stdin.write('{ invalid json }');
|
||||
proc.stdin.end();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
proc.on('close', (c) => {
|
||||
code = c;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Hook should not crash on malformed input (exit 0)
|
||||
assert.strictEqual(code, 0, 'Should handle malformed JSON gracefully');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('hooks parse valid tool_input correctly', async () => {
|
||||
// Test the console.log warning hook with valid input
|
||||
const command = 'node -e "const fs=require(\'fs\');let d=\'\';process.stdin.on(\'data\',c=>d+=c);process.stdin.on(\'end\',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||\'\';console.log(\'Path:\',p)})"';
|
||||
const match = command.match(/^node -e "(.+)"$/s);
|
||||
|
||||
const proc = spawn('node', ['-e', match[1]], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
proc.stdout.on('data', data => stdout += data);
|
||||
|
||||
proc.stdin.write(JSON.stringify({
|
||||
tool_input: { file_path: '/test/path.js' }
|
||||
}));
|
||||
proc.stdin.end();
|
||||
|
||||
await new Promise(resolve => proc.on('close', resolve));
|
||||
|
||||
assert.ok(stdout.includes('/test/path.js'), 'Should extract file_path from input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// Output Format Tests
|
||||
// ==========================================
|
||||
console.log('\nHook Output Format:');
|
||||
|
||||
if (await asyncTest('session-start logs diagnostics to stderr and emits structured stdout when context exists', async () => {
|
||||
const result = await runHookWithInput(path.join(scriptsDir, 'session-start.js'), {});
|
||||
// Session-start should write info to stderr
|
||||
assert.ok(result.stderr.length > 0, 'Should have stderr output');
|
||||
assert.ok(result.stderr.includes('[SessionStart]'), 'Should have [SessionStart] prefix');
|
||||
const payload = getSessionStartPayload(result.stdout);
|
||||
assert.ok(payload.hookSpecificOutput, 'Should include hookSpecificOutput');
|
||||
assert.strictEqual(payload.hookSpecificOutput.hookEventName, 'SessionStart');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('PreCompact hook logs to stderr', async () => {
|
||||
const result = await runHookWithInput(path.join(scriptsDir, 'pre-compact.js'), {});
|
||||
assert.ok(result.stderr.includes('[PreCompact]'), 'Should output to stderr with prefix');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('dev server hook transforms command to tmux session', async () => {
|
||||
// Test the auto-tmux dev hook — transforms dev commands to run in tmux
|
||||
const hookCommand = getHookCommandByDescription(
|
||||
hooks,
|
||||
'PreToolUse',
|
||||
'Auto-start dev servers in tmux'
|
||||
);
|
||||
const result = await runHookCommand(hookCommand, {
|
||||
tool_input: { command: 'npm run dev' }
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Hook should exit 0 (transforms, does not block)');
|
||||
// On Unix with tmux, stdout contains transformed JSON with tmux command
|
||||
// On Windows or without tmux, stdout contains original JSON passthrough
|
||||
const output = result.stdout.trim();
|
||||
if (output) {
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok(parsed.tool_input, 'Should output valid JSON with tool_input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// Exit Code Tests
|
||||
// ==========================================
|
||||
console.log('\nHook Exit Codes:');
|
||||
|
||||
if (await asyncTest('non-blocking hooks exit with code 0', async () => {
|
||||
const result = await runHookWithInput(path.join(scriptsDir, 'session-end.js'), {});
|
||||
assert.strictEqual(result.code, 0, 'Non-blocking hook should exit 0');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('dev server hook transforms yarn dev to tmux session', async () => {
|
||||
// The auto-tmux dev hook transforms dev commands (yarn dev, npm run dev, etc.)
|
||||
const hookCommand = getHookCommandByDescription(
|
||||
hooks,
|
||||
'PreToolUse',
|
||||
'Auto-start dev servers in tmux'
|
||||
);
|
||||
const result = await runHookCommand(hookCommand, {
|
||||
tool_input: { command: 'yarn dev' }
|
||||
});
|
||||
|
||||
// Hook always exits 0 — it transforms, never blocks
|
||||
assert.strictEqual(result.code, 0, 'Hook should exit 0 (transforms, does not block)');
|
||||
const output = result.stdout.trim();
|
||||
if (output) {
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok(parsed.tool_input, 'Should output valid JSON with tool_input');
|
||||
assert.ok(parsed.tool_input.command, 'Should have a command in output');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('MCP health hook blocks unhealthy MCP tool calls through hooks.json', async () => {
|
||||
const hookCommand = getHookCommandByDescription(
|
||||
hooks,
|
||||
'PreToolUse',
|
||||
'Check MCP server health before MCP tool execution'
|
||||
);
|
||||
|
||||
const testDir = createTestDir();
|
||||
const configPath = path.join(testDir, 'claude.json');
|
||||
const statePath = path.join(testDir, 'mcp-health.json');
|
||||
const serverScript = path.join(testDir, 'broken-mcp.js');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(serverScript, 'process.exit(1);\n');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
broken: {
|
||||
command: process.execPath,
|
||||
args: [serverScript]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const result = await runHookCommand(
|
||||
hookCommand,
|
||||
{ tool_name: 'mcp__broken__search', tool_input: {} },
|
||||
{
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_MCP_CONFIG_PATH: configPath,
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 2, 'Expected unhealthy MCP preflight to block');
|
||||
assert.ok(result.stderr.includes('broken is unavailable'), `Expected health warning, got: ${result.stderr}`);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('hooks handle missing files gracefully', async () => {
|
||||
const testDir = createTestDir();
|
||||
const transcriptPath = path.join(testDir, 'nonexistent.jsonl');
|
||||
|
||||
try {
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'evaluate-session.js'),
|
||||
{ transcript_path: transcriptPath }
|
||||
);
|
||||
|
||||
// Should not crash, just skip processing
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 for missing file');
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// Realistic Scenario Tests
|
||||
// ==========================================
|
||||
console.log('\nRealistic Scenarios:');
|
||||
|
||||
if (await asyncTest('suggest-compact increments and triggers at threshold', async () => {
|
||||
const sessionId = 'integration-test-' + Date.now();
|
||||
const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`);
|
||||
|
||||
try {
|
||||
// Set counter just below threshold
|
||||
fs.writeFileSync(counterFile, '49');
|
||||
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'suggest-compact.js'),
|
||||
{},
|
||||
{ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '50' }
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.stderr.includes('50 tool calls'),
|
||||
'Should suggest compact at threshold'
|
||||
);
|
||||
} finally {
|
||||
if (fs.existsSync(counterFile)) fs.unlinkSync(counterFile);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('evaluate-session processes transcript with sufficient messages', async () => {
|
||||
const testDir = createTestDir();
|
||||
const transcriptPath = path.join(testDir, 'transcript.jsonl');
|
||||
|
||||
// Create a transcript with 15 user messages
|
||||
const messages = Array(15).fill(null).map((_, i) => ({
|
||||
type: 'user',
|
||||
content: `Test message ${i + 1}`
|
||||
}));
|
||||
|
||||
fs.writeFileSync(
|
||||
transcriptPath,
|
||||
messages.map(m => JSON.stringify(m)).join('\n')
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'evaluate-session.js'),
|
||||
{ transcript_path: transcriptPath }
|
||||
);
|
||||
|
||||
assert.ok(result.stderr.includes('15 messages'), 'Should process session');
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => {
|
||||
// Find the PR logging hook
|
||||
const prHook = hooks.hooks.PostToolUse.find(h =>
|
||||
h.description && h.description.includes('PR URL')
|
||||
);
|
||||
|
||||
assert.ok(prHook, 'PR hook should exist');
|
||||
|
||||
const result = await runHookCommand(prHook.hooks[0].command, {
|
||||
tool_input: { command: 'gh pr create --title "Test"' },
|
||||
tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' }
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
result.stderr.includes('PR created') || result.stderr.includes('github.com'),
|
||||
'Should extract and log PR URL'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// Session End Transcript Parsing Tests
|
||||
// ==========================================
|
||||
console.log('\nSession End Transcript Parsing:');
|
||||
|
||||
if (await asyncTest('session-end extracts summary from mixed JSONL formats', async () => {
|
||||
const testDir = createTestDir();
|
||||
const transcriptPath = path.join(testDir, 'mixed-transcript.jsonl');
|
||||
|
||||
// Create transcript with both direct tool_use and nested assistant message formats
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'user', content: 'Fix the login bug' }),
|
||||
JSON.stringify({ type: 'tool_use', name: 'Read', input: { file_path: 'src/auth.ts' } }),
|
||||
JSON.stringify({ type: 'assistant', message: { content: [
|
||||
{ type: 'tool_use', name: 'Edit', input: { file_path: 'src/auth.ts' } }
|
||||
]}}),
|
||||
JSON.stringify({ type: 'user', content: 'Now add tests' }),
|
||||
JSON.stringify({ type: 'assistant', message: { content: [
|
||||
{ type: 'tool_use', name: 'Write', input: { file_path: 'tests/auth.test.ts' } },
|
||||
{ type: 'text', text: 'Here are the tests' }
|
||||
]}}),
|
||||
JSON.stringify({ type: 'user', content: 'Looks good, commit' })
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
try {
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'session-end.js'),
|
||||
{ transcript_path: transcriptPath },
|
||||
{ HOME: testDir, USERPROFILE: testDir }
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
assert.ok(result.stderr.includes('[SessionEnd]'), 'Should have SessionEnd log');
|
||||
|
||||
// Verify a session file was created
|
||||
const sessionsDir = path.join(testDir, '.claude', 'sessions');
|
||||
if (fs.existsSync(sessionsDir)) {
|
||||
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp'));
|
||||
assert.ok(files.length > 0, 'Should create a session file');
|
||||
|
||||
// Verify session content includes tasks from user messages
|
||||
const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8');
|
||||
assert.ok(content.includes('Fix the login bug'), 'Should include first user message');
|
||||
assert.ok(content.includes('auth.ts'), 'Should include modified files');
|
||||
}
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('session-end handles transcript with malformed lines gracefully', async () => {
|
||||
const testDir = createTestDir();
|
||||
const transcriptPath = path.join(testDir, 'malformed-transcript.jsonl');
|
||||
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'user', content: 'Task 1' }),
|
||||
'{broken json here',
|
||||
JSON.stringify({ type: 'user', content: 'Task 2' }),
|
||||
'{"truncated":',
|
||||
JSON.stringify({ type: 'user', content: 'Task 3' })
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
try {
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'session-end.js'),
|
||||
{ transcript_path: transcriptPath },
|
||||
{ HOME: testDir, USERPROFILE: testDir }
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0 despite malformed lines');
|
||||
// Should still process the valid lines
|
||||
assert.ok(result.stderr.includes('[SessionEnd]'), 'Should have SessionEnd log');
|
||||
assert.ok(result.stderr.includes('unparseable'), 'Should warn about unparseable lines');
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('session-end creates session file with nested user messages', async () => {
|
||||
const testDir = createTestDir();
|
||||
const transcriptPath = path.join(testDir, 'nested-transcript.jsonl');
|
||||
|
||||
// Claude Code JSONL format uses nested message.content arrays
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: [
|
||||
{ type: 'text', text: 'Refactor the utils module' }
|
||||
]}}),
|
||||
JSON.stringify({ type: 'assistant', message: { content: [
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: 'lib/utils.js' } }
|
||||
]}}),
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'Approve the changes' }})
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
try {
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'session-end.js'),
|
||||
{ transcript_path: transcriptPath },
|
||||
{ HOME: testDir, USERPROFILE: testDir }
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||
|
||||
// Check session file was created
|
||||
const sessionsDir = path.join(testDir, '.claude', 'sessions');
|
||||
if (fs.existsSync(sessionsDir)) {
|
||||
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp'));
|
||||
assert.ok(files.length > 0, 'Should create session file');
|
||||
const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8');
|
||||
assert.ok(content.includes('Refactor the utils module') || content.includes('Approve'),
|
||||
'Should extract user messages from nested format');
|
||||
}
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// Error Handling Tests
|
||||
// ==========================================
|
||||
console.log('\nError Handling:');
|
||||
|
||||
if (await asyncTest('hooks do not crash on unexpected input structure', async () => {
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'suggest-compact.js'),
|
||||
{ unexpected: { nested: { deeply: 'value' } } }
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Should handle unexpected input structure');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('hooks handle null and missing values in input', async () => {
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'session-start.js'),
|
||||
{ tool_input: null }
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Should handle null/missing values gracefully');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('hooks handle very large input without hanging', async () => {
|
||||
const largeInput = {
|
||||
tool_input: { file_path: '/test.js' },
|
||||
tool_output: { output: 'x'.repeat(100000) }
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'session-start.js'),
|
||||
largeInput
|
||||
);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
assert.strictEqual(result.code, 0, 'Should complete successfully');
|
||||
assert.ok(elapsed < 5000, `Should complete in <5s, took ${elapsed}ms`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('hooks survive stdin exceeding 1MB limit', async () => {
|
||||
// The post-edit-console-warn hook reads stdin up to 1MB then passes through
|
||||
// Send > 1MB to verify truncation doesn't crash the hook
|
||||
const oversizedInput = JSON.stringify({
|
||||
tool_input: { file_path: '/test.js' },
|
||||
tool_output: { output: 'x'.repeat(1200000) } // ~1.2MB
|
||||
});
|
||||
|
||||
const proc = spawn('node', [path.join(scriptsDir, 'post-edit-console-warn.js')], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let code = null;
|
||||
// MUST drain stdout/stderr to prevent backpressure blocking the child process
|
||||
proc.stdout.on('data', () => {});
|
||||
proc.stderr.on('data', () => {});
|
||||
proc.stdin.on('error', (err) => {
|
||||
if (err.code !== 'EPIPE' && err.code !== 'EOF') throw err;
|
||||
});
|
||||
proc.stdin.write(oversizedInput);
|
||||
proc.stdin.end();
|
||||
|
||||
await new Promise(resolve => {
|
||||
proc.on('close', (c) => { code = c; resolve(); });
|
||||
});
|
||||
|
||||
assert.strictEqual(code, 0, 'Should exit 0 despite oversized input');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('hooks handle truncated JSON from overflow gracefully', async () => {
|
||||
// session-end parses stdin JSON. If input is > 1MB and truncated mid-JSON,
|
||||
// JSON.parse should fail and fall back to env var
|
||||
const proc = spawn('node', [path.join(scriptsDir, 'session-end.js')], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let code = null;
|
||||
let stderr = '';
|
||||
// MUST drain stdout to prevent backpressure blocking the child process
|
||||
proc.stdout.on('data', () => {});
|
||||
proc.stderr.on('data', data => stderr += data);
|
||||
proc.stdin.on('error', (err) => {
|
||||
if (err.code !== 'EPIPE' && err.code !== 'EOF') throw err;
|
||||
});
|
||||
|
||||
// Build a string that will be truncated mid-JSON at 1MB
|
||||
const bigValue = 'x'.repeat(1200000);
|
||||
proc.stdin.write(`{"transcript_path":"/tmp/none","padding":"${bigValue}"}`);
|
||||
proc.stdin.end();
|
||||
|
||||
await new Promise(resolve => {
|
||||
proc.on('close', (c) => { code = c; resolve(); });
|
||||
});
|
||||
|
||||
// Should exit 0 even if JSON parse fails (falls back to env var or null)
|
||||
assert.strictEqual(code, 0, 'Should not crash on truncated JSON');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// Round 51: Timeout Enforcement
|
||||
// ==========================================
|
||||
console.log('\nRound 51: Timeout Enforcement:');
|
||||
|
||||
if (await asyncTest('runHookWithInput kills hanging hooks after timeout', async () => {
|
||||
const testDir = createTestDir();
|
||||
const hangingHookPath = path.join(testDir, 'hanging-hook.js');
|
||||
fs.writeFileSync(hangingHookPath, 'setInterval(() => {}, 100);');
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
await runHookWithInput(hangingHookPath, {}, {}, 500);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
assert.ok(error, 'Should throw timeout error');
|
||||
assert.ok(error.message.includes('timed out'), 'Error should mention timeout');
|
||||
assert.ok(elapsed >= 450, `Should wait at least ~500ms, waited ${elapsed}ms`);
|
||||
assert.ok(elapsed < 2000, `Should not wait much longer than 500ms, waited ${elapsed}ms`);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// Round 51: hooks.json Schema Validation
|
||||
// ==========================================
|
||||
console.log('\nRound 51: hooks.json Schema Validation:');
|
||||
|
||||
if (await asyncTest('hooks.json async hook has valid timeout field', async () => {
|
||||
const asyncHook = hooks.hooks.PostToolUse.find(h =>
|
||||
h.hooks && h.hooks[0] && h.hooks[0].async === true
|
||||
);
|
||||
|
||||
assert.ok(asyncHook, 'Should have at least one async hook defined');
|
||||
assert.strictEqual(asyncHook.hooks[0].async, true, 'async field should be true');
|
||||
assert.ok(asyncHook.hooks[0].timeout, 'Should have timeout field');
|
||||
assert.strictEqual(typeof asyncHook.hooks[0].timeout, 'number', 'Timeout should be a number');
|
||||
assert.ok(asyncHook.hooks[0].timeout > 0, 'Timeout should be positive');
|
||||
|
||||
const command = asyncHook.hooks[0].command;
|
||||
const isNodeInline = command.startsWith('node -e');
|
||||
const isNodeScript = command.startsWith('node "');
|
||||
const isShellWrapper =
|
||||
command.startsWith('bash "') ||
|
||||
command.startsWith('sh "') ||
|
||||
command.startsWith('bash -lc ') ||
|
||||
command.startsWith('sh -c ');
|
||||
assert.ok(
|
||||
isNodeInline || isNodeScript || isShellWrapper,
|
||||
`Async hook command should be runnable (node -e, node script, or shell wrapper), got: ${command.substring(0, 80)}`
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('all hook commands in hooks.json are valid format', async () => {
|
||||
for (const [hookType, hookArray] of Object.entries(hooks.hooks)) {
|
||||
for (const hookDef of hookArray) {
|
||||
assert.ok(hookDef.hooks, `${hookType} entry should have hooks array`);
|
||||
|
||||
for (const hook of hookDef.hooks) {
|
||||
assert.ok(hook.command, `Hook in ${hookType} should have command field`);
|
||||
|
||||
const isInline = hook.command.startsWith('node -e');
|
||||
const isFilePath = hook.command.startsWith('node "');
|
||||
const isNpx = hook.command.startsWith('npx ');
|
||||
const isShellWrapper =
|
||||
hook.command.startsWith('bash "') ||
|
||||
hook.command.startsWith('sh "') ||
|
||||
hook.command.startsWith('bash -lc ') ||
|
||||
hook.command.startsWith('sh -c ');
|
||||
const isShellScriptPath = hook.command.endsWith('.sh');
|
||||
|
||||
assert.ok(
|
||||
isInline || isFilePath || isNpx || isShellWrapper || isShellScriptPath,
|
||||
`Hook command in ${hookType} should be node -e, node script, npx, or shell wrapper/script, got: ${hook.command.substring(0, 80)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${passed + failed}\n`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Tests for scripts/lib/agent-compress.js
|
||||
*
|
||||
* Run with: node tests/lib/agent-compress.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
parseFrontmatter,
|
||||
extractSummary,
|
||||
loadAgent,
|
||||
loadAgents,
|
||||
compressToCatalog,
|
||||
compressToSummary,
|
||||
buildAgentCatalog,
|
||||
lazyLoadAgent,
|
||||
} = require('../../scripts/lib/agent-compress');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing agent-compress ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// --- parseFrontmatter ---
|
||||
|
||||
if (test('parseFrontmatter extracts YAML frontmatter and body', () => {
|
||||
const content = '---\nname: test-agent\ndescription: A test\ntools: ["Read", "Grep"]\nmodel: sonnet\n---\n\nBody text here.';
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
assert.strictEqual(frontmatter.name, 'test-agent');
|
||||
assert.strictEqual(frontmatter.description, 'A test');
|
||||
assert.deepStrictEqual(frontmatter.tools, ['Read', 'Grep']);
|
||||
assert.strictEqual(frontmatter.model, 'sonnet');
|
||||
assert.ok(body.includes('Body text here.'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parseFrontmatter handles content without frontmatter', () => {
|
||||
const content = 'Just a regular markdown file.';
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
assert.deepStrictEqual(frontmatter, {});
|
||||
assert.strictEqual(body, content);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parseFrontmatter handles colons in values', () => {
|
||||
const content = '---\nname: test\ndescription: Use this: it works\n---\n\nBody.';
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
assert.strictEqual(frontmatter.description, 'Use this: it works');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parseFrontmatter strips surrounding quotes', () => {
|
||||
const content = '---\nname: "quoted-name"\n---\n\nBody.';
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
assert.strictEqual(frontmatter.name, 'quoted-name');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parseFrontmatter handles content ending right after closing ---', () => {
|
||||
const content = '---\nname: test\ndescription: No body\n---';
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
assert.strictEqual(frontmatter.name, 'test');
|
||||
assert.strictEqual(frontmatter.description, 'No body');
|
||||
assert.strictEqual(body, '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- extractSummary ---
|
||||
|
||||
if (test('extractSummary returns the first paragraph of the body', () => {
|
||||
const body = '# Heading\n\nThis is the first paragraph. It has two sentences.\n\nSecond paragraph.';
|
||||
const summary = extractSummary(body);
|
||||
assert.strictEqual(summary, 'This is the first paragraph.');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('extractSummary returns empty string for empty body', () => {
|
||||
assert.strictEqual(extractSummary(''), '');
|
||||
assert.strictEqual(extractSummary('# Only Headings\n\n## Another'), '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('extractSummary skips code blocks', () => {
|
||||
const body = '```\ncode here\n```\n\nActual summary sentence.';
|
||||
const summary = extractSummary(body);
|
||||
assert.strictEqual(summary, 'Actual summary sentence.');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('extractSummary respects maxSentences', () => {
|
||||
const body = 'First sentence. Second sentence. Third sentence.';
|
||||
const one = extractSummary(body, 1);
|
||||
const two = extractSummary(body, 2);
|
||||
assert.strictEqual(one, 'First sentence.');
|
||||
assert.strictEqual(two, 'First sentence. Second sentence.');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('extractSummary skips plain bullet items', () => {
|
||||
const body = '- plain bullet\n- another bullet\n\nActual paragraph here.';
|
||||
const summary = extractSummary(body);
|
||||
assert.strictEqual(summary, 'Actual paragraph here.');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('extractSummary skips asterisk bullets and numbered lists', () => {
|
||||
const body = '* star bullet\n1. numbered item\n2. second item\n\nReal paragraph.';
|
||||
const summary = extractSummary(body);
|
||||
assert.strictEqual(summary, 'Real paragraph.');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- loadAgent / loadAgents ---
|
||||
|
||||
// Create a temp directory with test agent files
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-compress-test-'));
|
||||
const agentContent = '---\nname: test-agent\ndescription: A test agent\ntools: ["Read"]\nmodel: haiku\n---\n\nTest agent body paragraph.\n\n## Details\nMore info.';
|
||||
fs.writeFileSync(path.join(tmpDir, 'test-agent.md'), agentContent);
|
||||
fs.writeFileSync(path.join(tmpDir, 'not-an-agent.txt'), 'ignored');
|
||||
|
||||
if (test('loadAgent reads and parses a single agent file', () => {
|
||||
const agent = loadAgent(path.join(tmpDir, 'test-agent.md'));
|
||||
assert.strictEqual(agent.name, 'test-agent');
|
||||
assert.strictEqual(agent.description, 'A test agent');
|
||||
assert.deepStrictEqual(agent.tools, ['Read']);
|
||||
assert.strictEqual(agent.model, 'haiku');
|
||||
assert.ok(agent.body.includes('Test agent body paragraph'));
|
||||
assert.strictEqual(agent.fileName, 'test-agent');
|
||||
assert.ok(agent.byteSize > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loadAgents reads all .md files from a directory', () => {
|
||||
const agents = loadAgents(tmpDir);
|
||||
assert.strictEqual(agents.length, 1);
|
||||
assert.strictEqual(agents[0].name, 'test-agent');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loadAgents returns empty array for non-existent directory', () => {
|
||||
const agents = loadAgents(path.join(os.tmpdir(), 'does-not-exist-agent-compress-test'));
|
||||
assert.deepStrictEqual(agents, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- compressToCatalog / compressToSummary ---
|
||||
|
||||
const sampleAgent = loadAgent(path.join(tmpDir, 'test-agent.md'));
|
||||
|
||||
if (test('compressToCatalog strips body and keeps only metadata', () => {
|
||||
const catalog = compressToCatalog(sampleAgent);
|
||||
assert.strictEqual(catalog.name, 'test-agent');
|
||||
assert.strictEqual(catalog.description, 'A test agent');
|
||||
assert.deepStrictEqual(catalog.tools, ['Read']);
|
||||
assert.strictEqual(catalog.model, 'haiku');
|
||||
assert.strictEqual(catalog.body, undefined);
|
||||
assert.strictEqual(catalog.byteSize, undefined);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('compressToSummary includes first paragraph summary', () => {
|
||||
const summary = compressToSummary(sampleAgent);
|
||||
assert.strictEqual(summary.name, 'test-agent');
|
||||
assert.ok(summary.summary.includes('Test agent body paragraph'));
|
||||
assert.strictEqual(summary.body, undefined);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- buildAgentCatalog ---
|
||||
|
||||
if (test('buildAgentCatalog in catalog mode produces minimal output with stats', () => {
|
||||
const result = buildAgentCatalog(tmpDir, { mode: 'catalog' });
|
||||
assert.strictEqual(result.agents.length, 1);
|
||||
assert.strictEqual(result.agents[0].body, undefined);
|
||||
assert.strictEqual(result.stats.totalAgents, 1);
|
||||
assert.strictEqual(result.stats.mode, 'catalog');
|
||||
assert.ok(result.stats.originalBytes > 0);
|
||||
assert.ok(result.stats.compressedBytes < result.stats.originalBytes);
|
||||
assert.ok(result.stats.compressedTokenEstimate > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('buildAgentCatalog in summary mode includes summaries', () => {
|
||||
const result = buildAgentCatalog(tmpDir, { mode: 'summary' });
|
||||
assert.ok(result.agents[0].summary);
|
||||
assert.strictEqual(result.agents[0].body, undefined);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('buildAgentCatalog in full mode preserves body', () => {
|
||||
const result = buildAgentCatalog(tmpDir, { mode: 'full' });
|
||||
assert.ok(result.agents[0].body);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('buildAgentCatalog throws on invalid mode', () => {
|
||||
assert.throws(
|
||||
() => buildAgentCatalog(tmpDir, { mode: 'invalid' }),
|
||||
/Invalid mode "invalid"/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('buildAgentCatalog supports filter function', () => {
|
||||
// Add a second agent
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'other-agent.md'),
|
||||
'---\nname: other\ndescription: Other agent\ntools: ["Bash"]\nmodel: opus\n---\n\nOther body.'
|
||||
);
|
||||
const result = buildAgentCatalog(tmpDir, {
|
||||
filter: a => a.model === 'opus',
|
||||
});
|
||||
assert.strictEqual(result.agents.length, 1);
|
||||
assert.strictEqual(result.agents[0].name, 'other');
|
||||
// Clean up
|
||||
fs.unlinkSync(path.join(tmpDir, 'other-agent.md'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- lazyLoadAgent ---
|
||||
|
||||
if (test('lazyLoadAgent loads a single agent by name', () => {
|
||||
const agent = lazyLoadAgent(tmpDir, 'test-agent');
|
||||
assert.ok(agent);
|
||||
assert.strictEqual(agent.name, 'test-agent');
|
||||
assert.ok(agent.body.includes('Test agent body paragraph'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lazyLoadAgent returns null for non-existent agent', () => {
|
||||
const agent = lazyLoadAgent(tmpDir, 'does-not-exist');
|
||||
assert.strictEqual(agent, null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lazyLoadAgent rejects path traversal attempts', () => {
|
||||
const agent = lazyLoadAgent(tmpDir, '../etc/passwd');
|
||||
assert.strictEqual(agent, null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lazyLoadAgent rejects names with invalid characters', () => {
|
||||
const agent = lazyLoadAgent(tmpDir, 'foo/bar');
|
||||
assert.strictEqual(agent, null);
|
||||
const agent2 = lazyLoadAgent(tmpDir, 'foo bar');
|
||||
assert.strictEqual(agent2, null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Real agents directory ---
|
||||
|
||||
const realAgentsDir = path.resolve(__dirname, '../../agents');
|
||||
if (test('buildAgentCatalog works with real agents directory', () => {
|
||||
if (!fs.existsSync(realAgentsDir)) return; // skip if not present
|
||||
const result = buildAgentCatalog(realAgentsDir, { mode: 'catalog' });
|
||||
assert.ok(result.agents.length > 0, 'Should find at least one agent');
|
||||
assert.ok(result.stats.compressedBytes < result.stats.originalBytes, 'Catalog should be smaller than original');
|
||||
// Verify significant compression ratio
|
||||
const ratio = result.stats.compressedBytes / result.stats.originalBytes;
|
||||
assert.ok(ratio < 0.5, `Compression ratio ${ratio.toFixed(2)} should be < 0.5`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('catalog mode token estimate is under 5000 for real agents', () => {
|
||||
if (!fs.existsSync(realAgentsDir)) return;
|
||||
const result = buildAgentCatalog(realAgentsDir, { mode: 'catalog' });
|
||||
assert.ok(
|
||||
result.stats.compressedTokenEstimate < 5000,
|
||||
`Token estimate ${result.stats.compressedTokenEstimate} exceeds 5000`
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
232
.agent/knowledge/everything_claude/tests/lib/inspection.test.js
Normal file
232
.agent/knowledge/everything_claude/tests/lib/inspection.test.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Tests for inspection logic — pattern detection from failures.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const {
|
||||
normalizeFailureReason,
|
||||
groupFailures,
|
||||
detectPatterns,
|
||||
generateReport,
|
||||
suggestAction,
|
||||
DEFAULT_FAILURE_THRESHOLD,
|
||||
} = require('../../scripts/lib/inspection');
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeSkillRun(overrides = {}) {
|
||||
return {
|
||||
id: overrides.id || `run-${Math.random().toString(36).slice(2, 8)}`,
|
||||
skillId: overrides.skillId || 'test-skill',
|
||||
skillVersion: overrides.skillVersion || '1.0.0',
|
||||
sessionId: overrides.sessionId || 'session-1',
|
||||
taskDescription: overrides.taskDescription || 'test task',
|
||||
outcome: overrides.outcome || 'failure',
|
||||
failureReason: overrides.failureReason || 'generic error',
|
||||
tokensUsed: overrides.tokensUsed || 500,
|
||||
durationMs: overrides.durationMs || 1000,
|
||||
userFeedback: overrides.userFeedback || null,
|
||||
createdAt: overrides.createdAt || '2026-03-15T08:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing inspection ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (await test('normalizeFailureReason strips timestamps and UUIDs', async () => {
|
||||
const normalized = normalizeFailureReason(
|
||||
'Error at 2026-03-15T08:00:00.000Z for id 550e8400-e29b-41d4-a716-446655440000'
|
||||
);
|
||||
assert.ok(!normalized.includes('2026'));
|
||||
assert.ok(!normalized.includes('550e8400'));
|
||||
assert.ok(normalized.includes('<timestamp>'));
|
||||
assert.ok(normalized.includes('<uuid>'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('normalizeFailureReason strips file paths', async () => {
|
||||
const normalized = normalizeFailureReason('File not found: /usr/local/bin/node');
|
||||
assert.ok(!normalized.includes('/usr/local'));
|
||||
assert.ok(normalized.includes('<path>'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('normalizeFailureReason handles null and empty values', async () => {
|
||||
assert.strictEqual(normalizeFailureReason(null), 'unknown');
|
||||
assert.strictEqual(normalizeFailureReason(''), 'unknown');
|
||||
assert.strictEqual(normalizeFailureReason(undefined), 'unknown');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('groupFailures groups by skillId and normalized reason', async () => {
|
||||
const runs = [
|
||||
makeSkillRun({ id: 'r1', skillId: 'skill-a', failureReason: 'timeout' }),
|
||||
makeSkillRun({ id: 'r2', skillId: 'skill-a', failureReason: 'timeout' }),
|
||||
makeSkillRun({ id: 'r3', skillId: 'skill-b', failureReason: 'parse error' }),
|
||||
makeSkillRun({ id: 'r4', skillId: 'skill-a', outcome: 'success' }), // should be excluded
|
||||
];
|
||||
|
||||
const groups = groupFailures(runs);
|
||||
assert.strictEqual(groups.size, 2);
|
||||
|
||||
const skillAGroup = groups.get('skill-a::timeout');
|
||||
assert.ok(skillAGroup);
|
||||
assert.strictEqual(skillAGroup.runs.length, 2);
|
||||
|
||||
const skillBGroup = groups.get('skill-b::parse error');
|
||||
assert.ok(skillBGroup);
|
||||
assert.strictEqual(skillBGroup.runs.length, 1);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('groupFailures handles mixed outcome casing', async () => {
|
||||
const runs = [
|
||||
makeSkillRun({ id: 'r1', outcome: 'FAILURE', failureReason: 'timeout' }),
|
||||
makeSkillRun({ id: 'r2', outcome: 'Failed', failureReason: 'timeout' }),
|
||||
makeSkillRun({ id: 'r3', outcome: 'error', failureReason: 'timeout' }),
|
||||
];
|
||||
|
||||
const groups = groupFailures(runs);
|
||||
assert.strictEqual(groups.size, 1);
|
||||
const group = groups.values().next().value;
|
||||
assert.strictEqual(group.runs.length, 3);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectPatterns returns empty array when below threshold', async () => {
|
||||
const runs = [
|
||||
makeSkillRun({ id: 'r1', failureReason: 'timeout' }),
|
||||
makeSkillRun({ id: 'r2', failureReason: 'timeout' }),
|
||||
];
|
||||
|
||||
const patterns = detectPatterns(runs, { threshold: 3 });
|
||||
assert.strictEqual(patterns.length, 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectPatterns detects patterns at or above threshold', async () => {
|
||||
const runs = [
|
||||
makeSkillRun({ id: 'r1', failureReason: 'timeout', createdAt: '2026-03-15T08:00:00Z' }),
|
||||
makeSkillRun({ id: 'r2', failureReason: 'timeout', createdAt: '2026-03-15T08:01:00Z' }),
|
||||
makeSkillRun({ id: 'r3', failureReason: 'timeout', createdAt: '2026-03-15T08:02:00Z' }),
|
||||
];
|
||||
|
||||
const patterns = detectPatterns(runs, { threshold: 3 });
|
||||
assert.strictEqual(patterns.length, 1);
|
||||
assert.strictEqual(patterns[0].count, 3);
|
||||
assert.strictEqual(patterns[0].skillId, 'test-skill');
|
||||
assert.strictEqual(patterns[0].normalizedReason, 'timeout');
|
||||
assert.strictEqual(patterns[0].firstSeen, '2026-03-15T08:00:00Z');
|
||||
assert.strictEqual(patterns[0].lastSeen, '2026-03-15T08:02:00Z');
|
||||
assert.strictEqual(patterns[0].runIds.length, 3);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectPatterns uses default threshold', async () => {
|
||||
const runs = Array.from({ length: DEFAULT_FAILURE_THRESHOLD }, (_, i) =>
|
||||
makeSkillRun({ id: `r${i}`, failureReason: 'permission denied' })
|
||||
);
|
||||
|
||||
const patterns = detectPatterns(runs);
|
||||
assert.strictEqual(patterns.length, 1);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectPatterns sorts by count descending', async () => {
|
||||
const runs = [
|
||||
// 4 timeouts
|
||||
...Array.from({ length: 4 }, (_, i) =>
|
||||
makeSkillRun({ id: `t${i}`, skillId: 'skill-a', failureReason: 'timeout' })
|
||||
),
|
||||
// 3 parse errors
|
||||
...Array.from({ length: 3 }, (_, i) =>
|
||||
makeSkillRun({ id: `p${i}`, skillId: 'skill-b', failureReason: 'parse error' })
|
||||
),
|
||||
];
|
||||
|
||||
const patterns = detectPatterns(runs, { threshold: 3 });
|
||||
assert.strictEqual(patterns.length, 2);
|
||||
assert.strictEqual(patterns[0].count, 4);
|
||||
assert.strictEqual(patterns[0].skillId, 'skill-a');
|
||||
assert.strictEqual(patterns[1].count, 3);
|
||||
assert.strictEqual(patterns[1].skillId, 'skill-b');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectPatterns groups similar failure reasons with different timestamps', async () => {
|
||||
const runs = [
|
||||
makeSkillRun({ id: 'r1', failureReason: 'Error at 2026-03-15T08:00:00Z in /tmp/foo' }),
|
||||
makeSkillRun({ id: 'r2', failureReason: 'Error at 2026-03-15T09:00:00Z in /tmp/bar' }),
|
||||
makeSkillRun({ id: 'r3', failureReason: 'Error at 2026-03-15T10:00:00Z in /tmp/baz' }),
|
||||
];
|
||||
|
||||
const patterns = detectPatterns(runs, { threshold: 3 });
|
||||
assert.strictEqual(patterns.length, 1);
|
||||
assert.ok(patterns[0].normalizedReason.includes('<timestamp>'));
|
||||
assert.ok(patterns[0].normalizedReason.includes('<path>'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('detectPatterns tracks unique session IDs and versions', async () => {
|
||||
const runs = [
|
||||
makeSkillRun({ id: 'r1', sessionId: 'sess-1', skillVersion: '1.0.0', failureReason: 'err' }),
|
||||
makeSkillRun({ id: 'r2', sessionId: 'sess-2', skillVersion: '1.0.0', failureReason: 'err' }),
|
||||
makeSkillRun({ id: 'r3', sessionId: 'sess-1', skillVersion: '1.1.0', failureReason: 'err' }),
|
||||
];
|
||||
|
||||
const patterns = detectPatterns(runs, { threshold: 3 });
|
||||
assert.strictEqual(patterns.length, 1);
|
||||
assert.deepStrictEqual(patterns[0].sessionIds.sort(), ['sess-1', 'sess-2']);
|
||||
assert.deepStrictEqual(patterns[0].versions.sort(), ['1.0.0', '1.1.0']);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('generateReport returns clean status with no patterns', async () => {
|
||||
const report = generateReport([]);
|
||||
assert.strictEqual(report.status, 'clean');
|
||||
assert.strictEqual(report.patternCount, 0);
|
||||
assert.ok(report.summary.includes('No recurring'));
|
||||
assert.ok(report.generatedAt);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('generateReport produces structured report from patterns', async () => {
|
||||
const runs = [
|
||||
...Array.from({ length: 3 }, (_, i) =>
|
||||
makeSkillRun({ id: `r${i}`, skillId: 'my-skill', failureReason: 'timeout' })
|
||||
),
|
||||
];
|
||||
const patterns = detectPatterns(runs, { threshold: 3 });
|
||||
const report = generateReport(patterns, { generatedAt: '2026-03-15T09:00:00Z' });
|
||||
|
||||
assert.strictEqual(report.status, 'attention_needed');
|
||||
assert.strictEqual(report.patternCount, 1);
|
||||
assert.strictEqual(report.totalFailures, 3);
|
||||
assert.deepStrictEqual(report.affectedSkills, ['my-skill']);
|
||||
assert.strictEqual(report.patterns[0].skillId, 'my-skill');
|
||||
assert.ok(report.patterns[0].suggestedAction);
|
||||
assert.strictEqual(report.generatedAt, '2026-03-15T09:00:00Z');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('suggestAction returns timeout-specific advice', async () => {
|
||||
const action = suggestAction({ normalizedReason: 'timeout after 30s', versions: ['1.0.0'] });
|
||||
assert.ok(action.toLowerCase().includes('timeout'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('suggestAction returns permission-specific advice', async () => {
|
||||
const action = suggestAction({ normalizedReason: 'permission denied', versions: ['1.0.0'] });
|
||||
assert.ok(action.toLowerCase().includes('permission'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('suggestAction returns version-span advice when multiple versions affected', async () => {
|
||||
const action = suggestAction({ normalizedReason: 'something broke', versions: ['1.0.0', '1.1.0'] });
|
||||
assert.ok(action.toLowerCase().includes('version'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Tests for scripts/lib/install/config.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
findDefaultInstallConfigPath,
|
||||
loadInstallConfig,
|
||||
resolveInstallConfigPath,
|
||||
} = require('../../scripts/lib/install/config');
|
||||
|
||||
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 createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install/config.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('resolves relative config paths from the provided cwd', () => {
|
||||
const cwd = '/workspace/app';
|
||||
const resolved = resolveInstallConfigPath('configs/ecc-install.json', { cwd });
|
||||
assert.strictEqual(resolved, path.join(cwd, 'configs', 'ecc-install.json'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('finds the default project install config in the provided cwd', () => {
|
||||
const cwd = createTempDir('install-config-');
|
||||
|
||||
try {
|
||||
const configPath = path.join(cwd, 'ecc-install.json');
|
||||
writeJson(configPath, {
|
||||
version: 1,
|
||||
profile: 'core',
|
||||
});
|
||||
|
||||
assert.strictEqual(findDefaultInstallConfigPath({ cwd }), configPath);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns null when no default project install config exists', () => {
|
||||
const cwd = createTempDir('install-config-');
|
||||
|
||||
try {
|
||||
assert.strictEqual(findDefaultInstallConfigPath({ cwd }), null);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loads and normalizes a valid install config', () => {
|
||||
const cwd = createTempDir('install-config-');
|
||||
|
||||
try {
|
||||
const configPath = path.join(cwd, 'ecc-install.json');
|
||||
writeJson(configPath, {
|
||||
version: 1,
|
||||
target: 'cursor',
|
||||
profile: 'developer',
|
||||
modules: ['platform-configs', 'platform-configs'],
|
||||
include: ['lang:typescript', 'framework:nextjs', 'lang:typescript'],
|
||||
exclude: ['capability:media'],
|
||||
options: {
|
||||
includeExamples: false,
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadInstallConfig('ecc-install.json', { cwd });
|
||||
assert.strictEqual(config.path, configPath);
|
||||
assert.strictEqual(config.target, 'cursor');
|
||||
assert.strictEqual(config.profileId, 'developer');
|
||||
assert.deepStrictEqual(config.moduleIds, ['platform-configs']);
|
||||
assert.deepStrictEqual(config.includeComponentIds, ['lang:typescript', 'framework:nextjs']);
|
||||
assert.deepStrictEqual(config.excludeComponentIds, ['capability:media']);
|
||||
assert.deepStrictEqual(config.options, { includeExamples: false });
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects invalid config schema values', () => {
|
||||
const cwd = createTempDir('install-config-');
|
||||
|
||||
try {
|
||||
writeJson(path.join(cwd, 'ecc-install.json'), {
|
||||
version: 2,
|
||||
target: 'ghost-target',
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => loadInstallConfig('ecc-install.json', { cwd }),
|
||||
/Invalid install config/
|
||||
);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when the install config does not exist', () => {
|
||||
const cwd = createTempDir('install-config-');
|
||||
|
||||
try {
|
||||
assert.throws(
|
||||
() => loadInstallConfig('ecc-install.json', { cwd }),
|
||||
/Install config not found/
|
||||
);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,738 @@
|
||||
/**
|
||||
* Tests for scripts/lib/install-lifecycle.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
buildDoctorReport,
|
||||
discoverInstalledStates,
|
||||
repairInstalledStates,
|
||||
uninstallInstalledStates,
|
||||
} = require('../../scripts/lib/install-lifecycle');
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
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 createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install-lifecycle.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('discovers installed states for multiple targets in the current context', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const claudeStatePath = path.join(homeDir, '.claude', 'ecc', 'install-state.json');
|
||||
const cursorStatePath = path.join(projectRoot, '.cursor', 'ecc-install-state.json');
|
||||
|
||||
writeState(claudeStatePath, {
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
targetRoot: path.join(homeDir, '.claude'),
|
||||
installStatePath: claudeStatePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-claude-rules'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
writeState(cursorStatePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: path.join(projectRoot, '.cursor'),
|
||||
installStatePath: cursorStatePath,
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['rules-core', 'platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'def456',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const records = discoverInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['claude', 'cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(records.length, 2);
|
||||
assert.strictEqual(records[0].exists, true);
|
||||
assert.strictEqual(records[1].exists, true);
|
||||
assert.strictEqual(records[0].state.target.id, 'claude-home');
|
||||
assert.strictEqual(records[1].state.target.id, 'cursor-project');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('doctor reports missing managed files as an error', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath: path.join(targetRoot, 'hooks.json'),
|
||||
strategy: 'sync-root-children',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const report = buildDoctorReport({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(report.results.length, 1);
|
||||
assert.strictEqual(report.results[0].status, 'error');
|
||||
assert.ok(report.results[0].issues.some(issue => issue.code === 'missing-managed-files'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('doctor reports a healthy legacy install when managed files are present', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(homeDir, '.claude');
|
||||
const statePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const managedFile = path.join(targetRoot, 'rules', 'common', 'coding-style.md');
|
||||
const sourceContent = fs.readFileSync(path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'), 'utf8');
|
||||
fs.mkdirSync(path.dirname(managedFile), { recursive: true });
|
||||
fs.writeFileSync(managedFile, sourceContent);
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-claude-rules'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourceRelativePath: 'rules/common/coding-style.md',
|
||||
destinationPath: managedFile,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const report = buildDoctorReport({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['claude'],
|
||||
});
|
||||
|
||||
assert.strictEqual(report.results.length, 1);
|
||||
assert.strictEqual(report.results[0].status, 'ok');
|
||||
assert.strictEqual(report.results[0].issues.length, 0);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('doctor reports drifted managed files as a warning', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
const sourcePath = path.join(REPO_ROOT, '.cursor', 'hooks.json');
|
||||
const destinationPath = path.join(targetRoot, 'hooks.json');
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, '{"drifted":true}\n');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'platform-configs',
|
||||
sourcePath,
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath,
|
||||
strategy: 'sync-root-children',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const report = buildDoctorReport({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(report.results.length, 1);
|
||||
assert.strictEqual(report.results[0].status, 'warning');
|
||||
assert.ok(report.results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('doctor reports manifest resolution drift for non-legacy installs', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['rules-core'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const report = buildDoctorReport({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(report.results.length, 1);
|
||||
assert.strictEqual(report.results[0].status, 'warning');
|
||||
assert.ok(report.results[0].issues.some(issue => issue.code === 'resolution-drift'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair restores render-template outputs from recorded rendered content', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(homeDir, '.claude');
|
||||
const statePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const destinationPath = path.join(targetRoot, 'plugin.json');
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, '{"drifted":true}\n');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-claude-rules'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'render-template',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.claude-plugin/plugin.json.template',
|
||||
destinationPath,
|
||||
strategy: 'render-template',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
renderedContent: '{"ok":true}\n',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['claude'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'repaired');
|
||||
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), '{"ok":true}\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair reapplies merge-json operations without clobbering unrelated keys', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
const destinationPath = path.join(targetRoot, 'hooks.json');
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, JSON.stringify({
|
||||
existing: true,
|
||||
nested: {
|
||||
enabled: false,
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-cursor-install'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: {
|
||||
nested: {
|
||||
enabled: true,
|
||||
},
|
||||
managed: 'yes',
|
||||
},
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'repaired');
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), {
|
||||
existing: true,
|
||||
nested: {
|
||||
enabled: true,
|
||||
},
|
||||
managed: 'yes',
|
||||
});
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair re-applies managed remove operations when files reappear', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
const destinationPath = path.join(targetRoot, 'legacy-note.txt');
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, 'stale');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-cursor-install'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'remove',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/legacy-note.txt',
|
||||
destinationPath,
|
||||
strategy: 'remove',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'repaired');
|
||||
assert.ok(!fs.existsSync(destinationPath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall restores JSON merged files from recorded previous content', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
const destinationPath = path.join(targetRoot, 'hooks.json');
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, JSON.stringify({
|
||||
existing: true,
|
||||
managed: true,
|
||||
}, null, 2));
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-cursor-install'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: {
|
||||
managed: true,
|
||||
},
|
||||
previousContent: JSON.stringify({
|
||||
existing: true,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), {
|
||||
existing: true,
|
||||
});
|
||||
assert.ok(!fs.existsSync(statePath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall restores rendered template files from recorded previous content', () => {
|
||||
const tempDir = createTempDir('install-lifecycle-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(tempDir, '.claude');
|
||||
const statePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const destinationPath = path.join(targetRoot, 'plugin.json');
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, '{"generated":true}\n');
|
||||
|
||||
writeInstallState(statePath, createInstallState({
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: '1.8.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'render-template',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.claude/plugin.json.template',
|
||||
destinationPath,
|
||||
strategy: 'render-template',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
renderedContent: '{"generated":true}\n',
|
||||
previousContent: '{"existing":true}\n',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir: tempDir,
|
||||
projectRoot: tempDir,
|
||||
targets: ['claude'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.summary.uninstalledCount, 1);
|
||||
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), '{"existing":true}\n');
|
||||
assert.ok(!fs.existsSync(statePath));
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall restores files removed during install when previous content is recorded', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
const destinationPath = path.join(targetRoot, 'legacy-note.txt');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-cursor-install'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'remove',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/legacy-note.txt',
|
||||
destinationPath,
|
||||
strategy: 'remove',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
previousContent: 'restore me\n',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'restore me\n');
|
||||
assert.ok(!fs.existsSync(statePath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Tests for scripts/lib/install-manifests.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
loadInstallManifests,
|
||||
listInstallComponents,
|
||||
listLegacyCompatibilityLanguages,
|
||||
listInstallModules,
|
||||
listInstallProfiles,
|
||||
resolveInstallPlan,
|
||||
resolveLegacyCompatibilitySelection,
|
||||
validateInstallModuleIds,
|
||||
} = require('../../scripts/lib/install-manifests');
|
||||
|
||||
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 createTestRepo() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'install-manifests-'));
|
||||
fs.mkdirSync(path.join(root, 'manifests'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function cleanupTestRepo(root) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install-manifests.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('loads real project install manifests', () => {
|
||||
const manifests = loadInstallManifests();
|
||||
assert.ok(manifests.modules.length >= 1, 'Should load modules');
|
||||
assert.ok(Object.keys(manifests.profiles).length >= 1, 'Should load profiles');
|
||||
assert.ok(manifests.components.length >= 1, 'Should load components');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install profiles from the real project', () => {
|
||||
const profiles = listInstallProfiles();
|
||||
assert.ok(profiles.some(profile => profile.id === 'core'), 'Should include core profile');
|
||||
assert.ok(profiles.some(profile => profile.id === 'full'), 'Should include full profile');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install modules from the real project', () => {
|
||||
const modules = listInstallModules();
|
||||
assert.ok(modules.some(module => module.id === 'rules-core'), 'Should include rules-core');
|
||||
assert.ok(modules.some(module => module.id === 'orchestration'), 'Should include orchestration');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install components from the real project', () => {
|
||||
const components = listInstallComponents();
|
||||
assert.ok(components.some(component => component.id === 'lang:typescript'),
|
||||
'Should include lang:typescript');
|
||||
assert.ok(components.some(component => component.id === 'capability:security'),
|
||||
'Should include capability:security');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists supported legacy compatibility languages', () => {
|
||||
const languages = listLegacyCompatibilityLanguages();
|
||||
assert.ok(languages.includes('typescript'));
|
||||
assert.ok(languages.includes('python'));
|
||||
assert.ok(languages.includes('go'));
|
||||
assert.ok(languages.includes('golang'));
|
||||
assert.ok(languages.includes('kotlin'));
|
||||
assert.ok(languages.includes('rust'));
|
||||
assert.ok(languages.includes('cpp'));
|
||||
assert.ok(languages.includes('csharp'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves a real project profile with target-specific skips', () => {
|
||||
const projectRoot = '/workspace/app';
|
||||
const plan = resolveInstallPlan({ profileId: 'developer', target: 'cursor', projectRoot });
|
||||
assert.ok(plan.selectedModuleIds.includes('rules-core'), 'Should keep rules-core');
|
||||
assert.ok(plan.selectedModuleIds.includes('commands-core'), 'Should keep commands-core');
|
||||
assert.ok(!plan.selectedModuleIds.includes('orchestration'),
|
||||
'Should not select unsupported orchestration module for cursor');
|
||||
assert.ok(plan.skippedModuleIds.includes('orchestration'),
|
||||
'Should report unsupported orchestration module as skipped');
|
||||
assert.strictEqual(plan.targetAdapterId, 'cursor-project');
|
||||
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.cursor'));
|
||||
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json'));
|
||||
assert.ok(plan.operations.length > 0, 'Should include scaffold operations');
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === '.cursor'
|
||||
&& operation.strategy === 'sync-root-children'
|
||||
)),
|
||||
'Should flatten the native cursor root'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves antigravity profiles while skipping only unsupported modules', () => {
|
||||
const projectRoot = '/workspace/app';
|
||||
const plan = resolveInstallPlan({ profileId: 'core', target: 'antigravity', projectRoot });
|
||||
|
||||
assert.deepStrictEqual(
|
||||
plan.selectedModuleIds,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
);
|
||||
assert.ok(plan.skippedModuleIds.includes('hooks-runtime'));
|
||||
assert.ok(!plan.skippedModuleIds.includes('platform-configs'));
|
||||
assert.ok(!plan.skippedModuleIds.includes('workflow-quality'));
|
||||
assert.strictEqual(plan.targetAdapterId, 'antigravity-project');
|
||||
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.agent'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves explicit modules with dependency expansion', () => {
|
||||
const plan = resolveInstallPlan({ moduleIds: ['security'] });
|
||||
assert.ok(plan.selectedModuleIds.includes('security'), 'Should include requested module');
|
||||
assert.ok(plan.selectedModuleIds.includes('workflow-quality'),
|
||||
'Should include transitive dependency');
|
||||
assert.ok(plan.selectedModuleIds.includes('platform-configs'),
|
||||
'Should include nested dependency');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('validates explicit module IDs against the real manifest catalog', () => {
|
||||
const moduleIds = validateInstallModuleIds(['security', 'security', 'platform-configs']);
|
||||
assert.deepStrictEqual(moduleIds, ['security', 'platform-configs']);
|
||||
assert.throws(
|
||||
() => validateInstallModuleIds(['ghost-module']),
|
||||
/Unknown install module: ghost-module/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves legacy compatibility selections into manifest module IDs', () => {
|
||||
const selection = resolveLegacyCompatibilitySelection({
|
||||
target: 'cursor',
|
||||
legacyLanguages: ['typescript', 'go', 'golang'],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(selection.legacyLanguages, ['typescript', 'go', 'golang']);
|
||||
assert.ok(selection.moduleIds.includes('rules-core'));
|
||||
assert.ok(selection.moduleIds.includes('agents-core'));
|
||||
assert.ok(selection.moduleIds.includes('commands-core'));
|
||||
assert.ok(selection.moduleIds.includes('hooks-runtime'));
|
||||
assert.ok(selection.moduleIds.includes('platform-configs'));
|
||||
assert.ok(selection.moduleIds.includes('workflow-quality'));
|
||||
assert.ok(selection.moduleIds.includes('framework-language'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves rust legacy compatibility into framework-language module', () => {
|
||||
const selection = resolveLegacyCompatibilitySelection({
|
||||
target: 'cursor',
|
||||
legacyLanguages: ['rust'],
|
||||
});
|
||||
|
||||
assert.ok(selection.moduleIds.includes('rules-core'));
|
||||
assert.ok(selection.moduleIds.includes('framework-language'),
|
||||
'rust should resolve to framework-language module');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves cpp legacy compatibility into framework-language module', () => {
|
||||
const selection = resolveLegacyCompatibilitySelection({
|
||||
target: 'cursor',
|
||||
legacyLanguages: ['cpp'],
|
||||
});
|
||||
|
||||
assert.ok(selection.moduleIds.includes('rules-core'));
|
||||
assert.ok(selection.moduleIds.includes('framework-language'),
|
||||
'cpp should resolve to framework-language module');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves csharp legacy compatibility into framework-language module', () => {
|
||||
const selection = resolveLegacyCompatibilitySelection({
|
||||
target: 'cursor',
|
||||
legacyLanguages: ['csharp'],
|
||||
});
|
||||
|
||||
assert.ok(selection.moduleIds.includes('rules-core'));
|
||||
assert.ok(selection.moduleIds.includes('framework-language'),
|
||||
'csharp should resolve to framework-language module');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('keeps antigravity legacy compatibility selections target-safe', () => {
|
||||
const selection = resolveLegacyCompatibilitySelection({
|
||||
target: 'antigravity',
|
||||
legacyLanguages: ['typescript'],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(selection.moduleIds, ['rules-core', 'agents-core', 'commands-core']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unknown legacy compatibility languages', () => {
|
||||
assert.throws(
|
||||
() => resolveLegacyCompatibilitySelection({
|
||||
target: 'cursor',
|
||||
legacyLanguages: ['brainfuck'],
|
||||
}),
|
||||
/Unknown legacy language: brainfuck/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves included and excluded user-facing components', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
profileId: 'core',
|
||||
includeComponentIds: ['capability:security'],
|
||||
excludeComponentIds: ['capability:orchestration'],
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(plan.includedComponentIds, ['capability:security']);
|
||||
assert.deepStrictEqual(plan.excludedComponentIds, ['capability:orchestration']);
|
||||
assert.ok(plan.selectedModuleIds.includes('security'), 'Should include modules from selected components');
|
||||
assert.ok(!plan.selectedModuleIds.includes('orchestration'), 'Should exclude modules from excluded components');
|
||||
assert.ok(plan.excludedModuleIds.includes('orchestration'),
|
||||
'Should report modules removed by excluded components');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when a selected component depends on an excluded component module', () => {
|
||||
assert.throws(
|
||||
() => resolveInstallPlan({
|
||||
includeComponentIds: ['capability:social'],
|
||||
excludeComponentIds: ['capability:content'],
|
||||
}),
|
||||
/depends on excluded module business-content/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('throws on unknown install profile', () => {
|
||||
assert.throws(
|
||||
() => resolveInstallPlan({ profileId: 'ghost-profile' }),
|
||||
/Unknown install profile/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('throws on unknown install target', () => {
|
||||
assert.throws(
|
||||
() => resolveInstallPlan({ profileId: 'core', target: 'not-a-target' }),
|
||||
/Unknown install target/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('skips a requested module when its dependency chain does not support the target', () => {
|
||||
const repoRoot = createTestRepo();
|
||||
writeJson(path.join(repoRoot, 'manifests', 'install-modules.json'), {
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: 'parent',
|
||||
kind: 'skills',
|
||||
description: 'Parent',
|
||||
paths: ['parent'],
|
||||
targets: ['claude'],
|
||||
dependencies: ['child'],
|
||||
defaultInstall: false,
|
||||
cost: 'light',
|
||||
stability: 'stable'
|
||||
},
|
||||
{
|
||||
id: 'child',
|
||||
kind: 'skills',
|
||||
description: 'Child',
|
||||
paths: ['child'],
|
||||
targets: ['cursor'],
|
||||
dependencies: [],
|
||||
defaultInstall: false,
|
||||
cost: 'light',
|
||||
stability: 'stable'
|
||||
}
|
||||
]
|
||||
});
|
||||
writeJson(path.join(repoRoot, 'manifests', 'install-profiles.json'), {
|
||||
version: 1,
|
||||
profiles: {
|
||||
core: { description: 'Core', modules: ['parent'] }
|
||||
}
|
||||
});
|
||||
|
||||
const plan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' });
|
||||
assert.deepStrictEqual(plan.selectedModuleIds, []);
|
||||
assert.deepStrictEqual(plan.skippedModuleIds, ['parent']);
|
||||
cleanupTestRepo(repoRoot);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Tests for scripts/lib/install/request.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const {
|
||||
normalizeInstallRequest,
|
||||
parseInstallArgs,
|
||||
} = require('../../scripts/lib/install/request');
|
||||
|
||||
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 install/request.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parses manifest-mode CLI arguments', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node',
|
||||
'scripts/install-apply.js',
|
||||
'--target', 'cursor',
|
||||
'--profile', 'developer',
|
||||
'--modules', 'platform-configs, workflow-quality ,platform-configs',
|
||||
'--with', 'lang:typescript',
|
||||
'--without', 'capability:media',
|
||||
'--config', 'ecc-install.json',
|
||||
'--dry-run',
|
||||
'--json'
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.target, 'cursor');
|
||||
assert.strictEqual(parsed.profileId, 'developer');
|
||||
assert.strictEqual(parsed.configPath, 'ecc-install.json');
|
||||
assert.deepStrictEqual(parsed.moduleIds, ['platform-configs', 'workflow-quality']);
|
||||
assert.deepStrictEqual(parsed.includeComponentIds, ['lang:typescript']);
|
||||
assert.deepStrictEqual(parsed.excludeComponentIds, ['capability:media']);
|
||||
assert.strictEqual(parsed.dryRun, true);
|
||||
assert.strictEqual(parsed.json, true);
|
||||
assert.deepStrictEqual(parsed.languages, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('normalizes legacy language installs into a canonical request', () => {
|
||||
const request = normalizeInstallRequest({
|
||||
target: 'claude',
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
languages: ['typescript', 'python']
|
||||
});
|
||||
|
||||
assert.strictEqual(request.mode, 'legacy-compat');
|
||||
assert.strictEqual(request.target, 'claude');
|
||||
assert.deepStrictEqual(request.legacyLanguages, ['typescript', 'python']);
|
||||
assert.deepStrictEqual(request.moduleIds, []);
|
||||
assert.strictEqual(request.profileId, null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('normalizes manifest installs into a canonical request', () => {
|
||||
const request = normalizeInstallRequest({
|
||||
target: 'cursor',
|
||||
profileId: 'developer',
|
||||
moduleIds: [],
|
||||
includeComponentIds: ['lang:typescript'],
|
||||
excludeComponentIds: ['capability:media'],
|
||||
languages: []
|
||||
});
|
||||
|
||||
assert.strictEqual(request.mode, 'manifest');
|
||||
assert.strictEqual(request.target, 'cursor');
|
||||
assert.strictEqual(request.profileId, 'developer');
|
||||
assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript']);
|
||||
assert.deepStrictEqual(request.excludeComponentIds, ['capability:media']);
|
||||
assert.deepStrictEqual(request.legacyLanguages, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('merges config-backed component selections with CLI overrides', () => {
|
||||
const request = normalizeInstallRequest({
|
||||
target: 'cursor',
|
||||
profileId: null,
|
||||
moduleIds: ['platform-configs'],
|
||||
includeComponentIds: ['framework:nextjs'],
|
||||
excludeComponentIds: ['capability:media'],
|
||||
languages: [],
|
||||
configPath: '/workspace/app/ecc-install.json',
|
||||
config: {
|
||||
path: '/workspace/app/ecc-install.json',
|
||||
target: 'claude',
|
||||
profileId: 'developer',
|
||||
moduleIds: ['workflow-quality'],
|
||||
includeComponentIds: ['lang:typescript'],
|
||||
excludeComponentIds: ['capability:orchestration'],
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(request.mode, 'manifest');
|
||||
assert.strictEqual(request.target, 'cursor');
|
||||
assert.strictEqual(request.profileId, 'developer');
|
||||
assert.deepStrictEqual(request.moduleIds, ['workflow-quality', 'platform-configs']);
|
||||
assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript', 'framework:nextjs']);
|
||||
assert.deepStrictEqual(request.excludeComponentIds, ['capability:orchestration', 'capability:media']);
|
||||
assert.strictEqual(request.configPath, '/workspace/app/ecc-install.json');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('validates explicit module IDs against the manifest catalog', () => {
|
||||
assert.throws(
|
||||
() => normalizeInstallRequest({
|
||||
target: 'cursor',
|
||||
profileId: null,
|
||||
moduleIds: ['ghost-module'],
|
||||
includeComponentIds: [],
|
||||
excludeComponentIds: [],
|
||||
languages: [],
|
||||
}),
|
||||
/Unknown install module: ghost-module/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects mixing legacy languages with manifest flags', () => {
|
||||
assert.throws(
|
||||
() => normalizeInstallRequest({
|
||||
target: 'claude',
|
||||
profileId: 'core',
|
||||
moduleIds: [],
|
||||
includeComponentIds: [],
|
||||
excludeComponentIds: [],
|
||||
languages: ['typescript']
|
||||
}),
|
||||
/cannot be combined/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects empty install requests when not asking for help', () => {
|
||||
assert.throws(
|
||||
() => normalizeInstallRequest({
|
||||
target: 'claude',
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
includeComponentIds: [],
|
||||
excludeComponentIds: [],
|
||||
languages: [],
|
||||
help: false
|
||||
}),
|
||||
/No install profile, module IDs, included components, or legacy languages/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Tests for scripts/lib/install-state.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createInstallState,
|
||||
readInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
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 createTestDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'install-state-'));
|
||||
}
|
||||
|
||||
function cleanupTestDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing install-state.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('creates a valid install-state payload', () => {
|
||||
const state = createInstallState({
|
||||
adapter: { id: 'cursor-project' },
|
||||
targetRoot: '/repo/.cursor',
|
||||
installStatePath: '/repo/.cursor/ecc-install-state.json',
|
||||
request: {
|
||||
profile: 'developer',
|
||||
modules: ['orchestration'],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['rules-core', 'orchestration'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-path',
|
||||
moduleId: 'rules-core',
|
||||
sourceRelativePath: 'rules',
|
||||
destinationPath: '/repo/.cursor/rules',
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: true,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: '1.9.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
installedAt: '2026-03-13T00:00:00Z',
|
||||
});
|
||||
|
||||
assert.strictEqual(state.schemaVersion, 'ecc.install.v1');
|
||||
assert.strictEqual(state.target.id, 'cursor-project');
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
assert.strictEqual(state.operations.length, 1);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('writes and reads install-state from disk', () => {
|
||||
const testDir = createTestDir();
|
||||
const statePath = path.join(testDir, 'ecc-install-state.json');
|
||||
|
||||
try {
|
||||
const state = createInstallState({
|
||||
adapter: { id: 'claude-home' },
|
||||
targetRoot: path.join(testDir, '.claude'),
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['rules-core'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: '1.9.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
});
|
||||
|
||||
writeInstallState(statePath, state);
|
||||
const loaded = readInstallState(statePath);
|
||||
|
||||
assert.strictEqual(loaded.target.id, 'claude-home');
|
||||
assert.strictEqual(loaded.request.profile, 'core');
|
||||
assert.deepStrictEqual(loaded.resolution.selectedModules, ['rules-core']);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deep-clones nested operation metadata for lifecycle-managed operations', () => {
|
||||
const operation = {
|
||||
kind: 'merge-json',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath: '/repo/.cursor/hooks.json',
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: {
|
||||
nested: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
previousValue: {
|
||||
nested: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const state = createInstallState({
|
||||
adapter: { id: 'cursor-project' },
|
||||
targetRoot: '/repo/.cursor',
|
||||
installStatePath: '/repo/.cursor/ecc-install-state.json',
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [operation],
|
||||
source: {
|
||||
repoVersion: '1.9.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
});
|
||||
|
||||
operation.mergePayload.nested.enabled = false;
|
||||
operation.previousValue.nested.enabled = true;
|
||||
|
||||
assert.strictEqual(state.operations[0].mergePayload.nested.enabled, true);
|
||||
assert.strictEqual(state.operations[0].previousValue.nested.enabled, false);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects invalid install-state payloads on read', () => {
|
||||
const testDir = createTestDir();
|
||||
const statePath = path.join(testDir, 'ecc-install-state.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(statePath, JSON.stringify({ schemaVersion: 'ecc.install.v1' }, null, 2));
|
||||
assert.throws(
|
||||
() => readInstallState(statePath),
|
||||
/Invalid install-state/
|
||||
);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unexpected properties and missing required request fields', () => {
|
||||
const testDir = createTestDir();
|
||||
const statePath = path.join(testDir, 'ecc-install-state.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(statePath, JSON.stringify({
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt: '2026-03-13T00:00:00Z',
|
||||
unexpected: true,
|
||||
target: {
|
||||
id: 'cursor-project',
|
||||
root: '/repo/.cursor',
|
||||
installStatePath: '/repo/.cursor/ecc-install-state.json',
|
||||
},
|
||||
request: {
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: [],
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: '1.9.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
operations: [],
|
||||
}, null, 2));
|
||||
|
||||
assert.throws(
|
||||
() => readInstallState(statePath),
|
||||
/Invalid install-state/
|
||||
);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Tests for scripts/lib/install-targets/registry.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
getInstallTargetAdapter,
|
||||
listInstallTargetAdapters,
|
||||
planInstallTargetScaffold,
|
||||
} = require('../../scripts/lib/install-targets/registry');
|
||||
|
||||
function normalizedRelativePath(value) {
|
||||
return String(value || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
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 install-target adapters ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('lists supported target adapters', () => {
|
||||
const adapters = listInstallTargetAdapters();
|
||||
const targets = adapters.map(adapter => adapter.target);
|
||||
assert.ok(targets.includes('claude'), 'Should include claude target');
|
||||
assert.ok(targets.includes('cursor'), 'Should include cursor target');
|
||||
assert.ok(targets.includes('antigravity'), 'Should include antigravity target');
|
||||
assert.ok(targets.includes('codex'), 'Should include codex target');
|
||||
assert.ok(targets.includes('opencode'), 'Should include opencode target');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves cursor adapter root and install-state path from project root', () => {
|
||||
const adapter = getInstallTargetAdapter('cursor');
|
||||
const projectRoot = '/workspace/app';
|
||||
const root = adapter.resolveRoot({ projectRoot });
|
||||
const statePath = adapter.getInstallStatePath({ projectRoot });
|
||||
|
||||
assert.strictEqual(root, path.join(projectRoot, '.cursor'));
|
||||
assert.strictEqual(statePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves claude adapter root and install-state path from home dir', () => {
|
||||
const adapter = getInstallTargetAdapter('claude');
|
||||
const homeDir = '/Users/example';
|
||||
const root = adapter.resolveRoot({ homeDir, repoRoot: '/repo/ecc' });
|
||||
const statePath = adapter.getInstallStatePath({ homeDir, repoRoot: '/repo/ecc' });
|
||||
|
||||
assert.strictEqual(root, path.join(homeDir, '.claude'));
|
||||
assert.strictEqual(statePath, path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans scaffold operations and flattens native target roots', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
const modules = [
|
||||
{
|
||||
id: 'platform-configs',
|
||||
paths: ['.cursor', 'mcp-configs'],
|
||||
},
|
||||
{
|
||||
id: 'rules-core',
|
||||
paths: ['rules'],
|
||||
},
|
||||
];
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'cursor',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules,
|
||||
});
|
||||
|
||||
assert.strictEqual(plan.adapter.id, 'cursor-project');
|
||||
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 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(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')
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans cursor rules with flat namespaced filenames to avoid rule collisions', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'cursor',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules: [
|
||||
{
|
||||
id: 'rules-core',
|
||||
paths: ['rules'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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')
|
||||
)),
|
||||
'Should flatten common rules into namespaced files'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/typescript/testing.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md')
|
||||
)),
|
||||
'Should flatten language rules into namespaced files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common', 'coding-style.md')
|
||||
)),
|
||||
'Should not preserve nested rule directories for cursor installs'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans antigravity remaps for workflows, skills, and flat rules', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'antigravity',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules: [
|
||||
{
|
||||
id: 'commands-core',
|
||||
paths: ['commands'],
|
||||
},
|
||||
{
|
||||
id: 'agents-core',
|
||||
paths: ['agents'],
|
||||
},
|
||||
{
|
||||
id: 'rules-core',
|
||||
paths: ['rules'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === 'commands'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.agent', 'workflows')
|
||||
)),
|
||||
'Should remap commands into workflows'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === 'agents'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.agent', 'skills')
|
||||
)),
|
||||
'Should remap agents into skills'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.agent', 'rules', 'common-coding-style.md')
|
||||
)),
|
||||
'Should flatten common rules for antigravity'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exposes validate and planOperations on adapters', () => {
|
||||
const claudeAdapter = getInstallTargetAdapter('claude');
|
||||
const cursorAdapter = getInstallTargetAdapter('cursor');
|
||||
|
||||
assert.strictEqual(typeof claudeAdapter.planOperations, 'function');
|
||||
assert.strictEqual(typeof claudeAdapter.validate, 'function');
|
||||
assert.deepStrictEqual(
|
||||
claudeAdapter.validate({ homeDir: '/Users/example', repoRoot: '/repo/ecc' }),
|
||||
[]
|
||||
);
|
||||
|
||||
assert.strictEqual(typeof cursorAdapter.planOperations, 'function');
|
||||
assert.strictEqual(typeof cursorAdapter.validate, 'function');
|
||||
assert.deepStrictEqual(
|
||||
cursorAdapter.validate({ projectRoot: '/workspace/app', repoRoot: '/repo/ecc' }),
|
||||
[]
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('throws on unknown target adapter', () => {
|
||||
assert.throws(
|
||||
() => getInstallTargetAdapter('ghost-target'),
|
||||
/Unknown install target adapter/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,225 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
buildSessionSnapshot,
|
||||
listTmuxPanes,
|
||||
loadWorkerSnapshots,
|
||||
parseWorkerHandoff,
|
||||
parseWorkerStatus,
|
||||
parseWorkerTask,
|
||||
resolveSnapshotTarget
|
||||
} = require('../../scripts/lib/orchestration-session');
|
||||
|
||||
console.log('=== Testing orchestration-session.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${desc}: ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
test('parseWorkerStatus extracts structured status fields', () => {
|
||||
const status = parseWorkerStatus([
|
||||
'# Status',
|
||||
'',
|
||||
'- State: completed',
|
||||
'- Updated: 2026-03-12T14:09:15Z',
|
||||
'- Branch: feature-branch',
|
||||
'- Worktree: `/tmp/worktree`',
|
||||
'',
|
||||
'- Handoff file: `/tmp/handoff.md`'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(status, {
|
||||
state: 'completed',
|
||||
updated: '2026-03-12T14:09:15Z',
|
||||
branch: 'feature-branch',
|
||||
worktree: '/tmp/worktree',
|
||||
taskFile: null,
|
||||
handoffFile: '/tmp/handoff.md'
|
||||
});
|
||||
});
|
||||
|
||||
test('parseWorkerTask extracts objective and seeded overlays', () => {
|
||||
const task = parseWorkerTask([
|
||||
'# Worker Task',
|
||||
'',
|
||||
'## Seeded Local Overlays',
|
||||
'- `scripts/orchestrate-worktrees.js`',
|
||||
'- `commands/orchestrate.md`',
|
||||
'',
|
||||
'## Objective',
|
||||
'Verify seeded files and summarize status.'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(task.seedPaths, [
|
||||
'scripts/orchestrate-worktrees.js',
|
||||
'commands/orchestrate.md'
|
||||
]);
|
||||
assert.strictEqual(task.objective, 'Verify seeded files and summarize status.');
|
||||
});
|
||||
|
||||
test('parseWorkerHandoff extracts summary, validation, and risks', () => {
|
||||
const handoff = parseWorkerHandoff([
|
||||
'# Handoff',
|
||||
'',
|
||||
'## Summary',
|
||||
'- Worker completed successfully',
|
||||
'',
|
||||
'## Validation',
|
||||
'- Ran tests',
|
||||
'',
|
||||
'## Remaining Risks',
|
||||
'- No runtime screenshot'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(handoff.summary, ['Worker completed successfully']);
|
||||
assert.deepStrictEqual(handoff.validation, ['Ran tests']);
|
||||
assert.deepStrictEqual(handoff.remainingRisks, ['No runtime screenshot']);
|
||||
});
|
||||
|
||||
test('parseWorkerHandoff also supports bold section headers', () => {
|
||||
const handoff = parseWorkerHandoff([
|
||||
'# Handoff',
|
||||
'',
|
||||
'**Summary**',
|
||||
'- Worker completed successfully',
|
||||
'',
|
||||
'**Validation**',
|
||||
'- Ran tests',
|
||||
'',
|
||||
'**Remaining Risks**',
|
||||
'- No runtime screenshot'
|
||||
].join('\n'));
|
||||
|
||||
assert.deepStrictEqual(handoff.summary, ['Worker completed successfully']);
|
||||
assert.deepStrictEqual(handoff.validation, ['Ran tests']);
|
||||
assert.deepStrictEqual(handoff.remainingRisks, ['No runtime screenshot']);
|
||||
});
|
||||
|
||||
test('loadWorkerSnapshots reads coordination worker directories', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-session-'));
|
||||
const coordinationDir = path.join(tempRoot, 'coordination');
|
||||
const workerDir = path.join(coordinationDir, 'seed-check');
|
||||
const proofDir = path.join(coordinationDir, 'proof');
|
||||
fs.mkdirSync(workerDir, { recursive: true });
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(workerDir, 'status.md'), [
|
||||
'# Status',
|
||||
'',
|
||||
'- State: running',
|
||||
'- Branch: seed-branch',
|
||||
'- Worktree: `/tmp/seed-worktree`'
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(workerDir, 'task.md'), [
|
||||
'# Worker Task',
|
||||
'',
|
||||
'## Objective',
|
||||
'Inspect seed paths.'
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(workerDir, 'handoff.md'), [
|
||||
'# Handoff',
|
||||
'',
|
||||
'## Summary',
|
||||
'- Pending'
|
||||
].join('\n'));
|
||||
|
||||
const workers = loadWorkerSnapshots(coordinationDir);
|
||||
assert.strictEqual(workers.length, 1);
|
||||
assert.strictEqual(workers[0].workerSlug, 'seed-check');
|
||||
assert.strictEqual(workers[0].status.branch, 'seed-branch');
|
||||
assert.strictEqual(workers[0].task.objective, 'Inspect seed paths.');
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSessionSnapshot merges tmux panes with worker metadata', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-snapshot-'));
|
||||
const coordinationDir = path.join(tempRoot, 'coordination');
|
||||
const workerDir = path.join(coordinationDir, 'seed-check');
|
||||
fs.mkdirSync(workerDir, { recursive: true });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(workerDir, 'status.md'), '- State: completed\n- Branch: seed-branch\n');
|
||||
fs.writeFileSync(path.join(workerDir, 'task.md'), '## Objective\nInspect seed paths.\n');
|
||||
fs.writeFileSync(path.join(workerDir, 'handoff.md'), '## Summary\n- ok\n');
|
||||
|
||||
const snapshot = buildSessionSnapshot({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
coordinationDir,
|
||||
panes: [
|
||||
{
|
||||
paneId: '%95',
|
||||
windowIndex: 1,
|
||||
paneIndex: 2,
|
||||
title: 'seed-check',
|
||||
currentCommand: 'codex',
|
||||
currentPath: '/tmp/worktree',
|
||||
active: false,
|
||||
dead: false,
|
||||
pid: 1234
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.sessionActive, true);
|
||||
assert.strictEqual(snapshot.workerCount, 1);
|
||||
assert.strictEqual(snapshot.workerStates.completed, 1);
|
||||
assert.strictEqual(snapshot.workers[0].pane.paneId, '%95');
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('listTmuxPanes returns an empty array when tmux is unavailable', () => {
|
||||
const panes = listTmuxPanes('workflow-visual-proof', {
|
||||
spawnSyncImpl: () => ({
|
||||
error: Object.assign(new Error('tmux not found'), { code: 'ENOENT' })
|
||||
})
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(panes, []);
|
||||
});
|
||||
|
||||
test('resolveSnapshotTarget handles plan files and direct session names', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-target-'));
|
||||
const repoRoot = path.join(tempRoot, 'repo');
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
const planPath = path.join(repoRoot, 'plan.json');
|
||||
fs.writeFileSync(planPath, JSON.stringify({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
repoRoot,
|
||||
coordinationRoot: path.join(repoRoot, '.claude', 'orchestration')
|
||||
}));
|
||||
|
||||
try {
|
||||
const fromPlan = resolveSnapshotTarget(planPath, repoRoot);
|
||||
assert.strictEqual(fromPlan.targetType, 'plan');
|
||||
assert.strictEqual(fromPlan.sessionName, 'workflow-visual-proof');
|
||||
|
||||
const fromSession = resolveSnapshotTarget('workflow-visual-proof', repoRoot);
|
||||
assert.strictEqual(fromSession.targetType, 'session');
|
||||
assert.ok(fromSession.coordinationDir.endsWith(path.join('.claude', 'orchestration', 'workflow-visual-proof')));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
1669
.agent/knowledge/everything_claude/tests/lib/package-manager.test.js
Normal file
1669
.agent/knowledge/everything_claude/tests/lib/package-manager.test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Tests for scripts/lib/project-detect.js
|
||||
*
|
||||
* Run with: node tests/lib/project-detect.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
detectProjectType,
|
||||
LANGUAGE_RULES,
|
||||
FRAMEWORK_RULES,
|
||||
getPackageJsonDeps,
|
||||
getPythonDeps,
|
||||
getGoDeps,
|
||||
getRustDeps,
|
||||
getComposerDeps,
|
||||
getElixirDeps
|
||||
} = require('../../scripts/lib/project-detect');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a temporary directory for testing
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-test-'));
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
function cleanupDir(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Write a file in the temp directory
|
||||
function writeTestFile(dir, filePath, content = '') {
|
||||
const fullPath = path.join(dir, filePath);
|
||||
const dirName = path.dirname(fullPath);
|
||||
fs.mkdirSync(dirName, { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing project-detect.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Rule definitions tests
|
||||
console.log('Rule Definitions:');
|
||||
|
||||
if (test('LANGUAGE_RULES is non-empty array', () => {
|
||||
assert.ok(Array.isArray(LANGUAGE_RULES));
|
||||
assert.ok(LANGUAGE_RULES.length > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('FRAMEWORK_RULES is non-empty array', () => {
|
||||
assert.ok(Array.isArray(FRAMEWORK_RULES));
|
||||
assert.ok(FRAMEWORK_RULES.length > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('each language rule has type, markers, and extensions', () => {
|
||||
for (const rule of LANGUAGE_RULES) {
|
||||
assert.ok(typeof rule.type === 'string', `Missing type`);
|
||||
assert.ok(Array.isArray(rule.markers), `Missing markers for ${rule.type}`);
|
||||
assert.ok(Array.isArray(rule.extensions), `Missing extensions for ${rule.type}`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('each framework rule has framework, language, markers, packageKeys', () => {
|
||||
for (const rule of FRAMEWORK_RULES) {
|
||||
assert.ok(typeof rule.framework === 'string', `Missing framework`);
|
||||
assert.ok(typeof rule.language === 'string', `Missing language for ${rule.framework}`);
|
||||
assert.ok(Array.isArray(rule.markers), `Missing markers for ${rule.framework}`);
|
||||
assert.ok(Array.isArray(rule.packageKeys), `Missing packageKeys for ${rule.framework}`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Empty directory detection
|
||||
console.log('\nEmpty Directory:');
|
||||
|
||||
if (test('empty directory returns unknown primary', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
const result = detectProjectType(dir);
|
||||
assert.strictEqual(result.primary, 'unknown');
|
||||
assert.deepStrictEqual(result.languages, []);
|
||||
assert.deepStrictEqual(result.frameworks, []);
|
||||
assert.strictEqual(result.projectDir, dir);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Python detection
|
||||
console.log('\nPython Detection:');
|
||||
|
||||
if (test('detects python from requirements.txt', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'requirements.txt', 'flask==3.0.0\nrequests>=2.31');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.languages.includes('python'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects python from pyproject.toml', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'pyproject.toml', '[project]\nname = "test"');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.languages.includes('python'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects flask framework from requirements.txt', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'requirements.txt', 'flask==3.0.0\nrequests>=2.31');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.frameworks.includes('flask'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects django framework from manage.py', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'manage.py', '#!/usr/bin/env python');
|
||||
writeTestFile(dir, 'requirements.txt', 'django>=4.2');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.frameworks.includes('django'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects fastapi from pyproject.toml dependencies', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'pyproject.toml', '[project]\nname = "test"\ndependencies = [\n "fastapi>=0.100",\n "uvicorn"\n]');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.frameworks.includes('fastapi'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// TypeScript/JavaScript detection
|
||||
console.log('\nTypeScript/JavaScript Detection:');
|
||||
|
||||
if (test('detects typescript from tsconfig.json', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'tsconfig.json', '{}');
|
||||
writeTestFile(dir, 'package.json', '{"dependencies":{}}');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.languages.includes('typescript'));
|
||||
// Should NOT also include javascript when TS is detected
|
||||
assert.ok(!result.languages.includes('javascript'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects nextjs from next.config.mjs', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'tsconfig.json', '{}');
|
||||
writeTestFile(dir, 'next.config.mjs', 'export default {}');
|
||||
writeTestFile(dir, 'package.json', '{"dependencies":{"next":"14.0.0","react":"18.0.0"}}');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.frameworks.includes('nextjs'));
|
||||
assert.ok(result.frameworks.includes('react'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects react from package.json', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'package.json', '{"dependencies":{"react":"18.0.0","react-dom":"18.0.0"}}');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.frameworks.includes('react'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects angular from angular.json', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'angular.json', '{}');
|
||||
writeTestFile(dir, 'tsconfig.json', '{}');
|
||||
writeTestFile(dir, 'package.json', '{"dependencies":{"@angular/core":"17.0.0"}}');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.frameworks.includes('angular'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Go detection
|
||||
console.log('\nGo Detection:');
|
||||
|
||||
if (test('detects golang from go.mod', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'go.mod', 'module github.com/test/app\n\ngo 1.22\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n)');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.languages.includes('golang'));
|
||||
assert.ok(result.frameworks.includes('gin'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Rust detection
|
||||
console.log('\nRust Detection:');
|
||||
|
||||
if (test('detects rust from Cargo.toml', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'Cargo.toml', '[package]\nname = "test"\n\n[dependencies]\naxum = "0.7"');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.languages.includes('rust'));
|
||||
assert.ok(result.frameworks.includes('axum'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Ruby detection
|
||||
console.log('\nRuby Detection:');
|
||||
|
||||
if (test('detects ruby and rails', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'Gemfile', 'source "https://rubygems.org"\ngem "rails"');
|
||||
writeTestFile(dir, 'config/routes.rb', 'Rails.application.routes.draw do\nend');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.languages.includes('ruby'));
|
||||
assert.ok(result.frameworks.includes('rails'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// PHP detection
|
||||
console.log('\nPHP Detection:');
|
||||
|
||||
if (test('detects php and laravel', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'composer.json', '{"require":{"laravel/framework":"^10.0"}}');
|
||||
writeTestFile(dir, 'artisan', '#!/usr/bin/env php');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.languages.includes('php'));
|
||||
assert.ok(result.frameworks.includes('laravel'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Fullstack detection
|
||||
console.log('\nFullstack Detection:');
|
||||
|
||||
if (test('detects fullstack when frontend + backend frameworks present', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'package.json', '{"dependencies":{"react":"18.0.0","express":"4.18.0"}}');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.frameworks.includes('react'));
|
||||
assert.ok(result.frameworks.includes('express'));
|
||||
assert.strictEqual(result.primary, 'fullstack');
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Dependency reader tests
|
||||
console.log('\nDependency Readers:');
|
||||
|
||||
if (test('getPackageJsonDeps reads deps and devDeps', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'package.json', '{"dependencies":{"react":"18.0.0"},"devDependencies":{"typescript":"5.0.0"}}');
|
||||
const deps = getPackageJsonDeps(dir);
|
||||
assert.ok(deps.includes('react'));
|
||||
assert.ok(deps.includes('typescript'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getPythonDeps reads requirements.txt', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'requirements.txt', 'flask>=3.0\n# comment\nrequests==2.31\n-r other.txt');
|
||||
const deps = getPythonDeps(dir);
|
||||
assert.ok(deps.includes('flask'));
|
||||
assert.ok(deps.includes('requests'));
|
||||
assert.ok(!deps.includes('-r'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getGoDeps reads go.mod require block', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'go.mod', 'module test\n\ngo 1.22\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n\tgithub.com/lib/pq v1.10.9\n)');
|
||||
const deps = getGoDeps(dir);
|
||||
assert.ok(deps.some(d => d.includes('gin-gonic/gin')));
|
||||
assert.ok(deps.some(d => d.includes('lib/pq')));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getRustDeps reads Cargo.toml', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'Cargo.toml', '[package]\nname = "test"\n\n[dependencies]\nserde = "1.0"\ntokio = { version = "1.0", features = ["full"] }');
|
||||
const deps = getRustDeps(dir);
|
||||
assert.ok(deps.includes('serde'));
|
||||
assert.ok(deps.includes('tokio'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('returns empty arrays for missing files', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
assert.deepStrictEqual(getPackageJsonDeps(dir), []);
|
||||
assert.deepStrictEqual(getPythonDeps(dir), []);
|
||||
assert.deepStrictEqual(getGoDeps(dir), []);
|
||||
assert.deepStrictEqual(getRustDeps(dir), []);
|
||||
assert.deepStrictEqual(getComposerDeps(dir), []);
|
||||
assert.deepStrictEqual(getElixirDeps(dir), []);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Elixir detection
|
||||
console.log('\nElixir Detection:');
|
||||
|
||||
if (test('detects elixir from mix.exs', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'mix.exs', 'defmodule Test.MixProject do\n defp deps do\n [{:phoenix, "~> 1.7"},\n {:ecto, "~> 3.0"}]\n end\nend');
|
||||
const result = detectProjectType(dir);
|
||||
assert.ok(result.languages.includes('elixir'));
|
||||
assert.ok(result.frameworks.includes('phoenix'));
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Edge cases
|
||||
console.log('\nEdge Cases:');
|
||||
|
||||
if (test('handles non-existent directory gracefully', () => {
|
||||
const result = detectProjectType('/tmp/nonexistent-dir-' + Date.now());
|
||||
assert.strictEqual(result.primary, 'unknown');
|
||||
assert.deepStrictEqual(result.languages, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles malformed package.json', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'package.json', 'not valid json{{{');
|
||||
const deps = getPackageJsonDeps(dir);
|
||||
assert.deepStrictEqual(deps, []);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles malformed composer.json', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
writeTestFile(dir, 'composer.json', '{invalid');
|
||||
const deps = getComposerDeps(dir);
|
||||
assert.deepStrictEqual(deps, []);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Tests for scripts/lib/resolve-ecc-root.js
|
||||
*
|
||||
* Covers the ECC root resolution fallback chain:
|
||||
* 1. CLAUDE_PLUGIN_ROOT env var
|
||||
* 2. Standard install (~/.claude/)
|
||||
* 3. Exact legacy plugin roots under ~/.claude/plugins/
|
||||
* 4. Plugin cache auto-detection
|
||||
* 5. Fallback to ~/.claude/
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { resolveEccRoot, INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root');
|
||||
|
||||
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 createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-root-test-'));
|
||||
}
|
||||
|
||||
function setupStandardInstall(homeDir) {
|
||||
const claudeDir = path.join(homeDir, '.claude');
|
||||
const scriptDir = path.join(claudeDir, 'scripts', 'lib');
|
||||
fs.mkdirSync(scriptDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub');
|
||||
return claudeDir;
|
||||
}
|
||||
|
||||
function setupLegacyPluginInstall(homeDir, segments) {
|
||||
const legacyDir = path.join(homeDir, '.claude', 'plugins', ...segments);
|
||||
const scriptDir = path.join(legacyDir, 'scripts', 'lib');
|
||||
fs.mkdirSync(scriptDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub');
|
||||
return legacyDir;
|
||||
}
|
||||
function setupPluginCache(homeDir, orgName, version) {
|
||||
const cacheDir = path.join(
|
||||
homeDir, '.claude', 'plugins', 'cache',
|
||||
'everything-claude-code', orgName, version
|
||||
);
|
||||
const scriptDir = path.join(cacheDir, 'scripts', 'lib');
|
||||
fs.mkdirSync(scriptDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub');
|
||||
return cacheDir;
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing resolve-ecc-root.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// ─── Env Var Priority ───
|
||||
|
||||
if (test('returns CLAUDE_PLUGIN_ROOT when set', () => {
|
||||
const result = resolveEccRoot({ envRoot: '/custom/plugin/root' });
|
||||
assert.strictEqual(result, '/custom/plugin/root');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('trims whitespace from CLAUDE_PLUGIN_ROOT', () => {
|
||||
const result = resolveEccRoot({ envRoot: ' /trimmed/root ' });
|
||||
assert.strictEqual(result, '/trimmed/root');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('skips empty CLAUDE_PLUGIN_ROOT', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
setupStandardInstall(homeDir);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, path.join(homeDir, '.claude'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('skips whitespace-only CLAUDE_PLUGIN_ROOT', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
setupStandardInstall(homeDir);
|
||||
const result = resolveEccRoot({ envRoot: ' ', homeDir });
|
||||
assert.strictEqual(result, path.join(homeDir, '.claude'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Standard Install ───
|
||||
|
||||
if (test('finds standard install at ~/.claude/', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
setupStandardInstall(homeDir);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, path.join(homeDir, '.claude'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('finds exact legacy plugin install at ~/.claude/plugins/everything-claude-code', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['everything-claude-code']);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('finds exact legacy plugin install at ~/.claude/plugins/everything-claude-code@everything-claude-code', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['everything-claude-code@everything-claude-code']);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('finds marketplace legacy plugin install at ~/.claude/plugins/marketplace/everything-claude-code', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prefers exact legacy plugin install over plugin cache', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
|
||||
setupPluginCache(homeDir, 'everything-claude-code', '1.8.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
// ─── Plugin Cache Auto-Detection ───
|
||||
|
||||
if (test('discovers plugin root from cache directory', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupPluginCache(homeDir, 'everything-claude-code', '1.8.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prefers standard install over plugin cache', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const claudeDir = setupStandardInstall(homeDir);
|
||||
setupPluginCache(homeDir, 'everything-claude-code', '1.8.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, claudeDir,
|
||||
'Standard install should take precedence over plugin cache');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles multiple versions in plugin cache', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
setupPluginCache(homeDir, 'everything-claude-code', '1.7.0');
|
||||
const expected = setupPluginCache(homeDir, 'everything-claude-code', '1.8.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
// Should find one of them (either is valid)
|
||||
assert.ok(
|
||||
result === expected ||
|
||||
result === path.join(homeDir, '.claude', 'plugins', 'cache', 'everything-claude-code', 'everything-claude-code', '1.7.0'),
|
||||
'Should resolve to a valid plugin cache directory'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Fallback ───
|
||||
|
||||
if (test('falls back to ~/.claude/ when nothing is found', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
// Create ~/.claude but don't put scripts there
|
||||
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, path.join(homeDir, '.claude'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('falls back gracefully when ~/.claude/ does not exist', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, path.join(homeDir, '.claude'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Custom Probe ───
|
||||
|
||||
if (test('supports custom probe path', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const claudeDir = path.join(homeDir, '.claude');
|
||||
fs.mkdirSync(path.join(claudeDir, 'custom'), { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeDir, 'custom', 'marker.js'), '// probe');
|
||||
const result = resolveEccRoot({
|
||||
envRoot: '',
|
||||
homeDir,
|
||||
probe: path.join('custom', 'marker.js'),
|
||||
});
|
||||
assert.strictEqual(result, claudeDir);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── INLINE_RESOLVE ───
|
||||
|
||||
if (test('INLINE_RESOLVE is a non-empty string', () => {
|
||||
assert.ok(typeof INLINE_RESOLVE === 'string');
|
||||
assert.ok(INLINE_RESOLVE.length > 50, 'Should be a substantial inline expression');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('INLINE_RESOLVE returns CLAUDE_PLUGIN_ROOT when set', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const result = execFileSync('node', [
|
||||
'-e', `console.log(${INLINE_RESOLVE})`,
|
||||
], {
|
||||
env: { ...process.env, CLAUDE_PLUGIN_ROOT: '/inline/test/root' },
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
assert.strictEqual(result, '/inline/test/root');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('INLINE_RESOLVE discovers exact legacy plugin root when env var is unset', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
|
||||
const { execFileSync } = require('child_process');
|
||||
const result = execFileSync('node', [
|
||||
'-e', `console.log(${INLINE_RESOLVE})`,
|
||||
], {
|
||||
env: { PATH: process.env.PATH, HOME: homeDir, USERPROFILE: homeDir },
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
if (test('INLINE_RESOLVE discovers plugin cache when env var is unset', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupPluginCache(homeDir, 'everything-claude-code', '1.9.0');
|
||||
const { execFileSync } = require('child_process');
|
||||
const result = execFileSync('node', [
|
||||
'-e', `console.log(${INLINE_RESOLVE})`,
|
||||
], {
|
||||
env: { PATH: process.env.PATH, HOME: homeDir, USERPROFILE: homeDir },
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('INLINE_RESOLVE falls back to ~/.claude/ when nothing found', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const { execFileSync } = require('child_process');
|
||||
const result = execFileSync('node', [
|
||||
'-e', `console.log(${INLINE_RESOLVE})`,
|
||||
], {
|
||||
env: { PATH: process.env.PATH, HOME: homeDir, USERPROFILE: homeDir },
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
assert.strictEqual(result, path.join(homeDir, '.claude'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Tests for scripts/lib/resolve-formatter.js
|
||||
*
|
||||
* Run with: node tests/lib/resolve-formatter.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const { findProjectRoot, detectFormatter, resolveFormatterBin, clearCaches } = require('../../scripts/lib/resolve-formatter');
|
||||
|
||||
/**
|
||||
* Run a single test case, printing pass/fail.
|
||||
*
|
||||
* @param {string} name - Test description
|
||||
* @param {() => void} fn - Test body (throws on failure)
|
||||
* @returns {boolean} Whether the test passed
|
||||
*/
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Track all created tmp dirs for cleanup */
|
||||
const tmpDirs = [];
|
||||
|
||||
/**
|
||||
* Create a temporary directory and track it for cleanup.
|
||||
*
|
||||
* @returns {string} Absolute path to the new temp directory
|
||||
*/
|
||||
function makeTmpDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-fmt-'));
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all tracked temporary directories.
|
||||
*/
|
||||
function cleanupTmpDirs() {
|
||||
for (const dir of tmpDirs) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing resolve-formatter.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function run(name, fn) {
|
||||
clearCaches();
|
||||
if (test(name, fn)) passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
// ── findProjectRoot ───────────────────────────────────────────
|
||||
|
||||
run('findProjectRoot: finds package.json in parent dir', () => {
|
||||
const root = makeTmpDir();
|
||||
const sub = path.join(root, 'src', 'lib');
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'package.json'), '{}');
|
||||
|
||||
assert.strictEqual(findProjectRoot(sub), root);
|
||||
});
|
||||
|
||||
run('findProjectRoot: returns startDir when no package.json', () => {
|
||||
const root = makeTmpDir();
|
||||
const sub = path.join(root, 'deep');
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
|
||||
// No package.json anywhere in tmp → falls back to startDir
|
||||
assert.strictEqual(findProjectRoot(sub), sub);
|
||||
});
|
||||
|
||||
run('findProjectRoot: caches result for same startDir', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, 'package.json'), '{}');
|
||||
|
||||
const first = findProjectRoot(root);
|
||||
// Remove package.json — cache should still return the old result
|
||||
fs.unlinkSync(path.join(root, 'package.json'));
|
||||
const second = findProjectRoot(root);
|
||||
|
||||
assert.strictEqual(first, second);
|
||||
});
|
||||
|
||||
// ── detectFormatter ───────────────────────────────────────────
|
||||
|
||||
run('detectFormatter: detects biome.json', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, 'biome.json'), '{}');
|
||||
assert.strictEqual(detectFormatter(root), 'biome');
|
||||
});
|
||||
|
||||
run('detectFormatter: detects biome.jsonc', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, 'biome.jsonc'), '{}');
|
||||
assert.strictEqual(detectFormatter(root), 'biome');
|
||||
});
|
||||
|
||||
run('detectFormatter: detects .prettierrc', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, '.prettierrc'), '{}');
|
||||
assert.strictEqual(detectFormatter(root), 'prettier');
|
||||
});
|
||||
|
||||
run('detectFormatter: detects prettier.config.js', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, 'prettier.config.js'), 'module.exports = {}');
|
||||
assert.strictEqual(detectFormatter(root), 'prettier');
|
||||
});
|
||||
|
||||
run('detectFormatter: detects prettier key in package.json', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test', prettier: { singleQuote: true } }));
|
||||
assert.strictEqual(detectFormatter(root), 'prettier');
|
||||
});
|
||||
|
||||
run('detectFormatter: ignores package.json without prettier key', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test' }));
|
||||
assert.strictEqual(detectFormatter(root), null);
|
||||
});
|
||||
|
||||
run('detectFormatter: biome takes priority over prettier', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, 'biome.json'), '{}');
|
||||
fs.writeFileSync(path.join(root, '.prettierrc'), '{}');
|
||||
assert.strictEqual(detectFormatter(root), 'biome');
|
||||
});
|
||||
|
||||
run('detectFormatter: returns null when no config found', () => {
|
||||
const root = makeTmpDir();
|
||||
assert.strictEqual(detectFormatter(root), null);
|
||||
});
|
||||
|
||||
// ── resolveFormatterBin ───────────────────────────────────────
|
||||
|
||||
run('resolveFormatterBin: uses local biome binary when available', () => {
|
||||
const root = makeTmpDir();
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const binName = process.platform === 'win32' ? 'biome.cmd' : 'biome';
|
||||
fs.writeFileSync(path.join(binDir, binName), '');
|
||||
|
||||
const result = resolveFormatterBin(root, 'biome');
|
||||
assert.strictEqual(result.bin, path.join(binDir, binName));
|
||||
assert.deepStrictEqual(result.prefix, []);
|
||||
});
|
||||
|
||||
run('resolveFormatterBin: falls back to npx for biome', () => {
|
||||
const root = makeTmpDir();
|
||||
const result = resolveFormatterBin(root, 'biome');
|
||||
const expectedBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
assert.strictEqual(result.bin, expectedBin);
|
||||
assert.deepStrictEqual(result.prefix, ['@biomejs/biome']);
|
||||
});
|
||||
|
||||
run('resolveFormatterBin: uses local prettier binary when available', () => {
|
||||
const root = makeTmpDir();
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const binName = process.platform === 'win32' ? 'prettier.cmd' : 'prettier';
|
||||
fs.writeFileSync(path.join(binDir, binName), '');
|
||||
|
||||
const result = resolveFormatterBin(root, 'prettier');
|
||||
assert.strictEqual(result.bin, path.join(binDir, binName));
|
||||
assert.deepStrictEqual(result.prefix, []);
|
||||
});
|
||||
|
||||
run('resolveFormatterBin: falls back to npx for prettier', () => {
|
||||
const root = makeTmpDir();
|
||||
const result = resolveFormatterBin(root, 'prettier');
|
||||
const expectedBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
assert.strictEqual(result.bin, expectedBin);
|
||||
assert.deepStrictEqual(result.prefix, ['prettier']);
|
||||
});
|
||||
|
||||
run('resolveFormatterBin: returns null for unknown formatter', () => {
|
||||
const root = makeTmpDir();
|
||||
const result = resolveFormatterBin(root, 'unknown');
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
run('resolveFormatterBin: caches resolved binary', () => {
|
||||
const root = makeTmpDir();
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const binName = process.platform === 'win32' ? 'biome.cmd' : 'biome';
|
||||
fs.writeFileSync(path.join(binDir, binName), '');
|
||||
|
||||
const first = resolveFormatterBin(root, 'biome');
|
||||
fs.unlinkSync(path.join(binDir, binName));
|
||||
const second = resolveFormatterBin(root, 'biome');
|
||||
|
||||
assert.strictEqual(first.bin, second.bin);
|
||||
});
|
||||
|
||||
// ── clearCaches ───────────────────────────────────────────────
|
||||
|
||||
run('clearCaches: clears all cached values', () => {
|
||||
const root = makeTmpDir();
|
||||
fs.writeFileSync(path.join(root, 'package.json'), '{}');
|
||||
fs.writeFileSync(path.join(root, 'biome.json'), '{}');
|
||||
|
||||
findProjectRoot(root);
|
||||
detectFormatter(root);
|
||||
resolveFormatterBin(root, 'biome');
|
||||
|
||||
clearCaches();
|
||||
|
||||
// After clearing, removing config should change detection
|
||||
fs.unlinkSync(path.join(root, 'biome.json'));
|
||||
assert.strictEqual(detectFormatter(root), null);
|
||||
});
|
||||
|
||||
// ── Summary & Cleanup ─────────────────────────────────────────
|
||||
|
||||
cleanupTmpDirs();
|
||||
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${passed + failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,717 @@
|
||||
/**
|
||||
* Tests for --with / --without selective install flags (issue #470)
|
||||
*
|
||||
* Covers:
|
||||
* - CLI argument parsing for --with and --without
|
||||
* - Request normalization with include/exclude component IDs
|
||||
* - Component-to-module expansion via the manifest catalog
|
||||
* - End-to-end install plans with --with and --without
|
||||
* - Validation and error handling for unknown component IDs
|
||||
* - Combined --profile + --with + --without flows
|
||||
* - Standalone --with without a profile
|
||||
* - agent: and skill: component families
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
parseInstallArgs,
|
||||
normalizeInstallRequest,
|
||||
} = require('../../scripts/lib/install/request');
|
||||
|
||||
const {
|
||||
listInstallComponents,
|
||||
resolveInstallPlan,
|
||||
} = require('../../scripts/lib/install-manifests');
|
||||
|
||||
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 --with / --without selective install flags ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// ─── CLI Argument Parsing ───
|
||||
|
||||
if (test('parses single --with flag', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--profile', 'core',
|
||||
'--with', 'lang:typescript',
|
||||
]);
|
||||
assert.deepStrictEqual(parsed.includeComponentIds, ['lang:typescript']);
|
||||
assert.deepStrictEqual(parsed.excludeComponentIds, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses single --without flag', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--profile', 'developer',
|
||||
'--without', 'capability:orchestration',
|
||||
]);
|
||||
assert.deepStrictEqual(parsed.excludeComponentIds, ['capability:orchestration']);
|
||||
assert.deepStrictEqual(parsed.includeComponentIds, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses multiple --with flags', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--with', 'lang:typescript',
|
||||
'--with', 'framework:nextjs',
|
||||
'--with', 'capability:database',
|
||||
]);
|
||||
assert.deepStrictEqual(parsed.includeComponentIds, [
|
||||
'lang:typescript',
|
||||
'framework:nextjs',
|
||||
'capability:database',
|
||||
]);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses multiple --without flags', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--profile', 'full',
|
||||
'--without', 'capability:media',
|
||||
'--without', 'capability:social',
|
||||
]);
|
||||
assert.deepStrictEqual(parsed.excludeComponentIds, [
|
||||
'capability:media',
|
||||
'capability:social',
|
||||
]);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses combined --with and --without flags', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--profile', 'developer',
|
||||
'--with', 'lang:typescript',
|
||||
'--with', 'framework:nextjs',
|
||||
'--without', 'capability:orchestration',
|
||||
]);
|
||||
assert.strictEqual(parsed.profileId, 'developer');
|
||||
assert.deepStrictEqual(parsed.includeComponentIds, ['lang:typescript', 'framework:nextjs']);
|
||||
assert.deepStrictEqual(parsed.excludeComponentIds, ['capability:orchestration']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('ignores empty --with values', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--with', '',
|
||||
'--with', 'lang:python',
|
||||
]);
|
||||
assert.deepStrictEqual(parsed.includeComponentIds, ['lang:python']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('ignores empty --without values', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--profile', 'core',
|
||||
'--without', '',
|
||||
'--without', 'capability:media',
|
||||
]);
|
||||
assert.deepStrictEqual(parsed.excludeComponentIds, ['capability:media']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Request Normalization ───
|
||||
|
||||
if (test('normalizes --with-only request as manifest mode', () => {
|
||||
const request = normalizeInstallRequest({
|
||||
target: 'claude',
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
includeComponentIds: ['lang:typescript'],
|
||||
excludeComponentIds: [],
|
||||
languages: [],
|
||||
});
|
||||
assert.strictEqual(request.mode, 'manifest');
|
||||
assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript']);
|
||||
assert.deepStrictEqual(request.excludeComponentIds, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('normalizes --profile + --with + --without as manifest mode', () => {
|
||||
const request = normalizeInstallRequest({
|
||||
target: 'cursor',
|
||||
profileId: 'developer',
|
||||
moduleIds: [],
|
||||
includeComponentIds: ['lang:typescript', 'framework:nextjs'],
|
||||
excludeComponentIds: ['capability:orchestration'],
|
||||
languages: [],
|
||||
});
|
||||
assert.strictEqual(request.mode, 'manifest');
|
||||
assert.strictEqual(request.profileId, 'developer');
|
||||
assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript', 'framework:nextjs']);
|
||||
assert.deepStrictEqual(request.excludeComponentIds, ['capability:orchestration']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --with combined with legacy language arguments', () => {
|
||||
assert.throws(
|
||||
() => normalizeInstallRequest({
|
||||
target: 'claude',
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
includeComponentIds: ['lang:typescript'],
|
||||
excludeComponentIds: [],
|
||||
languages: ['python'],
|
||||
}),
|
||||
/cannot be combined/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --without combined with legacy language arguments', () => {
|
||||
assert.throws(
|
||||
() => normalizeInstallRequest({
|
||||
target: 'claude',
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
includeComponentIds: [],
|
||||
excludeComponentIds: ['capability:media'],
|
||||
languages: ['typescript'],
|
||||
}),
|
||||
/cannot be combined/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deduplicates repeated --with component IDs', () => {
|
||||
const request = normalizeInstallRequest({
|
||||
target: 'claude',
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
includeComponentIds: ['lang:typescript', 'lang:typescript', 'lang:python'],
|
||||
excludeComponentIds: [],
|
||||
languages: [],
|
||||
});
|
||||
assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript', 'lang:python']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deduplicates repeated --without component IDs', () => {
|
||||
const request = normalizeInstallRequest({
|
||||
target: 'claude',
|
||||
profileId: 'full',
|
||||
moduleIds: [],
|
||||
includeComponentIds: [],
|
||||
excludeComponentIds: ['capability:media', 'capability:media', 'capability:social'],
|
||||
languages: [],
|
||||
});
|
||||
assert.deepStrictEqual(request.excludeComponentIds, ['capability:media', 'capability:social']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Component Catalog Validation ───
|
||||
|
||||
if (test('component catalog includes lang: family entries', () => {
|
||||
const components = listInstallComponents({ family: 'language' });
|
||||
assert.ok(components.some(c => c.id === 'lang:typescript'), 'Should have lang:typescript');
|
||||
assert.ok(components.some(c => c.id === 'lang:python'), 'Should have lang:python');
|
||||
assert.ok(components.some(c => c.id === 'lang:go'), 'Should have lang:go');
|
||||
assert.ok(components.some(c => c.id === 'lang:java'), 'Should have lang:java');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('component catalog includes framework: family entries', () => {
|
||||
const components = listInstallComponents({ family: 'framework' });
|
||||
assert.ok(components.some(c => c.id === 'framework:react'), 'Should have framework:react');
|
||||
assert.ok(components.some(c => c.id === 'framework:nextjs'), 'Should have framework:nextjs');
|
||||
assert.ok(components.some(c => c.id === 'framework:django'), 'Should have framework:django');
|
||||
assert.ok(components.some(c => c.id === 'framework:springboot'), 'Should have framework:springboot');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('component catalog includes capability: family entries', () => {
|
||||
const components = listInstallComponents({ family: 'capability' });
|
||||
assert.ok(components.some(c => c.id === 'capability:database'), 'Should have capability:database');
|
||||
assert.ok(components.some(c => c.id === 'capability:security'), 'Should have capability:security');
|
||||
assert.ok(components.some(c => c.id === 'capability:orchestration'), 'Should have capability:orchestration');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('component catalog includes agent: family entries', () => {
|
||||
const components = listInstallComponents({ family: 'agent' });
|
||||
assert.ok(components.length > 0, 'Should have at least one agent component');
|
||||
assert.ok(components.some(c => c.id === 'agent:security-reviewer'), 'Should have agent:security-reviewer');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('component catalog includes skill: family entries', () => {
|
||||
const components = listInstallComponents({ family: 'skill' });
|
||||
assert.ok(components.length > 0, 'Should have at least one skill component');
|
||||
assert.ok(components.some(c => c.id === 'skill:continuous-learning'), 'Should have skill:continuous-learning');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Install Plan Resolution with --with ───
|
||||
|
||||
if (test('--with alone resolves component modules and their dependencies', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
includeComponentIds: ['lang:typescript'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.ok(plan.selectedModuleIds.includes('framework-language'),
|
||||
'Should include the module behind lang:typescript');
|
||||
assert.ok(plan.selectedModuleIds.includes('rules-core'),
|
||||
'Should include framework-language dependency rules-core');
|
||||
assert.ok(plan.selectedModuleIds.includes('platform-configs'),
|
||||
'Should include framework-language dependency platform-configs');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('--with adds modules on top of a profile', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
profileId: 'core',
|
||||
includeComponentIds: ['capability:security'],
|
||||
target: 'claude',
|
||||
});
|
||||
// core profile modules
|
||||
assert.ok(plan.selectedModuleIds.includes('rules-core'));
|
||||
assert.ok(plan.selectedModuleIds.includes('workflow-quality'));
|
||||
// added by --with
|
||||
assert.ok(plan.selectedModuleIds.includes('security'),
|
||||
'Should include security module from --with');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('multiple --with flags union their modules', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
includeComponentIds: ['lang:typescript', 'capability:database'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.ok(plan.selectedModuleIds.includes('framework-language'),
|
||||
'Should include framework-language from lang:typescript');
|
||||
assert.ok(plan.selectedModuleIds.includes('database'),
|
||||
'Should include database from capability:database');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Install Plan Resolution with --without ───
|
||||
|
||||
if (test('--without excludes modules from a profile', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
profileId: 'developer',
|
||||
excludeComponentIds: ['capability:orchestration'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.ok(!plan.selectedModuleIds.includes('orchestration'),
|
||||
'Should exclude orchestration module');
|
||||
assert.ok(plan.excludedModuleIds.includes('orchestration'),
|
||||
'Should report orchestration as excluded');
|
||||
// rest of developer profile should remain
|
||||
assert.ok(plan.selectedModuleIds.includes('rules-core'));
|
||||
assert.ok(plan.selectedModuleIds.includes('framework-language'));
|
||||
assert.ok(plan.selectedModuleIds.includes('database'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('multiple --without flags exclude multiple modules', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
profileId: 'full',
|
||||
excludeComponentIds: ['capability:media', 'capability:social', 'capability:supply-chain'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.ok(!plan.selectedModuleIds.includes('media-generation'));
|
||||
assert.ok(!plan.selectedModuleIds.includes('social-distribution'));
|
||||
assert.ok(!plan.selectedModuleIds.includes('supply-chain-domain'));
|
||||
assert.ok(plan.excludedModuleIds.includes('media-generation'));
|
||||
assert.ok(plan.excludedModuleIds.includes('social-distribution'));
|
||||
assert.ok(plan.excludedModuleIds.includes('supply-chain-domain'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Combined --with + --without ───
|
||||
|
||||
if (test('--with and --without work together on a profile', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
profileId: 'developer',
|
||||
includeComponentIds: ['capability:security'],
|
||||
excludeComponentIds: ['capability:orchestration'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.ok(plan.selectedModuleIds.includes('security'),
|
||||
'Should include security from --with');
|
||||
assert.ok(!plan.selectedModuleIds.includes('orchestration'),
|
||||
'Should exclude orchestration from --without');
|
||||
assert.ok(plan.selectedModuleIds.includes('rules-core'),
|
||||
'Should keep profile base modules');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('--without on a dependency of --with raises an error', () => {
|
||||
assert.throws(
|
||||
() => resolveInstallPlan({
|
||||
includeComponentIds: ['capability:social'],
|
||||
excludeComponentIds: ['capability:content'],
|
||||
}),
|
||||
/depends on excluded module/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Validation Errors ───
|
||||
|
||||
if (test('throws for unknown component ID in --with', () => {
|
||||
assert.throws(
|
||||
() => resolveInstallPlan({
|
||||
includeComponentIds: ['lang:brainfuck-plus-plus'],
|
||||
}),
|
||||
/Unknown install component/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('throws for unknown component ID in --without', () => {
|
||||
assert.throws(
|
||||
() => resolveInstallPlan({
|
||||
profileId: 'core',
|
||||
excludeComponentIds: ['capability:teleportation'],
|
||||
}),
|
||||
/Unknown install component/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('throws when all modules are excluded', () => {
|
||||
assert.throws(
|
||||
() => resolveInstallPlan({
|
||||
profileId: 'core',
|
||||
excludeComponentIds: [
|
||||
'baseline:rules',
|
||||
'baseline:agents',
|
||||
'baseline:commands',
|
||||
'baseline:hooks',
|
||||
'baseline:platform',
|
||||
'baseline:workflow',
|
||||
],
|
||||
target: 'claude',
|
||||
}),
|
||||
/excludes every requested install module/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Target-Specific Behavior ───
|
||||
|
||||
if (test('--with respects target compatibility filtering', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
includeComponentIds: ['capability:orchestration'],
|
||||
target: 'cursor',
|
||||
});
|
||||
// orchestration module only supports claude, codex, opencode
|
||||
assert.ok(!plan.selectedModuleIds.includes('orchestration'),
|
||||
'Should skip orchestration for cursor target');
|
||||
assert.ok(plan.skippedModuleIds.includes('orchestration'),
|
||||
'Should report orchestration as skipped for cursor');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('--without with agent: component excludes the agent module', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
profileId: 'core',
|
||||
excludeComponentIds: ['agent:security-reviewer'],
|
||||
target: 'claude',
|
||||
});
|
||||
// agent:security-reviewer maps to agents-core module
|
||||
// Since core profile includes agents-core and it is excluded, it should be gone
|
||||
assert.ok(!plan.selectedModuleIds.includes('agents-core'),
|
||||
'Should exclude agents-core when agent:security-reviewer is excluded');
|
||||
assert.ok(plan.excludedModuleIds.includes('agents-core'),
|
||||
'Should report agents-core as excluded');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('--with agent: component includes the agents-core module', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
includeComponentIds: ['agent:security-reviewer'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.ok(plan.selectedModuleIds.includes('agents-core'),
|
||||
'Should include agents-core module from agent:security-reviewer');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('--with skill: component includes the parent skill module', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
includeComponentIds: ['skill:continuous-learning'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.ok(plan.selectedModuleIds.includes('workflow-quality'),
|
||||
'Should include workflow-quality module from skill:continuous-learning');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Help Text ───
|
||||
|
||||
if (test('help text documents --with and --without flags', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const result = execFileSync('node', [scriptPath, '--help'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
assert.ok(result.includes('--with'), 'Help should mention --with');
|
||||
assert.ok(result.includes('--without'), 'Help should mention --without');
|
||||
assert.ok(result.includes('component'), 'Help should describe components');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── End-to-End Dry-Run ───
|
||||
|
||||
if (test('end-to-end: --profile developer --with capability:security --without capability:orchestration --dry-run', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-project-'));
|
||||
|
||||
try {
|
||||
const result = execFileSync('node', [
|
||||
scriptPath,
|
||||
'--profile', 'developer',
|
||||
'--with', 'capability:security',
|
||||
'--without', 'capability:orchestration',
|
||||
'--dry-run',
|
||||
], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
assert.ok(result.includes('Mode: manifest'), 'Should be manifest mode');
|
||||
assert.ok(result.includes('Profile: developer'), 'Should show developer profile');
|
||||
assert.ok(result.includes('capability:security'), 'Should show included component');
|
||||
assert.ok(result.includes('capability:orchestration'), 'Should show excluded component');
|
||||
assert.ok(result.includes('security'), 'Selected modules should include security');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('end-to-end: --with lang:python --with agent:security-reviewer --dry-run', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-project-'));
|
||||
|
||||
try {
|
||||
const result = execFileSync('node', [
|
||||
scriptPath,
|
||||
'--with', 'lang:python',
|
||||
'--with', 'agent:security-reviewer',
|
||||
'--dry-run',
|
||||
], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
assert.ok(result.includes('Mode: manifest'), 'Should be manifest mode');
|
||||
assert.ok(result.includes('lang:python'), 'Should show lang:python as included');
|
||||
assert.ok(result.includes('agent:security-reviewer'), 'Should show agent:security-reviewer as included');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('end-to-end: --with with unknown component fails cleanly', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
|
||||
let exitCode = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('node', [
|
||||
scriptPath,
|
||||
'--with', 'lang:nonexistent-language',
|
||||
'--dry-run',
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
exitCode = error.status || 1;
|
||||
stderr = error.stderr || '';
|
||||
}
|
||||
|
||||
assert.strictEqual(exitCode, 1, 'Should exit with error code 1');
|
||||
assert.ok(stderr.includes('Unknown install component'), 'Should report unknown component');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('end-to-end: --without with unknown component fails cleanly', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
|
||||
let exitCode = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('node', [
|
||||
scriptPath,
|
||||
'--profile', 'core',
|
||||
'--without', 'capability:nonexistent',
|
||||
'--dry-run',
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
exitCode = error.status || 1;
|
||||
stderr = error.stderr || '';
|
||||
}
|
||||
|
||||
assert.strictEqual(exitCode, 1, 'Should exit with error code 1');
|
||||
assert.ok(stderr.includes('Unknown install component'), 'Should report unknown component');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── End-to-End Actual Install ───
|
||||
|
||||
if (test('end-to-end: installs --profile core --with capability:security and writes state', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-project-'));
|
||||
|
||||
try {
|
||||
const _result = execFileSync('node', [
|
||||
scriptPath,
|
||||
'--profile', 'core',
|
||||
'--with', 'capability:security',
|
||||
], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
// Security skill should be installed (from --with)
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'security-review', 'SKILL.md')),
|
||||
'Should install security-review skill from --with');
|
||||
// Core profile modules should be installed
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md')),
|
||||
'Should install core rules');
|
||||
|
||||
// Install state should record include/exclude
|
||||
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.strictEqual(state.request.profile, 'core');
|
||||
assert.deepStrictEqual(state.request.includeComponents, ['capability:security']);
|
||||
assert.deepStrictEqual(state.request.excludeComponents, []);
|
||||
assert.ok(state.resolution.selectedModules.includes('security'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('end-to-end: installs --profile developer --without capability:orchestration and state reflects exclusion', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-project-'));
|
||||
|
||||
try {
|
||||
execFileSync('node', [
|
||||
scriptPath,
|
||||
'--profile', 'developer',
|
||||
'--without', 'capability:orchestration',
|
||||
], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
// Orchestration skills should NOT be installed (from --without)
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'skills', 'dmux-workflows', 'SKILL.md')),
|
||||
'Should not install orchestration skills');
|
||||
// Developer profile base modules should be installed
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md')),
|
||||
'Should install core rules');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')),
|
||||
'Should install workflow skills');
|
||||
|
||||
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
assert.deepStrictEqual(state.request.excludeComponents, ['capability:orchestration']);
|
||||
assert.ok(!state.resolution.selectedModules.includes('orchestration'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('end-to-end: --with alone (no profile) installs just the component modules', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-project-'));
|
||||
|
||||
try {
|
||||
execFileSync('node', [
|
||||
scriptPath,
|
||||
'--with', 'lang:typescript',
|
||||
], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
// framework-language skill (from lang:typescript) should be installed
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')),
|
||||
'Should install framework-language skills');
|
||||
// Its dependencies should be installed
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md')),
|
||||
'Should install dependency rules-core');
|
||||
|
||||
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.strictEqual(state.request.profile, null);
|
||||
assert.deepStrictEqual(state.request.includeComponents, ['lang:typescript']);
|
||||
assert.ok(state.resolution.selectedModules.includes('framework-language'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── JSON output mode ───
|
||||
|
||||
if (test('end-to-end: --dry-run --json includes component selections in output', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-project-'));
|
||||
|
||||
try {
|
||||
const output = execFileSync('node', [
|
||||
scriptPath,
|
||||
'--profile', 'core',
|
||||
'--with', 'capability:database',
|
||||
'--without', 'baseline:hooks',
|
||||
'--dry-run',
|
||||
'--json',
|
||||
], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const json = JSON.parse(output);
|
||||
assert.strictEqual(json.dryRun, true);
|
||||
assert.ok(json.plan, 'Should include plan object');
|
||||
assert.ok(
|
||||
json.plan.includedComponentIds.includes('capability:database'),
|
||||
'JSON output should include capability:database in included components'
|
||||
);
|
||||
assert.ok(
|
||||
json.plan.excludedComponentIds.includes('baseline:hooks'),
|
||||
'JSON output should include baseline:hooks in excluded components'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,578 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
getFallbackSessionRecordingPath,
|
||||
persistCanonicalSnapshot
|
||||
} = require('../../scripts/lib/session-adapters/canonical-session');
|
||||
const { createClaudeHistoryAdapter } = require('../../scripts/lib/session-adapters/claude-history');
|
||||
const { createDmuxTmuxAdapter } = require('../../scripts/lib/session-adapters/dmux-tmux');
|
||||
const {
|
||||
createAdapterRegistry,
|
||||
inspectSessionTarget
|
||||
} = require('../../scripts/lib/session-adapters/registry');
|
||||
|
||||
console.log('=== Testing session-adapters ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}: ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function withHome(homeDir, fn) {
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (typeof previousHome === 'string') {
|
||||
process.env.HOME = previousHome;
|
||||
} else {
|
||||
delete process.env.HOME;
|
||||
}
|
||||
|
||||
if (typeof previousUserProfile === 'string') {
|
||||
process.env.USERPROFILE = previousUserProfile;
|
||||
} else {
|
||||
delete process.env.USERPROFILE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('dmux adapter normalizes orchestration snapshots into canonical form', () => {
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-'));
|
||||
|
||||
try {
|
||||
const recentUpdated = new Date(Date.now() - 60000).toISOString();
|
||||
|
||||
const adapter = createDmuxTmuxAdapter({
|
||||
loadStateStoreImpl: () => null,
|
||||
collectSessionSnapshotImpl: () => ({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof',
|
||||
repoRoot: '/tmp/repo',
|
||||
targetType: 'plan',
|
||||
sessionActive: true,
|
||||
paneCount: 1,
|
||||
workerCount: 1,
|
||||
workerStates: { running: 1 },
|
||||
panes: [{
|
||||
paneId: '%95',
|
||||
windowIndex: 1,
|
||||
paneIndex: 0,
|
||||
title: 'seed-check',
|
||||
currentCommand: 'codex',
|
||||
currentPath: '/tmp/worktree',
|
||||
active: false,
|
||||
dead: false,
|
||||
pid: 1234
|
||||
}],
|
||||
workers: [{
|
||||
workerSlug: 'seed-check',
|
||||
workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check',
|
||||
status: {
|
||||
state: 'running',
|
||||
updated: recentUpdated,
|
||||
branch: 'feature/seed-check',
|
||||
worktree: '/tmp/worktree',
|
||||
taskFile: '/tmp/task.md',
|
||||
handoffFile: '/tmp/handoff.md'
|
||||
},
|
||||
task: {
|
||||
objective: 'Inspect seeded files.',
|
||||
seedPaths: ['scripts/orchestrate-worktrees.js']
|
||||
},
|
||||
handoff: {
|
||||
summary: ['Pending'],
|
||||
validation: [],
|
||||
remainingRisks: ['No screenshot yet']
|
||||
},
|
||||
files: {
|
||||
status: '/tmp/status.md',
|
||||
task: '/tmp/task.md',
|
||||
handoff: '/tmp/handoff.md'
|
||||
},
|
||||
pane: {
|
||||
paneId: '%95',
|
||||
title: 'seed-check'
|
||||
}
|
||||
}]
|
||||
}),
|
||||
recordingDir
|
||||
});
|
||||
|
||||
const snapshot = adapter.open('workflow-visual-proof').getSnapshot();
|
||||
const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir });
|
||||
const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8'));
|
||||
|
||||
assert.strictEqual(snapshot.schemaVersion, 'ecc.session.v1');
|
||||
assert.strictEqual(snapshot.adapterId, 'dmux-tmux');
|
||||
assert.strictEqual(snapshot.session.id, 'workflow-visual-proof');
|
||||
assert.strictEqual(snapshot.session.kind, 'orchestrated');
|
||||
assert.strictEqual(snapshot.session.state, 'active');
|
||||
assert.strictEqual(snapshot.session.sourceTarget.type, 'session');
|
||||
assert.strictEqual(snapshot.aggregates.workerCount, 1);
|
||||
assert.strictEqual(snapshot.workers[0].health, 'healthy');
|
||||
assert.strictEqual(snapshot.workers[0].runtime.kind, 'tmux-pane');
|
||||
assert.strictEqual(snapshot.workers[0].outputs.remainingRisks[0], 'No screenshot yet');
|
||||
assert.strictEqual(persisted.latest.session.state, 'active');
|
||||
assert.strictEqual(persisted.latest.adapterId, 'dmux-tmux');
|
||||
assert.strictEqual(persisted.history.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(recordingDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('dmux adapter marks finished sessions as completed and records history', () => {
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-'));
|
||||
|
||||
try {
|
||||
const adapter = createDmuxTmuxAdapter({
|
||||
loadStateStoreImpl: () => null,
|
||||
collectSessionSnapshotImpl: () => ({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof',
|
||||
repoRoot: '/tmp/repo',
|
||||
targetType: 'session',
|
||||
sessionActive: false,
|
||||
paneCount: 0,
|
||||
workerCount: 2,
|
||||
workerStates: { completed: 2 },
|
||||
panes: [],
|
||||
workers: [{
|
||||
workerSlug: 'seed-check',
|
||||
workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check',
|
||||
status: {
|
||||
state: 'completed',
|
||||
updated: '2026-03-13T00:00:00Z',
|
||||
branch: 'feature/seed-check',
|
||||
worktree: '/tmp/worktree-a',
|
||||
taskFile: '/tmp/task-a.md',
|
||||
handoffFile: '/tmp/handoff-a.md'
|
||||
},
|
||||
task: {
|
||||
objective: 'Inspect seeded files.',
|
||||
seedPaths: ['scripts/orchestrate-worktrees.js']
|
||||
},
|
||||
handoff: {
|
||||
summary: ['Finished'],
|
||||
validation: ['Reviewed outputs'],
|
||||
remainingRisks: []
|
||||
},
|
||||
files: {
|
||||
status: '/tmp/status-a.md',
|
||||
task: '/tmp/task-a.md',
|
||||
handoff: '/tmp/handoff-a.md'
|
||||
},
|
||||
pane: null
|
||||
}, {
|
||||
workerSlug: 'proof',
|
||||
workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/proof',
|
||||
status: {
|
||||
state: 'completed',
|
||||
updated: '2026-03-13T00:10:00Z',
|
||||
branch: 'feature/proof',
|
||||
worktree: '/tmp/worktree-b',
|
||||
taskFile: '/tmp/task-b.md',
|
||||
handoffFile: '/tmp/handoff-b.md'
|
||||
},
|
||||
task: {
|
||||
objective: 'Capture proof.',
|
||||
seedPaths: ['README.md']
|
||||
},
|
||||
handoff: {
|
||||
summary: ['Delivered proof'],
|
||||
validation: ['Checked screenshots'],
|
||||
remainingRisks: []
|
||||
},
|
||||
files: {
|
||||
status: '/tmp/status-b.md',
|
||||
task: '/tmp/task-b.md',
|
||||
handoff: '/tmp/handoff-b.md'
|
||||
},
|
||||
pane: null
|
||||
}]
|
||||
}),
|
||||
recordingDir
|
||||
});
|
||||
|
||||
const snapshot = adapter.open('workflow-visual-proof').getSnapshot();
|
||||
const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir });
|
||||
const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8'));
|
||||
|
||||
assert.strictEqual(snapshot.session.state, 'completed');
|
||||
assert.strictEqual(snapshot.aggregates.states.completed, 2);
|
||||
assert.strictEqual(snapshot.workers[0].health, 'healthy');
|
||||
assert.strictEqual(snapshot.workers[1].health, 'healthy');
|
||||
assert.strictEqual(persisted.latest.session.state, 'completed');
|
||||
assert.strictEqual(persisted.history.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(recordingDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fallback recording does not append duplicate history entries for unchanged snapshots', () => {
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-'));
|
||||
|
||||
try {
|
||||
const adapter = createDmuxTmuxAdapter({
|
||||
loadStateStoreImpl: () => null,
|
||||
collectSessionSnapshotImpl: () => ({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof',
|
||||
repoRoot: '/tmp/repo',
|
||||
targetType: 'session',
|
||||
sessionActive: true,
|
||||
paneCount: 1,
|
||||
workerCount: 1,
|
||||
workerStates: { running: 1 },
|
||||
panes: [],
|
||||
workers: [{
|
||||
workerSlug: 'seed-check',
|
||||
workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check',
|
||||
status: {
|
||||
state: 'running',
|
||||
updated: '2026-03-13T00:00:00Z',
|
||||
branch: 'feature/seed-check',
|
||||
worktree: '/tmp/worktree',
|
||||
taskFile: '/tmp/task.md',
|
||||
handoffFile: '/tmp/handoff.md'
|
||||
},
|
||||
task: {
|
||||
objective: 'Inspect seeded files.',
|
||||
seedPaths: ['scripts/orchestrate-worktrees.js']
|
||||
},
|
||||
handoff: {
|
||||
summary: ['Pending'],
|
||||
validation: [],
|
||||
remainingRisks: []
|
||||
},
|
||||
files: {
|
||||
status: '/tmp/status.md',
|
||||
task: '/tmp/task.md',
|
||||
handoff: '/tmp/handoff.md'
|
||||
},
|
||||
pane: null
|
||||
}]
|
||||
}),
|
||||
recordingDir
|
||||
});
|
||||
|
||||
const handle = adapter.open('workflow-visual-proof');
|
||||
const firstSnapshot = handle.getSnapshot();
|
||||
const secondSnapshot = handle.getSnapshot();
|
||||
const recordingPath = getFallbackSessionRecordingPath(firstSnapshot, { recordingDir });
|
||||
const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8'));
|
||||
|
||||
assert.deepStrictEqual(secondSnapshot, firstSnapshot);
|
||||
assert.strictEqual(persisted.history.length, 1);
|
||||
assert.deepStrictEqual(persisted.latest, secondSnapshot);
|
||||
} finally {
|
||||
fs.rmSync(recordingDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('claude-history adapter loads the latest recorded session', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-adapter-home-'));
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const sessionPath = path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp');
|
||||
fs.writeFileSync(sessionPath, [
|
||||
'# Session Review',
|
||||
'',
|
||||
'**Date:** 2026-03-13',
|
||||
'**Started:** 09:00',
|
||||
'**Last Updated:** 11:30',
|
||||
'**Project:** everything-claude-code',
|
||||
'**Branch:** feat/session-adapter',
|
||||
'**Worktree:** /tmp/ecc-worktree',
|
||||
'',
|
||||
'### Completed',
|
||||
'- [x] Build snapshot prototype',
|
||||
'',
|
||||
'### In Progress',
|
||||
'- [ ] Add CLI wrapper',
|
||||
'',
|
||||
'### Notes for Next Session',
|
||||
'Need a second adapter.',
|
||||
'',
|
||||
'### Context to Load',
|
||||
'```',
|
||||
'scripts/lib/orchestration-session.js',
|
||||
'```'
|
||||
].join('\n'));
|
||||
|
||||
try {
|
||||
withHome(homeDir, () => {
|
||||
const adapter = createClaudeHistoryAdapter({
|
||||
loadStateStoreImpl: () => null,
|
||||
recordingDir
|
||||
});
|
||||
const snapshot = adapter.open('claude:latest').getSnapshot();
|
||||
const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir });
|
||||
const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8'));
|
||||
|
||||
assert.strictEqual(snapshot.schemaVersion, 'ecc.session.v1');
|
||||
assert.strictEqual(snapshot.adapterId, 'claude-history');
|
||||
assert.strictEqual(snapshot.session.kind, 'history');
|
||||
assert.strictEqual(snapshot.session.state, 'recorded');
|
||||
assert.strictEqual(snapshot.workers.length, 1);
|
||||
assert.strictEqual(snapshot.workers[0].branch, 'feat/session-adapter');
|
||||
assert.strictEqual(snapshot.workers[0].worktree, '/tmp/ecc-worktree');
|
||||
assert.strictEqual(snapshot.workers[0].runtime.kind, 'claude-session');
|
||||
assert.deepStrictEqual(snapshot.workers[0].intent.seedPaths, ['scripts/lib/orchestration-session.js']);
|
||||
assert.strictEqual(snapshot.workers[0].artifacts.sessionFile, sessionPath);
|
||||
assert.ok(snapshot.workers[0].outputs.summary.includes('Build snapshot prototype'));
|
||||
assert.strictEqual(persisted.latest.adapterId, 'claude-history');
|
||||
assert.strictEqual(persisted.history.length, 1);
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(recordingDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('adapter registry routes plan files to dmux and explicit claude targets to history', () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-registry-repo-'));
|
||||
const planPath = path.join(repoRoot, 'workflow.json');
|
||||
fs.writeFileSync(planPath, JSON.stringify({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
repoRoot,
|
||||
coordinationRoot: path.join(repoRoot, '.claude', 'orchestration')
|
||||
}));
|
||||
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-registry-home-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-z9y8x7w6-session.tmp'),
|
||||
'# History Session\n\n**Branch:** feat/history\n'
|
||||
);
|
||||
|
||||
try {
|
||||
withHome(homeDir, () => {
|
||||
const registry = createAdapterRegistry({
|
||||
adapters: [
|
||||
createDmuxTmuxAdapter({
|
||||
loadStateStoreImpl: () => null,
|
||||
collectSessionSnapshotImpl: () => ({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
coordinationDir: path.join(repoRoot, '.claude', 'orchestration', 'workflow-visual-proof'),
|
||||
repoRoot,
|
||||
targetType: 'plan',
|
||||
sessionActive: false,
|
||||
paneCount: 0,
|
||||
workerCount: 0,
|
||||
workerStates: {},
|
||||
panes: [],
|
||||
workers: []
|
||||
})
|
||||
}),
|
||||
createClaudeHistoryAdapter({ loadStateStoreImpl: () => null })
|
||||
]
|
||||
});
|
||||
|
||||
const dmuxSnapshot = registry.open(planPath, { cwd: repoRoot }).getSnapshot();
|
||||
const claudeSnapshot = registry.open('claude:latest', { cwd: repoRoot }).getSnapshot();
|
||||
|
||||
assert.strictEqual(dmuxSnapshot.adapterId, 'dmux-tmux');
|
||||
assert.strictEqual(claudeSnapshot.adapterId, 'claude-history');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('adapter registry resolves structured target types into the correct adapter', () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-typed-repo-'));
|
||||
const planPath = path.join(repoRoot, 'workflow.json');
|
||||
fs.writeFileSync(planPath, JSON.stringify({
|
||||
sessionName: 'workflow-typed-proof',
|
||||
repoRoot,
|
||||
coordinationRoot: path.join(repoRoot, '.claude', 'orchestration')
|
||||
}));
|
||||
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-typed-home-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-z9y8x7w6-session.tmp'),
|
||||
'# Typed History Session\n\n**Branch:** feat/typed-targets\n'
|
||||
);
|
||||
|
||||
try {
|
||||
withHome(homeDir, () => {
|
||||
const registry = createAdapterRegistry({
|
||||
adapters: [
|
||||
createDmuxTmuxAdapter({
|
||||
loadStateStoreImpl: () => null,
|
||||
collectSessionSnapshotImpl: () => ({
|
||||
sessionName: 'workflow-typed-proof',
|
||||
coordinationDir: path.join(repoRoot, '.claude', 'orchestration', 'workflow-typed-proof'),
|
||||
repoRoot,
|
||||
targetType: 'plan',
|
||||
sessionActive: true,
|
||||
paneCount: 0,
|
||||
workerCount: 0,
|
||||
workerStates: {},
|
||||
panes: [],
|
||||
workers: []
|
||||
})
|
||||
}),
|
||||
createClaudeHistoryAdapter({ loadStateStoreImpl: () => null })
|
||||
]
|
||||
});
|
||||
|
||||
const dmuxSnapshot = registry.open({ type: 'plan', value: planPath }, { cwd: repoRoot }).getSnapshot();
|
||||
const claudeSnapshot = registry.open({ type: 'claude-history', value: 'latest' }, { cwd: repoRoot }).getSnapshot();
|
||||
|
||||
assert.strictEqual(dmuxSnapshot.adapterId, 'dmux-tmux');
|
||||
assert.strictEqual(dmuxSnapshot.session.sourceTarget.type, 'plan');
|
||||
assert.strictEqual(claudeSnapshot.adapterId, 'claude-history');
|
||||
assert.strictEqual(claudeSnapshot.session.sourceTarget.type, 'claude-history');
|
||||
assert.strictEqual(claudeSnapshot.workers[0].branch, 'feat/typed-targets');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('default registry forwards a nested state-store writer to adapters', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-registry-home-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-z9y8x7w6-session.tmp'),
|
||||
'# History Session\n\n**Branch:** feat/history\n'
|
||||
);
|
||||
|
||||
const stateStore = {
|
||||
sessions: {
|
||||
persisted: [],
|
||||
persistCanonicalSessionSnapshot(snapshot, metadata) {
|
||||
this.persisted.push({ snapshot, metadata });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
withHome(homeDir, () => {
|
||||
const snapshot = inspectSessionTarget('claude:latest', {
|
||||
cwd: process.cwd(),
|
||||
stateStore
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.adapterId, 'claude-history');
|
||||
assert.strictEqual(stateStore.sessions.persisted.length, 1);
|
||||
assert.strictEqual(stateStore.sessions.persisted[0].snapshot.adapterId, 'claude-history');
|
||||
assert.strictEqual(stateStore.sessions.persisted[0].metadata.sessionId, snapshot.session.id);
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('adapter registry lists adapter metadata and target types', () => {
|
||||
const registry = createAdapterRegistry();
|
||||
const adapters = registry.listAdapters();
|
||||
const ids = adapters.map(adapter => adapter.id);
|
||||
|
||||
assert.ok(ids.includes('claude-history'));
|
||||
assert.ok(ids.includes('dmux-tmux'));
|
||||
assert.ok(
|
||||
adapters.some(adapter => adapter.id === 'claude-history' && adapter.targetTypes.includes('claude-history')),
|
||||
'claude-history should advertise its canonical target type'
|
||||
);
|
||||
assert.ok(
|
||||
adapters.some(adapter => adapter.id === 'dmux-tmux' && adapter.targetTypes.includes('plan')),
|
||||
'dmux-tmux should advertise plan targets'
|
||||
);
|
||||
});
|
||||
|
||||
test('persistence only falls back when the state-store module is missing', () => {
|
||||
const snapshot = {
|
||||
schemaVersion: 'ecc.session.v1',
|
||||
adapterId: 'claude-history',
|
||||
session: {
|
||||
id: 'a1b2c3d4',
|
||||
kind: 'history',
|
||||
state: 'recorded',
|
||||
repoRoot: null,
|
||||
sourceTarget: {
|
||||
type: 'claude-history',
|
||||
value: 'latest'
|
||||
}
|
||||
},
|
||||
workers: [{
|
||||
id: 'a1b2c3d4',
|
||||
label: 'Session Review',
|
||||
state: 'recorded',
|
||||
health: 'healthy',
|
||||
branch: null,
|
||||
worktree: null,
|
||||
runtime: {
|
||||
kind: 'claude-session',
|
||||
command: 'claude',
|
||||
pid: null,
|
||||
active: false,
|
||||
dead: true
|
||||
},
|
||||
intent: {
|
||||
objective: 'Session Review',
|
||||
seedPaths: []
|
||||
},
|
||||
outputs: {
|
||||
summary: [],
|
||||
validation: [],
|
||||
remainingRisks: []
|
||||
},
|
||||
artifacts: {
|
||||
sessionFile: '/tmp/session.tmp',
|
||||
context: null
|
||||
}
|
||||
}],
|
||||
aggregates: {
|
||||
workerCount: 1,
|
||||
states: {
|
||||
recorded: 1
|
||||
},
|
||||
healths: {
|
||||
healthy: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadError = new Error('state-store bootstrap failed');
|
||||
loadError.code = 'ERR_STATE_STORE_BOOT';
|
||||
|
||||
assert.throws(() => {
|
||||
persistCanonicalSnapshot(snapshot, {
|
||||
loadStateStoreImpl() {
|
||||
throw loadError;
|
||||
}
|
||||
});
|
||||
}, /state-store bootstrap failed/);
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
1829
.agent/knowledge/everything_claude/tests/lib/session-aliases.test.js
Normal file
1829
.agent/knowledge/everything_claude/tests/lib/session-aliases.test.js
Normal file
File diff suppressed because it is too large
Load Diff
2650
.agent/knowledge/everything_claude/tests/lib/session-manager.test.js
Normal file
2650
.agent/knowledge/everything_claude/tests/lib/session-manager.test.js
Normal file
File diff suppressed because it is too large
Load Diff
114
.agent/knowledge/everything_claude/tests/lib/shell-split.test.js
Normal file
114
.agent/knowledge/everything_claude/tests/lib/shell-split.test.js
Normal file
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const { splitShellSegments } = require('../../scripts/lib/shell-split');
|
||||
|
||||
console.log('=== Testing shell-split.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${desc}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Basic operators
|
||||
console.log('Basic operators:');
|
||||
test('&& splits into two segments', () => {
|
||||
assert.deepStrictEqual(splitShellSegments('echo hi && echo bye'), ['echo hi', 'echo bye']);
|
||||
});
|
||||
test('|| splits into two segments', () => {
|
||||
assert.deepStrictEqual(splitShellSegments('echo hi || echo bye'), ['echo hi', 'echo bye']);
|
||||
});
|
||||
test('; splits into two segments', () => {
|
||||
assert.deepStrictEqual(splitShellSegments('echo hi; echo bye'), ['echo hi', 'echo bye']);
|
||||
});
|
||||
test('single & splits (background)', () => {
|
||||
assert.deepStrictEqual(splitShellSegments('sleep 1 & echo hi'), ['sleep 1', 'echo hi']);
|
||||
});
|
||||
|
||||
// Redirection operators should NOT split
|
||||
console.log('\nRedirection operators (should NOT split):');
|
||||
test('2>&1 stays as one segment', () => {
|
||||
const segs = splitShellSegments('cmd 2>&1 | grep error');
|
||||
assert.strictEqual(segs.length, 1);
|
||||
});
|
||||
test('&> stays as one segment', () => {
|
||||
const segs = splitShellSegments('cmd &> /dev/null');
|
||||
assert.strictEqual(segs.length, 1);
|
||||
});
|
||||
test('>& stays as one segment', () => {
|
||||
const segs = splitShellSegments('cmd >& /dev/null');
|
||||
assert.strictEqual(segs.length, 1);
|
||||
});
|
||||
|
||||
// Quoting
|
||||
console.log('\nQuoting:');
|
||||
test('double-quoted && not split', () => {
|
||||
const segs = splitShellSegments('tmux new -d "cd /app && echo hi"');
|
||||
assert.strictEqual(segs.length, 1);
|
||||
});
|
||||
test('single-quoted && not split', () => {
|
||||
const segs = splitShellSegments("tmux new -d 'cd /app && echo hi'");
|
||||
assert.strictEqual(segs.length, 1);
|
||||
});
|
||||
test('double-quoted ; not split', () => {
|
||||
const segs = splitShellSegments('echo "hello; world"');
|
||||
assert.strictEqual(segs.length, 1);
|
||||
});
|
||||
|
||||
// Escaped quotes
|
||||
console.log('\nEscaped quotes:');
|
||||
test('escaped double quote inside double quotes', () => {
|
||||
const segs = splitShellSegments('echo "hello \\"world\\"" && echo bye');
|
||||
assert.strictEqual(segs.length, 2);
|
||||
});
|
||||
test('escaped single quote inside single quotes', () => {
|
||||
const segs = splitShellSegments("echo 'hello \\'world\\'' && echo bye");
|
||||
assert.strictEqual(segs.length, 2);
|
||||
});
|
||||
|
||||
// Escaped operators outside quotes
|
||||
console.log('\nEscaped operators outside quotes:');
|
||||
test('escaped && outside quotes not split', () => {
|
||||
const segs = splitShellSegments('tmux new-session -d bash -lc cd /app \\&\\& npm run dev');
|
||||
assert.strictEqual(segs.length, 1);
|
||||
});
|
||||
test('escaped ; outside quotes not split', () => {
|
||||
const segs = splitShellSegments('echo hello \\; echo bye');
|
||||
assert.strictEqual(segs.length, 1);
|
||||
});
|
||||
|
||||
// Complex real-world cases
|
||||
console.log('\nReal-world cases:');
|
||||
test('tmux new-session with quoted compound command', () => {
|
||||
const segs = splitShellSegments('tmux new-session -d -s dev "cd /app && npm run dev"');
|
||||
assert.strictEqual(segs.length, 1);
|
||||
assert.ok(segs[0].includes('tmux'));
|
||||
assert.ok(segs[0].includes('npm run dev'));
|
||||
});
|
||||
test('chained: tmux ls then bare dev', () => {
|
||||
const segs = splitShellSegments('tmux ls; npm run dev');
|
||||
assert.strictEqual(segs.length, 2);
|
||||
assert.strictEqual(segs[1], 'npm run dev');
|
||||
});
|
||||
test('background dev server', () => {
|
||||
const segs = splitShellSegments('npm run dev & echo started');
|
||||
assert.strictEqual(segs.length, 2);
|
||||
assert.strictEqual(segs[0], 'npm run dev');
|
||||
});
|
||||
test('empty string returns empty array', () => {
|
||||
assert.deepStrictEqual(splitShellSegments(''), []);
|
||||
});
|
||||
test('single command no operators', () => {
|
||||
assert.deepStrictEqual(splitShellSegments('npm run dev'), ['npm run dev']);
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Tests for skill health dashboard.
|
||||
*
|
||||
* Run with: node tests/lib/skill-dashboard.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const dashboard = require('../../scripts/lib/skill-evolution/dashboard');
|
||||
const versioning = require('../../scripts/lib/skill-evolution/versioning');
|
||||
const _provenance = require('../../scripts/lib/skill-evolution/provenance');
|
||||
|
||||
const HEALTH_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'skills-health.js');
|
||||
|
||||
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 createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanupTempDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function createSkill(skillRoot, name, content) {
|
||||
const skillDir = path.join(skillRoot, name);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
function appendJsonl(filePath, rows) {
|
||||
const lines = rows.map(row => JSON.stringify(row)).join('\n');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${lines}\n`);
|
||||
}
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [HEALTH_SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing skill dashboard ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const repoRoot = createTempDir('skill-dashboard-repo-');
|
||||
const homeDir = createTempDir('skill-dashboard-home-');
|
||||
const skillsRoot = path.join(repoRoot, 'skills');
|
||||
const learnedRoot = path.join(homeDir, '.claude', 'skills', 'learned');
|
||||
const importedRoot = path.join(homeDir, '.claude', 'skills', 'imported');
|
||||
const runsFile = path.join(homeDir, '.claude', 'state', 'skill-runs.jsonl');
|
||||
const now = '2026-03-15T12:00:00.000Z';
|
||||
|
||||
fs.mkdirSync(skillsRoot, { recursive: true });
|
||||
fs.mkdirSync(learnedRoot, { recursive: true });
|
||||
fs.mkdirSync(importedRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
console.log('Chart primitives:');
|
||||
|
||||
if (test('sparkline maps float values to Unicode block characters', () => {
|
||||
const result = dashboard.sparkline([1, 0.5, 0]);
|
||||
assert.strictEqual(result.length, 3);
|
||||
assert.strictEqual(result[0], '\u2588');
|
||||
assert.strictEqual(result[2], '\u2581');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sparkline returns empty string for empty array', () => {
|
||||
assert.strictEqual(dashboard.sparkline([]), '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sparkline renders null values as empty block', () => {
|
||||
const result = dashboard.sparkline([null, 0.5, null]);
|
||||
assert.strictEqual(result[0], '\u2591');
|
||||
assert.strictEqual(result[2], '\u2591');
|
||||
assert.strictEqual(result.length, 3);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('horizontalBar renders correct fill ratio', () => {
|
||||
const result = dashboard.horizontalBar(5, 10, 10);
|
||||
const filled = (result.match(/\u2588/g) || []).length;
|
||||
const empty = (result.match(/\u2591/g) || []).length;
|
||||
assert.strictEqual(filled, 5);
|
||||
assert.strictEqual(empty, 5);
|
||||
assert.strictEqual(result.length, 10);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('horizontalBar handles zero value', () => {
|
||||
const result = dashboard.horizontalBar(0, 10, 10);
|
||||
const filled = (result.match(/\u2588/g) || []).length;
|
||||
assert.strictEqual(filled, 0);
|
||||
assert.strictEqual(result.length, 10);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('panelBox renders box-drawing characters with title', () => {
|
||||
const result = dashboard.panelBox('Test Panel', ['line one', 'line two'], 30);
|
||||
assert.match(result, /\u250C/);
|
||||
assert.match(result, /\u2510/);
|
||||
assert.match(result, /\u2514/);
|
||||
assert.match(result, /\u2518/);
|
||||
assert.match(result, /Test Panel/);
|
||||
assert.match(result, /line one/);
|
||||
assert.match(result, /line two/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nTime-series bucketing:');
|
||||
|
||||
if (test('bucketByDay groups records into daily bins', () => {
|
||||
const nowMs = Date.parse(now);
|
||||
const records = [
|
||||
{ skill_id: 'alpha', outcome: 'success', recorded_at: '2026-03-15T10:00:00.000Z' },
|
||||
{ skill_id: 'alpha', outcome: 'failure', recorded_at: '2026-03-15T08:00:00.000Z' },
|
||||
{ skill_id: 'alpha', outcome: 'success', recorded_at: '2026-03-14T10:00:00.000Z' },
|
||||
];
|
||||
|
||||
const buckets = dashboard.bucketByDay(records, nowMs, 3);
|
||||
assert.strictEqual(buckets.length, 3);
|
||||
const todayBucket = buckets[buckets.length - 1];
|
||||
assert.strictEqual(todayBucket.runs, 2);
|
||||
assert.strictEqual(todayBucket.rate, 0.5);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('bucketByDay returns null rate for empty days', () => {
|
||||
const nowMs = Date.parse(now);
|
||||
const buckets = dashboard.bucketByDay([], nowMs, 5);
|
||||
assert.strictEqual(buckets.length, 5);
|
||||
for (const bucket of buckets) {
|
||||
assert.strictEqual(bucket.rate, null);
|
||||
assert.strictEqual(bucket.runs, 0);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nPanel renderers:');
|
||||
|
||||
const alphaSkillDir = createSkill(skillsRoot, 'alpha', '# Alpha\n');
|
||||
const betaSkillDir = createSkill(learnedRoot, 'beta', '# Beta\n');
|
||||
|
||||
versioning.createVersion(alphaSkillDir, {
|
||||
timestamp: '2026-03-14T11:00:00.000Z',
|
||||
author: 'observer',
|
||||
reason: 'bootstrap',
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(alphaSkillDir, 'SKILL.md'), '# Alpha v2\n');
|
||||
versioning.createVersion(alphaSkillDir, {
|
||||
timestamp: '2026-03-15T11:00:00.000Z',
|
||||
author: 'observer',
|
||||
reason: 'accepted-amendment',
|
||||
});
|
||||
|
||||
versioning.createVersion(betaSkillDir, {
|
||||
timestamp: '2026-03-14T11:00:00.000Z',
|
||||
author: 'observer',
|
||||
reason: 'bootstrap',
|
||||
});
|
||||
|
||||
const { appendFile } = require('../../scripts/lib/utils');
|
||||
const alphaAmendmentsPath = path.join(alphaSkillDir, '.evolution', 'amendments.jsonl');
|
||||
appendFile(alphaAmendmentsPath, JSON.stringify({
|
||||
event: 'proposal',
|
||||
status: 'pending',
|
||||
created_at: '2026-03-15T07:00:00.000Z',
|
||||
}) + '\n');
|
||||
|
||||
appendJsonl(runsFile, [
|
||||
{
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v2',
|
||||
task_description: 'Success task',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 100,
|
||||
duration_ms: 1000,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-14T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v2',
|
||||
task_description: 'Failed task',
|
||||
outcome: 'failure',
|
||||
failure_reason: 'Regression',
|
||||
tokens_used: 100,
|
||||
duration_ms: 1000,
|
||||
user_feedback: 'rejected',
|
||||
recorded_at: '2026-03-13T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v1',
|
||||
task_description: 'Older success',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 100,
|
||||
duration_ms: 1000,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-02-20T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'beta',
|
||||
skill_version: 'v1',
|
||||
task_description: 'Beta success',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 90,
|
||||
duration_ms: 800,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-15T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'beta',
|
||||
skill_version: 'v1',
|
||||
task_description: 'Beta failure',
|
||||
outcome: 'failure',
|
||||
failure_reason: 'Bad import',
|
||||
tokens_used: 90,
|
||||
duration_ms: 800,
|
||||
user_feedback: 'corrected',
|
||||
recorded_at: '2026-02-20T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const testRecords = [
|
||||
{ skill_id: 'alpha', outcome: 'success', failure_reason: null, recorded_at: '2026-03-14T10:00:00.000Z' },
|
||||
{ skill_id: 'alpha', outcome: 'failure', failure_reason: 'Regression', recorded_at: '2026-03-13T10:00:00.000Z' },
|
||||
{ skill_id: 'alpha', outcome: 'success', failure_reason: null, recorded_at: '2026-02-20T10:00:00.000Z' },
|
||||
{ skill_id: 'beta', outcome: 'success', failure_reason: null, recorded_at: '2026-03-15T09:00:00.000Z' },
|
||||
{ skill_id: 'beta', outcome: 'failure', failure_reason: 'Bad import', recorded_at: '2026-02-20T09:00:00.000Z' },
|
||||
];
|
||||
|
||||
if (test('renderSuccessRatePanel produces one row per skill with sparklines', () => {
|
||||
const skills = [{ skill_id: 'alpha' }, { skill_id: 'beta' }];
|
||||
const result = dashboard.renderSuccessRatePanel(testRecords, skills, { now });
|
||||
|
||||
assert.ok(result.text.includes('Success Rate'));
|
||||
assert.ok(result.data.skills.length >= 2);
|
||||
|
||||
const alpha = result.data.skills.find(s => s.skill_id === 'alpha');
|
||||
assert.ok(alpha);
|
||||
assert.ok(Array.isArray(alpha.daily_rates));
|
||||
assert.strictEqual(alpha.daily_rates.length, 30);
|
||||
assert.ok(typeof alpha.sparkline === 'string');
|
||||
assert.ok(alpha.sparkline.length > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renderFailureClusterPanel groups failures by reason', () => {
|
||||
const failureRecords = [
|
||||
{ skill_id: 'alpha', outcome: 'failure', failure_reason: 'Regression' },
|
||||
{ skill_id: 'alpha', outcome: 'failure', failure_reason: 'Regression' },
|
||||
{ skill_id: 'beta', outcome: 'failure', failure_reason: 'Bad import' },
|
||||
{ skill_id: 'alpha', outcome: 'success', failure_reason: null },
|
||||
];
|
||||
|
||||
const result = dashboard.renderFailureClusterPanel(failureRecords);
|
||||
assert.ok(result.text.includes('Failure Patterns'));
|
||||
assert.strictEqual(result.data.clusters.length, 2);
|
||||
assert.strictEqual(result.data.clusters[0].pattern, 'regression');
|
||||
assert.strictEqual(result.data.clusters[0].count, 2);
|
||||
assert.strictEqual(result.data.total_failures, 3);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renderAmendmentPanel lists pending amendments', () => {
|
||||
const skillsById = new Map();
|
||||
skillsById.set('alpha', { skill_id: 'alpha', skill_dir: alphaSkillDir });
|
||||
|
||||
const result = dashboard.renderAmendmentPanel(skillsById);
|
||||
assert.ok(result.text.includes('Pending Amendments'));
|
||||
assert.ok(result.data.total >= 1);
|
||||
assert.ok(result.data.amendments.some(a => a.skill_id === 'alpha'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renderVersionTimelinePanel shows version history', () => {
|
||||
const skillsById = new Map();
|
||||
skillsById.set('alpha', { skill_id: 'alpha', skill_dir: alphaSkillDir });
|
||||
skillsById.set('beta', { skill_id: 'beta', skill_dir: betaSkillDir });
|
||||
|
||||
const result = dashboard.renderVersionTimelinePanel(skillsById);
|
||||
assert.ok(result.text.includes('Version History'));
|
||||
assert.ok(result.data.skills.length >= 1);
|
||||
|
||||
const alphaVersions = result.data.skills.find(s => s.skill_id === 'alpha');
|
||||
assert.ok(alphaVersions);
|
||||
assert.ok(alphaVersions.versions.length >= 2);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nFull dashboard:');
|
||||
|
||||
if (test('renderDashboard produces all four panels', () => {
|
||||
const result = dashboard.renderDashboard({
|
||||
skillsRoot,
|
||||
learnedRoot,
|
||||
importedRoot,
|
||||
homeDir,
|
||||
runsFilePath: runsFile,
|
||||
now,
|
||||
warnThreshold: 0.1,
|
||||
});
|
||||
|
||||
assert.ok(result.text.includes('ECC Skill Health Dashboard'));
|
||||
assert.ok(result.text.includes('Success Rate'));
|
||||
assert.ok(result.text.includes('Failure Patterns'));
|
||||
assert.ok(result.text.includes('Pending Amendments'));
|
||||
assert.ok(result.text.includes('Version History'));
|
||||
assert.ok(result.data.generated_at === now);
|
||||
assert.ok(result.data.summary);
|
||||
assert.ok(result.data.panels['success-rate']);
|
||||
assert.ok(result.data.panels['failures']);
|
||||
assert.ok(result.data.panels['amendments']);
|
||||
assert.ok(result.data.panels['versions']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renderDashboard supports single panel selection', () => {
|
||||
const result = dashboard.renderDashboard({
|
||||
skillsRoot,
|
||||
learnedRoot,
|
||||
importedRoot,
|
||||
homeDir,
|
||||
runsFilePath: runsFile,
|
||||
now,
|
||||
panel: 'failures',
|
||||
});
|
||||
|
||||
assert.ok(result.text.includes('Failure Patterns'));
|
||||
assert.ok(!result.text.includes('Version History'));
|
||||
assert.ok(result.data.panels['failures']);
|
||||
assert.ok(!result.data.panels['versions']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renderDashboard rejects unknown panel names', () => {
|
||||
assert.throws(() => {
|
||||
dashboard.renderDashboard({
|
||||
skillsRoot,
|
||||
learnedRoot,
|
||||
importedRoot,
|
||||
homeDir,
|
||||
runsFilePath: runsFile,
|
||||
now,
|
||||
panel: 'nonexistent',
|
||||
});
|
||||
}, /Unknown panel/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nCLI integration:');
|
||||
|
||||
if (test('CLI --dashboard --json returns valid JSON with all panels', () => {
|
||||
const result = runCli([
|
||||
'--dashboard',
|
||||
'--json',
|
||||
'--skills-root', skillsRoot,
|
||||
'--learned-root', learnedRoot,
|
||||
'--imported-root', importedRoot,
|
||||
'--home', homeDir,
|
||||
'--runs-file', runsFile,
|
||||
'--now', now,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout.trim());
|
||||
assert.ok(payload.panels);
|
||||
assert.ok(payload.panels['success-rate']);
|
||||
assert.ok(payload.panels['failures']);
|
||||
assert.ok(payload.summary);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI --panel failures --json returns only the failures panel', () => {
|
||||
const result = runCli([
|
||||
'--dashboard',
|
||||
'--panel', 'failures',
|
||||
'--json',
|
||||
'--skills-root', skillsRoot,
|
||||
'--learned-root', learnedRoot,
|
||||
'--imported-root', importedRoot,
|
||||
'--home', homeDir,
|
||||
'--runs-file', runsFile,
|
||||
'--now', now,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout.trim());
|
||||
assert.ok(payload.panels['failures']);
|
||||
assert.ok(!payload.panels['versions']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI --help mentions --dashboard', () => {
|
||||
const result = runCli(['--help']);
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.match(result.stdout, /--dashboard/);
|
||||
assert.match(result.stdout, /--panel/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nEdge cases:');
|
||||
|
||||
if (test('dashboard renders gracefully with no execution records', () => {
|
||||
const emptyRunsFile = path.join(homeDir, '.claude', 'state', 'empty-runs.jsonl');
|
||||
fs.mkdirSync(path.dirname(emptyRunsFile), { recursive: true });
|
||||
fs.writeFileSync(emptyRunsFile, '', 'utf8');
|
||||
|
||||
const emptySkillsRoot = path.join(repoRoot, 'empty-skills');
|
||||
fs.mkdirSync(emptySkillsRoot, { recursive: true });
|
||||
|
||||
const result = dashboard.renderDashboard({
|
||||
skillsRoot: emptySkillsRoot,
|
||||
learnedRoot: path.join(homeDir, '.claude', 'skills', 'empty-learned'),
|
||||
importedRoot: path.join(homeDir, '.claude', 'skills', 'empty-imported'),
|
||||
homeDir,
|
||||
runsFilePath: emptyRunsFile,
|
||||
now,
|
||||
});
|
||||
|
||||
assert.ok(result.text.includes('ECC Skill Health Dashboard'));
|
||||
assert.ok(result.text.includes('No failure patterns detected'));
|
||||
assert.strictEqual(result.data.summary.total_skills, 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('failure cluster panel handles all successes', () => {
|
||||
const successRecords = [
|
||||
{ skill_id: 'alpha', outcome: 'success', failure_reason: null },
|
||||
{ skill_id: 'beta', outcome: 'success', failure_reason: null },
|
||||
];
|
||||
|
||||
const result = dashboard.renderFailureClusterPanel(successRecords);
|
||||
assert.strictEqual(result.data.clusters.length, 0);
|
||||
assert.strictEqual(result.data.total_failures, 0);
|
||||
assert.ok(result.text.includes('No failure patterns detected'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
} finally {
|
||||
cleanupTempDir(repoRoot);
|
||||
cleanupTempDir(homeDir);
|
||||
}
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Tests for skill evolution helpers.
|
||||
*
|
||||
* Run with: node tests/lib/skill-evolution.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const provenance = require('../../scripts/lib/skill-evolution/provenance');
|
||||
const versioning = require('../../scripts/lib/skill-evolution/versioning');
|
||||
const tracker = require('../../scripts/lib/skill-evolution/tracker');
|
||||
const health = require('../../scripts/lib/skill-evolution/health');
|
||||
const skillEvolution = require('../../scripts/lib/skill-evolution');
|
||||
|
||||
const HEALTH_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'skills-health.js');
|
||||
|
||||
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 createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanupTempDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function createSkill(skillRoot, name, content) {
|
||||
const skillDir = path.join(skillRoot, name);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
function appendJsonl(filePath, rows) {
|
||||
const lines = rows.map(row => JSON.stringify(row)).join('\n');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${lines}\n`);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function runCli(args, options = {}) {
|
||||
return spawnSync(process.execPath, [HEALTH_SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.env || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing skill evolution ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const repoRoot = createTempDir('skill-evolution-repo-');
|
||||
const homeDir = createTempDir('skill-evolution-home-');
|
||||
const skillsRoot = path.join(repoRoot, 'skills');
|
||||
const learnedRoot = path.join(homeDir, '.claude', 'skills', 'learned');
|
||||
const importedRoot = path.join(homeDir, '.claude', 'skills', 'imported');
|
||||
const runsFile = path.join(homeDir, '.claude', 'state', 'skill-runs.jsonl');
|
||||
const now = '2026-03-15T12:00:00.000Z';
|
||||
|
||||
fs.mkdirSync(skillsRoot, { recursive: true });
|
||||
fs.mkdirSync(learnedRoot, { recursive: true });
|
||||
fs.mkdirSync(importedRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
console.log('Provenance:');
|
||||
|
||||
if (test('classifies curated, learned, and imported skill directories', () => {
|
||||
const curatedSkillDir = createSkill(skillsRoot, 'curated-alpha', '# Curated\n');
|
||||
const learnedSkillDir = createSkill(learnedRoot, 'learned-beta', '# Learned\n');
|
||||
const importedSkillDir = createSkill(importedRoot, 'imported-gamma', '# Imported\n');
|
||||
|
||||
const roots = provenance.getSkillRoots({ repoRoot, homeDir });
|
||||
|
||||
assert.strictEqual(roots.curated, skillsRoot);
|
||||
assert.strictEqual(roots.learned, learnedRoot);
|
||||
assert.strictEqual(roots.imported, importedRoot);
|
||||
assert.strictEqual(
|
||||
provenance.classifySkillPath(curatedSkillDir, { repoRoot, homeDir }),
|
||||
provenance.SKILL_TYPES.CURATED
|
||||
);
|
||||
assert.strictEqual(
|
||||
provenance.classifySkillPath(learnedSkillDir, { repoRoot, homeDir }),
|
||||
provenance.SKILL_TYPES.LEARNED
|
||||
);
|
||||
assert.strictEqual(
|
||||
provenance.classifySkillPath(importedSkillDir, { repoRoot, homeDir }),
|
||||
provenance.SKILL_TYPES.IMPORTED
|
||||
);
|
||||
assert.strictEqual(
|
||||
provenance.requiresProvenance(curatedSkillDir, { repoRoot, homeDir }),
|
||||
false
|
||||
);
|
||||
assert.strictEqual(
|
||||
provenance.requiresProvenance(learnedSkillDir, { repoRoot, homeDir }),
|
||||
true
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('writes and validates provenance metadata for non-curated skills', () => {
|
||||
const importedSkillDir = createSkill(importedRoot, 'imported-delta', '# Imported\n');
|
||||
const provenanceRecord = {
|
||||
source: 'https://example.com/skills/imported-delta',
|
||||
created_at: '2026-03-15T10:00:00.000Z',
|
||||
confidence: 0.86,
|
||||
author: 'external-importer',
|
||||
};
|
||||
|
||||
const writeResult = provenance.writeProvenance(importedSkillDir, provenanceRecord, {
|
||||
repoRoot,
|
||||
homeDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(writeResult.path, path.join(importedSkillDir, '.provenance.json'));
|
||||
assert.deepStrictEqual(readJson(writeResult.path), provenanceRecord);
|
||||
assert.deepStrictEqual(
|
||||
provenance.readProvenance(importedSkillDir, { repoRoot, homeDir }),
|
||||
provenanceRecord
|
||||
);
|
||||
assert.throws(
|
||||
() => provenance.writeProvenance(importedSkillDir, {
|
||||
source: 'bad',
|
||||
created_at: '2026-03-15T10:00:00.000Z',
|
||||
author: 'external-importer',
|
||||
}, { repoRoot, homeDir }),
|
||||
/confidence/
|
||||
);
|
||||
assert.throws(
|
||||
() => provenance.readProvenance(path.join(learnedRoot, 'missing-provenance'), {
|
||||
repoRoot,
|
||||
homeDir,
|
||||
required: true,
|
||||
}),
|
||||
/Missing provenance metadata/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exports the consolidated module surface from index.js', () => {
|
||||
assert.strictEqual(skillEvolution.provenance, provenance);
|
||||
assert.strictEqual(skillEvolution.versioning, versioning);
|
||||
assert.strictEqual(skillEvolution.tracker, tracker);
|
||||
assert.strictEqual(skillEvolution.health, health);
|
||||
assert.strictEqual(typeof skillEvolution.collectSkillHealth, 'function');
|
||||
assert.strictEqual(typeof skillEvolution.recordSkillExecution, 'function');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nVersioning:');
|
||||
|
||||
if (test('creates version snapshots and evolution logs for a skill', () => {
|
||||
const skillDir = createSkill(skillsRoot, 'alpha', '# Alpha v1\n');
|
||||
|
||||
const versionOne = versioning.createVersion(skillDir, {
|
||||
timestamp: '2026-03-15T11:00:00.000Z',
|
||||
reason: 'bootstrap',
|
||||
author: 'observer',
|
||||
});
|
||||
|
||||
assert.strictEqual(versionOne.version, 1);
|
||||
assert.ok(fs.existsSync(path.join(skillDir, '.versions', 'v1.md')));
|
||||
assert.ok(fs.existsSync(path.join(skillDir, '.evolution', 'observations.jsonl')));
|
||||
assert.ok(fs.existsSync(path.join(skillDir, '.evolution', 'inspections.jsonl')));
|
||||
assert.ok(fs.existsSync(path.join(skillDir, '.evolution', 'amendments.jsonl')));
|
||||
assert.strictEqual(versioning.getCurrentVersion(skillDir), 1);
|
||||
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Alpha v2\n');
|
||||
const versionTwo = versioning.createVersion(skillDir, {
|
||||
timestamp: '2026-03-16T11:00:00.000Z',
|
||||
reason: 'accepted-amendment',
|
||||
author: 'observer',
|
||||
});
|
||||
|
||||
assert.strictEqual(versionTwo.version, 2);
|
||||
assert.deepStrictEqual(
|
||||
versioning.listVersions(skillDir).map(entry => entry.version),
|
||||
[1, 2]
|
||||
);
|
||||
|
||||
const amendments = versioning.getEvolutionLog(skillDir, 'amendments');
|
||||
assert.strictEqual(amendments.length, 2);
|
||||
assert.strictEqual(amendments[0].event, 'snapshot');
|
||||
assert.strictEqual(amendments[1].version, 2);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rolls back to a previous snapshot without losing history', () => {
|
||||
const skillDir = path.join(skillsRoot, 'alpha');
|
||||
|
||||
const rollback = versioning.rollbackTo(skillDir, 1, {
|
||||
timestamp: '2026-03-17T11:00:00.000Z',
|
||||
author: 'maintainer',
|
||||
reason: 'restore known-good version',
|
||||
});
|
||||
|
||||
assert.strictEqual(rollback.version, 3);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'),
|
||||
'# Alpha v1\n'
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
versioning.listVersions(skillDir).map(entry => entry.version),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert.strictEqual(versioning.getCurrentVersion(skillDir), 3);
|
||||
|
||||
const amendments = versioning.getEvolutionLog(skillDir, 'amendments');
|
||||
const rollbackEntry = amendments[amendments.length - 1];
|
||||
assert.strictEqual(rollbackEntry.event, 'rollback');
|
||||
assert.strictEqual(rollbackEntry.target_version, 1);
|
||||
assert.strictEqual(rollbackEntry.version, 3);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nTracking:');
|
||||
|
||||
if (test('records skill execution rows to JSONL fallback storage', () => {
|
||||
const result = tracker.recordSkillExecution({
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v3',
|
||||
task_description: 'Fix flaky tests',
|
||||
outcome: 'partial',
|
||||
failure_reason: 'One integration test still flakes',
|
||||
tokens_used: 812,
|
||||
duration_ms: 4400,
|
||||
user_feedback: 'corrected',
|
||||
recorded_at: '2026-03-15T11:30:00.000Z',
|
||||
}, {
|
||||
runsFilePath: runsFile,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.storage, 'jsonl');
|
||||
assert.strictEqual(result.path, runsFile);
|
||||
|
||||
const records = tracker.readSkillExecutionRecords({ runsFilePath: runsFile });
|
||||
assert.strictEqual(records.length, 1);
|
||||
assert.strictEqual(records[0].skill_id, 'alpha');
|
||||
assert.strictEqual(records[0].task_description, 'Fix flaky tests');
|
||||
assert.strictEqual(records[0].outcome, 'partial');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('falls back to JSONL when a state-store adapter is unavailable', () => {
|
||||
const result = tracker.recordSkillExecution({
|
||||
skill_id: 'beta',
|
||||
skill_version: 'v1',
|
||||
task_description: 'Import external skill',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 215,
|
||||
duration_ms: 900,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-15T11:35:00.000Z',
|
||||
}, {
|
||||
runsFilePath: runsFile,
|
||||
stateStore: {
|
||||
recordSkillExecution() {
|
||||
throw new Error('state store offline');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.storage, 'jsonl');
|
||||
assert.strictEqual(tracker.readSkillExecutionRecords({ runsFilePath: runsFile }).length, 2);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('ignores malformed JSONL rows when reading execution records', () => {
|
||||
const malformedRunsFile = path.join(homeDir, '.claude', 'state', 'malformed-skill-runs.jsonl');
|
||||
fs.writeFileSync(
|
||||
malformedRunsFile,
|
||||
`${JSON.stringify({
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v3',
|
||||
task_description: 'Good row',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 1,
|
||||
duration_ms: 1,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-15T11:45:00.000Z',
|
||||
})}\n{bad-json}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const records = tracker.readSkillExecutionRecords({ runsFilePath: malformedRunsFile });
|
||||
assert.strictEqual(records.length, 1);
|
||||
assert.strictEqual(records[0].skill_id, 'alpha');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves zero-valued telemetry fields during normalization', () => {
|
||||
const record = tracker.normalizeExecutionRecord({
|
||||
skill_id: 'zero-telemetry',
|
||||
skill_version: 'v1',
|
||||
task_description: 'No-op hook',
|
||||
outcome: 'success',
|
||||
tokens_used: 0,
|
||||
duration_ms: 0,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-15T11:40:00.000Z',
|
||||
});
|
||||
|
||||
assert.strictEqual(record.tokens_used, 0);
|
||||
assert.strictEqual(record.duration_ms, 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nHealth:');
|
||||
|
||||
if (test('computes per-skill health metrics and flags declining skills', () => {
|
||||
const betaSkillDir = createSkill(learnedRoot, 'beta', '# Beta v1\n');
|
||||
provenance.writeProvenance(betaSkillDir, {
|
||||
source: 'observer://session/123',
|
||||
created_at: '2026-03-14T10:00:00.000Z',
|
||||
confidence: 0.72,
|
||||
author: 'observer',
|
||||
}, {
|
||||
repoRoot,
|
||||
homeDir,
|
||||
});
|
||||
versioning.createVersion(betaSkillDir, {
|
||||
timestamp: '2026-03-14T11:00:00.000Z',
|
||||
author: 'observer',
|
||||
reason: 'bootstrap',
|
||||
});
|
||||
|
||||
appendJsonl(path.join(skillsRoot, 'alpha', '.evolution', 'amendments.jsonl'), [
|
||||
{
|
||||
event: 'proposal',
|
||||
status: 'pending',
|
||||
created_at: '2026-03-15T07:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
appendJsonl(runsFile, [
|
||||
{
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v3',
|
||||
task_description: 'Recent success',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 100,
|
||||
duration_ms: 1000,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-14T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v3',
|
||||
task_description: 'Recent failure',
|
||||
outcome: 'failure',
|
||||
failure_reason: 'Regression',
|
||||
tokens_used: 100,
|
||||
duration_ms: 1000,
|
||||
user_feedback: 'rejected',
|
||||
recorded_at: '2026-03-13T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v2',
|
||||
task_description: 'Prior success',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 100,
|
||||
duration_ms: 1000,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-06T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'alpha',
|
||||
skill_version: 'v1',
|
||||
task_description: 'Older success',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 100,
|
||||
duration_ms: 1000,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-02-24T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'beta',
|
||||
skill_version: 'v1',
|
||||
task_description: 'Recent success',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 90,
|
||||
duration_ms: 800,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-15T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
skill_id: 'beta',
|
||||
skill_version: 'v1',
|
||||
task_description: 'Older failure',
|
||||
outcome: 'failure',
|
||||
failure_reason: 'Bad import',
|
||||
tokens_used: 90,
|
||||
duration_ms: 800,
|
||||
user_feedback: 'corrected',
|
||||
recorded_at: '2026-02-20T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const report = health.collectSkillHealth({
|
||||
repoRoot,
|
||||
homeDir,
|
||||
runsFilePath: runsFile,
|
||||
now,
|
||||
warnThreshold: 0.1,
|
||||
});
|
||||
|
||||
const alpha = report.skills.find(skill => skill.skill_id === 'alpha');
|
||||
const beta = report.skills.find(skill => skill.skill_id === 'beta');
|
||||
|
||||
assert.ok(alpha);
|
||||
assert.ok(beta);
|
||||
assert.strictEqual(alpha.current_version, 'v3');
|
||||
assert.strictEqual(alpha.pending_amendments, 1);
|
||||
assert.strictEqual(alpha.success_rate_7d, 0.5);
|
||||
assert.strictEqual(alpha.success_rate_30d, 0.75);
|
||||
assert.strictEqual(alpha.failure_trend, 'worsening');
|
||||
assert.strictEqual(alpha.declining, true);
|
||||
assert.strictEqual(beta.failure_trend, 'improving');
|
||||
|
||||
const summary = health.summarizeHealthReport(report);
|
||||
assert.deepStrictEqual(summary, {
|
||||
total_skills: 6,
|
||||
healthy_skills: 5,
|
||||
declining_skills: 1,
|
||||
});
|
||||
|
||||
const human = health.formatHealthReport(report, { json: false });
|
||||
assert.match(human, /alpha/);
|
||||
assert.match(human, /worsening/);
|
||||
assert.match(
|
||||
human,
|
||||
new RegExp(`Skills: ${summary.total_skills} total, ${summary.healthy_skills} healthy, ${summary.declining_skills} declining`)
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('treats an unsnapshotted SKILL.md as v1 and orders last_run by actual time', () => {
|
||||
const gammaSkillDir = createSkill(skillsRoot, 'gamma', '# Gamma v1\n');
|
||||
const offsetRunsFile = path.join(homeDir, '.claude', 'state', 'offset-skill-runs.jsonl');
|
||||
|
||||
appendJsonl(offsetRunsFile, [
|
||||
{
|
||||
skill_id: 'gamma',
|
||||
skill_version: 'v1',
|
||||
task_description: 'Offset timestamp run',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 10,
|
||||
duration_ms: 100,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-15T00:00:00+02:00',
|
||||
},
|
||||
{
|
||||
skill_id: 'gamma',
|
||||
skill_version: 'v1',
|
||||
task_description: 'UTC timestamp run',
|
||||
outcome: 'success',
|
||||
failure_reason: null,
|
||||
tokens_used: 11,
|
||||
duration_ms: 110,
|
||||
user_feedback: 'accepted',
|
||||
recorded_at: '2026-03-14T23:30:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const report = health.collectSkillHealth({
|
||||
repoRoot,
|
||||
homeDir,
|
||||
runsFilePath: offsetRunsFile,
|
||||
now,
|
||||
warnThreshold: 0.1,
|
||||
});
|
||||
|
||||
const gamma = report.skills.find(skill => skill.skill_id === path.basename(gammaSkillDir));
|
||||
assert.ok(gamma);
|
||||
assert.strictEqual(gamma.current_version, 'v1');
|
||||
assert.strictEqual(gamma.last_run, '2026-03-14T23:30:00Z');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI emits JSON health output for standalone integration', () => {
|
||||
const result = runCli([
|
||||
'--json',
|
||||
'--skills-root', skillsRoot,
|
||||
'--learned-root', learnedRoot,
|
||||
'--imported-root', importedRoot,
|
||||
'--home', homeDir,
|
||||
'--runs-file', runsFile,
|
||||
'--now', now,
|
||||
'--warn-threshold', '0.1',
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout.trim());
|
||||
assert.ok(Array.isArray(payload.skills));
|
||||
assert.strictEqual(payload.skills[0].skill_id, 'alpha');
|
||||
assert.strictEqual(payload.skills[0].declining, true);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('CLI shows help and rejects missing option values', () => {
|
||||
const helpResult = runCli(['--help']);
|
||||
assert.strictEqual(helpResult.status, 0);
|
||||
assert.match(helpResult.stdout, /--learned-root <path>/);
|
||||
assert.match(helpResult.stdout, /--imported-root <path>/);
|
||||
|
||||
const errorResult = runCli(['--skills-root']);
|
||||
assert.strictEqual(errorResult.status, 1);
|
||||
assert.match(errorResult.stderr, /Missing value for --skills-root/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
} finally {
|
||||
cleanupTempDir(repoRoot);
|
||||
cleanupTempDir(homeDir);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,186 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
appendSkillObservation,
|
||||
createSkillObservation,
|
||||
getSkillObservationsPath,
|
||||
readSkillObservations
|
||||
} = require('../../scripts/lib/skill-improvement/observations');
|
||||
const { buildSkillHealthReport } = require('../../scripts/lib/skill-improvement/health');
|
||||
const { proposeSkillAmendment } = require('../../scripts/lib/skill-improvement/amendify');
|
||||
const { buildSkillEvaluationScaffold } = require('../../scripts/lib/skill-improvement/evaluate');
|
||||
|
||||
console.log('=== Testing skill-improvement ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}: ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function makeProjectRoot(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
test('observation layer writes and reads structured skill outcomes', () => {
|
||||
const projectRoot = makeProjectRoot('ecc-skill-observe-');
|
||||
|
||||
try {
|
||||
const observation = createSkillObservation({
|
||||
task: 'Fix flaky Playwright test',
|
||||
skill: {
|
||||
id: 'e2e-testing',
|
||||
path: 'skills/e2e-testing/SKILL.md'
|
||||
},
|
||||
success: false,
|
||||
error: 'playwright timeout',
|
||||
feedback: 'Timed out waiting for locator',
|
||||
sessionId: 'sess-1234'
|
||||
});
|
||||
|
||||
appendSkillObservation(observation, { projectRoot });
|
||||
const records = readSkillObservations({ projectRoot });
|
||||
|
||||
assert.strictEqual(records.length, 1);
|
||||
assert.strictEqual(records[0].schemaVersion, 'ecc.skill-observation.v1');
|
||||
assert.strictEqual(records[0].task, 'Fix flaky Playwright test');
|
||||
assert.strictEqual(records[0].skill.id, 'e2e-testing');
|
||||
assert.strictEqual(records[0].outcome.success, false);
|
||||
assert.strictEqual(records[0].outcome.error, 'playwright timeout');
|
||||
assert.strictEqual(getSkillObservationsPath({ projectRoot }), path.join(projectRoot, '.claude', 'ecc', 'skills', 'observations.jsonl'));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
});
|
||||
|
||||
test('health inspector traces recurring failures for a skill across runs', () => {
|
||||
const projectRoot = makeProjectRoot('ecc-skill-health-');
|
||||
|
||||
try {
|
||||
[
|
||||
createSkillObservation({
|
||||
task: 'Ship Next.js auth middleware',
|
||||
skill: { id: 'security-review', path: 'skills/security-review/SKILL.md' },
|
||||
success: false,
|
||||
error: 'missing csrf guidance',
|
||||
feedback: 'Did not mention CSRF'
|
||||
}),
|
||||
createSkillObservation({
|
||||
task: 'Harden Next.js auth middleware',
|
||||
skill: { id: 'security-review', path: 'skills/security-review/SKILL.md' },
|
||||
success: false,
|
||||
error: 'missing csrf guidance',
|
||||
feedback: 'Repeated omission'
|
||||
}),
|
||||
createSkillObservation({
|
||||
task: 'Review payment webhook security',
|
||||
skill: { id: 'security-review', path: 'skills/security-review/SKILL.md' },
|
||||
success: true
|
||||
})
|
||||
].forEach(record => appendSkillObservation(record, { projectRoot }));
|
||||
|
||||
const report = buildSkillHealthReport(readSkillObservations({ projectRoot }), {
|
||||
minFailureCount: 2
|
||||
});
|
||||
const skill = report.skills.find(entry => entry.skill.id === 'security-review');
|
||||
|
||||
assert.ok(skill, 'security-review should appear in the report');
|
||||
assert.strictEqual(skill.totalRuns, 3);
|
||||
assert.strictEqual(skill.failures, 2);
|
||||
assert.strictEqual(skill.status, 'failing');
|
||||
assert.strictEqual(skill.recurringErrors[0].error, 'missing csrf guidance');
|
||||
assert.strictEqual(skill.recurringErrors[0].count, 2);
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
});
|
||||
|
||||
test('amendify proposes SKILL.md patch content from failure evidence', () => {
|
||||
const records = [
|
||||
createSkillObservation({
|
||||
task: 'Add API rate limiting',
|
||||
skill: { id: 'api-design', path: 'skills/api-design/SKILL.md' },
|
||||
success: false,
|
||||
error: 'missing rate limiting guidance',
|
||||
feedback: 'No rate-limit section'
|
||||
}),
|
||||
createSkillObservation({
|
||||
task: 'Design public API error envelopes',
|
||||
skill: { id: 'api-design', path: 'skills/api-design/SKILL.md' },
|
||||
success: false,
|
||||
error: 'missing error response examples',
|
||||
feedback: 'Need explicit examples'
|
||||
})
|
||||
];
|
||||
|
||||
const proposal = proposeSkillAmendment('api-design', records);
|
||||
|
||||
assert.strictEqual(proposal.schemaVersion, 'ecc.skill-amendment-proposal.v1');
|
||||
assert.strictEqual(proposal.skill.id, 'api-design');
|
||||
assert.strictEqual(proposal.status, 'proposed');
|
||||
assert.ok(proposal.patch.preview.includes('## Failure-Driven Amendments'));
|
||||
assert.ok(proposal.patch.preview.includes('rate limiting'));
|
||||
assert.ok(proposal.patch.preview.includes('error response'));
|
||||
});
|
||||
|
||||
test('evaluation scaffold compares amended and baseline performance', () => {
|
||||
const records = [
|
||||
createSkillObservation({
|
||||
task: 'Fix flaky login test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
success: false,
|
||||
variant: 'baseline'
|
||||
}),
|
||||
createSkillObservation({
|
||||
task: 'Fix flaky checkout test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
success: true,
|
||||
variant: 'baseline'
|
||||
}),
|
||||
createSkillObservation({
|
||||
task: 'Fix flaky login test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
success: true,
|
||||
variant: 'amended',
|
||||
amendmentId: 'amend-1'
|
||||
}),
|
||||
createSkillObservation({
|
||||
task: 'Fix flaky checkout test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
success: true,
|
||||
variant: 'amended',
|
||||
amendmentId: 'amend-1'
|
||||
})
|
||||
];
|
||||
|
||||
const evaluation = buildSkillEvaluationScaffold('e2e-testing', records, {
|
||||
amendmentId: 'amend-1',
|
||||
minimumRunsPerVariant: 2
|
||||
});
|
||||
|
||||
assert.strictEqual(evaluation.schemaVersion, 'ecc.skill-evaluation.v1');
|
||||
assert.strictEqual(evaluation.baseline.runs, 2);
|
||||
assert.strictEqual(evaluation.amended.runs, 2);
|
||||
assert.strictEqual(evaluation.delta.successRate, 0.5);
|
||||
assert.strictEqual(evaluation.recommendation, 'promote-amendment');
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
489
.agent/knowledge/everything_claude/tests/lib/state-store.test.js
Normal file
489
.agent/knowledge/everything_claude/tests/lib/state-store.test.js
Normal file
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* Tests for the SQLite-backed ECC state store and CLI commands.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const {
|
||||
createStateStore,
|
||||
resolveStateStorePath,
|
||||
} = require('../../scripts/lib/state-store');
|
||||
|
||||
const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
|
||||
const STATUS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'status.js');
|
||||
const SESSIONS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'sessions-cli.js');
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanupTempDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runNode(scriptPath, args = [], options = {}) {
|
||||
return spawnSync('node', [scriptPath, ...args], {
|
||||
encoding: 'utf8',
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.env || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(stdout) {
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
async function seedStore(dbPath) {
|
||||
const store = await createStateStore({ dbPath });
|
||||
|
||||
store.upsertSession({
|
||||
id: 'session-active',
|
||||
adapterId: 'dmux-tmux',
|
||||
harness: 'claude',
|
||||
state: 'active',
|
||||
repoRoot: '/tmp/ecc-repo',
|
||||
startedAt: '2026-03-15T08:00:00.000Z',
|
||||
endedAt: null,
|
||||
snapshot: {
|
||||
schemaVersion: 'ecc.session.v1',
|
||||
adapterId: 'dmux-tmux',
|
||||
session: {
|
||||
id: 'session-active',
|
||||
kind: 'orchestrated',
|
||||
state: 'active',
|
||||
repoRoot: '/tmp/ecc-repo',
|
||||
},
|
||||
workers: [
|
||||
{
|
||||
id: 'worker-1',
|
||||
label: 'Worker 1',
|
||||
state: 'active',
|
||||
branch: 'feat/state-store',
|
||||
worktree: '/tmp/ecc-repo/.worktrees/worker-1',
|
||||
},
|
||||
{
|
||||
id: 'worker-2',
|
||||
label: 'Worker 2',
|
||||
state: 'idle',
|
||||
branch: 'feat/state-store',
|
||||
worktree: '/tmp/ecc-repo/.worktrees/worker-2',
|
||||
},
|
||||
],
|
||||
aggregates: {
|
||||
workerCount: 2,
|
||||
states: {
|
||||
active: 1,
|
||||
idle: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.upsertSession({
|
||||
id: 'session-recorded',
|
||||
adapterId: 'claude-history',
|
||||
harness: 'claude',
|
||||
state: 'recorded',
|
||||
repoRoot: '/tmp/ecc-repo',
|
||||
startedAt: '2026-03-14T18:00:00.000Z',
|
||||
endedAt: '2026-03-14T19:00:00.000Z',
|
||||
snapshot: {
|
||||
schemaVersion: 'ecc.session.v1',
|
||||
adapterId: 'claude-history',
|
||||
session: {
|
||||
id: 'session-recorded',
|
||||
kind: 'history',
|
||||
state: 'recorded',
|
||||
repoRoot: '/tmp/ecc-repo',
|
||||
},
|
||||
workers: [
|
||||
{
|
||||
id: 'worker-hist',
|
||||
label: 'History Worker',
|
||||
state: 'recorded',
|
||||
branch: 'main',
|
||||
worktree: '/tmp/ecc-repo',
|
||||
},
|
||||
],
|
||||
aggregates: {
|
||||
workerCount: 1,
|
||||
states: {
|
||||
recorded: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.insertSkillRun({
|
||||
id: 'skill-run-1',
|
||||
skillId: 'tdd-workflow',
|
||||
skillVersion: '1.0.0',
|
||||
sessionId: 'session-active',
|
||||
taskDescription: 'Write store tests',
|
||||
outcome: 'success',
|
||||
failureReason: null,
|
||||
tokensUsed: 1200,
|
||||
durationMs: 3500,
|
||||
userFeedback: 'useful',
|
||||
createdAt: '2026-03-15T08:05:00.000Z',
|
||||
});
|
||||
|
||||
store.insertSkillRun({
|
||||
id: 'skill-run-2',
|
||||
skillId: 'security-review',
|
||||
skillVersion: '1.0.0',
|
||||
sessionId: 'session-active',
|
||||
taskDescription: 'Review state-store design',
|
||||
outcome: 'failed',
|
||||
failureReason: 'timeout',
|
||||
tokensUsed: 800,
|
||||
durationMs: 1800,
|
||||
userFeedback: null,
|
||||
createdAt: '2026-03-15T08:06:00.000Z',
|
||||
});
|
||||
|
||||
store.insertSkillRun({
|
||||
id: 'skill-run-3',
|
||||
skillId: 'code-reviewer',
|
||||
skillVersion: '1.0.0',
|
||||
sessionId: 'session-recorded',
|
||||
taskDescription: 'Inspect CLI formatting',
|
||||
outcome: 'success',
|
||||
failureReason: null,
|
||||
tokensUsed: 500,
|
||||
durationMs: 900,
|
||||
userFeedback: 'clear',
|
||||
createdAt: '2026-03-15T08:07:00.000Z',
|
||||
});
|
||||
|
||||
store.insertSkillRun({
|
||||
id: 'skill-run-4',
|
||||
skillId: 'planner',
|
||||
skillVersion: '1.0.0',
|
||||
sessionId: 'session-recorded',
|
||||
taskDescription: 'Outline ECC 2.0 work',
|
||||
outcome: 'unknown',
|
||||
failureReason: null,
|
||||
tokensUsed: 300,
|
||||
durationMs: 500,
|
||||
userFeedback: null,
|
||||
createdAt: '2026-03-15T08:08:00.000Z',
|
||||
});
|
||||
|
||||
store.upsertSkillVersion({
|
||||
skillId: 'tdd-workflow',
|
||||
version: '1.0.0',
|
||||
contentHash: 'abc123',
|
||||
amendmentReason: 'initial',
|
||||
promotedAt: '2026-03-10T00:00:00.000Z',
|
||||
rolledBackAt: null,
|
||||
});
|
||||
|
||||
store.insertDecision({
|
||||
id: 'decision-1',
|
||||
sessionId: 'session-active',
|
||||
title: 'Use SQLite for durable state',
|
||||
rationale: 'Need queryable local state for ECC control plane',
|
||||
alternatives: ['json-files', 'memory-only'],
|
||||
supersedes: null,
|
||||
status: 'active',
|
||||
createdAt: '2026-03-15T08:09:00.000Z',
|
||||
});
|
||||
|
||||
store.upsertInstallState({
|
||||
targetId: 'claude-home',
|
||||
targetRoot: '/tmp/home/.claude',
|
||||
profile: 'developer',
|
||||
modules: ['rules-core', 'orchestration'],
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
destinationPath: '/tmp/home/.claude/agents/planner.md',
|
||||
},
|
||||
],
|
||||
installedAt: '2026-03-15T07:00:00.000Z',
|
||||
sourceVersion: '1.8.0',
|
||||
});
|
||||
|
||||
store.insertGovernanceEvent({
|
||||
id: 'gov-1',
|
||||
sessionId: 'session-active',
|
||||
eventType: 'policy-review-required',
|
||||
payload: {
|
||||
severity: 'warning',
|
||||
owner: 'security-reviewer',
|
||||
},
|
||||
resolvedAt: null,
|
||||
resolution: null,
|
||||
createdAt: '2026-03-15T08:10:00.000Z',
|
||||
});
|
||||
|
||||
store.insertGovernanceEvent({
|
||||
id: 'gov-2',
|
||||
sessionId: 'session-recorded',
|
||||
eventType: 'decision-accepted',
|
||||
payload: {
|
||||
severity: 'info',
|
||||
},
|
||||
resolvedAt: '2026-03-15T08:11:00.000Z',
|
||||
resolution: 'accepted',
|
||||
createdAt: '2026-03-15T08:09:30.000Z',
|
||||
});
|
||||
|
||||
store.close();
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing state-store ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (await test('creates the default state.db path and applies migrations idempotently', async () => {
|
||||
const homeDir = createTempDir('ecc-state-home-');
|
||||
|
||||
try {
|
||||
const expectedPath = path.join(homeDir, '.claude', 'ecc', 'state.db');
|
||||
assert.strictEqual(resolveStateStorePath({ homeDir }), expectedPath);
|
||||
|
||||
const firstStore = await createStateStore({ homeDir });
|
||||
const firstMigrations = firstStore.getAppliedMigrations();
|
||||
firstStore.close();
|
||||
|
||||
assert.strictEqual(firstMigrations.length, 1);
|
||||
assert.strictEqual(firstMigrations[0].version, 1);
|
||||
assert.ok(fs.existsSync(expectedPath));
|
||||
|
||||
const secondStore = await createStateStore({ homeDir });
|
||||
const secondMigrations = secondStore.getAppliedMigrations();
|
||||
secondStore.close();
|
||||
|
||||
assert.strictEqual(secondMigrations.length, 1);
|
||||
assert.strictEqual(secondMigrations[0].version, 1);
|
||||
} finally {
|
||||
cleanupTempDir(homeDir);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('preserves SQLite special database names like :memory:', async () => {
|
||||
const tempDir = createTempDir('ecc-state-memory-');
|
||||
const previousCwd = process.cwd();
|
||||
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
assert.strictEqual(resolveStateStorePath({ dbPath: ':memory:' }), ':memory:');
|
||||
|
||||
const store = await createStateStore({ dbPath: ':memory:' });
|
||||
assert.strictEqual(store.dbPath, ':memory:');
|
||||
assert.strictEqual(store.getAppliedMigrations().length, 1);
|
||||
store.close();
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(tempDir, ':memory:')));
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('stores sessions and returns detailed session views with workers, skill runs, and decisions', async () => {
|
||||
const testDir = createTempDir('ecc-state-db-');
|
||||
const dbPath = path.join(testDir, 'state.db');
|
||||
|
||||
try {
|
||||
await seedStore(dbPath);
|
||||
|
||||
const store = await createStateStore({ dbPath });
|
||||
const listResult = store.listRecentSessions({ limit: 10 });
|
||||
const detail = store.getSessionDetail('session-active');
|
||||
store.close();
|
||||
|
||||
assert.strictEqual(listResult.totalCount, 2);
|
||||
assert.strictEqual(listResult.sessions[0].id, 'session-active');
|
||||
assert.strictEqual(detail.session.id, 'session-active');
|
||||
assert.strictEqual(detail.workers.length, 2);
|
||||
assert.strictEqual(detail.skillRuns.length, 2);
|
||||
assert.strictEqual(detail.decisions.length, 1);
|
||||
assert.deepStrictEqual(detail.decisions[0].alternatives, ['json-files', 'memory-only']);
|
||||
} finally {
|
||||
cleanupTempDir(testDir);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('builds a status snapshot with active sessions, skill rates, install health, and pending governance', async () => {
|
||||
const testDir = createTempDir('ecc-state-db-');
|
||||
const dbPath = path.join(testDir, 'state.db');
|
||||
|
||||
try {
|
||||
await seedStore(dbPath);
|
||||
|
||||
const store = await createStateStore({ dbPath });
|
||||
const status = store.getStatus();
|
||||
store.close();
|
||||
|
||||
assert.strictEqual(status.activeSessions.activeCount, 1);
|
||||
assert.strictEqual(status.activeSessions.sessions[0].id, 'session-active');
|
||||
assert.strictEqual(status.skillRuns.summary.totalCount, 4);
|
||||
assert.strictEqual(status.skillRuns.summary.successCount, 2);
|
||||
assert.strictEqual(status.skillRuns.summary.failureCount, 1);
|
||||
assert.strictEqual(status.skillRuns.summary.unknownCount, 1);
|
||||
assert.strictEqual(status.installHealth.status, 'healthy');
|
||||
assert.strictEqual(status.installHealth.totalCount, 1);
|
||||
assert.strictEqual(status.governance.pendingCount, 1);
|
||||
assert.strictEqual(status.governance.events[0].id, 'gov-1');
|
||||
} finally {
|
||||
cleanupTempDir(testDir);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('validates entity payloads before writing to the database', async () => {
|
||||
const testDir = createTempDir('ecc-state-db-');
|
||||
const dbPath = path.join(testDir, 'state.db');
|
||||
|
||||
try {
|
||||
const store = await createStateStore({ dbPath });
|
||||
assert.throws(() => {
|
||||
store.upsertSession({
|
||||
id: '',
|
||||
adapterId: 'dmux-tmux',
|
||||
harness: 'claude',
|
||||
state: 'active',
|
||||
repoRoot: '/tmp/repo',
|
||||
startedAt: '2026-03-15T08:00:00.000Z',
|
||||
endedAt: null,
|
||||
snapshot: {},
|
||||
});
|
||||
}, /Invalid session/);
|
||||
|
||||
assert.throws(() => {
|
||||
store.insertDecision({
|
||||
id: 'decision-invalid',
|
||||
sessionId: 'missing-session',
|
||||
title: 'Reject non-array alternatives',
|
||||
rationale: 'alternatives must be an array',
|
||||
alternatives: { unexpected: true },
|
||||
supersedes: null,
|
||||
status: 'active',
|
||||
createdAt: '2026-03-15T08:15:00.000Z',
|
||||
});
|
||||
}, /Invalid decision/);
|
||||
|
||||
assert.throws(() => {
|
||||
store.upsertInstallState({
|
||||
targetId: 'claude-home',
|
||||
targetRoot: '/tmp/home/.claude',
|
||||
profile: 'developer',
|
||||
modules: 'rules-core',
|
||||
operations: [],
|
||||
installedAt: '2026-03-15T07:00:00.000Z',
|
||||
sourceVersion: '1.8.0',
|
||||
});
|
||||
}, /Invalid installState/);
|
||||
|
||||
store.close();
|
||||
} finally {
|
||||
cleanupTempDir(testDir);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('status CLI supports human-readable and --json output', async () => {
|
||||
const testDir = createTempDir('ecc-state-cli-');
|
||||
const dbPath = path.join(testDir, 'state.db');
|
||||
|
||||
try {
|
||||
await seedStore(dbPath);
|
||||
|
||||
const jsonResult = runNode(STATUS_SCRIPT, ['--db', dbPath, '--json']);
|
||||
assert.strictEqual(jsonResult.status, 0, jsonResult.stderr);
|
||||
const jsonPayload = parseJson(jsonResult.stdout);
|
||||
assert.strictEqual(jsonPayload.activeSessions.activeCount, 1);
|
||||
assert.strictEqual(jsonPayload.governance.pendingCount, 1);
|
||||
|
||||
const humanResult = runNode(STATUS_SCRIPT, ['--db', dbPath]);
|
||||
assert.strictEqual(humanResult.status, 0, humanResult.stderr);
|
||||
assert.match(humanResult.stdout, /Active sessions: 1/);
|
||||
assert.match(humanResult.stdout, /Skill runs \(last 20\):/);
|
||||
assert.match(humanResult.stdout, /Install health: healthy/);
|
||||
assert.match(humanResult.stdout, /Pending governance events: 1/);
|
||||
} finally {
|
||||
cleanupTempDir(testDir);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('sessions CLI supports list and detail views in human-readable and --json output', async () => {
|
||||
const testDir = createTempDir('ecc-state-cli-');
|
||||
const dbPath = path.join(testDir, 'state.db');
|
||||
|
||||
try {
|
||||
await seedStore(dbPath);
|
||||
|
||||
const listJsonResult = runNode(SESSIONS_SCRIPT, ['--db', dbPath, '--json']);
|
||||
assert.strictEqual(listJsonResult.status, 0, listJsonResult.stderr);
|
||||
const listPayload = parseJson(listJsonResult.stdout);
|
||||
assert.strictEqual(listPayload.totalCount, 2);
|
||||
assert.strictEqual(listPayload.sessions[0].id, 'session-active');
|
||||
|
||||
const detailJsonResult = runNode(SESSIONS_SCRIPT, ['session-active', '--db', dbPath, '--json']);
|
||||
assert.strictEqual(detailJsonResult.status, 0, detailJsonResult.stderr);
|
||||
const detailPayload = parseJson(detailJsonResult.stdout);
|
||||
assert.strictEqual(detailPayload.session.id, 'session-active');
|
||||
assert.strictEqual(detailPayload.workers.length, 2);
|
||||
assert.strictEqual(detailPayload.skillRuns.length, 2);
|
||||
assert.strictEqual(detailPayload.decisions.length, 1);
|
||||
|
||||
const detailHumanResult = runNode(SESSIONS_SCRIPT, ['session-active', '--db', dbPath]);
|
||||
assert.strictEqual(detailHumanResult.status, 0, detailHumanResult.stderr);
|
||||
assert.match(detailHumanResult.stdout, /Session: session-active/);
|
||||
assert.match(detailHumanResult.stdout, /Workers: 2/);
|
||||
assert.match(detailHumanResult.stdout, /Skill runs: 2/);
|
||||
assert.match(detailHumanResult.stdout, /Decisions: 1/);
|
||||
} finally {
|
||||
cleanupTempDir(testDir);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('ecc CLI delegates the new status and sessions subcommands', async () => {
|
||||
const testDir = createTempDir('ecc-state-cli-');
|
||||
const dbPath = path.join(testDir, 'state.db');
|
||||
|
||||
try {
|
||||
await seedStore(dbPath);
|
||||
|
||||
const statusResult = runNode(ECC_SCRIPT, ['status', '--db', dbPath, '--json']);
|
||||
assert.strictEqual(statusResult.status, 0, statusResult.stderr);
|
||||
const statusPayload = parseJson(statusResult.stdout);
|
||||
assert.strictEqual(statusPayload.activeSessions.activeCount, 1);
|
||||
|
||||
const sessionsResult = runNode(ECC_SCRIPT, ['sessions', 'session-active', '--db', dbPath, '--json']);
|
||||
assert.strictEqual(sessionsResult.status, 0, sessionsResult.stderr);
|
||||
const sessionsPayload = parseJson(sessionsResult.stdout);
|
||||
assert.strictEqual(sessionsPayload.session.id, 'session-active');
|
||||
assert.strictEqual(sessionsPayload.skillRuns.length, 2);
|
||||
} finally {
|
||||
cleanupTempDir(testDir);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,423 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
slugify,
|
||||
renderTemplate,
|
||||
buildOrchestrationPlan,
|
||||
executePlan,
|
||||
materializePlan,
|
||||
normalizeSeedPaths,
|
||||
overlaySeedPaths
|
||||
} = require('../../scripts/lib/tmux-worktree-orchestrator');
|
||||
|
||||
console.log('=== Testing tmux-worktree-orchestrator.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${desc}: ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Helpers:');
|
||||
test('slugify normalizes mixed punctuation and casing', () => {
|
||||
assert.strictEqual(slugify('Feature Audit: Docs + Tmux'), 'feature-audit-docs-tmux');
|
||||
});
|
||||
|
||||
test('renderTemplate replaces supported placeholders', () => {
|
||||
const rendered = renderTemplate('run {worker_name} in {worktree_path}', {
|
||||
worker_name: 'Docs Fixer',
|
||||
worktree_path: '/tmp/repo-worker'
|
||||
});
|
||||
assert.strictEqual(rendered, 'run Docs Fixer in /tmp/repo-worker');
|
||||
});
|
||||
|
||||
test('renderTemplate rejects unknown placeholders', () => {
|
||||
assert.throws(
|
||||
() => renderTemplate('missing {unknown}', { worker_name: 'docs' }),
|
||||
/Unknown template variable/
|
||||
);
|
||||
});
|
||||
|
||||
console.log('\nPlan generation:');
|
||||
test('buildOrchestrationPlan creates worktrees, branches, and tmux commands', () => {
|
||||
const repoRoot = path.join('/tmp', 'ecc');
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot,
|
||||
sessionName: 'Skill Audit',
|
||||
baseRef: 'main',
|
||||
launcherCommand: 'codex exec --cwd {worktree_path} --task-file {task_file}',
|
||||
workers: [
|
||||
{ name: 'Docs A', task: 'Fix skills 1-4' },
|
||||
{ name: 'Docs B', task: 'Fix skills 5-8' }
|
||||
]
|
||||
});
|
||||
|
||||
assert.strictEqual(plan.sessionName, 'skill-audit');
|
||||
assert.strictEqual(plan.workerPlans.length, 2);
|
||||
assert.strictEqual(plan.workerPlans[0].branchName, 'orchestrator-skill-audit-docs-a');
|
||||
assert.strictEqual(plan.workerPlans[1].branchName, 'orchestrator-skill-audit-docs-b');
|
||||
assert.deepStrictEqual(
|
||||
plan.workerPlans[0].gitArgs.slice(0, 4),
|
||||
['worktree', 'add', '-b', 'orchestrator-skill-audit-docs-a'],
|
||||
'Should create branch-backed worktrees'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].worktreePath.endsWith(path.join('ecc-skill-audit-docs-a')),
|
||||
'Should create sibling worktree path'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].taskFilePath.endsWith(path.join('.orchestration', 'skill-audit', 'docs-a', 'task.md')),
|
||||
'Should create per-worker task file'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].handoffFilePath.endsWith(path.join('.orchestration', 'skill-audit', 'docs-a', 'handoff.md')),
|
||||
'Should create per-worker handoff file'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(plan.workerPlans[0].taskFilePath),
|
||||
'Launch command should interpolate task file'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(plan.workerPlans[0].worktreePath),
|
||||
'Launch command should interpolate worktree path'
|
||||
);
|
||||
assert.ok(
|
||||
plan.tmuxCommands.some(command => command.args.includes('split-window')),
|
||||
'Should include tmux split commands'
|
||||
);
|
||||
assert.ok(
|
||||
plan.tmuxCommands.some(command => command.args.includes('select-layout')),
|
||||
'Should include tiled layout command'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan requires at least one worker', () => {
|
||||
assert.throws(
|
||||
() => buildOrchestrationPlan({
|
||||
repoRoot: '/tmp/ecc',
|
||||
sessionName: 'empty',
|
||||
launcherCommand: 'codex exec --task-file {task_file}',
|
||||
workers: []
|
||||
}),
|
||||
/at least one worker/
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan normalizes global and worker seed paths', () => {
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot: '/tmp/ecc',
|
||||
sessionName: 'seeded',
|
||||
launcherCommand: 'echo run',
|
||||
seedPaths: ['scripts/orchestrate-worktrees.js', './.claude/plan/workflow-e2e-test.json'],
|
||||
workers: [
|
||||
{
|
||||
name: 'Docs',
|
||||
task: 'Update docs',
|
||||
seedPaths: ['commands/multi-workflow.md']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(plan.workerPlans[0].seedPaths, [
|
||||
'scripts/orchestrate-worktrees.js',
|
||||
'.claude/plan/workflow-e2e-test.json',
|
||||
'commands/multi-workflow.md'
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan rejects worker names that collapse to the same slug', () => {
|
||||
assert.throws(
|
||||
() => buildOrchestrationPlan({
|
||||
repoRoot: '/tmp/ecc',
|
||||
sessionName: 'duplicates',
|
||||
launcherCommand: 'echo run',
|
||||
workers: [
|
||||
{ name: 'Docs A', task: 'Fix skill docs' },
|
||||
{ name: 'Docs/A', task: 'Fix tests' }
|
||||
]
|
||||
}),
|
||||
/unique slugs/
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan exposes shell-safe launcher aliases alongside raw defaults', () => {
|
||||
const repoRoot = path.join('/tmp', 'My Repo');
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot,
|
||||
sessionName: 'Spacing Audit',
|
||||
launcherCommand: 'bash {repo_root_sh}/scripts/orchestrate-codex-worker.sh {task_file_sh} {handoff_file_sh} {status_file_sh} {worker_name_sh} {worker_name}',
|
||||
workers: [{ name: 'Docs Fixer', task: 'Update docs' }]
|
||||
});
|
||||
const quote = value => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
const resolvedRepoRoot = plan.workerPlans[0].repoRoot;
|
||||
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(`bash ${quote(resolvedRepoRoot)}/scripts/orchestrate-codex-worker.sh`),
|
||||
'repo_root_sh should provide a shell-safe path'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(quote(plan.workerPlans[0].taskFilePath)),
|
||||
'task_file_sh should provide a shell-safe path'
|
||||
);
|
||||
assert.ok(
|
||||
plan.workerPlans[0].launchCommand.includes(`${quote(plan.workerPlans[0].workerName)} ${plan.workerPlans[0].workerName}`),
|
||||
'raw defaults should remain available alongside shell-safe aliases'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOrchestrationPlan shell-quotes the orchestration banner command', () => {
|
||||
const repoRoot = path.join('/tmp', "O'Hare Repo");
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot,
|
||||
sessionName: 'Quote Audit',
|
||||
launcherCommand: 'echo run',
|
||||
workers: [{ name: 'Docs', task: 'Update docs' }]
|
||||
});
|
||||
const quote = value => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
const bannerCommand = plan.tmuxCommands[1].args[3];
|
||||
|
||||
assert.strictEqual(
|
||||
bannerCommand,
|
||||
`printf '%s\\n' ${quote(`Session: ${plan.sessionName}`)} ${quote(`Coordination: ${plan.coordinationDir}`)}`,
|
||||
'Banner command should quote coordination paths safely for tmux send-keys'
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSeedPaths rejects paths outside the repo root', () => {
|
||||
assert.throws(
|
||||
() => normalizeSeedPaths(['../outside.txt'], '/tmp/ecc'),
|
||||
/inside repoRoot/
|
||||
);
|
||||
});
|
||||
|
||||
test('materializePlan keeps worker instructions inside the worktree boundary', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orchestrator-test-'));
|
||||
|
||||
try {
|
||||
const plan = buildOrchestrationPlan({
|
||||
repoRoot: tempRoot,
|
||||
coordinationRoot: path.join(tempRoot, '.claude', 'orchestration'),
|
||||
sessionName: 'Workflow E2E',
|
||||
launcherCommand: 'bash {repo_root}/scripts/orchestrate-codex-worker.sh {task_file} {handoff_file} {status_file}',
|
||||
workers: [{ name: 'Docs', task: 'Update the workflow docs.' }]
|
||||
});
|
||||
|
||||
materializePlan(plan);
|
||||
|
||||
const taskFile = fs.readFileSync(plan.workerPlans[0].taskFilePath, 'utf8');
|
||||
|
||||
assert.ok(
|
||||
taskFile.includes('Report results in your final response.'),
|
||||
'Task file should tell the worker to report in stdout'
|
||||
);
|
||||
assert.ok(
|
||||
taskFile.includes('Do not spawn subagents or external agents for this task.'),
|
||||
'Task file should keep nested workers single-session'
|
||||
);
|
||||
assert.ok(
|
||||
!taskFile.includes('Write results and handoff notes to'),
|
||||
'Task file should not require writing handoff files outside the worktree'
|
||||
);
|
||||
assert.ok(
|
||||
!taskFile.includes('Update `'),
|
||||
'Task file should not instruct the nested worker to update orchestration status files'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('overlaySeedPaths copies local overlays into the worker worktree', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orchestrator-overlay-'));
|
||||
const repoRoot = path.join(tempRoot, 'repo');
|
||||
const worktreePath = path.join(tempRoot, 'worktree');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(repoRoot, 'scripts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, '.claude', 'plan'), { recursive: true });
|
||||
fs.mkdirSync(path.join(worktreePath, 'scripts'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'scripts', 'orchestrate-worktrees.js'),
|
||||
'local-version\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, '.claude', 'plan', 'workflow-e2e-test.json'),
|
||||
'{"seeded":true}\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(worktreePath, 'scripts', 'orchestrate-worktrees.js'),
|
||||
'head-version\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
overlaySeedPaths({
|
||||
repoRoot,
|
||||
seedPaths: [
|
||||
'scripts/orchestrate-worktrees.js',
|
||||
'.claude/plan/workflow-e2e-test.json'
|
||||
],
|
||||
worktreePath
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(worktreePath, 'scripts', 'orchestrate-worktrees.js'), 'utf8'),
|
||||
'local-version\n'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(worktreePath, '.claude', 'plan', 'workflow-e2e-test.json'), 'utf8'),
|
||||
'{"seeded":true}\n'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('executePlan rolls back partial setup when orchestration fails mid-run', () => {
|
||||
const plan = {
|
||||
repoRoot: '/tmp/ecc',
|
||||
sessionName: 'rollback-test',
|
||||
coordinationDir: '/tmp/ecc/.orchestration/rollback-test',
|
||||
replaceExisting: false,
|
||||
workerPlans: [
|
||||
{
|
||||
workerName: 'Docs',
|
||||
workerSlug: 'docs',
|
||||
worktreePath: '/tmp/ecc-rollback-docs',
|
||||
seedPaths: ['commands/orchestrate.md'],
|
||||
gitArgs: ['worktree', 'add', '-b', 'orchestrator-rollback-test-docs', '/tmp/ecc-rollback-docs', 'HEAD'],
|
||||
launchCommand: 'echo run'
|
||||
}
|
||||
]
|
||||
};
|
||||
const calls = [];
|
||||
const rollbackCalls = [];
|
||||
|
||||
assert.throws(
|
||||
() => executePlan(plan, {
|
||||
spawnSync(program, args) {
|
||||
calls.push({ type: 'spawnSync', program, args });
|
||||
if (program === 'tmux' && args[0] === 'has-session') {
|
||||
return { status: 1, stdout: '', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected spawnSync call: ${program} ${args.join(' ')}`);
|
||||
},
|
||||
runCommand(program, args) {
|
||||
calls.push({ type: 'runCommand', program, args });
|
||||
if (program === 'git' && args[0] === 'rev-parse') {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (program === 'tmux' && args[0] === '-V') {
|
||||
return { status: 0, stdout: 'tmux 3.4\n', stderr: '' };
|
||||
}
|
||||
if (program === 'git' && args[0] === 'worktree') {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected runCommand call: ${program} ${args.join(' ')}`);
|
||||
},
|
||||
materializePlan(receivedPlan) {
|
||||
calls.push({ type: 'materializePlan', receivedPlan });
|
||||
},
|
||||
overlaySeedPaths() {
|
||||
throw new Error('overlay failed');
|
||||
},
|
||||
rollbackCreatedResources(receivedPlan, createdState) {
|
||||
rollbackCalls.push({ receivedPlan, createdState });
|
||||
}
|
||||
}),
|
||||
/overlay failed/
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
rollbackCalls.map(call => call.receivedPlan),
|
||||
[plan],
|
||||
'executePlan should invoke rollback on failure'
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
rollbackCalls[0].createdState.workerPlans,
|
||||
plan.workerPlans,
|
||||
'executePlan should only roll back resources created before the failure'
|
||||
);
|
||||
assert.ok(
|
||||
calls.some(call => call.type === 'runCommand' && call.program === 'git' && call.args[0] === 'worktree'),
|
||||
'executePlan should attempt setup before rolling back'
|
||||
);
|
||||
});
|
||||
|
||||
test('executePlan does not mark pre-existing resources for rollback when worktree creation fails', () => {
|
||||
const plan = {
|
||||
repoRoot: '/tmp/ecc',
|
||||
sessionName: 'rollback-existing',
|
||||
coordinationDir: '/tmp/ecc/.orchestration/rollback-existing',
|
||||
replaceExisting: false,
|
||||
workerPlans: [
|
||||
{
|
||||
workerName: 'Docs',
|
||||
workerSlug: 'docs',
|
||||
worktreePath: '/tmp/ecc-existing-docs',
|
||||
seedPaths: [],
|
||||
gitArgs: ['worktree', 'add', '-b', 'orchestrator-rollback-existing-docs', '/tmp/ecc-existing-docs', 'HEAD'],
|
||||
launchCommand: 'echo run',
|
||||
branchName: 'orchestrator-rollback-existing-docs'
|
||||
}
|
||||
]
|
||||
};
|
||||
const rollbackCalls = [];
|
||||
|
||||
assert.throws(
|
||||
() => executePlan(plan, {
|
||||
spawnSync(program, args) {
|
||||
if (program === 'tmux' && args[0] === 'has-session') {
|
||||
return { status: 1, stdout: '', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected spawnSync call: ${program} ${args.join(' ')}`);
|
||||
},
|
||||
runCommand(program, args) {
|
||||
if (program === 'git' && args[0] === 'rev-parse') {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (program === 'tmux' && args[0] === '-V') {
|
||||
return { status: 0, stdout: 'tmux 3.4\n', stderr: '' };
|
||||
}
|
||||
if (program === 'git' && args[0] === 'worktree') {
|
||||
throw new Error('branch already exists');
|
||||
}
|
||||
throw new Error(`Unexpected runCommand call: ${program} ${args.join(' ')}`);
|
||||
},
|
||||
materializePlan() {},
|
||||
rollbackCreatedResources(receivedPlan, createdState) {
|
||||
rollbackCalls.push({ receivedPlan, createdState });
|
||||
}
|
||||
}),
|
||||
/branch already exists/
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
rollbackCalls[0].createdState.workerPlans,
|
||||
[],
|
||||
'Failures before creation should not schedule any worker resources for rollback'
|
||||
);
|
||||
assert.strictEqual(
|
||||
rollbackCalls[0].createdState.sessionCreated,
|
||||
false,
|
||||
'Failures before tmux session creation should not mark a session for rollback'
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
2602
.agent/knowledge/everything_claude/tests/lib/utils.test.js
Normal file
2602
.agent/knowledge/everything_claude/tests/lib/utils.test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Tests for .opencode/opencode.json local file references.
|
||||
*
|
||||
* Run with: node tests/opencode-config.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const opencodeDir = path.join(repoRoot, '.opencode');
|
||||
const configPath = path.join(opencodeDir, 'opencode.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
test('plugin paths do not duplicate the .opencode directory', () => {
|
||||
const plugins = config.plugin || [];
|
||||
for (const pluginPath of plugins) {
|
||||
assert.ok(!pluginPath.includes('.opencode/'), `Plugin path should be config-relative, got: ${pluginPath}`);
|
||||
assert.ok(fs.existsSync(path.resolve(opencodeDir, pluginPath)), `Plugin path should resolve from .opencode/: ${pluginPath}`);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('file references are config-relative and resolve to existing files', () => {
|
||||
const refs = [];
|
||||
|
||||
function walk(value) {
|
||||
if (typeof value === 'string') {
|
||||
const matches = value.matchAll(/\{file:([^}]+)\}/g);
|
||||
for (const match of matches) {
|
||||
refs.push(match[1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach(walk);
|
||||
}
|
||||
}
|
||||
|
||||
walk(config);
|
||||
|
||||
assert.ok(refs.length > 0, 'Expected to find file references in opencode.json');
|
||||
|
||||
for (const ref of refs) {
|
||||
assert.ok(!ref.startsWith('.opencode/'), `File ref should not duplicate .opencode/: ${ref}`);
|
||||
assert.ok(fs.existsSync(path.resolve(opencodeDir, ref)), `File ref should resolve from .opencode/: ${ref}`);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
118
.agent/knowledge/everything_claude/tests/run-all.js
Normal file
118
.agent/knowledge/everything_claude/tests/run-all.js
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run all tests
|
||||
*
|
||||
* Usage: node tests/run-all.js
|
||||
*/
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const testsDir = __dirname;
|
||||
const repoRoot = path.resolve(testsDir, '..');
|
||||
const TEST_GLOB = 'tests/**/*.test.js';
|
||||
|
||||
function matchesTestGlob(relativePath) {
|
||||
const normalized = relativePath.split(path.sep).join('/');
|
||||
if (typeof path.matchesGlob === 'function') {
|
||||
return path.matchesGlob(normalized, TEST_GLOB);
|
||||
}
|
||||
|
||||
return /^tests\/(?:.+\/)?[^/]+\.test\.js$/.test(normalized);
|
||||
}
|
||||
|
||||
function walkFiles(dir, acc = []) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(fullPath, acc);
|
||||
} else if (entry.isFile()) {
|
||||
acc.push(fullPath);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function discoverTestFiles() {
|
||||
return walkFiles(testsDir)
|
||||
.map(fullPath => path.relative(repoRoot, fullPath))
|
||||
.filter(matchesTestGlob)
|
||||
.map(repoRelativePath => path.relative(testsDir, path.join(repoRoot, repoRelativePath)))
|
||||
.sort();
|
||||
}
|
||||
|
||||
const testFiles = discoverTestFiles();
|
||||
|
||||
const BOX_W = 58; // inner width between ║ delimiters
|
||||
const boxLine = s => `║${s.padEnd(BOX_W)}║`;
|
||||
|
||||
console.log('╔' + '═'.repeat(BOX_W) + '╗');
|
||||
console.log(boxLine(' Everything Claude Code - Test Suite'));
|
||||
console.log('╚' + '═'.repeat(BOX_W) + '╝');
|
||||
console.log();
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
console.log(`✗ No test files matched ${TEST_GLOB}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
let totalTests = 0;
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
const testPath = path.join(testsDir, testFile);
|
||||
const displayPath = testFile.split(path.sep).join('/');
|
||||
|
||||
if (!fs.existsSync(testPath)) {
|
||||
console.log(`⚠ Skipping ${displayPath} (file not found)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`\n━━━ Running ${displayPath} ━━━`);
|
||||
|
||||
const result = spawnSync('node', [testPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const stdout = result.stdout || '';
|
||||
const stderr = result.stderr || '';
|
||||
|
||||
// Show both stdout and stderr so hook warnings are visible
|
||||
if (stdout) console.log(stdout);
|
||||
if (stderr) console.log(stderr);
|
||||
|
||||
// Parse results from combined output
|
||||
const combined = stdout + stderr;
|
||||
const passedMatch = combined.match(/Passed:\s*(\d+)/);
|
||||
const failedMatch = combined.match(/Failed:\s*(\d+)/);
|
||||
|
||||
if (passedMatch) totalPassed += parseInt(passedMatch[1], 10);
|
||||
if (failedMatch) totalFailed += parseInt(failedMatch[1], 10);
|
||||
|
||||
if (result.error) {
|
||||
console.log(`✗ ${displayPath} failed to start: ${result.error.message}`);
|
||||
totalFailed += failedMatch ? 0 : 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.log(`✗ ${displayPath} exited with status ${result.status}`);
|
||||
totalFailed += failedMatch ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
totalTests = totalPassed + totalFailed;
|
||||
|
||||
console.log('\n╔' + '═'.repeat(BOX_W) + '╗');
|
||||
console.log(boxLine(' Final Results'));
|
||||
console.log('╠' + '═'.repeat(BOX_W) + '╣');
|
||||
console.log(boxLine(` Total Tests: ${String(totalTests).padStart(4)}`));
|
||||
console.log(boxLine(` Passed: ${String(totalPassed).padStart(4)} ✓`));
|
||||
console.log(boxLine(` Failed: ${String(totalFailed).padStart(4)} ${totalFailed > 0 ? '✗' : ' '}`));
|
||||
console.log('╚' + '═'.repeat(BOX_W) + '╝');
|
||||
|
||||
process.exit(totalFailed > 0 ? 1 : 0);
|
||||
104
.agent/knowledge/everything_claude/tests/scripts/catalog.test.js
Normal file
104
.agent/knowledge/everything_claude/tests/scripts/catalog.test.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for scripts/catalog.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'catalog.js');
|
||||
|
||||
function run(args = []) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function 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 catalog.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with no arguments', () => {
|
||||
const result = run();
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Discover ECC install components and profiles'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows help with an explicit help flag', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
assert.ok(result.stdout.includes('node scripts/catalog.js show <component-id>'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install profiles', () => {
|
||||
const result = run(['profiles']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install profiles'));
|
||||
assert.ok(result.stdout.includes('core'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filters components by family and emits JSON', () => {
|
||||
const result = run(['components', '--family', 'language', '--json']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.ok(Array.isArray(parsed.components));
|
||||
assert.ok(parsed.components.length > 0);
|
||||
assert.ok(parsed.components.every(component => component.family === 'language'));
|
||||
assert.ok(parsed.components.some(component => component.id === 'lang:typescript'));
|
||||
assert.ok(parsed.components.every(component => component.id !== 'framework:nextjs'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows a resolved component payload', () => {
|
||||
const result = run(['show', 'framework:nextjs', '--json']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.id, 'framework:nextjs');
|
||||
assert.strictEqual(parsed.family, 'framework');
|
||||
assert.deepStrictEqual(parsed.moduleIds, ['framework-language']);
|
||||
assert.ok(Array.isArray(parsed.modules));
|
||||
assert.strictEqual(parsed.modules[0].id, 'framework-language');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on unknown subcommands', () => {
|
||||
const result = run(['bogus']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown catalog command'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on unknown component ids', () => {
|
||||
const result = run(['show', 'framework:not-real']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown install component'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
325
.agent/knowledge/everything_claude/tests/scripts/claw.test.js
Normal file
325
.agent/knowledge/everything_claude/tests/scripts/claw.test.js
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Tests for scripts/claw.js
|
||||
*
|
||||
* Tests the NanoClaw agent REPL module — storage, context, delegation, meta.
|
||||
*
|
||||
* Run with: node tests/scripts/claw.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
getClawDir,
|
||||
getSessionPath,
|
||||
listSessions,
|
||||
loadHistory,
|
||||
appendTurn,
|
||||
loadECCContext,
|
||||
buildPrompt,
|
||||
askClaude,
|
||||
isValidSessionName,
|
||||
handleClear,
|
||||
getSessionMetrics,
|
||||
searchSessions,
|
||||
branchSession,
|
||||
exportSession,
|
||||
compactSession
|
||||
} = require(path.join(__dirname, '..', '..', 'scripts', 'claw.js'));
|
||||
|
||||
// Test helper — matches ECC's custom test pattern
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
if (err.stack) { console.log(` Stack: ${err.stack}`); }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'claw-test-'));
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing claw.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// ── Storage tests (6) ──────────────────────────────────────────────────
|
||||
|
||||
console.log('Storage:');
|
||||
|
||||
if (test('getClawDir() returns path ending in .claude/claw', () => {
|
||||
const dir = getClawDir();
|
||||
assert.ok(dir.endsWith(path.join('.claude', 'claw')),
|
||||
`Expected path ending in .claude/claw, got: ${dir}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getSessionPath("foo") returns correct .md path', () => {
|
||||
const p = getSessionPath('foo');
|
||||
assert.ok(p.endsWith(path.join('.claude', 'claw', 'foo.md')),
|
||||
`Expected path ending in .claude/claw/foo.md, got: ${p}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('listSessions() returns empty array for empty dir', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
const sessions = listSessions(tmpDir);
|
||||
assert.deepStrictEqual(sessions, []);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('listSessions() finds .md files and strips extension', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, 'alpha.md'), 'test');
|
||||
fs.writeFileSync(path.join(tmpDir, 'beta.md'), 'test');
|
||||
fs.writeFileSync(path.join(tmpDir, 'not-a-session.txt'), 'test');
|
||||
const sessions = listSessions(tmpDir);
|
||||
assert.ok(sessions.includes('alpha'), 'Should find alpha');
|
||||
assert.ok(sessions.includes('beta'), 'Should find beta');
|
||||
assert.strictEqual(sessions.length, 2, 'Should only find .md files');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loadHistory() returns "" for non-existent file', () => {
|
||||
const result = loadHistory('/tmp/claw-test-nonexistent-' + Date.now() + '.md');
|
||||
assert.strictEqual(result, '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('appendTurn() writes correct markdown format', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'test.md');
|
||||
try {
|
||||
appendTurn(filePath, 'User', 'Hello world', '2025-01-15T10:00:00.000Z');
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
assert.ok(content.includes('### [2025-01-15T10:00:00.000Z] User'),
|
||||
'Should include timestamp and role header');
|
||||
assert.ok(content.includes('Hello world'), 'Should include content');
|
||||
assert.ok(content.includes('---'), 'Should include separator');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Context tests (3) ─────────────────────────────────────────────────
|
||||
|
||||
console.log('\nContext:');
|
||||
|
||||
if (test('loadECCContext() returns "" when no skills specified', () => {
|
||||
const result = loadECCContext('');
|
||||
assert.strictEqual(result, '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loadECCContext() skips missing skill directories gracefully', () => {
|
||||
const result = loadECCContext('nonexistent-skill-xyz');
|
||||
assert.strictEqual(result, '');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loadECCContext() concatenates multiple skill files', () => {
|
||||
// Use real skills from the ECC repo if they exist
|
||||
const skillsDir = path.join(process.cwd(), 'skills');
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
console.log(' (skipped — no skills/ directory in CWD)');
|
||||
return;
|
||||
}
|
||||
const available = fs.readdirSync(skillsDir).filter(d => {
|
||||
const skillFile = path.join(skillsDir, d, 'SKILL.md');
|
||||
return fs.existsSync(skillFile);
|
||||
});
|
||||
if (available.length < 2) {
|
||||
console.log(' (skipped — need 2+ skills with SKILL.md)');
|
||||
return;
|
||||
}
|
||||
const twoSkills = available.slice(0, 2).join(',');
|
||||
const result = loadECCContext(twoSkills);
|
||||
assert.ok(result.length > 0, 'Should return non-empty context');
|
||||
// Should contain content from both skills
|
||||
for (const name of available.slice(0, 2)) {
|
||||
const skillContent = fs.readFileSync(
|
||||
path.join(skillsDir, name, 'SKILL.md'), 'utf8'
|
||||
);
|
||||
// Check that at least part of each skill is present
|
||||
const firstLine = skillContent.split('\n').find(l => l.trim().length > 10);
|
||||
if (firstLine) {
|
||||
assert.ok(result.includes(firstLine.trim()),
|
||||
`Should include content from skill ${name}`);
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Delegation tests (2) ──────────────────────────────────────────────
|
||||
|
||||
console.log('\nDelegation:');
|
||||
|
||||
if (test('buildPrompt() constructs correct prompt structure', () => {
|
||||
const prompt = buildPrompt('system info', 'chat history', 'user question');
|
||||
assert.ok(prompt.includes('=== SYSTEM CONTEXT ==='), 'Should have system section');
|
||||
assert.ok(prompt.includes('system info'), 'Should include system prompt');
|
||||
assert.ok(prompt.includes('=== CONVERSATION HISTORY ==='), 'Should have history section');
|
||||
assert.ok(prompt.includes('chat history'), 'Should include history');
|
||||
assert.ok(prompt.includes('=== USER MESSAGE ==='), 'Should have user section');
|
||||
assert.ok(prompt.includes('user question'), 'Should include user message');
|
||||
// Sections should be in order
|
||||
const sysIdx = prompt.indexOf('SYSTEM CONTEXT');
|
||||
const histIdx = prompt.indexOf('CONVERSATION HISTORY');
|
||||
const userIdx = prompt.indexOf('USER MESSAGE');
|
||||
assert.ok(sysIdx < histIdx, 'System should come before history');
|
||||
assert.ok(histIdx < userIdx, 'History should come before user message');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('askClaude() handles subprocess error gracefully', () => {
|
||||
// Use a non-existent command to trigger an error
|
||||
const result = askClaude('sys', 'hist', 'msg');
|
||||
// Should return an error string, not throw
|
||||
assert.strictEqual(typeof result, 'string', 'Should return a string');
|
||||
// If claude is not installed, we get an error message
|
||||
// If claude IS installed, we get an actual response — both are valid
|
||||
assert.ok(result.length > 0, 'Should return non-empty result');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── REPL/Meta tests (3) ───────────────────────────────────────────────
|
||||
|
||||
console.log('\nREPL/Meta:');
|
||||
|
||||
if (test('module exports all required functions', () => {
|
||||
const claw = require(path.join(__dirname, '..', '..', 'scripts', 'claw.js'));
|
||||
const required = [
|
||||
'getClawDir', 'getSessionPath', 'listSessions', 'loadHistory',
|
||||
'appendTurn', 'loadECCContext', 'askClaude', 'main'
|
||||
];
|
||||
for (const fn of required) {
|
||||
assert.strictEqual(typeof claw[fn], 'function',
|
||||
`Should export function ${fn}`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('/clear truncates session file', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'session.md');
|
||||
try {
|
||||
fs.writeFileSync(filePath, 'some existing history content');
|
||||
assert.ok(fs.readFileSync(filePath, 'utf8').length > 0, 'File should have content before clear');
|
||||
handleClear(filePath);
|
||||
const after = fs.readFileSync(filePath, 'utf8');
|
||||
assert.strictEqual(after, '', 'File should be empty after clear');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('isValidSessionName rejects invalid characters', () => {
|
||||
assert.strictEqual(isValidSessionName('my-project'), true);
|
||||
assert.strictEqual(isValidSessionName('default'), true);
|
||||
assert.strictEqual(isValidSessionName('test123'), true);
|
||||
assert.strictEqual(isValidSessionName('a'), true);
|
||||
assert.strictEqual(isValidSessionName(''), false);
|
||||
assert.strictEqual(isValidSessionName('has spaces'), false);
|
||||
assert.strictEqual(isValidSessionName('has/slash'), false);
|
||||
assert.strictEqual(isValidSessionName('../traversal'), false);
|
||||
assert.strictEqual(isValidSessionName('-starts-dash'), false);
|
||||
assert.strictEqual(isValidSessionName(null), false);
|
||||
assert.strictEqual(isValidSessionName(undefined), false);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nNanoClaw v2:');
|
||||
|
||||
if (test('getSessionMetrics returns non-zero token estimate for populated history', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'metrics.md');
|
||||
try {
|
||||
appendTurn(filePath, 'User', 'Implement auth');
|
||||
appendTurn(filePath, 'Assistant', 'Working on it');
|
||||
const metrics = getSessionMetrics(filePath);
|
||||
assert.strictEqual(metrics.turns, 2);
|
||||
assert.ok(metrics.tokenEstimate > 0);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('searchSessions finds query in saved session', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
const clawDir = path.join(tmpDir, '.claude', 'claw');
|
||||
const sessionPath = path.join(clawDir, 'alpha.md');
|
||||
fs.mkdirSync(clawDir, { recursive: true });
|
||||
appendTurn(sessionPath, 'User', 'Need oauth migration');
|
||||
const results = searchSessions('oauth', clawDir);
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].session, 'alpha');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('branchSession copies history into new branch session', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
const clawDir = path.join(tmpDir, '.claude', 'claw');
|
||||
const source = path.join(clawDir, 'base.md');
|
||||
fs.mkdirSync(clawDir, { recursive: true });
|
||||
appendTurn(source, 'User', 'base content');
|
||||
const result = branchSession(source, 'feature-branch', clawDir);
|
||||
assert.strictEqual(result.ok, true);
|
||||
const branched = fs.readFileSync(result.path, 'utf8');
|
||||
assert.ok(branched.includes('base content'));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exportSession writes JSON export', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'export.md');
|
||||
const outPath = path.join(tmpDir, 'export.json');
|
||||
try {
|
||||
appendTurn(filePath, 'User', 'hello');
|
||||
appendTurn(filePath, 'Assistant', 'world');
|
||||
const result = exportSession(filePath, 'json', outPath);
|
||||
assert.strictEqual(result.ok, true);
|
||||
const exported = JSON.parse(fs.readFileSync(outPath, 'utf8'));
|
||||
assert.strictEqual(Array.isArray(exported.turns), true);
|
||||
assert.strictEqual(exported.turns.length, 2);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('compactSession reduces long histories', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
const filePath = path.join(tmpDir, 'compact.md');
|
||||
try {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
appendTurn(filePath, i % 2 ? 'Assistant' : 'User', `turn-${i}`);
|
||||
}
|
||||
const changed = compactSession(filePath, 10);
|
||||
assert.strictEqual(changed, true);
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
assert.ok(content.includes('NanoClaw Compaction'));
|
||||
assert.ok(!content.includes('turn-0'));
|
||||
assert.ok(content.includes('turn-29'));
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Tests for Codex shell helpers.
|
||||
*/
|
||||
|
||||
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 installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh');
|
||||
const installSource = fs.readFileSync(installScript, 'utf8');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function toBashPath(filePath) {
|
||||
if (process.platform !== 'win32') {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return String(filePath)
|
||||
.replace(/^([A-Za-z]):/, (_, driveLetter) => `/${driveLetter.toLowerCase()}`)
|
||||
.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function runBash(scriptPath, args = [], env = {}, cwd = repoRoot) {
|
||||
return spawnSync('bash', [toBashPath(scriptPath), ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
test('install-global-git-hooks.sh does not use eval and executes argv directly', () => {
|
||||
assert.ok(!installSource.includes('eval "$*"'), 'Expected installer to avoid eval');
|
||||
assert.ok(installSource.includes(' "$@"'), 'Expected installer to execute argv directly');
|
||||
assert.ok(installSource.includes(`printf ' %q' "$@"`), 'Expected dry-run logging to shell-escape argv');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('install-global-git-hooks.sh handles shell-sensitive hook paths without shell injection', () => {
|
||||
const homeDir = createTempDir('codex-hooks-home-');
|
||||
const weirdHooksDir = path.join(homeDir, "git-hooks 'quoted' & spaced");
|
||||
|
||||
try {
|
||||
const result = runBash(installScript, [], {
|
||||
HOME: toBashPath(homeDir),
|
||||
ECC_GLOBAL_HOOKS_DIR: toBashPath(weirdHooksDir)
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
|
||||
assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-commit')));
|
||||
assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-push')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
190
.agent/knowledge/everything_claude/tests/scripts/doctor.test.js
Normal file
190
.agent/knowledge/everything_claude/tests/scripts/doctor.test.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Tests for scripts/doctor.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'doctor.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function 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 doctor.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('reports a healthy install with exit code 0', () => {
|
||||
const homeDir = createTempDir('doctor-home-');
|
||||
const projectRoot = createTempDir('doctor-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(homeDir, '.claude');
|
||||
const statePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const managedFile = path.join(targetRoot, 'rules', 'common', 'coding-style.md');
|
||||
const sourceContent = fs.readFileSync(path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'), 'utf8');
|
||||
fs.mkdirSync(path.dirname(managedFile), { recursive: true });
|
||||
fs.writeFileSync(managedFile, sourceContent);
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['legacy-claude-rules'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourceRelativePath: 'rules/common/coding-style.md',
|
||||
destinationPath: managedFile,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = run(['--target', 'claude'], { cwd: projectRoot, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Doctor report'));
|
||||
assert.ok(result.stdout.includes('Status: OK'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reports issues and exits 1 for unhealthy installs', () => {
|
||||
const homeDir = createTempDir('doctor-home-');
|
||||
const projectRoot = createTempDir('doctor-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const statePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath: path.join(targetRoot, 'hooks.json'),
|
||||
strategy: 'sync-root-children',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = run(['--target', 'cursor', '--json'], { cwd: projectRoot, homeDir });
|
||||
assert.strictEqual(result.code, 1);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.summary.errorCount, 1);
|
||||
assert.ok(parsed.results[0].issues.some(issue => issue.code === 'missing-managed-files'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
167
.agent/knowledge/everything_claude/tests/scripts/ecc.test.js
Normal file
167
.agent/knowledge/everything_claude/tests/scripts/ecc.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Tests for scripts/ecc.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
|
||||
|
||||
function runCli(args, options = {}) {
|
||||
const envOverrides = {
|
||||
...(options.env || {}),
|
||||
};
|
||||
|
||||
if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) {
|
||||
envOverrides.USERPROFILE = envOverrides.HOME;
|
||||
}
|
||||
|
||||
if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) {
|
||||
envOverrides.HOME = envOverrides.USERPROFILE;
|
||||
}
|
||||
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
cwd: options.cwd || process.cwd(),
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function parseJson(stdout) {
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
function runTest(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.error(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('\n=== Testing ecc.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const tests = [
|
||||
['shows top-level help', () => {
|
||||
const result = runCli(['--help']);
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.match(result.stdout, /ECC selective-install CLI/);
|
||||
assert.match(result.stdout, /catalog/);
|
||||
assert.match(result.stdout, /list-installed/);
|
||||
assert.match(result.stdout, /doctor/);
|
||||
}],
|
||||
['delegates explicit install command', () => {
|
||||
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.dryRun, true);
|
||||
assert.strictEqual(payload.plan.mode, 'legacy-compat');
|
||||
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
|
||||
assert.ok(payload.plan.selectedModuleIds.includes('framework-language'));
|
||||
}],
|
||||
['routes implicit top-level args to install', () => {
|
||||
const result = runCli(['--dry-run', '--json', 'typescript']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.dryRun, true);
|
||||
assert.strictEqual(payload.plan.mode, 'legacy-compat');
|
||||
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
|
||||
assert.ok(payload.plan.selectedModuleIds.includes('framework-language'));
|
||||
}],
|
||||
['delegates plan command', () => {
|
||||
const result = runCli(['plan', '--list-profiles', '--json']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.ok(Array.isArray(payload.profiles));
|
||||
assert.ok(payload.profiles.length > 0);
|
||||
}],
|
||||
['delegates catalog command', () => {
|
||||
const result = runCli(['catalog', 'show', 'framework:nextjs', '--json']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.id, 'framework:nextjs');
|
||||
assert.deepStrictEqual(payload.moduleIds, ['framework-language']);
|
||||
}],
|
||||
['delegates lifecycle commands', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const projectRoot = createTempDir('ecc-cli-project-');
|
||||
const result = runCli(['list-installed', '--json'], {
|
||||
cwd: projectRoot,
|
||||
env: { HOME: homeDir },
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.deepStrictEqual(payload.records, []);
|
||||
}],
|
||||
['delegates session-inspect command', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'),
|
||||
'# ECC Session\n\n**Branch:** feat/ecc-cli\n'
|
||||
);
|
||||
|
||||
const result = runCli(['session-inspect', 'claude:latest'], {
|
||||
env: { HOME: homeDir },
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.strictEqual(payload.adapterId, 'claude-history');
|
||||
assert.strictEqual(payload.workers[0].branch, 'feat/ecc-cli');
|
||||
}],
|
||||
['supports help for a subcommand', () => {
|
||||
const result = runCli(['help', 'repair']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/repair\.js/);
|
||||
}],
|
||||
['supports help for the catalog subcommand', () => {
|
||||
const result = runCli(['help', 'catalog']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /node scripts\/catalog\.js show <component-id>/);
|
||||
}],
|
||||
['fails on unknown commands instead of treating them as installs', () => {
|
||||
const result = runCli(['bogus']);
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.match(result.stderr, /Unknown command: bogus/);
|
||||
}],
|
||||
['fails on unknown help subcommands', () => {
|
||||
const result = runCli(['help', 'bogus']);
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.match(result.stderr, /Unknown command: bogus/);
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
if (runTest(name, fn)) {
|
||||
passed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Tests for scripts/harness-audit.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'harness-audit.js');
|
||||
|
||||
function run(args = []) {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: path.join(__dirname, '..', '..'),
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
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 harness-audit.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('json output is deterministic between runs', () => {
|
||||
const first = run(['repo', '--format', 'json']);
|
||||
const second = run(['repo', '--format', 'json']);
|
||||
|
||||
assert.strictEqual(first, second);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('report includes bounded scores and fixed categories', () => {
|
||||
const parsed = JSON.parse(run(['repo', '--format', 'json']));
|
||||
|
||||
assert.strictEqual(parsed.deterministic, true);
|
||||
assert.strictEqual(parsed.rubric_version, '2026-03-16');
|
||||
assert.ok(parsed.overall_score >= 0);
|
||||
assert.ok(parsed.max_score > 0);
|
||||
assert.ok(parsed.overall_score <= parsed.max_score);
|
||||
|
||||
const categoryNames = Object.keys(parsed.categories);
|
||||
assert.ok(categoryNames.includes('Tool Coverage'));
|
||||
assert.ok(categoryNames.includes('Context Efficiency'));
|
||||
assert.ok(categoryNames.includes('Quality Gates'));
|
||||
assert.ok(categoryNames.includes('Memory Persistence'));
|
||||
assert.ok(categoryNames.includes('Eval Coverage'));
|
||||
assert.ok(categoryNames.includes('Security Guardrails'));
|
||||
assert.ok(categoryNames.includes('Cost Efficiency'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('scope filtering changes max score and check list', () => {
|
||||
const full = JSON.parse(run(['repo', '--format', 'json']));
|
||||
const scoped = JSON.parse(run(['hooks', '--format', 'json']));
|
||||
|
||||
assert.strictEqual(scoped.scope, 'hooks');
|
||||
assert.ok(scoped.max_score < full.max_score);
|
||||
assert.ok(scoped.checks.length < full.checks.length);
|
||||
assert.ok(scoped.checks.every(check => check.path.includes('hooks') || check.path.includes('scripts/hooks')));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('text format includes summary header', () => {
|
||||
const output = run(['repo']);
|
||||
assert.ok(output.includes('Harness Audit (repo):'));
|
||||
assert.ok(output.includes('Top 3 Actions:') || output.includes('Checks:'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Tests for scripts/install-apply.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
...(options.env || {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function 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 install-apply.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with --help', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
assert.ok(result.stdout.includes('--dry-run'));
|
||||
assert.ok(result.stdout.includes('--profile <name>'));
|
||||
assert.ok(result.stdout.includes('--modules <id,id,...>'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects mixing legacy languages with manifest profile flags', () => {
|
||||
const result = run(['--profile', 'core', 'typescript']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('cannot be combined'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs Claude rules and writes install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'typescript', 'testing.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'utils.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
|
||||
|
||||
const statePath = path.join(homeDir, '.claude', 'ecc', 'install-state.json');
|
||||
const state = readJson(statePath);
|
||||
assert.strictEqual(state.target.id, 'claude-home');
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
|
||||
assert.strictEqual(state.request.legacyMode, true);
|
||||
assert.deepStrictEqual(state.request.modules, []);
|
||||
assert.ok(state.resolution.selectedModules.includes('rules-core'));
|
||||
assert.ok(state.resolution.selectedModules.includes('framework-language'));
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath === path.join(claudeRoot, 'rules', 'common', 'coding-style.md')
|
||||
)),
|
||||
'Should record common rule file operation'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs Cursor configs and writes install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
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', 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks', 'session-start.js')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'scripts', 'lib', 'utils.js')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'coding-standards', 'SKILL.md')));
|
||||
|
||||
const statePath = path.join(projectDir, '.cursor', 'ecc-install-state.json');
|
||||
const state = readJson(statePath);
|
||||
const normalizedProjectDir = fs.realpathSync(projectDir);
|
||||
assert.strictEqual(state.target.id, 'cursor-project');
|
||||
assert.strictEqual(state.target.root, path.join(normalizedProjectDir, '.cursor'));
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
|
||||
assert.strictEqual(state.request.legacyMode, true);
|
||||
assert.ok(state.resolution.selectedModules.includes('framework-language'));
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath === path.join(normalizedProjectDir, '.cursor', 'commands', 'plan.md')
|
||||
)),
|
||||
'Should record manifest command file copy operation'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs Antigravity configs and writes install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'antigravity', 'typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common-coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'typescript-testing.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'workflows', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'architect.md')));
|
||||
|
||||
const statePath = path.join(projectDir, '.agent', 'ecc-install-state.json');
|
||||
const state = readJson(statePath);
|
||||
assert.strictEqual(state.target.id, 'antigravity-project');
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
|
||||
assert.strictEqual(state.request.legacyMode, true);
|
||||
assert.deepStrictEqual(state.resolution.selectedModules, ['rules-core', 'agents-core', 'commands-core']);
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath.endsWith(path.join('.agent', 'workflows', 'plan.md'))
|
||||
)),
|
||||
'Should record manifest command file copy operation'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports dry-run without mutating the target project', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Dry-run install plan'));
|
||||
assert.ok(result.stdout.includes('Mode: legacy-compat'));
|
||||
assert.ok(result.stdout.includes('Legacy languages: typescript'));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'ecc-install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports manifest profile dry-runs through the installer', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--profile', 'core', '--dry-run'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Mode: manifest'));
|
||||
assert.ok(result.stdout.includes('Profile: core'));
|
||||
assert.ok(result.stdout.includes('Included components: (none)'));
|
||||
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, hooks-runtime, platform-configs, workflow-quality'));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs manifest profiles and writes non-legacy install-state', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'session-manager.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
|
||||
|
||||
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'core');
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, []);
|
||||
assert.ok(state.resolution.selectedModules.includes('platform-configs'));
|
||||
assert.ok(
|
||||
state.operations.some(operation => (
|
||||
operation.destinationPath === path.join(claudeRoot, 'commands', 'plan.md')
|
||||
)),
|
||||
'Should record manifest-driven command file copy'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs antigravity manifest profiles while skipping only unsupported modules', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'antigravity', '--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common-coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'workflows', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(projectDir, '.agent', 'ecc-install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'core');
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.deepStrictEqual(
|
||||
state.resolution.selectedModules,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
);
|
||||
assert.ok(state.resolution.skippedModules.includes('hooks-runtime'));
|
||||
assert.ok(!state.resolution.skippedModules.includes('workflow-quality'));
|
||||
assert.ok(!state.resolution.skippedModules.includes('platform-configs'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs explicit modules for cursor using manifest operations', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', '--modules', 'platform-configs'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
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')));
|
||||
|
||||
const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json'));
|
||||
assert.strictEqual(state.request.profile, null);
|
||||
assert.deepStrictEqual(state.request.modules, ['platform-configs']);
|
||||
assert.deepStrictEqual(state.request.includeComponents, []);
|
||||
assert.deepStrictEqual(state.request.excludeComponents, []);
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.ok(state.resolution.selectedModules.includes('platform-configs'));
|
||||
assert.ok(
|
||||
!state.operations.some(operation => operation.destinationPath.endsWith('ecc-install-state.json')),
|
||||
'Manifest copy operations should not include generated install-state files'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unknown explicit manifest modules before resolution', () => {
|
||||
const result = run(['--modules', 'ghost-module']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs from ecc-install.json and persists component selections', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
const configPath = path.join(projectDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'claude',
|
||||
profile: 'developer',
|
||||
include: ['capability:security'],
|
||||
exclude: ['capability:orchestration'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['--config', configPath], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
assert.deepStrictEqual(state.request.includeComponents, ['capability:security']);
|
||||
assert.deepStrictEqual(state.request.excludeComponents, ['capability:orchestration']);
|
||||
assert.ok(state.resolution.selectedModules.includes('security'));
|
||||
assert.ok(!state.resolution.selectedModules.includes('orchestration'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('auto-detects ecc-install.json from the project root', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
const configPath = path.join(projectDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'claude',
|
||||
profile: 'developer',
|
||||
include: ['capability:security'],
|
||||
exclude: ['capability:orchestration'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run([], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
assert.deepStrictEqual(state.request.includeComponents, ['capability:security']);
|
||||
assert.deepStrictEqual(state.request.excludeComponents, ['capability:orchestration']);
|
||||
assert.ok(state.resolution.selectedModules.includes('security'));
|
||||
assert.ok(!state.resolution.selectedModules.includes('orchestration'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves legacy language installs when a project config is present', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
const configPath = path.join(projectDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'claude',
|
||||
profile: 'developer',
|
||||
include: ['capability:security'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.legacyMode, true);
|
||||
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
|
||||
assert.strictEqual(state.request.profile, null);
|
||||
assert.deepStrictEqual(state.request.includeComponents, []);
|
||||
assert.ok(state.resolution.selectedModules.includes('framework-language'));
|
||||
assert.ok(!state.resolution.selectedModules.includes('security'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Tests for scripts/install-plan.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-plan.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: options.cwd,
|
||||
timeout: 10000,
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 install-plan.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with no arguments', () => {
|
||||
const result = run();
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Inspect ECC selective-install manifests'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install profiles', () => {
|
||||
const result = run(['--list-profiles']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install profiles'));
|
||||
assert.ok(result.stdout.includes('core'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install modules', () => {
|
||||
const result = run(['--list-modules']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install modules'));
|
||||
assert.ok(result.stdout.includes('rules-core'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists install components', () => {
|
||||
const result = run(['--list-components', '--family', 'language']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install components'));
|
||||
assert.ok(result.stdout.includes('lang:typescript'));
|
||||
assert.ok(!result.stdout.includes('capability:security'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prints a filtered install plan for a profile and target', () => {
|
||||
const result = run([
|
||||
'--profile', 'developer',
|
||||
'--with', 'capability:security',
|
||||
'--without', 'capability:orchestration',
|
||||
'--target', 'cursor'
|
||||
]);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install plan'));
|
||||
assert.ok(result.stdout.includes('Included components: capability:security'));
|
||||
assert.ok(result.stdout.includes('Excluded components: capability:orchestration'));
|
||||
assert.ok(result.stdout.includes('Adapter: cursor-project'));
|
||||
assert.ok(result.stdout.includes('Target root:'));
|
||||
assert.ok(result.stdout.includes('Install-state:'));
|
||||
assert.ok(result.stdout.includes('Operation plan'));
|
||||
assert.ok(result.stdout.includes('Excluded by selection'));
|
||||
assert.ok(result.stdout.includes('security'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('emits JSON for explicit module resolution', () => {
|
||||
const result = run([
|
||||
'--modules', 'security',
|
||||
'--with', 'capability:research',
|
||||
'--target', 'cursor',
|
||||
'--json'
|
||||
]);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.ok(parsed.selectedModuleIds.includes('security'));
|
||||
assert.ok(parsed.selectedModuleIds.includes('research-apis'));
|
||||
assert.ok(parsed.selectedModuleIds.includes('workflow-quality'));
|
||||
assert.deepStrictEqual(parsed.includedComponentIds, ['capability:research']);
|
||||
assert.strictEqual(parsed.targetAdapterId, 'cursor-project');
|
||||
assert.ok(Array.isArray(parsed.operations));
|
||||
assert.ok(parsed.operations.length > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loads planning intent from ecc-install.json', () => {
|
||||
const configDir = path.join(__dirname, '..', 'fixtures', 'tmp-install-plan-config');
|
||||
const configPath = path.join(configDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
require('fs').mkdirSync(configDir, { recursive: true });
|
||||
require('fs').writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'cursor',
|
||||
profile: 'core',
|
||||
include: ['capability:security'],
|
||||
exclude: ['capability:orchestration'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['--config', configPath, '--json']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.target, 'cursor');
|
||||
assert.deepStrictEqual(parsed.includedComponentIds, ['capability:security']);
|
||||
assert.deepStrictEqual(parsed.excludedComponentIds, ['capability:orchestration']);
|
||||
assert.ok(parsed.selectedModuleIds.includes('security'));
|
||||
assert.ok(!parsed.selectedModuleIds.includes('orchestration'));
|
||||
} finally {
|
||||
require('fs').rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('auto-detects planning intent from project ecc-install.json', () => {
|
||||
const configDir = path.join(__dirname, '..', 'fixtures', 'tmp-install-plan-autodetect');
|
||||
const configPath = path.join(configDir, 'ecc-install.json');
|
||||
|
||||
try {
|
||||
require('fs').mkdirSync(configDir, { recursive: true });
|
||||
require('fs').writeFileSync(configPath, JSON.stringify({
|
||||
version: 1,
|
||||
target: 'cursor',
|
||||
profile: 'core',
|
||||
include: ['capability:security'],
|
||||
}, null, 2));
|
||||
|
||||
const result = run(['--json'], { cwd: configDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.target, 'cursor');
|
||||
assert.strictEqual(parsed.profileId, 'core');
|
||||
assert.deepStrictEqual(parsed.includedComponentIds, ['capability:security']);
|
||||
assert.ok(parsed.selectedModuleIds.includes('security'));
|
||||
} finally {
|
||||
require('fs').rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on unknown arguments', () => {
|
||||
const result = run(['--unknown-flag']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown argument'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on invalid install target', () => {
|
||||
const result = run(['--profile', 'core', '--target', 'not-a-target']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown install target'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Tests for install.ps1 wrapper delegation
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'install.ps1');
|
||||
const PACKAGE_JSON = path.join(__dirname, '..', '..', 'package.json');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function resolvePowerShellCommand() {
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['powershell.exe', 'pwsh.exe', 'pwsh']
|
||||
: ['pwsh'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const result = spawnSync(candidate, ['-NoLogo', '-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (!result.error && result.status === 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function run(powerShellCommand, args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
USERPROFILE: options.homeDir || process.env.USERPROFILE,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync(powerShellCommand, ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function 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 install.ps1 ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const powerShellCommand = resolvePowerShellCommand();
|
||||
|
||||
if (test('publishes ecc-install through the Node installer runtime for cross-platform npm usage', () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8'));
|
||||
assert.strictEqual(packageJson.bin['ecc-install'], 'scripts/install-apply.js');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (!powerShellCommand) {
|
||||
console.log(' - skipped delegation test; PowerShell is not available in PATH');
|
||||
} else if (test('delegates to the Node installer and preserves dry-run output', () => {
|
||||
const homeDir = createTempDir('install-ps1-home-');
|
||||
const projectDir = createTempDir('install-ps1-project-');
|
||||
|
||||
try {
|
||||
const result = run(powerShellCommand, ['--target', 'cursor', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Dry-run install plan'));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests for install.sh wrapper delegation
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'install.sh');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('bash', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function 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 install.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' - skipped on Windows; install.ps1 covers the native wrapper path');
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (test('delegates to the Node installer and preserves dry-run output', () => {
|
||||
const homeDir = createTempDir('install-sh-home-');
|
||||
const projectDir = createTempDir('install-sh-project-');
|
||||
|
||||
try {
|
||||
const result = run(['--target', 'cursor', '--dry-run', 'typescript'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Dry-run install plan'));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for scripts/list-installed.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'list-installed.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function 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 list-installed.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('reports when no install-state files are present', () => {
|
||||
const homeDir = createTempDir('list-installed-home-');
|
||||
const projectRoot = createTempDir('list-installed-project-');
|
||||
|
||||
try {
|
||||
const result = run([], { cwd: projectRoot, homeDir });
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('No ECC install-state files found'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('emits JSON for discovered install-state records', () => {
|
||||
const homeDir = createTempDir('list-installed-home-');
|
||||
const projectRoot = createTempDir('list-installed-project-');
|
||||
|
||||
try {
|
||||
const statePath = path.join(projectRoot, '.cursor', 'ecc-install-state.json');
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: path.join(projectRoot, '.cursor'),
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['rules-core', 'platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = run(['--json'], { cwd: projectRoot, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.records.length, 1);
|
||||
assert.strictEqual(parsed.records[0].state.target.id, 'cursor-project');
|
||||
assert.strictEqual(parsed.records[0].state.request.profile, 'core');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'orchestrate-codex-worker.sh');
|
||||
|
||||
console.log('=== Testing orchestrate-codex-worker.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${desc}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${desc}: ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
test('fails fast for an unreadable task file and records failure artifacts', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-worker-'));
|
||||
const handoffFile = path.join(tempRoot, '.orchestration', 'docs', 'handoff.md');
|
||||
const statusFile = path.join(tempRoot, '.orchestration', 'docs', 'status.md');
|
||||
const missingTaskFile = path.join(tempRoot, '.orchestration', 'docs', 'task.md');
|
||||
|
||||
try {
|
||||
spawnSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' });
|
||||
|
||||
const result = spawnSync('bash', [SCRIPT, missingTaskFile, handoffFile, statusFile], {
|
||||
cwd: tempRoot,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
assert.notStrictEqual(result.status, 0, 'Script should fail when task file is unreadable');
|
||||
assert.ok(fs.existsSync(statusFile), 'Script should still write a status file');
|
||||
assert.ok(fs.existsSync(handoffFile), 'Script should still write a handoff file');
|
||||
|
||||
const statusContent = fs.readFileSync(statusFile, 'utf8');
|
||||
const handoffContent = fs.readFileSync(handoffFile, 'utf8');
|
||||
|
||||
assert.ok(statusContent.includes('- State: failed'), 'Status file should record the failure state');
|
||||
assert.ok(
|
||||
statusContent.includes('task file is missing or unreadable'),
|
||||
'Status file should explain the task-file failure'
|
||||
);
|
||||
assert.ok(
|
||||
handoffContent.includes('Task file is missing or unreadable'),
|
||||
'Handoff file should explain the task-file failure'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Tests for scripts/orchestration-status.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'orchestration-status.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 orchestration-status.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('emits canonical dmux snapshots for plan files', () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-status-repo-'));
|
||||
|
||||
try {
|
||||
const planPath = path.join(repoRoot, 'workflow.json');
|
||||
fs.writeFileSync(planPath, JSON.stringify({
|
||||
sessionName: 'workflow-visual-proof',
|
||||
repoRoot,
|
||||
coordinationRoot: path.join(repoRoot, '.claude', 'orchestration')
|
||||
}));
|
||||
|
||||
const result = run([planPath], { cwd: repoRoot });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.adapterId, 'dmux-tmux');
|
||||
assert.strictEqual(payload.session.id, 'workflow-visual-proof');
|
||||
assert.strictEqual(payload.session.sourceTarget.type, 'plan');
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
313
.agent/knowledge/everything_claude/tests/scripts/repair.test.js
Normal file
313
.agent/knowledge/everything_claude/tests/scripts/repair.test.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Tests for scripts/repair.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const DOCTOR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'doctor.js');
|
||||
const REPAIR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'repair.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function runNode(scriptPath, args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [scriptPath, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function 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 repair.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('repairs drifted files from a real install-apply state', () => {
|
||||
const homeDir = createTempDir('repair-home-');
|
||||
const projectRoot = createTempDir('repair-project-');
|
||||
|
||||
try {
|
||||
const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', 'typescript'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(installResult.code, 0, installResult.stderr);
|
||||
|
||||
const normalizedProjectRoot = fs.realpathSync(projectRoot);
|
||||
const managedPath = path.join(normalizedProjectRoot, '.cursor', 'hooks', 'session-start.js');
|
||||
const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json');
|
||||
const expectedContent = fs.readFileSync(
|
||||
path.join(REPO_ROOT, '.cursor', 'hooks', 'session-start.js'),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(managedPath, '// drifted\n');
|
||||
|
||||
const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(doctorBefore.code, 1);
|
||||
assert.ok(JSON.parse(doctorBefore.stdout).results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
|
||||
|
||||
const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(repairResult.code, 0, repairResult.stderr);
|
||||
|
||||
const parsed = JSON.parse(repairResult.stdout);
|
||||
assert.strictEqual(parsed.results[0].status, 'repaired');
|
||||
assert.ok(parsed.results[0].repairedPaths.includes(managedPath));
|
||||
assert.strictEqual(fs.readFileSync(managedPath, 'utf8'), expectedContent);
|
||||
assert.ok(fs.existsSync(statePath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repairs drifted non-copy managed operations and refreshes install-state', () => {
|
||||
const homeDir = createTempDir('repair-home-');
|
||||
const projectRoot = createTempDir('repair-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||
const jsonPath = path.join(normalizedTargetRoot, 'hooks.json');
|
||||
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
|
||||
const removedPath = path.join(normalizedTargetRoot, 'legacy-note.txt');
|
||||
fs.writeFileSync(jsonPath, JSON.stringify({ existing: true, managed: false }, null, 2));
|
||||
fs.writeFileSync(renderedPath, '# drifted\n');
|
||||
fs.writeFileSync(removedPath, 'stale\n');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: normalizedTargetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath: jsonPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: {
|
||||
managed: true,
|
||||
nested: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'render-template',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/generated.md.template',
|
||||
destinationPath: renderedPath,
|
||||
strategy: 'render-template',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
renderedContent: '# generated\n',
|
||||
},
|
||||
{
|
||||
kind: 'remove',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/legacy-note.txt',
|
||||
destinationPath: removedPath,
|
||||
strategy: 'remove',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(doctorBefore.code, 1);
|
||||
assert.ok(JSON.parse(doctorBefore.stdout).results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
|
||||
|
||||
const installedAtBefore = JSON.parse(fs.readFileSync(statePath, 'utf8')).installedAt;
|
||||
const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(repairResult.code, 0, repairResult.stderr);
|
||||
|
||||
const parsed = JSON.parse(repairResult.stdout);
|
||||
assert.strictEqual(parsed.results[0].status, 'repaired');
|
||||
assert.ok(parsed.results[0].repairedPaths.includes(jsonPath));
|
||||
assert.ok(parsed.results[0].repairedPaths.includes(renderedPath));
|
||||
assert.ok(parsed.results[0].repairedPaths.includes(removedPath));
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(jsonPath, 'utf8')), {
|
||||
existing: true,
|
||||
managed: true,
|
||||
nested: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
assert.strictEqual(fs.readFileSync(renderedPath, 'utf8'), '# generated\n');
|
||||
assert.ok(!fs.existsSync(removedPath));
|
||||
|
||||
const repairedState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.strictEqual(repairedState.installedAt, installedAtBefore);
|
||||
assert.ok(repairedState.lastValidatedAt);
|
||||
|
||||
const doctorAfter = runNode(DOCTOR_SCRIPT, ['--target', 'cursor'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(doctorAfter.code, 0, doctorAfter.stderr);
|
||||
assert.ok(doctorAfter.stdout.includes('Status: OK'));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports dry-run without mutating drifted non-copy operations', () => {
|
||||
const homeDir = createTempDir('repair-home-');
|
||||
const projectRoot = createTempDir('repair-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
|
||||
fs.writeFileSync(renderedPath, '# drifted\n');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: normalizedTargetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'render-template',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/generated.md.template',
|
||||
destinationPath: renderedPath,
|
||||
strategy: 'render-template',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
renderedContent: '# generated\n',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--dry-run', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(repairResult.code, 0, repairResult.stderr);
|
||||
const parsed = JSON.parse(repairResult.stdout);
|
||||
assert.strictEqual(parsed.dryRun, true);
|
||||
assert.ok(parsed.results[0].plannedRepairs.includes(renderedPath));
|
||||
assert.strictEqual(fs.readFileSync(renderedPath, 'utf8'), '# drifted\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Tests for scripts/session-inspect.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { getFallbackSessionRecordingPath } = require('../../scripts/lib/session-adapters/canonical-session');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'session-inspect.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const envOverrides = {
|
||||
...(options.env || {})
|
||||
};
|
||||
|
||||
if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) {
|
||||
envOverrides.USERPROFILE = envOverrides.HOME;
|
||||
}
|
||||
|
||||
if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) {
|
||||
envOverrides.HOME = envOverrides.USERPROFILE;
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides
|
||||
}
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 session-inspect.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows usage when no target is provided', () => {
|
||||
const result = run();
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists registered adapters', () => {
|
||||
const result = run(['--list-adapters']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.ok(Array.isArray(payload.adapters));
|
||||
assert.ok(payload.adapters.some(adapter => adapter.id === 'claude-history'));
|
||||
assert.ok(payload.adapters.some(adapter => adapter.id === 'dmux-tmux'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prints canonical JSON for claude history targets', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-'));
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-recordings-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'),
|
||||
'# Inspect Session\n\n**Branch:** feat/session-inspect\n'
|
||||
);
|
||||
|
||||
const result = run(['claude:latest'], {
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
ECC_SESSION_RECORDING_DIR: recordingDir
|
||||
}
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
const recordingPath = getFallbackSessionRecordingPath(payload, { recordingDir });
|
||||
const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8'));
|
||||
assert.strictEqual(payload.adapterId, 'claude-history');
|
||||
assert.strictEqual(payload.session.kind, 'history');
|
||||
assert.strictEqual(payload.workers[0].branch, 'feat/session-inspect');
|
||||
assert.strictEqual(persisted.latest.adapterId, 'claude-history');
|
||||
assert.strictEqual(persisted.history.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(recordingDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports explicit target types for structured registry routing', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'),
|
||||
'# Inspect Session\n\n**Branch:** feat/typed-inspect\n'
|
||||
);
|
||||
|
||||
const result = run(['latest', '--target-type', 'claude-history'], {
|
||||
env: { HOME: homeDir }
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.adapterId, 'claude-history');
|
||||
assert.strictEqual(payload.session.sourceTarget.type, 'claude-history');
|
||||
assert.strictEqual(payload.workers[0].branch, 'feat/typed-inspect');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('writes snapshot JSON to disk when --write is provided', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-'));
|
||||
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-out-'));
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const outputPath = path.join(outputDir, 'snapshot.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'),
|
||||
'# Inspect Session\n\n**Branch:** feat/session-inspect\n'
|
||||
);
|
||||
|
||||
const result = run(['claude:latest', '--write', outputPath], {
|
||||
env: { HOME: homeDir }
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(outputPath));
|
||||
const written = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||
assert.strictEqual(written.adapterId, 'claude-history');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('inspects skill health from recorded observations', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-skills-'));
|
||||
const observationsDir = path.join(projectRoot, '.claude', 'ecc', 'skills');
|
||||
fs.mkdirSync(observationsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(observationsDir, 'observations.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-1',
|
||||
timestamp: '2026-03-14T12:00:00.000Z',
|
||||
task: 'Review auth middleware',
|
||||
skill: { id: 'security-review', path: 'skills/security-review/SKILL.md' },
|
||||
outcome: { success: false, status: 'failure', error: 'missing csrf guidance', feedback: 'Need CSRF coverage' },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-1' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-2',
|
||||
timestamp: '2026-03-14T12:05:00.000Z',
|
||||
task: 'Review auth middleware',
|
||||
skill: { id: 'security-review', path: 'skills/security-review/SKILL.md' },
|
||||
outcome: { success: false, status: 'failure', error: 'missing csrf guidance', feedback: null },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-2' }
|
||||
})
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = run(['skills:health'], { cwd: projectRoot });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.skill-health.v1');
|
||||
assert.ok(payload.skills.some(skill => skill.skill.id === 'security-review'));
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('proposes skill amendments through session-inspect', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-amend-'));
|
||||
const observationsDir = path.join(projectRoot, '.claude', 'ecc', 'skills');
|
||||
fs.mkdirSync(observationsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(observationsDir, 'observations.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-1',
|
||||
timestamp: '2026-03-14T12:00:00.000Z',
|
||||
task: 'Add rate limiting',
|
||||
skill: { id: 'api-design', path: 'skills/api-design/SKILL.md' },
|
||||
outcome: { success: false, status: 'failure', error: 'missing rate limiting guidance', feedback: 'Need rate limiting examples' },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-1' }
|
||||
})
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = run(['skills:amendify', '--skill', 'api-design'], { cwd: projectRoot });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.skill-amendment-proposal.v1');
|
||||
assert.strictEqual(payload.skill.id, 'api-design');
|
||||
assert.ok(payload.patch.preview.includes('Failure-Driven Amendments'));
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('builds skill evaluation scaffolding through session-inspect', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-eval-'));
|
||||
const observationsDir = path.join(projectRoot, '.claude', 'ecc', 'skills');
|
||||
fs.mkdirSync(observationsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(observationsDir, 'observations.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-1',
|
||||
timestamp: '2026-03-14T12:00:00.000Z',
|
||||
task: 'Fix flaky login test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
outcome: { success: false, status: 'failure', error: null, feedback: null },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-1' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-2',
|
||||
timestamp: '2026-03-14T12:10:00.000Z',
|
||||
task: 'Fix flaky checkout test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
outcome: { success: true, status: 'success', error: null, feedback: null },
|
||||
run: { variant: 'baseline', amendmentId: null, sessionId: 'sess-2' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-3',
|
||||
timestamp: '2026-03-14T12:20:00.000Z',
|
||||
task: 'Fix flaky login test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
outcome: { success: true, status: 'success', error: null, feedback: null },
|
||||
run: { variant: 'amended', amendmentId: 'amend-1', sessionId: 'sess-3' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 'ecc.skill-observation.v1',
|
||||
observationId: 'obs-4',
|
||||
timestamp: '2026-03-14T12:30:00.000Z',
|
||||
task: 'Fix flaky checkout test',
|
||||
skill: { id: 'e2e-testing', path: 'skills/e2e-testing/SKILL.md' },
|
||||
outcome: { success: true, status: 'success', error: null, feedback: null },
|
||||
run: { variant: 'amended', amendmentId: 'amend-1', sessionId: 'sess-4' }
|
||||
})
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = run(['skills:evaluate', '--skill', 'e2e-testing', '--amendment-id', 'amend-1'], { cwd: projectRoot });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.skill-evaluation.v1');
|
||||
assert.strictEqual(payload.recommendation, 'promote-amendment');
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Tests for scripts/setup-package-manager.js
|
||||
*
|
||||
* Tests CLI argument parsing and output via subprocess invocation.
|
||||
*
|
||||
* Run with: node tests/scripts/setup-package-manager.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'setup-package-manager.js');
|
||||
|
||||
// Run the script with given args, return { stdout, stderr, code }
|
||||
function run(args = [], env = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...env },
|
||||
timeout: 10000
|
||||
});
|
||||
return { stdout, stderr: '', code: 0 };
|
||||
} catch (err) {
|
||||
return {
|
||||
stdout: err.stdout || '',
|
||||
stderr: err.stderr || '',
|
||||
code: err.status || 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing setup-package-manager.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// --help flag
|
||||
console.log('--help:');
|
||||
|
||||
if (test('shows help with --help flag', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Package Manager Setup'));
|
||||
assert.ok(result.stdout.includes('--detect'));
|
||||
assert.ok(result.stdout.includes('--global'));
|
||||
assert.ok(result.stdout.includes('--project'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows help with -h flag', () => {
|
||||
const result = run(['-h']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Package Manager Setup'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows help with no arguments', () => {
|
||||
const result = run([]);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Package Manager Setup'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --detect flag
|
||||
console.log('\n--detect:');
|
||||
|
||||
if (test('detects current package manager', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Package Manager Detection'));
|
||||
assert.ok(result.stdout.includes('Current selection'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows detection sources', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.ok(result.stdout.includes('From package.json'));
|
||||
assert.ok(result.stdout.includes('From lock file'));
|
||||
assert.ok(result.stdout.includes('Environment var'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows available managers in detection output', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.ok(result.stdout.includes('npm'));
|
||||
assert.ok(result.stdout.includes('pnpm'));
|
||||
assert.ok(result.stdout.includes('yarn'));
|
||||
assert.ok(result.stdout.includes('bun'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --list flag
|
||||
console.log('\n--list:');
|
||||
|
||||
if (test('lists available package managers', () => {
|
||||
const result = run(['--list']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Available Package Managers'));
|
||||
assert.ok(result.stdout.includes('npm'));
|
||||
assert.ok(result.stdout.includes('Lock file'));
|
||||
assert.ok(result.stdout.includes('Install'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --global flag
|
||||
console.log('\n--global:');
|
||||
|
||||
if (test('rejects --global without package manager name', () => {
|
||||
const result = run(['--global']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --global with unknown package manager', () => {
|
||||
const result = run(['--global', 'unknown-pm']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown package manager'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --project flag
|
||||
console.log('\n--project:');
|
||||
|
||||
if (test('rejects --project without package manager name', () => {
|
||||
const result = run(['--project']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --project with unknown package manager', () => {
|
||||
const result = run(['--project', 'unknown-pm']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown package manager'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Positional argument
|
||||
console.log('\npositional argument:');
|
||||
|
||||
if (test('rejects unknown positional argument', () => {
|
||||
const result = run(['not-a-pm']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Unknown option or package manager'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Environment variable
|
||||
console.log('\nenvironment variable:');
|
||||
|
||||
if (test('detects env var override', () => {
|
||||
const result = run(['--detect'], { CLAUDE_PACKAGE_MANAGER: 'pnpm' });
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('pnpm'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --detect output completeness
|
||||
console.log('\n--detect output completeness:');
|
||||
|
||||
if (test('shows all three command types in detection output', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Install:'), 'Should show Install command');
|
||||
assert.ok(result.stdout.includes('Run script:'), 'Should show Run script command');
|
||||
assert.ok(result.stdout.includes('Execute binary:'), 'Should show Execute binary command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shows current marker for active package manager', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.ok(result.stdout.includes('(current)'), 'Should mark current PM');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 31: flag-as-PM-name rejection ──
|
||||
// Note: --help, --detect, --list are checked BEFORE --global/--project in argv
|
||||
// parsing, so passing e.g. --global --list triggers the --list handler first.
|
||||
// The startsWith('-') fix protects against flags that AREN'T caught earlier,
|
||||
// like --global --project or --project --unknown-flag.
|
||||
console.log('\n--global flag validation (Round 31):');
|
||||
|
||||
if (test('rejects --global --project (flag not caught by earlier checks)', () => {
|
||||
const result = run(['--global', '--project']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --global --unknown-flag (arbitrary flag as PM name)', () => {
|
||||
const result = run(['--global', '--foo-bar']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --global -x (single-dash flag as PM name)', () => {
|
||||
const result = run(['--global', '-x']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('--global --list is handled by --list check first (exit 0)', () => {
|
||||
// --list is checked before --global in the parsing order
|
||||
const result = run(['--global', '--list']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Available Package Managers'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\n--project flag validation (Round 31):');
|
||||
|
||||
if (test('rejects --project --global (cross-flag confusion)', () => {
|
||||
// --global handler runs before --project, catches it first
|
||||
const result = run(['--project', '--global']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --project --unknown-flag', () => {
|
||||
const result = run(['--project', '--bar']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects --project -z (single-dash flag)', () => {
|
||||
const result = run(['--project', '-z']);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('requires a package manager name'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 45: output completeness and marker uniqueness ──
|
||||
console.log('\n--detect marker uniqueness (Round 45):');
|
||||
|
||||
if (test('--detect output shows exactly one (current) marker', () => {
|
||||
const result = run(['--detect']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const lines = result.stdout.split('\n');
|
||||
const currentLines = lines.filter(l => l.includes('(current)'));
|
||||
assert.strictEqual(currentLines.length, 1, `Expected exactly 1 "(current)" marker, found ${currentLines.length}`);
|
||||
// The (current) marker should be on a line with a PM name
|
||||
assert.ok(/\b(npm|pnpm|yarn|bun)\b/.test(currentLines[0]), 'Current marker should be on a PM line');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\n--list output completeness (Round 45):');
|
||||
|
||||
if (test('--list shows all four supported package managers', () => {
|
||||
const result = run(['--list']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
for (const pm of ['npm', 'pnpm', 'yarn', 'bun']) {
|
||||
assert.ok(result.stdout.includes(pm), `Should list ${pm}`);
|
||||
}
|
||||
// Each PM should show Lock file and Install info
|
||||
const lockFileCount = (result.stdout.match(/Lock file:/g) || []).length;
|
||||
assert.strictEqual(lockFileCount, 4, `Expected 4 "Lock file:" entries, found ${lockFileCount}`);
|
||||
const installCount = (result.stdout.match(/Install:/g) || []).length;
|
||||
assert.strictEqual(installCount, 4, `Expected 4 "Install:" entries, found ${installCount}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 62: --global success path and bare PM name ──
|
||||
console.log('\n--global success path (Round 62):');
|
||||
|
||||
if (test('--global npm writes config and succeeds', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `spm-test-global-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
try {
|
||||
const result = run(['--global', 'npm'], { HOME: tmpDir, USERPROFILE: tmpDir });
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Global preference set to'), 'Should show success message');
|
||||
assert.ok(result.stdout.includes('npm'), 'Should mention npm');
|
||||
// Verify config file was created
|
||||
const configPath = path.join(tmpDir, '.claude', 'package-manager.json');
|
||||
assert.ok(fs.existsSync(configPath), 'Config file should be created');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.packageManager, 'npm', 'Config should contain npm');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nbare PM name success (Round 62):');
|
||||
|
||||
if (test('bare npm sets global preference and succeeds', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `spm-test-bare-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
try {
|
||||
const result = run(['npm'], { HOME: tmpDir, USERPROFILE: tmpDir });
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Global preference set to'), 'Should show success message');
|
||||
// Verify config file was created
|
||||
const configPath = path.join(tmpDir, '.claude', 'package-manager.json');
|
||||
assert.ok(fs.existsSync(configPath), 'Config file should be created');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.packageManager, 'npm', 'Config should contain npm');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\n--detect source label (Round 62):');
|
||||
|
||||
if (test('--detect with env var shows source as environment', () => {
|
||||
const result = run(['--detect'], { CLAUDE_PACKAGE_MANAGER: 'pnpm' });
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('Source: environment'), 'Should show environment as source');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 68: --project success path and --list (current) marker ──
|
||||
console.log('\n--project success path (Round 68):');
|
||||
|
||||
if (test('--project npm writes project config and succeeds', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `spm-test-project-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
try {
|
||||
const result = require('child_process').spawnSync('node', [SCRIPT, '--project', 'npm'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
timeout: 10000,
|
||||
cwd: tmpDir
|
||||
});
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Project preference set to'), 'Should show project success message');
|
||||
assert.ok(result.stdout.includes('npm'), 'Should mention npm');
|
||||
// Verify config file was created in the project CWD
|
||||
const configPath = path.join(tmpDir, '.claude', 'package-manager.json');
|
||||
assert.ok(fs.existsSync(configPath), 'Project config file should be created in CWD');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.packageManager, 'npm', 'Config should contain npm');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\n--list (current) marker (Round 68):');
|
||||
|
||||
if (test('--list output includes (current) marker for active PM', () => {
|
||||
const result = run(['--list']);
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.ok(result.stdout.includes('(current)'), '--list should mark the active PM with (current)');
|
||||
// The (current) marker should appear exactly once
|
||||
const currentCount = (result.stdout.match(/\(current\)/g) || []).length;
|
||||
assert.strictEqual(currentCount, 1, `Expected exactly 1 "(current)" in --list, found ${currentCount}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 74: setGlobal catch — setPreferredPackageManager throws ──
|
||||
console.log('\nRound 74: setGlobal catch (save failure):');
|
||||
|
||||
if (test('--global npm fails when HOME is not a directory', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' (skipped — /dev/null not available on Windows)');
|
||||
return;
|
||||
}
|
||||
// HOME=/dev/null causes ensureDir to throw ENOTDIR when creating ~/.claude/
|
||||
const result = run(['--global', 'npm'], { HOME: '/dev/null', USERPROFILE: '/dev/null' });
|
||||
assert.strictEqual(result.code, 1, `Expected exit 1, got ${result.code}`);
|
||||
assert.ok(result.stderr.includes('Error:'),
|
||||
`stderr should contain Error:, got: ${result.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 74: setProject catch — setProjectPackageManager throws ──
|
||||
console.log('\nRound 74: setProject catch (save failure):');
|
||||
|
||||
if (test('--project npm fails when CWD is read-only', () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
||||
console.log(' (skipped — chmod ineffective on Windows/root)');
|
||||
return;
|
||||
}
|
||||
const tmpDir = path.join(os.tmpdir(), `spm-test-ro-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
try {
|
||||
// Make CWD read-only so .claude/ dir creation fails with EACCES
|
||||
fs.chmodSync(tmpDir, 0o555);
|
||||
const result = require('child_process').spawnSync('node', [SCRIPT, '--project', 'npm'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
timeout: 10000,
|
||||
cwd: tmpDir
|
||||
});
|
||||
assert.strictEqual(result.status, 1,
|
||||
`Expected exit 1, got ${result.status}. stderr: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('Error:'),
|
||||
`stderr should contain Error:, got: ${result.stderr}`);
|
||||
} finally {
|
||||
try { fs.chmodSync(tmpDir, 0o755); } catch { /* best-effort */ }
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Tests for scripts/skill-create-output.js
|
||||
*
|
||||
* Tests the SkillCreateOutput class and helper functions.
|
||||
*
|
||||
* Run with: node tests/scripts/skill-create-output.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
// Import the module
|
||||
const { SkillCreateOutput } = require('../../scripts/skill-create-output');
|
||||
|
||||
// We also need to test the un-exported helpers by requiring the source
|
||||
// and extracting them from the module scope. Since they're not exported,
|
||||
// we test them indirectly through the class methods, plus test the
|
||||
// exported class directly.
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip ANSI escape sequences for assertions
|
||||
function stripAnsi(str) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
// Capture console.log output
|
||||
function captureLog(fn) {
|
||||
const logs = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...args) => logs.push(args.join(' '));
|
||||
try {
|
||||
fn();
|
||||
return logs;
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing skill-create-output.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Constructor tests
|
||||
console.log('SkillCreateOutput constructor:');
|
||||
|
||||
if (test('creates instance with repo name', () => {
|
||||
const output = new SkillCreateOutput('test-repo');
|
||||
assert.strictEqual(output.repoName, 'test-repo');
|
||||
assert.strictEqual(output.width, 70); // default width
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('accepts custom width option', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 100 });
|
||||
assert.strictEqual(output.width, 100);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// header() tests
|
||||
console.log('\nheader():');
|
||||
|
||||
if (test('outputs header with repo name', () => {
|
||||
const output = new SkillCreateOutput('my-project');
|
||||
const logs = captureLog(() => output.header());
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Skill Creator'), 'Should include Skill Creator');
|
||||
assert.ok(combined.includes('my-project'), 'Should include repo name');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header handles long repo names without crash', () => {
|
||||
const output = new SkillCreateOutput('a-very-long-repository-name-that-exceeds-normal-width-limits');
|
||||
// Should not throw RangeError
|
||||
const logs = captureLog(() => output.header());
|
||||
assert.ok(logs.length > 0, 'Should produce output');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// analysisResults() tests
|
||||
console.log('\nanalysisResults():');
|
||||
|
||||
if (test('displays analysis data', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.analysisResults({
|
||||
commits: 150,
|
||||
timeRange: 'Jan 2026 - Feb 2026',
|
||||
contributors: 3,
|
||||
files: 200,
|
||||
}));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('150'), 'Should show commit count');
|
||||
assert.ok(combined.includes('Jan 2026'), 'Should show time range');
|
||||
assert.ok(combined.includes('200'), 'Should show file count');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// patterns() tests
|
||||
console.log('\npatterns():');
|
||||
|
||||
if (test('displays patterns with confidence bars', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Test Pattern', trigger: 'when testing', confidence: 0.9, evidence: 'Tests exist' },
|
||||
{ name: 'Another Pattern', trigger: 'when building', confidence: 0.5, evidence: 'Build exists' },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Test Pattern'), 'Should show pattern name');
|
||||
assert.ok(combined.includes('when testing'), 'Should show trigger');
|
||||
assert.ok(stripAnsi(combined).includes('90%'), 'Should show confidence as percentage');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('handles patterns with missing confidence', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
// Should default to 0.8 confidence
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'No Confidence', trigger: 'always', evidence: 'evidence' },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(stripAnsi(combined).includes('80%'), 'Should default to 80% confidence');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// instincts() tests
|
||||
console.log('\ninstincts():');
|
||||
|
||||
if (test('displays instincts in a box', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'instinct-1', confidence: 0.95 },
|
||||
{ name: 'instinct-2', confidence: 0.7 },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('instinct-1'), 'Should show instinct name');
|
||||
assert.ok(combined.includes('95%'), 'Should show confidence percentage');
|
||||
assert.ok(combined.includes('70%'), 'Should show second confidence');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// output() tests
|
||||
console.log('\noutput():');
|
||||
|
||||
if (test('displays file paths', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.output(
|
||||
'/path/to/SKILL.md',
|
||||
'/path/to/instincts.yaml'
|
||||
));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('SKILL.md'), 'Should show skill path');
|
||||
assert.ok(combined.includes('instincts.yaml'), 'Should show instincts path');
|
||||
assert.ok(combined.includes('Complete'), 'Should show completion message');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// nextSteps() tests
|
||||
console.log('\nnextSteps():');
|
||||
|
||||
if (test('displays next steps with commands', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.nextSteps());
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Next Steps'), 'Should show Next Steps title');
|
||||
assert.ok(combined.includes('/instinct-import'), 'Should show import command');
|
||||
assert.ok(combined.includes('/evolve'), 'Should show evolve command');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// footer() tests
|
||||
console.log('\nfooter():');
|
||||
|
||||
if (test('displays footer with attribution', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.footer());
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Everything Claude Code'), 'Should include project name');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// progressBar edge cases (tests the clamp fix)
|
||||
console.log('\nprogressBar edge cases:');
|
||||
|
||||
if (test('does not crash with confidence > 1.0 (percent > 100)', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
// confidence 1.5 => percent 150 — previously crashed with RangeError
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Overconfident', trigger: 'always', confidence: 1.5, evidence: 'too much' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('150%'), 'Should show 150%');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renders 0% confidence bar without crash', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Zero Confidence', trigger: 'never', confidence: 0.0, evidence: 'none' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('0%'), 'Should show 0%');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('renders 100% confidence bar without crash', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Perfect', trigger: 'always', confidence: 1.0, evidence: 'certain' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('100%'), 'Should show 100%');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Empty array edge cases
|
||||
console.log('\nempty array edge cases:');
|
||||
|
||||
if (test('patterns() with empty array produces header but no entries', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Patterns'), 'Should show header');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('instincts() with empty array produces box but no entries', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.instincts([]));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('Instincts'), 'Should show box title');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Box drawing crash fix (regression test)
|
||||
console.log('\nbox() crash prevention:');
|
||||
|
||||
if (test('box does not crash on title longer than width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 20 });
|
||||
// The instincts() method calls box() internally with a title
|
||||
// that could exceed the narrow width
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'a-very-long-instinct-name', confidence: 0.9 },
|
||||
]));
|
||||
assert.ok(logs.length > 0, 'Should produce output without crash');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('analysisResults does not crash with very narrow width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 10 });
|
||||
// box() is called with a title that exceeds width=10
|
||||
const logs = captureLog(() => output.analysisResults({
|
||||
commits: 1, timeRange: 'today', contributors: 1, files: 1,
|
||||
}));
|
||||
assert.ok(logs.length > 0, 'Should produce output without crash');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// box() alignment regression test
|
||||
console.log('\nbox() alignment:');
|
||||
|
||||
if (test('top, middle, and bottom lines have equal visual width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 40 });
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'test', confidence: 0.9 },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => stripAnsi(l).trim().length > 0);
|
||||
// Find lines that start with box-drawing characters
|
||||
const boxDrawn = boxLines.filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
if (boxDrawn.length >= 3) {
|
||||
const widths = boxDrawn.map(l => stripAnsi(l).length);
|
||||
const firstWidth = widths[0];
|
||||
widths.forEach((w, i) => {
|
||||
assert.strictEqual(w, firstWidth,
|
||||
`Line ${i} width ${w} should match first line width ${firstWidth}`);
|
||||
});
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 27: box and progressBar edge cases ──
|
||||
console.log('\nbox() content overflow:');
|
||||
|
||||
if (test('box does not crash when content line exceeds width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 30 });
|
||||
// Force a very long instinct name that exceeds width
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'this-is-an-extremely-long-instinct-name-that-clearly-exceeds-width', confidence: 0.9 },
|
||||
]));
|
||||
// Math.max(0, padding) should prevent RangeError
|
||||
assert.ok(logs.length > 0, 'Should produce output without RangeError');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('patterns renders negative confidence without crash', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
// confidence -0.1 => percent -10 — Math.max(0, ...) should clamp filled to 0
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Negative', trigger: 'never', confidence: -0.1, evidence: 'impossible' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('-10%'), 'Should show -10%');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header does not crash with very long repo name', () => {
|
||||
const longRepo = 'A'.repeat(100);
|
||||
const output = new SkillCreateOutput(longRepo);
|
||||
// Math.max(0, 55 - stripAnsi(subtitle).length) protects against negative repeat
|
||||
const logs = captureLog(() => output.header());
|
||||
assert.ok(logs.length > 0, 'Should produce output without crash');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('stripAnsi handles nested ANSI codes with multi-digit params', () => {
|
||||
// Simulate bold + color + reset
|
||||
const ansiStr = '\x1b[1m\x1b[36mBold Cyan\x1b[0m\x1b[0m';
|
||||
const stripped = stripAnsi(ansiStr);
|
||||
assert.strictEqual(stripped, 'Bold Cyan', 'Should strip all nested ANSI sequences');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('footer produces output', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.footer());
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
assert.ok(combined.includes('Powered by'), 'Should include attribution text');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 34: header width alignment ──
|
||||
console.log('\nheader() width alignment (Round 34):');
|
||||
|
||||
if (test('header subtitle line matches border width', () => {
|
||||
const output = new SkillCreateOutput('test-repo');
|
||||
const logs = captureLog(() => output.header());
|
||||
// Find the border and subtitle lines
|
||||
const lines = logs.map(l => stripAnsi(l));
|
||||
const borderLine = lines.find(l => l.includes('═══'));
|
||||
const subtitleLine = lines.find(l => l.includes('Extracting patterns'));
|
||||
assert.ok(borderLine, 'Should find border line');
|
||||
assert.ok(subtitleLine, 'Should find subtitle line');
|
||||
// Both lines should have the same visible width
|
||||
assert.strictEqual(subtitleLine.length, borderLine.length,
|
||||
`Subtitle width (${subtitleLine.length}) should match border width (${borderLine.length})`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header all lines have consistent width for short repo name', () => {
|
||||
const output = new SkillCreateOutput('abc');
|
||||
const logs = captureLog(() => output.header());
|
||||
const lines = logs.map(l => stripAnsi(l)).filter(l => l.includes('║') || l.includes('╔') || l.includes('╚'));
|
||||
assert.ok(lines.length >= 4, 'Should have at least 4 box lines');
|
||||
const widths = lines.map(l => l.length);
|
||||
const first = widths[0];
|
||||
widths.forEach((w, i) => {
|
||||
assert.strictEqual(w, first,
|
||||
`Line ${i} width (${w}) should match first line (${first})`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header subtitle has correct content area width of 64 chars', () => {
|
||||
const output = new SkillCreateOutput('myrepo');
|
||||
const logs = captureLog(() => output.header());
|
||||
const lines = logs.map(l => stripAnsi(l));
|
||||
const subtitleLine = lines.find(l => l.includes('Extracting patterns'));
|
||||
assert.ok(subtitleLine, 'Should find subtitle line');
|
||||
// Content between ║ and ║ should be 64 chars (border is 66 total)
|
||||
// Format: ║ + content(64) + ║ = 66
|
||||
assert.strictEqual(subtitleLine.length, 66,
|
||||
`Total subtitle line width should be 66, got ${subtitleLine.length}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('header subtitle line does not truncate with medium-length repo name', () => {
|
||||
const output = new SkillCreateOutput('my-medium-repo-name');
|
||||
const logs = captureLog(() => output.header());
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('my-medium-repo-name'), 'Should include full repo name');
|
||||
const lines = logs.map(l => stripAnsi(l));
|
||||
const subtitleLine = lines.find(l => l.includes('Extracting patterns'));
|
||||
assert.ok(subtitleLine, 'Should have subtitle line');
|
||||
// Should still be 66 chars even with a longer name
|
||||
assert.strictEqual(subtitleLine.length, 66,
|
||||
`Subtitle line should be 66 chars, got ${subtitleLine.length}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 35: box() width accuracy ──
|
||||
console.log('\nbox() width accuracy (Round 35):');
|
||||
|
||||
if (test('box lines in instincts() match the default box width of 60', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'test-instinct', confidence: 0.85 },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should have at least 3 box lines');
|
||||
// The box() default width is 60 — each line should be exactly 60 chars
|
||||
boxLines.forEach((l, i) => {
|
||||
const w = stripAnsi(l).length;
|
||||
assert.strictEqual(w, 60,
|
||||
`Box line ${i} should be 60 chars wide, got ${w}`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('box lines with custom width match the requested width', () => {
|
||||
const output = new SkillCreateOutput('repo', { width: 40 });
|
||||
const logs = captureLog(() => output.instincts([
|
||||
{ name: 'short', confidence: 0.9 },
|
||||
]));
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should have at least 3 box lines');
|
||||
// instincts() calls box() with no explicit width, so it uses the default 60
|
||||
// regardless of this.width — verify self-consistency at least
|
||||
const firstWidth = stripAnsi(boxLines[0]).length;
|
||||
boxLines.forEach((l, i) => {
|
||||
const w = stripAnsi(l).length;
|
||||
assert.strictEqual(w, firstWidth,
|
||||
`Box line ${i} width ${w} should match first line ${firstWidth}`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('analysisResults box lines are all 60 chars wide', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.analysisResults({
|
||||
commits: 50, timeRange: 'Jan 2026', contributors: 2, files: 100,
|
||||
}));
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should have at least 3 box lines');
|
||||
boxLines.forEach((l, i) => {
|
||||
const w = stripAnsi(l).length;
|
||||
assert.strictEqual(w, 60,
|
||||
`Analysis box line ${i} should be 60 chars, got ${w}`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('nextSteps box lines are all 60 chars wide', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.nextSteps());
|
||||
const combined = logs.join('\n');
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should have at least 3 box lines');
|
||||
boxLines.forEach((l, i) => {
|
||||
const w = stripAnsi(l).length;
|
||||
assert.strictEqual(w, 60,
|
||||
`NextSteps box line ${i} should be 60 chars, got ${w}`);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 54: analysisResults with zero values ──
|
||||
console.log('\nanalysisResults zero values (Round 54):');
|
||||
|
||||
if (test('analysisResults handles zero values for all data fields', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.analysisResults({
|
||||
commits: 0, timeRange: '', contributors: 0, files: 0,
|
||||
}));
|
||||
const combined = logs.join('\n');
|
||||
assert.ok(combined.includes('0'), 'Should display zero values');
|
||||
assert.ok(logs.length > 0, 'Should produce output without crash');
|
||||
// Box lines should still be 60 chars wide
|
||||
const boxLines = combined.split('\n').filter(l => {
|
||||
const s = stripAnsi(l).trim();
|
||||
return s.startsWith('\u256D') || s.startsWith('\u2502') || s.startsWith('\u2570');
|
||||
});
|
||||
assert.ok(boxLines.length >= 3, 'Should render a complete box');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 68: demo function export ──
|
||||
console.log('\ndemo export (Round 68):');
|
||||
|
||||
if (test('module exports demo function alongside SkillCreateOutput', () => {
|
||||
const mod = require('../../scripts/skill-create-output');
|
||||
assert.ok(mod.demo, 'Should export demo function');
|
||||
assert.strictEqual(typeof mod.demo, 'function', 'demo should be a function');
|
||||
assert.ok(mod.SkillCreateOutput, 'Should also export SkillCreateOutput');
|
||||
assert.strictEqual(typeof mod.SkillCreateOutput, 'function', 'SkillCreateOutput should be a constructor');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 85: patterns() confidence=0 uses ?? (not ||) ──
|
||||
console.log('\nRound 85: patterns() confidence=0 nullish coalescing:');
|
||||
|
||||
if (test('patterns() with confidence=0 shows 0%, not 80% (nullish coalescing fix)', () => {
|
||||
const output = new SkillCreateOutput('repo');
|
||||
const logs = captureLog(() => output.patterns([
|
||||
{ name: 'Zero Confidence', trigger: 'never', confidence: 0, evidence: 'none' },
|
||||
]));
|
||||
const combined = stripAnsi(logs.join('\n'));
|
||||
// With ?? operator: 0 ?? 0.8 = 0 → Math.round(0 * 100) = 0 → shows "0%"
|
||||
// With || operator (bug): 0 || 0.8 = 0.8 → shows "80%"
|
||||
assert.ok(combined.includes('0%'), 'Should show 0% for zero confidence');
|
||||
assert.ok(!combined.includes('80%'),
|
||||
'Should NOT show 80% — confidence=0 is explicitly provided, not missing');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ── Round 87: analyzePhase() async method (untested) ──
|
||||
console.log('\nRound 87: analyzePhase() async method:');
|
||||
|
||||
if (test('analyzePhase completes without error and writes to stdout', () => {
|
||||
const output = new SkillCreateOutput('test-repo');
|
||||
// analyzePhase is async and calls animateProgress which uses sleep() and
|
||||
// process.stdout.write/clearLine/cursorTo. In non-TTY environments clearLine
|
||||
// and cursorTo are undefined, but the code uses optional chaining (?.) to
|
||||
// handle this safely. We verify it resolves without throwing.
|
||||
// Capture stdout.write to verify output was produced.
|
||||
const writes = [];
|
||||
const origWrite = process.stdout.write;
|
||||
process.stdout.write = function(str) { writes.push(String(str)); return true; };
|
||||
try {
|
||||
// Call synchronously by accessing the returned promise — we just need to
|
||||
// verify it doesn't throw during setup. The sleeps total 1.9s so we
|
||||
// verify the promise is a thenable (async function returns Promise).
|
||||
const promise = output.analyzePhase({ commits: 42 });
|
||||
assert.ok(promise && typeof promise.then === 'function',
|
||||
'analyzePhase should return a Promise');
|
||||
} finally {
|
||||
process.stdout.write = origWrite;
|
||||
}
|
||||
// Verify that process.stdout.write was called (the header line is written synchronously)
|
||||
assert.ok(writes.length > 0, 'Should have written output via process.stdout.write');
|
||||
assert.ok(writes.some(w => w.includes('Analyzing')), 'Should include "Analyzing" label');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Source-level tests for scripts/sync-ecc-to-codex.sh
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'sync-ecc-to-codex.sh');
|
||||
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||
const normalizedSource = source.replace(/\r\n/g, '\n');
|
||||
const runOrEchoSource = (() => {
|
||||
const start = normalizedSource.indexOf('run_or_echo() {');
|
||||
if (start < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let bodyStart = normalizedSource.indexOf('{', start);
|
||||
if (bodyStart < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (let i = bodyStart; i < normalizedSource.length; i++) {
|
||||
const char = normalizedSource[i];
|
||||
if (char === '{') {
|
||||
depth += 1;
|
||||
} else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return normalizedSource.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
})();
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing sync-ecc-to-codex.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('run_or_echo does not use eval', () => {
|
||||
assert.ok(runOrEchoSource, 'Expected to locate run_or_echo function body');
|
||||
assert.ok(!runOrEchoSource.includes('eval "$@"'), 'run_or_echo should not execute through eval');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('run_or_echo executes argv directly', () => {
|
||||
assert.ok(runOrEchoSource.includes(' "$@"'), 'run_or_echo should execute the argv vector directly');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('dry-run output shell-escapes argv', () => {
|
||||
assert.ok(runOrEchoSource.includes(`printf ' %q' "$@"`), 'Dry-run mode should print shell-escaped argv');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('filesystem-changing calls use argv-form run_or_echo invocations', () => {
|
||||
assert.ok(source.includes('run_or_echo mkdir -p "$BACKUP_DIR"'), 'mkdir should use argv form');
|
||||
assert.ok(source.includes('run_or_echo rm -rf "$dest"'), 'rm should use argv form');
|
||||
assert.ok(source.includes('run_or_echo cp -R "$skill_dir" "$dest"'), 'recursive copy should use argv form');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Tests for scripts/uninstall.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'uninstall.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')
|
||||
).version;
|
||||
const CURRENT_MANIFEST_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8')
|
||||
).version;
|
||||
const {
|
||||
createInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeState(filePath, options) {
|
||||
const state = createInstallState(options);
|
||||
writeInstallState(filePath, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
};
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function 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 uninstall.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('uninstalls files from a real install-apply state and preserves unrelated files', () => {
|
||||
const homeDir = createTempDir('uninstall-home-');
|
||||
const projectRoot = createTempDir('uninstall-project-');
|
||||
|
||||
try {
|
||||
const installStdout = execFileSync('node', [INSTALL_SCRIPT, '--target', 'cursor', 'typescript'], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
assert.ok(installStdout.includes('Done. Install-state written'));
|
||||
|
||||
const normalizedProjectRoot = fs.realpathSync(projectRoot);
|
||||
const managedPath = path.join(normalizedProjectRoot, '.cursor', 'hooks.json');
|
||||
const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json');
|
||||
const unrelatedPath = path.join(normalizedProjectRoot, '.cursor', 'custom-user-note.txt');
|
||||
fs.writeFileSync(unrelatedPath, 'leave me alone');
|
||||
|
||||
const uninstallResult = run(['--target', 'cursor'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
|
||||
assert.ok(uninstallResult.stdout.includes('Uninstall summary'));
|
||||
assert.ok(!fs.existsSync(managedPath));
|
||||
assert.ok(!fs.existsSync(statePath));
|
||||
assert.ok(fs.existsSync(unrelatedPath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reverses non-copy operations and keeps unrelated files', () => {
|
||||
const homeDir = createTempDir('uninstall-home-');
|
||||
const projectRoot = createTempDir('uninstall-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||
const copiedPath = path.join(normalizedTargetRoot, 'managed-rule.md');
|
||||
const mergedPath = path.join(normalizedTargetRoot, 'hooks.json');
|
||||
const removedPath = path.join(normalizedTargetRoot, 'legacy-note.txt');
|
||||
const unrelatedPath = path.join(normalizedTargetRoot, 'custom-user-note.txt');
|
||||
fs.writeFileSync(copiedPath, 'managed\n');
|
||||
fs.writeFileSync(mergedPath, JSON.stringify({
|
||||
existing: true,
|
||||
managed: true,
|
||||
}, null, 2));
|
||||
fs.writeFileSync(unrelatedPath, 'leave me alone');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: normalizedTargetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: 'rules/common/coding-style.md',
|
||||
destinationPath: copiedPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/hooks.json',
|
||||
destinationPath: mergedPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: {
|
||||
managed: true,
|
||||
},
|
||||
previousContent: JSON.stringify({
|
||||
existing: true,
|
||||
}, null, 2),
|
||||
},
|
||||
{
|
||||
kind: 'remove',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/legacy-note.txt',
|
||||
destinationPath: removedPath,
|
||||
strategy: 'remove',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
previousContent: 'restore me\n',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallResult = run(['--target', 'cursor'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
|
||||
assert.ok(uninstallResult.stdout.includes('Uninstall summary'));
|
||||
assert.ok(!fs.existsSync(copiedPath));
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(mergedPath, 'utf8')), {
|
||||
existing: true,
|
||||
});
|
||||
assert.strictEqual(fs.readFileSync(removedPath, 'utf8'), 'restore me\n');
|
||||
assert.ok(!fs.existsSync(statePath));
|
||||
assert.ok(fs.existsSync(unrelatedPath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('supports dry-run without mutating managed files', () => {
|
||||
const homeDir = createTempDir('uninstall-home-');
|
||||
const projectRoot = createTempDir('uninstall-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
|
||||
fs.writeFileSync(renderedPath, '# generated\n');
|
||||
|
||||
writeState(statePath, {
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
targetRoot: normalizedTargetRoot,
|
||||
installStatePath: statePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
kind: 'render-template',
|
||||
moduleId: 'platform-configs',
|
||||
sourceRelativePath: '.cursor/generated.md.template',
|
||||
destinationPath: renderedPath,
|
||||
strategy: 'render-template',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
renderedContent: '# generated\n',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallResult = run(['--target', 'cursor', '--dry-run', '--json'], {
|
||||
cwd: projectRoot,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
|
||||
|
||||
const parsed = JSON.parse(uninstallResult.stdout);
|
||||
assert.strictEqual(parsed.dryRun, true);
|
||||
assert.ok(parsed.results[0].plannedRemovals.includes(renderedPath));
|
||||
assert.ok(fs.existsSync(renderedPath));
|
||||
assert.ok(fs.existsSync(statePath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
Reference in New Issue
Block a user