Files
everything-claude-code/tests/scripts/claw.test.js
andydiaz122 853c64d7c1 feat: add NanoClaw agent REPL — persistent session-aware CLI for ECC
Implements a barebones agent loop that delegates to `claude -p` with
markdown-as-database session persistence and ECC skill context loading.
Zero external dependencies, ~264 lines of pure Node.js CommonJS.

- scripts/claw.js: core module (storage, context, delegation, REPL)
- commands/claw.md: slash command definition with usage docs
- tests/scripts/claw.test.js: 14 unit tests covering all modules
- package.json: add claw script and files entry
- tests/run-all.js: register claw tests in test manifest
2026-02-15 12:02:19 -05:00

238 lines
9.4 KiB
JavaScript

/**
* 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
} = 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}`);
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++;
// ── Summary ───────────────────────────────────────────────────────────
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();