# Pack Testing Framework **Status**: ๐Ÿ”„ Design Document **Created**: 2026-01-20 **Purpose**: Define how packs are tested programmatically during installation and validation --- ## Overview The Pack Testing Framework enables automatic discovery and execution of pack tests during: - Pack installation/loading - Pack updates - System validation - CI/CD pipelines This ensures that packs work correctly in the target environment before they're activated. --- ## Design Principles 1. **Runtime-Aware Testing**: Tests execute in the same runtime as actions 2. **Fail-Fast Installation**: Packs don't activate if tests fail (unless forced) 3. **Dependency Validation**: Tests verify all dependencies are satisfied 4. **Standardized Results**: Common test result format across all runner types 5. **Optional but Recommended**: Tests are optional but strongly encouraged 6. **Self-Documenting**: Test results stored for auditing and troubleshooting --- ## Pack Manifest Extension ### pack.yaml Schema Addition ```yaml # Pack Testing Configuration testing: # Enable/disable testing during installation enabled: true # Test discovery method discovery: method: "directory" # directory, manifest, executable path: "tests" # relative to pack root # Test runners by runtime type runners: shell: type: "script" entry_point: "tests/run_tests.sh" timeout: 60 # seconds python: type: "pytest" entry_point: "tests/test_actions.py" requirements: "tests/requirements-test.txt" # optional timeout: 120 node: type: "jest" entry_point: "tests/" config: "tests/jest.config.js" timeout: 90 # Test result expectations result_format: "junit-xml" # junit-xml, tap, json result_path: "tests/results/" # where to find test results # Minimum passing criteria min_pass_rate: 1.0 # 100% tests must pass (0.0-1.0) # What to do on test failure on_failure: "block" # block, warn, ignore ``` ### Example: Core Pack ```yaml # packs/core/pack.yaml ref: core label: "Core Pack" version: "1.0.0" # ... existing config ... testing: enabled: true discovery: method: "directory" path: "tests" runners: shell: type: "script" entry_point: "tests/run_tests.sh" timeout: 60 python: type: "pytest" entry_point: "tests/test_actions.py" timeout: 120 result_format: "junit-xml" result_path: "tests/results/" min_pass_rate: 1.0 on_failure: "block" ``` --- ## Test Discovery Methods ### Method 1: Directory-Based (Recommended) **Convention**: ``` pack_name/ โ”œโ”€โ”€ actions/ โ”œโ”€โ”€ sensors/ โ”œโ”€โ”€ tests/ # Test directory โ”‚ โ”œโ”€โ”€ run_tests.sh # Shell test runner โ”‚ โ”œโ”€โ”€ test_*.py # Python tests โ”‚ โ”œโ”€โ”€ test_*.js # Node.js tests โ”‚ โ””โ”€โ”€ results/ # Test output directory โ””โ”€โ”€ pack.yaml ``` **Discovery Logic**: 1. Check if `tests/` directory exists 2. Look for test runners matching pack's runtime types 3. Execute all discovered test runners 4. Aggregate results ### Method 2: Manifest-Based **Explicit test listing in pack.yaml**: ```yaml testing: enabled: true discovery: method: "manifest" tests: - name: "Action Tests" runner: "python" command: "pytest tests/test_actions.py -v" timeout: 60 - name: "Integration Tests" runner: "shell" command: "bash tests/integration_tests.sh" timeout: 120 ``` ### Method 3: Executable-Based **Single test executable**: ```yaml testing: enabled: true discovery: method: "executable" command: "make test" timeout: 180 ``` --- ## Test Execution Workflow ### 1. Pack Installation Flow ``` User: attune pack install ./packs/my_pack โ†“ CLI validates pack structure โ†“ CLI reads pack.yaml โ†’ testing section โ†“ CLI discovers test runners โ†“ For each runtime type in pack: โ†“ Worker Service executes tests โ†“ Collect test results โ†“ Parse results (JUnit XML, JSON, etc.) โ†“ All tests pass? โ†“ YES โ†“ NO โ†“ โ†“ Activate pack on_failure = "block"? โ†“ YES โ†“ NO โ†“ โ†“ Abort install Show warning, Show errors allow install ``` ### 2. Test Execution Process ```rust // Pseudocode for test execution async fn execute_pack_tests(pack: &Pack) -> TestResults { let test_config = pack.testing.unwrap_or_default(); if !test_config.enabled { return TestResults::Skipped; } let mut results = Vec::new(); // Discover tests based on method let tests = discover_tests(&pack, &test_config)?; // Execute each test suite for test_suite in tests { let runtime = get_runtime_for_test(test_suite.runner)?; let result = runtime.execute_test( test_suite.command, test_suite.timeout, test_suite.env_vars ).await?; results.push(result); } // Parse and aggregate results let aggregate = aggregate_test_results(results, test_config.result_format)?; // Store in database store_test_results(&pack, &aggregate).await?; // Check pass criteria if aggregate.pass_rate < test_config.min_pass_rate { return TestResults::Failed(aggregate); } TestResults::Passed(aggregate) } ``` --- ## Test Result Format ### Standardized Test Result Structure ```rust // Common library: models.rs #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PackTestResult { pub pack_ref: String, pub pack_version: String, pub execution_time: DateTime, pub total_tests: i32, pub passed: i32, pub failed: i32, pub skipped: i32, pub pass_rate: f64, pub duration_ms: i64, pub test_suites: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestSuiteResult { pub name: String, pub runner_type: String, // shell, python, node pub total: i32, pub passed: i32, pub failed: i32, pub skipped: i32, pub duration_ms: i64, pub test_cases: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestCaseResult { pub name: String, pub status: TestStatus, pub duration_ms: i64, pub error_message: Option, pub stdout: Option, pub stderr: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TestStatus { Passed, Failed, Skipped, Error, } ``` ### Example JSON Output ```json { "pack_ref": "core", "pack_version": "1.0.0", "execution_time": "2026-01-20T10:30:00Z", "total_tests": 36, "passed": 36, "failed": 0, "skipped": 0, "pass_rate": 1.0, "duration_ms": 20145, "test_suites": [ { "name": "Bash Test Runner", "runner_type": "shell", "total": 36, "passed": 36, "failed": 0, "skipped": 0, "duration_ms": 20145, "test_cases": [ { "name": "echo: basic message", "status": "Passed", "duration_ms": 245, "error_message": null, "stdout": "Hello, Attune!\n", "stderr": null }, { "name": "noop: invalid exit code", "status": "Passed", "duration_ms": 189, "error_message": null, "stdout": "", "stderr": "ERROR: exit_code must be between 0 and 255\n" } ] } ] } ``` --- ## Database Schema ### Migration: `add_pack_test_results.sql` ```sql -- Pack test execution tracking CREATE TABLE attune.pack_test_execution ( id BIGSERIAL PRIMARY KEY, pack_id BIGINT NOT NULL REFERENCES attune.pack(id) ON DELETE CASCADE, pack_version VARCHAR(50) NOT NULL, execution_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), trigger_reason VARCHAR(50) NOT NULL, -- 'install', 'update', 'manual', 'validation' total_tests INT NOT NULL, passed INT NOT NULL, failed INT NOT NULL, skipped INT NOT NULL, pass_rate DECIMAL(5,4) NOT NULL, -- 0.0000 to 1.0000 duration_ms BIGINT NOT NULL, result JSONB NOT NULL, -- Full test result structure created TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_pack_test_execution_pack_id ON attune.pack_test_execution(pack_id); CREATE INDEX idx_pack_test_execution_time ON attune.pack_test_execution(execution_time DESC); CREATE INDEX idx_pack_test_execution_pass_rate ON attune.pack_test_execution(pass_rate); -- Pack test result summary view CREATE VIEW attune.pack_test_summary AS SELECT p.id AS pack_id, p.ref AS pack_ref, p.label AS pack_label, pte.pack_version, pte.execution_time AS last_test_time, pte.total_tests, pte.passed, pte.failed, pte.skipped, pte.pass_rate, pte.trigger_reason, ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pte.execution_time DESC) AS rn FROM attune.pack p LEFT JOIN attune.pack_test_execution pte ON p.id = pte.pack_id WHERE pte.id IS NOT NULL; -- Latest test results per pack CREATE VIEW attune.pack_latest_test AS SELECT pack_id, pack_ref, pack_label, pack_version, last_test_time, total_tests, passed, failed, skipped, pass_rate, trigger_reason FROM attune.pack_test_summary WHERE rn = 1; ``` --- ## Worker Service Integration ### Test Execution in Worker ```rust // crates/worker/src/test_executor.rs use attune_common::models::{PackTestResult, TestSuiteResult}; use std::path::PathBuf; use std::time::Duration; pub struct TestExecutor { runtime_manager: Arc, } impl TestExecutor { pub async fn execute_pack_tests( &self, pack_dir: &PathBuf, test_config: &TestConfig, ) -> Result { let mut suites = Vec::new(); // Execute tests for each runner type for (runner_type, runner_config) in &test_config.runners { let suite_result = self.execute_test_suite( pack_dir, runner_type, runner_config, ).await?; suites.push(suite_result); } // Aggregate results let total: i32 = suites.iter().map(|s| s.total).sum(); let passed: i32 = suites.iter().map(|s| s.passed).sum(); let failed: i32 = suites.iter().map(|s| s.failed).sum(); let skipped: i32 = suites.iter().map(|s| s.skipped).sum(); let duration_ms: i64 = suites.iter().map(|s| s.duration_ms).sum(); Ok(PackTestResult { pack_ref: pack_dir.file_name().unwrap().to_string_lossy().to_string(), pack_version: "1.0.0".to_string(), // TODO: Get from pack.yaml execution_time: Utc::now(), total_tests: total, passed, failed, skipped, pass_rate: if total > 0 { passed as f64 / total as f64 } else { 0.0 }, duration_ms, test_suites: suites, }) } async fn execute_test_suite( &self, pack_dir: &PathBuf, runner_type: &str, runner_config: &RunnerConfig, ) -> Result { let runtime = self.runtime_manager.get_runtime(runner_type)?; // Build test command let test_script = pack_dir.join(&runner_config.entry_point); // Execute with timeout let timeout = Duration::from_secs(runner_config.timeout); let output = runtime.execute_with_timeout( &test_script, HashMap::new(), // env vars timeout, ).await?; // Parse test results based on format let test_result = match runner_config.result_format.as_str() { "junit-xml" => self.parse_junit_xml(&output.stdout)?, "json" => self.parse_json_results(&output.stdout)?, "tap" => self.parse_tap_results(&output.stdout)?, _ => self.parse_simple_output(&output)?, }; Ok(test_result) } fn parse_simple_output(&self, output: &CommandOutput) -> Result { // Parse simple output format (what our bash runner uses) // Look for patterns like: // "Total Tests: 36" // "Passed: 36" // "Failed: 0" let stdout = String::from_utf8_lossy(&output.stdout); let total = self.extract_number(&stdout, "Total Tests:")?; let passed = self.extract_number(&stdout, "Passed:")?; let failed = self.extract_number(&stdout, "Failed:")?; Ok(TestSuiteResult { name: "Shell Test Runner".to_string(), runner_type: "shell".to_string(), total, passed, failed, skipped: 0, duration_ms: output.duration_ms, test_cases: vec![], // Could parse individual test lines }) } } ``` --- ## CLI Commands ### Pack Test Command ```bash # Test a pack before installation attune pack test ./packs/my_pack # Test an installed pack attune pack test core # Test with verbose output attune pack test core --verbose # Test and show detailed results attune pack test core --detailed # Test specific runtime attune pack test core --runtime python # Force install even if tests fail attune pack install ./packs/my_pack --skip-tests attune pack install ./packs/my_pack --force ``` ### CLI Implementation ```rust // crates/cli/src/commands/pack.rs pub async fn test_pack(pack_path: &str, options: TestOptions) -> Result<()> { println!("๐Ÿงช Testing pack: {}", pack_path); println!(); // Load pack configuration let pack_yaml = load_pack_yaml(pack_path)?; let test_config = pack_yaml.testing.ok_or("No test configuration found")?; if !test_config.enabled { println!("โš ๏ธ Testing disabled for this pack"); return Ok(()); } // Execute tests via worker let client = create_worker_client().await?; let result = client.execute_pack_tests(pack_path, test_config).await?; // Display results display_test_results(&result, options.verbose)?; // Exit with appropriate code if result.failed > 0 { println!(); println!("โŒ Tests failed: {}/{}", result.failed, result.total_tests); std::process::exit(1); } else { println!(); println!("โœ… All tests passed: {}/{}", result.passed, result.total_tests); Ok(()) } } ``` --- ## Test Result Parsers ### JUnit XML Parser ```rust // crates/worker/src/test_parsers/junit.rs pub fn parse_junit_xml(xml: &str) -> Result { // Parse JUnit XML format (pytest --junit-xml, Jest, etc.) // // // // Stack trace // // // Implementation using quick-xml or roxmltree crate } ``` ### TAP Parser ```rust // crates/worker/src/test_parsers/tap.rs pub fn parse_tap(tap_output: &str) -> Result { // Parse TAP (Test Anything Protocol) format // 1..36 // ok 1 - echo: basic message // ok 2 - echo: default message // not ok 3 - echo: invalid parameter // --- // message: 'Expected failure' // ... } ``` --- ## Pack Installation Integration ### Modified Pack Load Workflow ```rust // crates/api/src/services/pack_service.rs pub async fn install_pack( pack_path: &Path, options: InstallOptions, ) -> Result { // 1. Validate pack structure validate_pack_structure(pack_path)?; // 2. Load pack.yaml let pack_config = load_pack_yaml(pack_path)?; // 3. Check if testing is enabled if pack_config.testing.map(|t| t.enabled).unwrap_or(false) { if !options.skip_tests { println!("๐Ÿงช Running pack tests..."); let test_result = execute_pack_tests(pack_path, &pack_config).await?; // Store test results store_test_results(&test_result).await?; // Check if tests passed if test_result.failed > 0 { let on_failure = pack_config.testing .and_then(|t| t.on_failure) .unwrap_or(OnFailure::Block); match on_failure { OnFailure::Block => { if !options.force { return Err(Error::PackTestsFailed { failed: test_result.failed, total: test_result.total_tests, }); } } OnFailure::Warn => { eprintln!("โš ๏ธ Warning: {} tests failed", test_result.failed); } OnFailure::Ignore => { // Continue installation } } } else { println!("โœ… All tests passed!"); } } } // 4. Register pack in database let pack = register_pack(&pack_config).await?; // 5. Register actions, sensors, triggers register_pack_components(&pack, pack_path).await?; // 6. Set up runtime environments setup_pack_environments(&pack, pack_path).await?; Ok(pack) } ``` --- ## API Endpoints ### Test Results API ```rust // GET /api/v1/packs/:pack_ref/tests // List test executions for a pack // GET /api/v1/packs/:pack_ref/tests/latest // Get latest test results for a pack // GET /api/v1/packs/:pack_ref/tests/:execution_id // Get specific test execution details // POST /api/v1/packs/:pack_ref/test // Manually trigger pack tests // GET /api/v1/packs/tests // List all pack test results (admin) ``` --- ## Best Practices for Pack Authors ### 1. Always Include Tests ```yaml # pack.yaml testing: enabled: true runners: shell: entry_point: "tests/run_tests.sh" ``` ### 2. Test All Actions Every action should have at least: - One successful execution test - One error handling test - Parameter validation tests ### 3. Use Exit Codes Correctly ```bash # tests/run_tests.sh if [ $FAILURES -gt 0 ]; then exit 1 # Non-zero exit = test failure else exit 0 # Zero exit = success fi ``` ### 4. Output Parseable Results ```bash # Simple format the worker can parse echo "Total Tests: $TOTAL" echo "Passed: $PASSED" echo "Failed: $FAILED" ``` ### 5. Test Dependencies ```python # tests/test_dependencies.py def test_required_libraries(): """Verify all required libraries are available""" import requests import croniter assert True ``` --- ## Implementation Phases ### Phase 1: Core Framework โœ… (Current) - [x] Design document (this file) - [x] Core pack tests implemented - [x] Test infrastructure created - [ ] Database schema for test results - [ ] Worker test executor implementation ### Phase 2: Worker Integration - [ ] Test executor in worker service - [ ] Simple output parser - [ ] Test result storage - [ ] Error handling and timeouts ### Phase 3: CLI Integration - [ ] `attune pack test` command - [ ] Test result display - [ ] Integration with pack install - [ ] Force/skip test options ### Phase 4: Advanced Features - [ ] JUnit XML parser - [ ] TAP parser - [ ] API endpoints for test results - [ ] Web UI for test results - [ ] Test history and trends --- ## Future Enhancements - **Parallel Test Execution**: Run tests for different runtimes in parallel - **Test Caching**: Cache test results for unchanged packs - **Selective Testing**: Test only changed actions - **Performance Benchmarks**: Track test execution time trends - **Test Coverage Reports**: Integration with coverage tools - **Remote Test Execution**: Distribute tests across workers - **Test Environments**: Isolated test environments per pack --- ## Conclusion The Pack Testing Framework provides a standardized way to validate packs during installation, ensuring reliability and catching issues early. By making tests a first-class feature of the pack system, we enable: - **Confident Installation**: Know that packs will work before activating them - **Dependency Validation**: Verify all required dependencies are present - **Regression Prevention**: Detect breaking changes when updating packs - **Quality Assurance**: Encourage pack authors to write comprehensive tests - **Audit Trail**: Track test results over time for compliance and debugging --- **Next Steps**: Implement Phase 1 (database schema and worker test executor) to enable programmatic test execution during pack installation.