test(cli): add zx TDD infrastructure and phase 1 tests
- Install zx as devDependency for E2E testing - Create tests/ directory with setup utilities - Add setup.ts with test context helpers: - Random port generation (never 6767) - Temp directory creation for PASEO_HOME and workDir - Daemon startup/waitForDaemon helper (WebSocket only) - Cleanup function - paseo() helper to run CLI commands against test daemon - Add Phase 1 foundation tests (01-foundation.test.mts): - Test paseo --version outputs version - Test paseo --help shows commands - Add test runner (run-all.mts) that discovers and runs all tests - Add test:e2e script to package.json
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env npx zx
|
||||
|
||||
/**
|
||||
* Phase 1: Foundation Tests
|
||||
*
|
||||
* Tests basic CLI functionality that doesn't require a daemon:
|
||||
* - paseo --version outputs version
|
||||
* - paseo --help shows commands
|
||||
*/
|
||||
|
||||
import { $ } from 'zx'
|
||||
|
||||
$.verbose = false
|
||||
|
||||
console.log('📋 Phase 1: Foundation Tests\n')
|
||||
|
||||
// Test 1.1: --version outputs version
|
||||
console.log(' Testing paseo --version...')
|
||||
const versionResult = await $`paseo --version`.nothrow()
|
||||
if (versionResult.exitCode !== 0) {
|
||||
console.error(' ❌ paseo --version failed with exit code', versionResult.exitCode)
|
||||
console.error(' stderr:', versionResult.stderr)
|
||||
process.exit(1)
|
||||
}
|
||||
const versionOutput = versionResult.stdout.trim()
|
||||
if (!versionOutput.match(/\d+\.\d+\.\d+/)) {
|
||||
console.error(' ❌ paseo --version output does not contain version number')
|
||||
console.error(' output:', versionOutput)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(' ✅ paseo --version outputs:', versionOutput)
|
||||
|
||||
// Test 1.2: --help shows commands
|
||||
console.log(' Testing paseo --help...')
|
||||
const helpResult = await $`paseo --help`.nothrow()
|
||||
if (helpResult.exitCode !== 0) {
|
||||
console.error(' ❌ paseo --help failed with exit code', helpResult.exitCode)
|
||||
console.error(' stderr:', helpResult.stderr)
|
||||
process.exit(1)
|
||||
}
|
||||
const helpOutput = helpResult.stdout
|
||||
|
||||
// Check for expected sections in help output
|
||||
const expectedTerms = ['agent', 'daemon', 'Usage', 'Options', 'Commands']
|
||||
const missingTerms = expectedTerms.filter(term => !helpOutput.includes(term))
|
||||
if (missingTerms.length > 0) {
|
||||
console.error(' ❌ paseo --help missing expected terms:', missingTerms.join(', '))
|
||||
console.error(' output:', helpOutput)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(' ✅ paseo --help shows commands')
|
||||
|
||||
console.log('\n✅ Phase 1: Foundation Tests PASSED')
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env npx zx
|
||||
|
||||
/**
|
||||
* Test runner for Paseo CLI E2E tests
|
||||
*
|
||||
* Runs all test phases in sequence and reports results.
|
||||
* Each test is a separate .mts file that can also be run independently.
|
||||
*/
|
||||
|
||||
import { $ } from 'zx'
|
||||
import { readdir } from 'fs/promises'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
$.verbose = false
|
||||
|
||||
console.log('🧪 Paseo CLI E2E Test Runner\n')
|
||||
console.log('='.repeat(50))
|
||||
|
||||
// Discover all test files
|
||||
const files = await readdir(__dirname)
|
||||
const testFiles = files
|
||||
.filter(f => f.match(/^\d{2}-.*\.test\.mts$/))
|
||||
.sort()
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
console.log('⚠️ No test files found')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`Found ${testFiles.length} test file(s):\n`)
|
||||
for (const file of testFiles) {
|
||||
console.log(` - ${file}`)
|
||||
}
|
||||
console.log()
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
const failures: { test: string; error: string }[] = []
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
const testPath = join(__dirname, testFile)
|
||||
const testName = testFile.replace(/\.test\.mts$/, '')
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`)
|
||||
console.log(`📋 Running ${testName}...`)
|
||||
console.log('─'.repeat(50))
|
||||
|
||||
try {
|
||||
const result = await $`npx zx ${testPath}`.nothrow()
|
||||
if (result.exitCode === 0) {
|
||||
console.log(`\n✅ ${testName} PASSED`)
|
||||
passed++
|
||||
} else {
|
||||
console.log(`\n❌ ${testName} FAILED (exit code: ${result.exitCode})`)
|
||||
if (result.stderr) {
|
||||
console.log('stderr:', result.stderr)
|
||||
}
|
||||
failed++
|
||||
failures.push({ test: testName, error: result.stderr || `Exit code: ${result.exitCode}` })
|
||||
}
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e)
|
||||
console.log(`\n❌ ${testName} FAILED`)
|
||||
console.log('Error:', error)
|
||||
failed++
|
||||
failures.push({ test: testName, error })
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(50))
|
||||
console.log('📊 Test Results')
|
||||
console.log('='.repeat(50))
|
||||
console.log(` ✅ Passed: ${passed}`)
|
||||
console.log(` ❌ Failed: ${failed}`)
|
||||
console.log(` 📝 Total: ${passed + failed}`)
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ Failed tests:')
|
||||
for (const { test, error } of failures) {
|
||||
console.log(` - ${test}`)
|
||||
if (error) {
|
||||
console.log(` ${error.split('\n')[0]}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Test setup utilities for Paseo CLI E2E tests
|
||||
*
|
||||
* Critical rules from design doc:
|
||||
* 1. Port: Random port via 10000 + Math.floor(Math.random() * 50000) - NEVER 6767
|
||||
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
|
||||
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
|
||||
* 4. Model: Always --provider claude with haiku model for agent tests
|
||||
* 5. Cleanup: Kill daemon and remove temp dirs after each test
|
||||
*/
|
||||
|
||||
import { $, ProcessPromise, sleep } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
export interface TestContext {
|
||||
/** Random port for test daemon (never 6767) */
|
||||
port: number
|
||||
/** Temp directory for PASEO_HOME */
|
||||
paseoHome: string
|
||||
/** Temp directory for agent working directory */
|
||||
workDir: string
|
||||
/** Running daemon process */
|
||||
daemon: ProcessPromise | null
|
||||
/** Run a paseo CLI command against the test daemon */
|
||||
paseo: (args: string[]) => ProcessPromise
|
||||
/** Clean up all resources */
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random port for test daemon
|
||||
* NEVER uses 6767 (user's running daemon)
|
||||
*/
|
||||
export function getRandomPort(): number {
|
||||
return 10000 + Math.floor(Math.random() * 50000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create isolated temp directories for testing
|
||||
*/
|
||||
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'paseo-test-work-'))
|
||||
return { paseoHome, workDir }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for daemon to be ready by testing WebSocket connection
|
||||
* Uses `paseo agent ps` which connects via WebSocket
|
||||
*/
|
||||
export async function waitForDaemon(port: number, timeout = 30000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const result = await $`PASEO_HOST=localhost:${port} paseo agent ps`.nothrow()
|
||||
if (result.exitCode === 0) return
|
||||
} catch {
|
||||
// Connection failed, keep trying
|
||||
}
|
||||
await sleep(100)
|
||||
}
|
||||
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an isolated test daemon
|
||||
*/
|
||||
export async function startDaemon(
|
||||
port: number,
|
||||
paseoHome: string
|
||||
): Promise<ProcessPromise> {
|
||||
$.verbose = false
|
||||
const daemon = $`PASEO_HOME=${paseoHome} PASEO_PORT=${port} paseo daemon start --foreground`.nothrow()
|
||||
return daemon
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a full test context with daemon, temp dirs, and helpers
|
||||
*/
|
||||
export async function createTestContext(): Promise<TestContext> {
|
||||
const port = getRandomPort()
|
||||
const { paseoHome, workDir } = await createTempDirs()
|
||||
|
||||
// Helper to run CLI commands against test daemon
|
||||
const paseo = (args: string[]): ProcessPromise => {
|
||||
$.verbose = false
|
||||
return $`PASEO_HOST=localhost:${port} paseo ${args}`.nothrow()
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = async (): Promise<void> => {
|
||||
if (ctx.daemon) {
|
||||
ctx.daemon.kill()
|
||||
}
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(workDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const ctx: TestContext = {
|
||||
port,
|
||||
paseoHome,
|
||||
workDir,
|
||||
daemon: null,
|
||||
paseo,
|
||||
cleanup,
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test context and start the daemon
|
||||
* Use this for tests that need a running daemon
|
||||
*/
|
||||
export async function createTestContextWithDaemon(): Promise<TestContext> {
|
||||
const ctx = await createTestContext()
|
||||
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome)
|
||||
await waitForDaemon(ctx.port)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cleanup handlers for process exit
|
||||
*/
|
||||
export function registerCleanupHandlers(cleanup: () => Promise<void>): void {
|
||||
const handler = async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('exit', () => {
|
||||
// Can't await in exit handler, but at least try to kill daemon
|
||||
})
|
||||
process.on('SIGINT', handler)
|
||||
process.on('SIGTERM', handler)
|
||||
}
|
||||
Reference in New Issue
Block a user