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