From 924bac4ddff784fe3a9a521488d72e1b31f6f592 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 12 Feb 2026 16:46:06 -0800 Subject: [PATCH] fix: add word boundary to dev server hook regex, fix box() crash, add 27 tests - hooks.json: add \b word boundary anchors to dev server blocking regex to prevent false positives matching "npm run develop", "npm run devtools" etc. - skill-create-output.js: guard box() horizontal repeat with Math.max(0, ...) to prevent RangeError when title exceeds container width - Add 13 tests for setup-package-manager.js CLI argument parsing - Add 14 tests for skill-create-output.js SkillCreateOutput class - All 333 tests passing --- hooks/hooks.json | 2 +- scripts/skill-create-output.js | 2 +- tests/run-all.js | 4 +- tests/scripts/setup-package-manager.test.js | 167 ++++++++++++++++ tests/scripts/skill-create-output.test.js | 211 ++++++++++++++++++++ 5 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 tests/scripts/setup-package-manager.test.js create mode 100644 tests/scripts/skill-create-output.test.js diff --git a/hooks/hooks.json b/hooks/hooks.json index 76402687..bbd7329f 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const cmd=i.tool_input?.command||'';if(/(npm run dev|pnpm( run)? dev|yarn dev|bun run dev)/.test(cmd)){console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');console.error('[Hook] Use: tmux new-session -d -s dev \\\"npm run dev\\\"');console.error('[Hook] Then: tmux attach -t dev');process.exit(2)}}catch{}console.log(d)})\"" + "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const cmd=i.tool_input?.command||'';if(/(npm run dev\\b|pnpm( run)? dev\\b|yarn dev\\b|bun run dev\\b)/.test(cmd)){console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');console.error('[Hook] Use: tmux new-session -d -s dev \\\"npm run dev\\\"');console.error('[Hook] Then: tmux attach -t dev');process.exit(2)}}catch{}console.log(d)})\"" } ], "description": "Block dev servers outside tmux - ensures you can access logs" diff --git a/scripts/skill-create-output.js b/scripts/skill-create-output.js index 27412608..e6274f1f 100644 --- a/scripts/skill-create-output.js +++ b/scripts/skill-create-output.js @@ -38,7 +38,7 @@ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', // Helper functions function box(title, content, width = 60) { const lines = content.split('\n'); - const top = `${BOX.topLeft}${BOX.horizontal} ${chalk.bold(chalk.cyan(title))} ${BOX.horizontal.repeat(width - title.length - 5)}${BOX.topRight}`; + const top = `${BOX.topLeft}${BOX.horizontal} ${chalk.bold(chalk.cyan(title))} ${BOX.horizontal.repeat(Math.max(0, width - title.length - 5))}${BOX.topRight}`; const bottom = `${BOX.bottomLeft}${BOX.horizontal.repeat(width - 1)}${BOX.bottomRight}`; const middle = lines.map(line => { const padding = width - 3 - stripAnsi(line).length; diff --git a/tests/run-all.js b/tests/run-all.js index 11f08e28..c9ffd028 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -17,7 +17,9 @@ const testFiles = [ 'lib/session-aliases.test.js', 'hooks/hooks.test.js', 'integration/hooks.test.js', - 'ci/validators.test.js' + 'ci/validators.test.js', + 'scripts/setup-package-manager.test.js', + 'scripts/skill-create-output.test.js' ]; console.log('╔══════════════════════════════════════════════════════════╗'); diff --git a/tests/scripts/setup-package-manager.test.js b/tests/scripts/setup-package-manager.test.js new file mode 100644 index 00000000..4a458132 --- /dev/null +++ b/tests/scripts/setup-package-manager.test.js @@ -0,0 +1,167 @@ +/** + * 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 { 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++; + + // Summary + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/skill-create-output.test.js b/tests/scripts/skill-create-output.test.js new file mode 100644 index 00000000..ce981dc7 --- /dev/null +++ b/tests/scripts/skill-create-output.test.js @@ -0,0 +1,211 @@ +/** + * 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'); +const path = require('path'); + +// 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++; + + // 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++; + + // Summary + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests();