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>
This commit is contained in:
502
npm-package/test/integration.test.js
Executable file
502
npm-package/test/integration.test.js
Executable file
@@ -0,0 +1,502 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user