From agent-almanac
Writes integration tests for Node.js CLI apps using built-in node:test and execSync. Covers output assertions, filesystem verification, cleanup hooks, JSON parsing, error testing, state restoration. Use for existing CLIs, new commands, adapters, or CI setup.
npx claudepluginhub pjt222/agent-almanacThis skill is limited to using the following tools:
Write integration tests for a Node.js CLI using the built-in `node:test` module with `execSync`.
Scaffolds new CLI commands using Commander.js with standard options, action handlers, three output modes (human-readable/quiet/JSON), optional ceremony variant, error handling, and integration tests. Use for extending existing CLIs or standardizing multi-command structure.
Writes unit (mocked), E2E live, subprocess, and VCR integration tests for Python cli-web-* CLIs using pytest; documents plans and results in TEST.md.
Generates and verifies passing tests for files/functions/directories: happy path, edge cases, error paths. Matches project framework (Jest/Vitest/pytest etc.), auto-detects patterns, runs/fixes failures.
Share bugs, ideas, or general feedback.
Write integration tests for a Node.js CLI using the built-in node:test module with execSync.
cli/index.js)import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'child_process';
import { existsSync, rmSync } from 'fs';
import { resolve } from 'path';
const CLI = 'node cli/index.js';
const ROOT = process.cwd();
function run(args) {
return execSync(`${CLI} ${args}`, {
cwd: ROOT,
encoding: 'utf8',
timeout: 10000,
});
}
Key design decisions:
node:test is built-in — no test runner dependency neededexecSync runs the CLI as a subprocess — tests the actual binary, not internal functionsencoding: 'utf8' gives string output for regex matchingROOT for reproducibilityExpected: A test file that imports from node:test and has a working run() helper.
On failure: If node:test is not available, your Node.js version is below 18. Upgrade or use a polyfill.
Smoke tests verify the CLI starts, parses arguments, and produces expected output shapes:
describe('meta', () => {
it('shows version', () => {
const out = run('--version');
assert.match(out, /\d+\.\d+\.\d+/);
});
it('shows help with all commands', () => {
const out = run('--help');
assert.match(out, /install/);
assert.match(out, /list/);
assert.match(out, /detect/);
});
});
describe('registry', () => {
it('list shows expected counts', () => {
const out = run('list --domains');
assert.match(out, /\d+ domains/);
});
it('search finds known items', () => {
const out = run('search "docker"');
assert.match(out, /result\(s\) for "docker"/);
});
it('search returns 0 for nonsense', () => {
const out = run('search "xyzzy-nonexistent"');
assert.match(out, /0 result/);
});
});
Smoke test patterns:
--version and --help always workExpected: Smoke tests confirm the CLI is functional and data is loaded.
On failure: If registry counts change frequently, use \d+ instead of hardcoded numbers.
Lifecycle tests verify create → verify → delete sequences with cleanup:
describe('install', () => {
const testPath = resolve(ROOT, '.agents/skills/commit-changes');
after(() => {
// Always clean up, even if tests fail
try { rmSync(testPath); } catch {}
try { rmSync(resolve(ROOT, '.agents/skills'), { recursive: true }); } catch {}
try { rmSync(resolve(ROOT, '.agents'), { recursive: true }); } catch {}
});
it('dry-run does not create files', () => {
const out = run('install commit-changes --dry-run');
assert.match(out, /DRY RUN/);
assert.ok(!existsSync(testPath));
});
it('installs creates the target', () => {
run('install commit-changes');
assert.ok(existsSync(testPath));
});
it('skips already installed', () => {
const out = run('install commit-changes');
assert.match(out, /skipped/);
});
it('uninstall removes the target', () => {
run('uninstall commit-changes');
assert.ok(!existsSync(testPath));
});
});
Cleanup rules:
after() hooks, not afterEach() — lifecycle tests build on each othertry/catch — cleanup must not fail the test suiteExpected: Tests run in sequence within the describe block, cleanup runs even on failure.
On failure: If tests run in parallel (non-default in node:test), force sequential with { concurrency: 1 }.
Test each adapter's target path without making changes:
describe('adapter: cursor (dry-run)', () => {
it('targets .cursor/skills/ path', () => {
const out = run('install commit-changes --framework cursor --dry-run');
assert.match(out, /\.cursor\/skills/i);
});
});
describe('adapter: copilot (dry-run)', () => {
it('targets .github/ path', () => {
const out = run('install commit-changes --framework copilot --dry-run');
assert.match(out, /\.github/i);
});
});
This pattern scales to any number of adapters. Each test:
--framework to bypass auto-detection--dry-run so no files are createdExpected: One describe block per adapter, each with at least a path assertion.
On failure: If the adapter doesn't exist in the project, the test will fail with "Unknown framework." This is correct — adapter tests should only exist for implemented adapters.
describe('errors', () => {
it('rejects unknown items', () => {
assert.throws(
() => run('install nonexistent-skill-xyz'),
/No matching items|Unknown/,
);
});
it('rejects unknown framework', () => {
assert.throws(
() => run('install commit-changes --framework nonexistent'),
/Unknown framework/,
);
});
it('handles missing state gracefully', () => {
assert.throws(
() => run('scatter nonexistent-team'),
/not burning|Unknown/,
);
});
});
Error testing patterns:
assert.throws catches non-zero exit codes from execSyncExpected: All error paths produce non-zero exit codes and helpful messages.
On failure: execSync throws on non-zero exit. The error's stderr or stdout contains the message. Check error.stdout if assert.throws regex doesn't match.
describe('json output', () => {
it('campfire --json outputs valid JSON', () => {
const out = run('campfire --json');
const data = JSON.parse(out);
assert.ok(typeof data.totalTeams === 'number');
assert.ok(Array.isArray(data.fires));
});
it('gather --dry-run --json outputs structured data', () => {
const out = run('gather tending --dry-run --json');
// JSON may follow a DRY RUN header — extract from first '{'
const jsonStart = out.indexOf('{');
assert.ok(jsonStart >= 0, 'Should contain JSON');
const data = JSON.parse(out.slice(jsonStart));
assert.equal(data.team, 'tending');
});
});
JSON testing gotchas:
{ characterExpected: JSON output is parseable and contains expected keys.
On failure: If JSON.parse fails, the command may be mixing human text with JSON. Either fix the command to output pure JSON in --json mode, or extract the JSON substring.
describe('stateful commands', () => {
const stateDir = resolve(ROOT, '.agent-almanac');
after(() => {
// Remove state file created by tests
try { rmSync(stateDir, { recursive: true }); } catch {}
});
// Tests that create/modify state...
});
// Restore symlinks that destructive tests may remove
describe('destructive tests', () => {
after(() => {
// Restore symlinks that scatter/uninstall removed
const skills = ['heal', 'meditate', 'remote-viewing'];
for (const skill of skills) {
const link = resolve(ROOT, `.claude/skills/${skill}`);
if (!existsSync(link)) {
try {
execSync(`ln -s ../../skills/${skill} ${link}`, { cwd: ROOT });
} catch {}
}
}
});
});
State restoration rules:
.agent-almanac/state.json) must be cleaned after testsscatter/uninstall must be restoredagent-almanac.yml) created by init must be removedafter() hooks run in reverse declaration order — declare restore hooks lastExpected: The test suite leaves the project in the same state it found it.
On failure: If CI reports leftover files after test runs, add the cleanup to after(). Use git status after test runs to detect leaked state.
node --test cli/test/cli.test.js--version, --help, and registry loading\d+ regex or read the count dynamically instead of asserting 329 skills.node:test runs suites in declaration order by default, but tests within a suite may not. Use lifecycle suites (create → verify → delete) within a single describe to guarantee order.after() still runs. But if you throw in before(), subsequent tests and after() may not run. Keep before() minimal.execSync. Either pipe echo y | or ensure --yes is always passed in tests..claude/skills/ or .agents/skills/ modify the working tree. CI may fail on "dirty working directory" checks. Always clean up.scaffold-cli-command — build the commands that these tests verifybuild-cli-plugin — build the adapters tested in Step 4design-cli-output — output patterns that tests assert against