Files
beads/npm-package/test/integration.test.js
Steve Yegge fc71d4e192 Add integration tests and release documentation for npm package
Integration Tests:
- Comprehensive test suite covering all major functionality
- 5 test scenarios: installation, binary functionality, workflow,
  Claude Code for Web simulation, platform detection
- Tests JSONL import/export across sessions
- Tests all major commands (init, create, list, show, update, close, ready)
- All tests passing 

Testing Documentation:
- TESTING.md with complete test documentation
- Describes unit vs integration tests
- Manual testing scenarios
- CI/CD recommendations
- Troubleshooting guide

Release Documentation:
- RELEASING.md with comprehensive release process
- Covers all distribution channels: GitHub, Homebrew, PyPI, npm
- Step-by-step instructions for each channel
- Version numbering and release cadence
- Hotfix and rollback procedures
- Automation opportunities with GitHub Actions

npm Package Updates:
- Added test:integration and test:all scripts
- Integration tests validate real-world usage patterns
- Tests simulate Claude Code for Web SessionStart hooks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 11:54:37 -08:00

503 lines
15 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Integration tests for @beads/bd npm package
*
* Tests:
* 1. Package installation in clean environment
* 2. Binary download and extraction
* 3. Basic bd commands (version, init, create, list, etc.)
* 4. Claude Code for Web simulation
*/
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Test configuration
const TEST_DIR = path.join(os.tmpdir(), `bd-integration-test-${Date.now()}`);
const PACKAGE_DIR = path.join(__dirname, '..');
// ANSI colors for output
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
gray: '\x1b[90m'
};
function log(msg, color = 'reset') {
console.log(`${colors[color]}${msg}${colors.reset}`);
}
function logTest(name) {
log(`\n${name}`, 'blue');
}
function logSuccess(msg) {
log(`${msg}`, 'green');
}
function logError(msg) {
log(`${msg}`, 'red');
}
function logInfo(msg) {
log(` ${msg}`, 'gray');
}
// Test utilities
function exec(cmd, opts = {}) {
const defaultOpts = {
stdio: 'pipe',
encoding: 'utf8',
...opts
};
try {
return execSync(cmd, defaultOpts);
} catch (err) {
if (opts.throwOnError !== false) {
throw err;
}
return err.stdout || err.stderr || '';
}
}
function setupTestDir() {
if (fs.existsSync(TEST_DIR)) {
fs.rmSync(TEST_DIR, { recursive: true, force: true });
}
fs.mkdirSync(TEST_DIR, { recursive: true });
logInfo(`Test directory: ${TEST_DIR}`);
}
function cleanupTestDir() {
if (fs.existsSync(TEST_DIR)) {
fs.rmSync(TEST_DIR, { recursive: true, force: true });
}
}
// Test 1: Package installation
async function testPackageInstallation() {
logTest('Test 1: Package Installation');
try {
// Pack the package
logInfo('Packing npm package...');
const packOutput = exec('npm pack', { cwd: PACKAGE_DIR });
const tarball = packOutput.trim().split('\n').pop();
const tarballPath = path.join(PACKAGE_DIR, tarball);
logSuccess(`Package created: ${tarball}`);
// Install from tarball in test directory
logInfo('Installing package in test environment...');
const npmPrefix = path.join(TEST_DIR, 'npm-global');
fs.mkdirSync(npmPrefix, { recursive: true });
exec(`npm install -g "${tarballPath}" --prefix "${npmPrefix}"`, {
cwd: TEST_DIR,
env: { ...process.env, npm_config_prefix: npmPrefix }
});
logSuccess('Package installed successfully');
// Verify binary exists
const bdPath = path.join(npmPrefix, 'bin', 'bd');
if (!fs.existsSync(bdPath) && !fs.existsSync(bdPath + '.cmd')) {
// On Windows, might be bd.cmd
const windowsPath = path.join(npmPrefix, 'bd.cmd');
if (!fs.existsSync(windowsPath)) {
throw new Error(`bd binary not found at ${bdPath}`);
}
}
logSuccess('bd binary installed');
// Cleanup tarball
fs.unlinkSync(tarballPath);
return { npmPrefix, bdPath };
} catch (err) {
logError(`Package installation failed: ${err.message}`);
throw err;
}
}
// Test 2: Binary functionality
async function testBinaryFunctionality(npmPrefix) {
logTest('Test 2: Binary Functionality');
const bdCmd = path.join(npmPrefix, 'bin', 'bd');
const env = { ...process.env, PATH: `${path.join(npmPrefix, 'bin')}:${process.env.PATH}` };
try {
// Test version command
logInfo('Testing version command...');
const version = exec(`"${bdCmd}" version`, { env });
if (!version.includes('bd version')) {
throw new Error(`Unexpected version output: ${version}`);
}
logSuccess(`Version: ${version.trim()}`);
// Test help command
logInfo('Testing help command...');
const help = exec(`"${bdCmd}" --help`, { env });
if (!help.includes('Available Commands')) {
throw new Error('Help command did not return expected output');
}
logSuccess('Help command works');
return true;
} catch (err) {
logError(`Binary functionality test failed: ${err.message}`);
throw err;
}
}
// Test 3: Basic bd workflow
async function testBasicWorkflow(npmPrefix) {
logTest('Test 3: Basic bd Workflow');
const projectDir = path.join(TEST_DIR, 'test-project');
fs.mkdirSync(projectDir, { recursive: true });
// Initialize git repo
exec('git init', { cwd: projectDir });
exec('git config user.email "test@example.com"', { cwd: projectDir });
exec('git config user.name "Test User"', { cwd: projectDir });
const bdCmd = path.join(npmPrefix, 'bin', 'bd');
const env = {
...process.env,
PATH: `${path.join(npmPrefix, 'bin')}:${process.env.PATH}`,
BD_ACTOR: 'integration-test'
};
try {
// Test bd init
logInfo('Testing bd init...');
exec(`"${bdCmd}" init --quiet`, { cwd: projectDir, env });
if (!fs.existsSync(path.join(projectDir, '.beads'))) {
throw new Error('.beads directory not created');
}
logSuccess('bd init successful');
// Test bd create
logInfo('Testing bd create...');
const createOutput = exec(`"${bdCmd}" create "Test issue" -t task -p 1 --json`, {
cwd: projectDir,
env
});
const issue = JSON.parse(createOutput);
if (!issue.id || typeof issue.id !== 'string') {
throw new Error(`Invalid issue created: ${JSON.stringify(issue)}`);
}
// ID format can be bd-xxxx or projectname-xxxx depending on configuration
logSuccess(`Created issue: ${issue.id}`);
// Test bd list
logInfo('Testing bd list...');
const listOutput = exec(`"${bdCmd}" list --json`, { cwd: projectDir, env });
const issues = JSON.parse(listOutput);
if (!Array.isArray(issues) || issues.length !== 1) {
throw new Error('bd list did not return expected issues');
}
logSuccess(`Listed ${issues.length} issue(s)`);
// Test bd show
logInfo('Testing bd show...');
const showOutput = exec(`"${bdCmd}" show ${issue.id} --json`, { cwd: projectDir, env });
const showResult = JSON.parse(showOutput);
// bd show --json returns an array with one element
const showIssue = Array.isArray(showResult) ? showResult[0] : showResult;
// Compare IDs - both should be present and match
if (!showIssue.id || showIssue.id !== issue.id) {
throw new Error(`bd show returned wrong issue: expected ${issue.id}, got ${showIssue.id}`);
}
logSuccess(`Show issue: ${showIssue.title}`);
// Test bd update
logInfo('Testing bd update...');
exec(`"${bdCmd}" update ${issue.id} --status in_progress`, { cwd: projectDir, env });
const updatedOutput = exec(`"${bdCmd}" show ${issue.id} --json`, { cwd: projectDir, env });
const updatedResult = JSON.parse(updatedOutput);
const updatedIssue = Array.isArray(updatedResult) ? updatedResult[0] : updatedResult;
if (updatedIssue.status !== 'in_progress') {
throw new Error(`bd update did not change status: expected 'in_progress', got '${updatedIssue.status}'`);
}
logSuccess('Updated issue status');
// Test bd close
logInfo('Testing bd close...');
exec(`"${bdCmd}" close ${issue.id} --reason "Test completed"`, { cwd: projectDir, env });
const closedOutput = exec(`"${bdCmd}" show ${issue.id} --json`, { cwd: projectDir, env });
const closedResult = JSON.parse(closedOutput);
const closedIssue = Array.isArray(closedResult) ? closedResult[0] : closedResult;
if (closedIssue.status !== 'closed') {
throw new Error(`bd close did not close issue: expected 'closed', got '${closedIssue.status}'`);
}
logSuccess('Closed issue');
// Test bd ready (should be empty after closing)
logInfo('Testing bd ready...');
const readyOutput = exec(`"${bdCmd}" ready --json`, { cwd: projectDir, env });
const readyIssues = JSON.parse(readyOutput);
if (readyIssues.length !== 0) {
throw new Error('bd ready should return no issues after closing all');
}
logSuccess('Ready work detection works');
return true;
} catch (err) {
logError(`Basic workflow test failed: ${err.message}`);
throw err;
}
}
// Test 4: Claude Code for Web simulation
async function testClaudeCodeWebSimulation(npmPrefix) {
logTest('Test 4: Claude Code for Web Simulation');
const sessionDir = path.join(TEST_DIR, 'claude-code-session');
fs.mkdirSync(sessionDir, { recursive: true });
try {
// Initialize git repo (simulating a cloned project)
exec('git init', { cwd: sessionDir });
exec('git config user.email "agent@example.com"', { cwd: sessionDir });
exec('git config user.name "Claude Agent"', { cwd: sessionDir });
const bdCmd = path.join(npmPrefix, 'bin', 'bd');
const env = {
...process.env,
PATH: `${path.join(npmPrefix, 'bin')}:${process.env.PATH}`,
BD_ACTOR: 'claude-agent'
};
// First session: initialize and create an issue
logInfo('Session 1: Initialize and create issue...');
exec(`"${bdCmd}" init --quiet`, { cwd: sessionDir, env });
const createOutput = exec(
`"${bdCmd}" create "Existing issue from previous session" -t task -p 1 --json`,
{ cwd: sessionDir, env }
);
const existingIssue = JSON.parse(createOutput);
logSuccess(`Created issue in first session: ${existingIssue.id}`);
// Simulate sync to git (bd automatically exports to JSONL)
const beadsDir = path.join(sessionDir, '.beads');
const jsonlPath = path.join(beadsDir, 'issues.jsonl');
// Wait a moment for auto-export
execSync('sleep 1');
// Verify JSONL exists
if (!fs.existsSync(jsonlPath)) {
throw new Error('JSONL file not created');
}
// Remove the database to simulate a fresh clone
const dbFiles = fs.readdirSync(beadsDir).filter(f => f.endsWith('.db'));
dbFiles.forEach(f => fs.unlinkSync(path.join(beadsDir, f)));
// Session 2: Re-initialize (simulating SessionStart hook in new session)
logInfo('Session 2: Re-initialize from JSONL...');
exec(`"${bdCmd}" init --quiet`, { cwd: sessionDir, env });
logSuccess('bd init re-imported from JSONL');
// Verify issue was imported
const listOutput = exec(`"${bdCmd}" list --json`, { cwd: sessionDir, env });
const issues = JSON.parse(listOutput);
if (!issues.some(i => i.id === existingIssue.id)) {
throw new Error(`Existing issue ${existingIssue.id} not imported from JSONL`);
}
logSuccess('Existing issues imported successfully');
// Simulate agent finding ready work
const readyOutput = exec(`"${bdCmd}" ready --json`, { cwd: sessionDir, env });
const readyIssues = JSON.parse(readyOutput);
if (readyIssues.length === 0) {
throw new Error('No ready work found');
}
logSuccess(`Found ${readyIssues.length} ready issue(s)`);
// Simulate agent creating a new issue
const newCreateOutput = exec(
`"${bdCmd}" create "Bug discovered during session" -t bug -p 0 --json`,
{ cwd: sessionDir, env }
);
const newIssue = JSON.parse(newCreateOutput);
logSuccess(`Agent created new issue: ${newIssue.id}`);
// Verify JSONL was updated
const jsonlContent = fs.readFileSync(
path.join(beadsDir, 'issues.jsonl'),
'utf8'
);
const jsonlLines = jsonlContent.trim().split('\n');
if (jsonlLines.length < 2) {
throw new Error('JSONL not updated with new issue');
}
logSuccess('JSONL auto-export working');
return true;
} catch (err) {
logError(`Claude Code for Web simulation failed: ${err.message}`);
throw err;
}
}
// Test 5: Multi-platform binary detection
async function testPlatformDetection() {
logTest('Test 5: Platform Detection');
try {
const platform = os.platform();
const arch = os.arch();
logInfo(`Current platform: ${platform}`);
logInfo(`Current architecture: ${arch}`);
// Verify postinstall would work for this platform
const supportedPlatforms = {
darwin: ['x64', 'arm64'],
linux: ['x64', 'arm64'],
win32: ['x64', 'arm64']
};
if (!supportedPlatforms[platform]) {
throw new Error(`Unsupported platform: ${platform}`);
}
const archMap = { x64: 'amd64', arm64: 'arm64' };
const mappedArch = archMap[arch];
if (!supportedPlatforms[platform].includes(arch)) {
throw new Error(`Unsupported architecture: ${arch} for platform ${platform}`);
}
logSuccess(`Platform ${platform}-${mappedArch} is supported`);
// Check if GitHub release has this binary
const version = require(path.join(PACKAGE_DIR, 'package.json')).version;
const ext = platform === 'win32' ? 'zip' : 'tar.gz';
const binaryUrl = `https://github.com/steveyegge/beads/releases/download/v${version}/beads_${version}_${platform}_${mappedArch}.${ext}`;
logInfo(`Expected binary URL: ${binaryUrl}`);
logSuccess('Platform detection logic validated');
return true;
} catch (err) {
logError(`Platform detection test failed: ${err.message}`);
throw err;
}
}
// Main test runner
async function runTests() {
log('\n╔════════════════════════════════════════╗', 'blue');
log('║ @beads/bd Integration Tests ║', 'blue');
log('╚════════════════════════════════════════╝', 'blue');
let npmPrefix;
const results = {
passed: 0,
failed: 0,
total: 0
};
try {
setupTestDir();
// Test 1: Installation
results.total++;
try {
const installResult = await testPackageInstallation();
npmPrefix = installResult.npmPrefix;
results.passed++;
} catch (err) {
results.failed++;
log('\n⚠ Skipping remaining tests due to installation failure', 'yellow');
throw err;
}
// Test 2: Binary functionality
results.total++;
try {
await testBinaryFunctionality(npmPrefix);
results.passed++;
} catch (err) {
results.failed++;
}
// Test 3: Basic workflow
results.total++;
try {
await testBasicWorkflow(npmPrefix);
results.passed++;
} catch (err) {
results.failed++;
}
// Test 4: Claude Code for Web
results.total++;
try {
await testClaudeCodeWebSimulation(npmPrefix);
results.passed++;
} catch (err) {
results.failed++;
}
// Test 5: Platform detection
results.total++;
try {
await testPlatformDetection();
results.passed++;
} catch (err) {
results.failed++;
}
} finally {
// Cleanup
logInfo('\nCleaning up test directory...');
cleanupTestDir();
}
// Print summary
log('\n╔════════════════════════════════════════╗', 'blue');
log('║ Test Summary ║', 'blue');
log('╚════════════════════════════════════════╝', 'blue');
log(`\nTotal tests: ${results.total}`, 'blue');
log(`Passed: ${results.passed}`, results.passed === results.total ? 'green' : 'yellow');
log(`Failed: ${results.failed}`, results.failed > 0 ? 'red' : 'green');
if (results.failed > 0) {
log('\n❌ Some tests failed', 'red');
process.exit(1);
} else {
log('\n✅ All tests passed!', 'green');
process.exit(0);
}
}
// Run tests
if (require.main === module) {
runTests().catch(err => {
log(`\n❌ Test suite failed: ${err.message}`, 'red');
console.error(err);
cleanupTestDir();
process.exit(1);
});
}
module.exports = { runTests };