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