brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
test-cli-applicationWrite integration tests for a Node.js CLI application using the built-in node:test module. Covers the exec helper pattern, output assertions, filesystem state verification, cleanup hooks, JSON output parsing, error case testing, and state restoration after destructive tests. Use when adding tests to an existing CLI, testing a new command, verifying adapter …
Independent GitHub snapshot · Automated classification · Original documentation governs
Write integration tests for Node.js CLI using 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 reproducibilityGot: Test file imports from node:test, has working run() helper.
If fail: node:test not available? Node.js version below 18. Upgrade or use polyfill.
Smoke tests verify CLI starts, parses arguments, 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 workGot: Smoke tests confirm CLI functional and data loaded.
If fail: Registry counts change frequent? 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 suiteGot: Tests run in sequence within describe block, cleanup runs even on failure.
If fail: 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 createdGot: One describe block per adapter, each with at least path assertion.
If fail: Adapter doesn't exist in project? Test will fail with "Unknown framework." 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 execSyncGot: All error paths produce non-zero exit codes and helpful messages.
If fail: execSync throws on non-zero exit. Error's stderr or stdout contains 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:
{ characterGot: JSON output parseable, contains expected keys.
If fail: JSON.parse fails? Command may be mixing human text with JSON. Either fix command to output pure JSON in --json mode, or extract 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 lastGot: Test suite leaves project in same state it found it.
If fail: CI reports leftover files after test runs? Add cleanup to after(). Use git status after test runs to detect leaked state.
node --test cli/test/cli.test.js--version, --help, registry loading\d+ regex or read count dynamic instead of asserting 329 skills.node:test runs suites in declaration order default, but tests within suite may not. Use lifecycle suites (create → verify → delete) within single describe to guarantee order.after() still runs. But throw in before()? Subsequent tests and after() may not run. Keep before() minimal.execSync. Either pipe echo y | or ensure --yes always passed in tests..claude/skills/ or .agents/skills/ modify working tree. CI may fail on "dirty working directory" checks. Always clean up.scaffold-cli-command — build commands that these tests verifybuild-cli-plugin — build adapters tested in Step 4design-cli-output — output patterns that tests assert against