# CI Pipeline and Burn-In Strategy ## Principle CI pipelines must execute tests reliably, quickly, and provide clear feedback. Burn-in testing (running changed tests multiple times) flushes out flakiness before merge. Stage jobs strategically: install/cache once, run changed specs first for fast feedback, then shard full suites with fail-fast disabled to preserve evidence. ## Rationale CI is the quality gate for production. A poorly configured pipeline either wastes developer time (slow feedback, false positives) or ships broken code (false negatives, insufficient coverage). Burn-in testing ensures reliability by stress-testing changed code, while parallel execution and intelligent test selection optimize speed without sacrificing thoroughness. ## Pattern Examples ### Example 1: GitHub Actions Workflow with Parallel Execution **Context**: Production-ready CI/CD pipeline for E2E tests with caching, parallelization, and burn-in testing. **Implementation**: ```yaml # .github/workflows/e2e-tests.yml name: E2E Tests on: pull_request: push: branches: [main, develop] env: NODE_VERSION_FILE: '.nvmrc' CACHE_KEY: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} jobs: install-dependencies: name: Install & Cache Dependencies runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: ${{ env.NODE_VERSION_FILE }} cache: 'npm' - name: Cache node modules uses: actions/cache@v4 id: npm-cache with: path: | ~/.npm node_modules ~/.cache/Cypress ~/.cache/ms-playwright key: ${{ env.CACHE_KEY }} restore-keys: | ${{ runner.os }}-node- - name: Install dependencies if: steps.npm-cache.outputs.cache-hit != 'true' run: npm ci --prefer-offline --no-audit - name: Install Playwright browsers if: steps.npm-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium test-changed-specs: name: Test Changed Specs First (Burn-In) needs: install-dependencies runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 # Full history for accurate diff - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: ${{ env.NODE_VERSION_FILE }} cache: 'npm' - name: Restore dependencies uses: actions/cache@v4 with: path: | ~/.npm node_modules ~/.cache/ms-playwright key: ${{ env.CACHE_KEY }} - name: Detect changed test files id: changed-tests run: | CHANGED_SPECS=$(git diff --name-only origin/main...HEAD | grep -E '\.(spec|test)\.(ts|js|tsx|jsx)$' || echo "") echo "changed_specs=${CHANGED_SPECS}" >> $GITHUB_OUTPUT echo "Changed specs: ${CHANGED_SPECS}" - name: Run burn-in on changed specs (10 iterations) if: steps.changed-tests.outputs.changed_specs != '' run: | SPECS="${{ steps.changed-tests.outputs.changed_specs }}" echo "Running burn-in: 10 iterations on changed specs" for i in {1..10}; do echo "Burn-in iteration $i/10" npm run test -- $SPECS || { echo "āŒ Burn-in failed on iteration $i" exit 1 } done echo "āœ… Burn-in passed - 10/10 successful runs" - name: Upload artifacts on failure if: failure() uses: actions/upload-artifact@v4 with: name: burn-in-failure-artifacts path: | test-results/ playwright-report/ screenshots/ retention-days: 7 test-e2e-sharded: name: E2E Tests (Shard ${{ matrix.shard }}/${{ strategy.job-total }}) needs: [install-dependencies, test-changed-specs] runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false # Run all shards even if one fails matrix: shard: [1, 2, 3, 4] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: ${{ env.NODE_VERSION_FILE }} cache: 'npm' - name: Restore dependencies uses: actions/cache@v4 with: path: | ~/.npm node_modules ~/.cache/ms-playwright key: ${{ env.CACHE_KEY }} - name: Run E2E tests (shard ${{ matrix.shard }}) run: npm run test:e2e -- --shard=${{ matrix.shard }}/4 env: TEST_ENV: staging CI: true - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results-shard-${{ matrix.shard }} path: | test-results/ playwright-report/ retention-days: 30 - name: Upload JUnit report if: always() uses: actions/upload-artifact@v4 with: name: junit-results-shard-${{ matrix.shard }} path: test-results/junit.xml retention-days: 30 merge-test-results: name: Merge Test Results & Generate Report needs: test-e2e-sharded runs-on: ubuntu-latest if: always() steps: - name: Download all shard results uses: actions/download-artifact@v4 with: pattern: test-results-shard-* path: all-results/ - name: Merge HTML reports run: | npx playwright merge-reports --reporter=html all-results/ echo "Merged report available in playwright-report/" - name: Upload merged report uses: actions/upload-artifact@v4 with: name: merged-playwright-report path: playwright-report/ retention-days: 30 - name: Comment PR with results if: github.event_name == 'pull_request' uses: daun/playwright-report-comment@v3 with: report-path: playwright-report/ ``` **Key Points**: - **Install once, reuse everywhere**: Dependencies cached across all jobs - **Burn-in first**: Changed specs run 10x before full suite - **Fail-fast disabled**: All shards run to completion for full evidence - **Parallel execution**: 4 shards cut execution time by ~75% - **Artifact retention**: 30 days for reports, 7 days for failure debugging --- ### Example 2: Burn-In Loop Pattern (Standalone Script) **Context**: Reusable bash script for burn-in testing changed specs locally or in CI. **Implementation**: ```bash #!/bin/bash # scripts/burn-in-changed.sh # Usage: ./scripts/burn-in-changed.sh [iterations] [base-branch] set -e # Exit on error # Configuration ITERATIONS=${1:-10} BASE_BRANCH=${2:-main} SPEC_PATTERN='\.(spec|test)\.(ts|js|tsx|jsx)$' echo "šŸ”„ Burn-In Test Runner" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Iterations: $ITERATIONS" echo "Base branch: $BASE_BRANCH" echo "" # Detect changed test files echo "šŸ“‹ Detecting changed test files..." CHANGED_SPECS=$(git diff --name-only $BASE_BRANCH...HEAD | grep -E "$SPEC_PATTERN" || echo "") if [ -z "$CHANGED_SPECS" ]; then echo "āœ… No test files changed. Skipping burn-in." exit 0 fi echo "Changed test files:" echo "$CHANGED_SPECS" | sed 's/^/ - /' echo "" # Count specs SPEC_COUNT=$(echo "$CHANGED_SPECS" | wc -l | xargs) echo "Running burn-in on $SPEC_COUNT test file(s)..." echo "" # Burn-in loop FAILURES=() for i in $(seq 1 $ITERATIONS); do echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "šŸ”„ Iteration $i/$ITERATIONS" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # Run tests with explicit file list if npm run test -- $CHANGED_SPECS 2>&1 | tee "burn-in-log-$i.txt"; then echo "āœ… Iteration $i passed" else echo "āŒ Iteration $i failed" FAILURES+=($i) # Save failure artifacts mkdir -p burn-in-failures/iteration-$i cp -r test-results/ burn-in-failures/iteration-$i/ 2>/dev/null || true cp -r screenshots/ burn-in-failures/iteration-$i/ 2>/dev/null || true echo "" echo "šŸ›‘ BURN-IN FAILED on iteration $i" echo "Failure artifacts saved to: burn-in-failures/iteration-$i/" echo "Logs saved to: burn-in-log-$i.txt" echo "" exit 1 fi echo "" done # Success summary echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "šŸŽ‰ BURN-IN PASSED" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "All $ITERATIONS iterations passed for $SPEC_COUNT test file(s)" echo "Changed specs are stable and ready to merge." echo "" # Cleanup logs rm -f burn-in-log-*.txt exit 0 ``` **Usage**: ```bash # Run locally with default settings (10 iterations, compare to main) ./scripts/burn-in-changed.sh # Custom iterations and base branch ./scripts/burn-in-changed.sh 20 develop # Add to package.json { "scripts": { "test:burn-in": "bash scripts/burn-in-changed.sh", "test:burn-in:strict": "bash scripts/burn-in-changed.sh 20" } } ``` **Key Points**: - **Exit on first failure**: Flaky tests caught immediately - **Failure artifacts**: Saved per-iteration for debugging - **Flexible configuration**: Iterations and base branch customizable - **CI/local parity**: Same script runs in both environments - **Clear output**: Visual feedback on progress and results --- ### Example 3: Shard Orchestration with Result Aggregation **Context**: Advanced sharding strategy for large test suites with intelligent result merging. **Implementation**: ```javascript // scripts/run-sharded-tests.js const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); /** * Run tests across multiple shards and aggregate results * Usage: node scripts/run-sharded-tests.js --shards=4 --env=staging */ const SHARD_COUNT = parseInt(process.env.SHARD_COUNT || '4'); const TEST_ENV = process.env.TEST_ENV || 'local'; const RESULTS_DIR = path.join(__dirname, '../test-results'); console.log(`šŸš€ Running tests across ${SHARD_COUNT} shards`); console.log(`Environment: ${TEST_ENV}`); console.log('━'.repeat(50)); // Ensure results directory exists if (!fs.existsSync(RESULTS_DIR)) { fs.mkdirSync(RESULTS_DIR, { recursive: true }); } /** * Run a single shard */ function runShard(shardIndex) { return new Promise((resolve, reject) => { const shardId = `${shardIndex}/${SHARD_COUNT}`; console.log(`\nšŸ“¦ Starting shard ${shardId}...`); const child = spawn('npx', ['playwright', 'test', `--shard=${shardId}`, '--reporter=json'], { env: { ...process.env, TEST_ENV, SHARD_INDEX: shardIndex }, stdio: 'pipe', }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { stdout += data.toString(); process.stdout.write(data); }); child.stderr.on('data', (data) => { stderr += data.toString(); process.stderr.write(data); }); child.on('close', (code) => { // Save shard results const resultFile = path.join(RESULTS_DIR, `shard-${shardIndex}.json`); try { const result = JSON.parse(stdout); fs.writeFileSync(resultFile, JSON.stringify(result, null, 2)); console.log(`āœ… Shard ${shardId} completed (exit code: ${code})`); resolve({ shardIndex, code, result }); } catch (error) { console.error(`āŒ Shard ${shardId} failed to parse results:`, error.message); reject({ shardIndex, code, error }); } }); child.on('error', (error) => { console.error(`āŒ Shard ${shardId} process error:`, error.message); reject({ shardIndex, error }); }); }); } /** * Aggregate results from all shards */ function aggregateResults() { console.log('\nšŸ“Š Aggregating results from all shards...'); const shardResults = []; let totalTests = 0; let totalPassed = 0; let totalFailed = 0; let totalSkipped = 0; let totalFlaky = 0; for (let i = 1; i <= SHARD_COUNT; i++) { const resultFile = path.join(RESULTS_DIR, `shard-${i}.json`); if (fs.existsSync(resultFile)) { const result = JSON.parse(fs.readFileSync(resultFile, 'utf8')); shardResults.push(result); // Aggregate stats totalTests += result.stats?.expected || 0; totalPassed += result.stats?.expected || 0; totalFailed += result.stats?.unexpected || 0; totalSkipped += result.stats?.skipped || 0; totalFlaky += result.stats?.flaky || 0; } } const summary = { totalShards: SHARD_COUNT, environment: TEST_ENV, totalTests, passed: totalPassed, failed: totalFailed, skipped: totalSkipped, flaky: totalFlaky, duration: shardResults.reduce((acc, r) => acc + (r.duration || 0), 0), timestamp: new Date().toISOString(), }; // Save aggregated summary fs.writeFileSync(path.join(RESULTS_DIR, 'summary.json'), JSON.stringify(summary, null, 2)); console.log('\n━'.repeat(50)); console.log('šŸ“ˆ Test Results Summary'); console.log('━'.repeat(50)); console.log(`Total tests: ${totalTests}`); console.log(`āœ… Passed: ${totalPassed}`); console.log(`āŒ Failed: ${totalFailed}`); console.log(`ā­ļø Skipped: ${totalSkipped}`); console.log(`āš ļø Flaky: ${totalFlaky}`); console.log(`ā±ļø Duration: ${(summary.duration / 1000).toFixed(2)}s`); console.log('━'.repeat(50)); return summary; } /** * Main execution */ async function main() { const startTime = Date.now(); const shardPromises = []; // Run all shards in parallel for (let i = 1; i <= SHARD_COUNT; i++) { shardPromises.push(runShard(i)); } try { await Promise.allSettled(shardPromises); } catch (error) { console.error('āŒ One or more shards failed:', error); } // Aggregate results const summary = aggregateResults(); const totalTime = ((Date.now() - startTime) / 1000).toFixed(2); console.log(`\nā±ļø Total execution time: ${totalTime}s`); // Exit with failure if any tests failed if (summary.failed > 0) { console.error('\nāŒ Test suite failed'); process.exit(1); } console.log('\nāœ… All tests passed'); process.exit(0); } main().catch((error) => { console.error('Fatal error:', error); process.exit(1); }); ``` **package.json integration**: ```json { "scripts": { "test:sharded": "node scripts/run-sharded-tests.js", "test:sharded:ci": "SHARD_COUNT=8 TEST_ENV=staging node scripts/run-sharded-tests.js" } } ``` **Key Points**: - **Parallel shard execution**: All shards run simultaneously - **Result aggregation**: Unified summary across shards - **Failure detection**: Exit code reflects overall test status - **Artifact preservation**: Individual shard results saved for debugging - **CI/local compatibility**: Same script works in both environments --- ### Example 4: Selective Test Execution (Changed Files + Tags) **Context**: Optimize CI by running only relevant tests based on file changes and tags. **Implementation**: ```bash #!/bin/bash # scripts/selective-test-runner.sh # Intelligent test selection based on changed files and test tags set -e BASE_BRANCH=${BASE_BRANCH:-main} TEST_ENV=${TEST_ENV:-local} echo "šŸŽÆ Selective Test Runner" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Base branch: $BASE_BRANCH" echo "Environment: $TEST_ENV" echo "" # Detect changed files (all types, not just tests) CHANGED_FILES=$(git diff --name-only $BASE_BRANCH...HEAD) if [ -z "$CHANGED_FILES" ]; then echo "āœ… No files changed. Skipping tests." exit 0 fi echo "Changed files:" echo "$CHANGED_FILES" | sed 's/^/ - /' echo "" # Determine test strategy based on changes run_smoke_only=false run_all_tests=false affected_specs="" # Critical files = run all tests if echo "$CHANGED_FILES" | grep -qE '(package\.json|package-lock\.json|playwright\.config|cypress\.config|\.github/workflows)'; then echo "āš ļø Critical configuration files changed. Running ALL tests." run_all_tests=true # Auth/security changes = run all auth + smoke tests elif echo "$CHANGED_FILES" | grep -qE '(auth|login|signup|security)'; then echo "šŸ”’ Auth/security files changed. Running auth + smoke tests." npm run test -- --grep "@auth|@smoke" exit $? # API changes = run integration + smoke tests elif echo "$CHANGED_FILES" | grep -qE '(api|service|controller)'; then echo "šŸ”Œ API files changed. Running integration + smoke tests." npm run test -- --grep "@integration|@smoke" exit $? # UI component changes = run related component tests elif echo "$CHANGED_FILES" | grep -qE '\.(tsx|jsx|vue)$'; then echo "šŸŽØ UI components changed. Running component + smoke tests." # Extract component names and find related tests components=$(echo "$CHANGED_FILES" | grep -E '\.(tsx|jsx|vue)$' | xargs -I {} basename {} | sed 's/\.[^.]*$//') for component in $components; do # Find tests matching component name affected_specs+=$(find tests -name "*${component}*" -type f) || true done if [ -n "$affected_specs" ]; then echo "Running tests for: $affected_specs" npm run test -- $affected_specs --grep "@smoke" else echo "No specific tests found. Running smoke tests only." npm run test -- --grep "@smoke" fi exit $? # Documentation/config only = run smoke tests elif echo "$CHANGED_FILES" | grep -qE '\.(md|txt|json|yml|yaml)$'; then echo "šŸ“ Documentation/config files changed. Running smoke tests only." run_smoke_only=true else echo "āš™ļø Other files changed. Running smoke tests." run_smoke_only=true fi # Execute selected strategy if [ "$run_all_tests" = true ]; then echo "" echo "Running full test suite..." npm run test elif [ "$run_smoke_only" = true ]; then echo "" echo "Running smoke tests..." npm run test -- --grep "@smoke" fi ``` **Usage in GitHub Actions**: ```yaml # .github/workflows/selective-tests.yml name: Selective Tests on: pull_request jobs: selective-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Run selective tests run: bash scripts/selective-test-runner.sh env: BASE_BRANCH: ${{ github.base_ref }} TEST_ENV: staging ``` **Key Points**: - **Intelligent routing**: Tests selected based on changed file types - **Tag-based filtering**: Use @smoke, @auth, @integration tags - **Fast feedback**: Only relevant tests run on most PRs - **Safety net**: Critical changes trigger full suite - **Component mapping**: UI changes run related component tests --- ## CI Configuration Checklist Before deploying your CI pipeline, verify: - [ ] **Caching strategy**: node_modules, npm cache, browser binaries cached - [ ] **Timeout budgets**: Each job has reasonable timeout (10-30 min) - [ ] **Artifact retention**: 30 days for reports, 7 days for failure artifacts - [ ] **Parallelization**: Matrix strategy uses fail-fast: false - [ ] **Burn-in enabled**: Changed specs run 5-10x before merge - [ ] **wait-on app startup**: CI waits for app (wait-on: 'http://localhost:3000') - [ ] **Secrets documented**: README lists required secrets (API keys, tokens) - [ ] **Local parity**: CI scripts runnable locally (npm run test:ci) ## Integration Points - Used in workflows: `*ci` (CI/CD pipeline setup) - Related fragments: `selective-testing.md`, `playwright-config.md`, `test-quality.md` - CI tools: GitHub Actions, GitLab CI, CircleCI, Jenkins _Source: Murat CI/CD strategy blog, Playwright/Cypress workflow examples, SEON production pipelines_