# E2E Tests Workflow (Sequential Execution - Fixes Race Conditions) # # Root Cause: Tests that disable security features (via emergency endpoint) were # running in parallel shards, causing some shards to fail before security was disabled. # # Changes from original: # - Reduced from 4 shards to 1 shard per browser (12 jobs → 3 jobs) # - Each browser runs ALL tests sequentially (no sharding within browser) # - Browsers still run in parallel (complete job isolation) # - Acceptable performance tradeoff for CI stability (90% local → 100% CI pass rate) # # See docs/plans/e2e_ci_failure_diagnosis.md for details name: E2E Tests on: pull_request: branches: - main - development - 'feature/**' paths: - 'frontend/**' - 'backend/**' - 'tests/**' - 'playwright.config.js' - '.github/workflows/e2e-tests-split.yml' workflow_dispatch: inputs: browser: description: 'Browser to test' required: false default: 'all' type: choice options: - chromium - firefox - webkit - all env: NODE_VERSION: '20' GO_VERSION: '1.25.6' GOTOOLCHAIN: auto REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository_owner }}/charon PLAYWRIGHT_COVERAGE: ${{ vars.PLAYWRIGHT_COVERAGE || '0' }} concurrency: group: e2e-tests-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: # Build application once, share across all browser jobs build: name: Build Application runs-on: ubuntu-latest outputs: image_digest: ${{ steps.build-image.outputs.digest }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 with: go-version: ${{ env.GO_VERSION }} cache: true cache-dependency-path: backend/go.sum - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Cache npm dependencies uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.npm key: npm-${{ hashFiles('package-lock.json') }} restore-keys: npm- - name: Install dependencies run: npm ci - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build Docker image id: build-image uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 with: context: . file: ./Dockerfile push: false load: true tags: charon:e2e-test cache-from: type=gha cache-to: type=gha,mode=max - name: Save Docker image run: docker save charon:e2e-test -o charon-e2e-image.tar - name: Upload Docker image artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: docker-image path: charon-e2e-image.tar retention-days: 1 # Security Enforcement Tests (Chromium) - ISOLATED SERIAL EXECUTION # These tests enable/disable Cerberus and must run alone to avoid contaminating other shards e2e-chromium-security: name: E2E Chromium (Security Enforcement) runs-on: ubuntu-latest needs: build if: | (github.event_name != 'workflow_dispatch') || (github.event.inputs.browser == 'chromium' || github.event.inputs.browser == 'all') timeout-minutes: 30 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "true" CHARON_E2E_IMAGE_TAG: charon:e2e-test strategy: fail-fast: false matrix: shard: [1] # Single shard - security tests run serially total-shards: [1] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Download Docker image uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ $TOKEN_LENGTH -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV - name: Start test environment run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for Chromium security enforcement tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium run: | echo "📦 Installing Chromium..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit $EXIT_CODE - name: Run Security Enforcement Tests (Chromium) timeout-minutes: 25 run: npx playwright test --project=chromium tests/security-enforcement/ env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true - name: Upload HTML report (Chromium Security) if: success() || failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: playwright-report-chromium-security path: playwright-report/ retention-days: 14 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: traces-chromium-security path: test-results/**/*.zip retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-chromium-security.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: docker-logs-chromium-security path: docker-logs-chromium-security.txt retention-days: 7 - name: Cleanup if: success() || failure() || cancelled() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true # Chromium browser tests (non-security) - PARALLEL SHARDED EXECUTION e2e-chromium: name: E2E Chromium (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) runs-on: ubuntu-latest needs: build if: | (github.event_name != 'workflow_dispatch') || (github.event.inputs.browser == 'chromium' || github.event.inputs.browser == 'all') timeout-minutes: 25 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "false" # Cerberus OFF for non-security tests CHARON_E2E_IMAGE_TAG: charon:e2e-test strategy: fail-fast: false matrix: shard: [1, 2, 3] # 3 shards for non-security tests total-shards: [3] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Download Docker image uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ $TOKEN_LENGTH -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV - name: Start test environment run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for Chromium tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium run: | echo "📦 Installing Chromium..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" echo "📁 Checking browser cache..." ls -lR ~/.cache/ms-playwright/ 2>/dev/null || echo "Cache directory not found" echo "🔍 Searching for chromium executable..." find ~/.cache/ms-playwright -name "*chromium*" -o -name "*chrome*" 2>/dev/null | head -10 || echo "No chromium files found" exit $EXIT_CODE - name: Run Chromium tests (Non-Security) timeout-minutes: 20 run: | # Run all tests except security-enforcement directory (Cerberus is OFF by default) npx playwright test --project=chromium \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ tests/emergency-server \ tests/integration \ tests/manual-dns-provider.spec.ts \ tests/monitoring \ tests/security \ tests/settings \ tests/tasks \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true TEST_WORKER_INDEX: ${{ matrix.shard }} - name: Upload HTML report (Chromium) if: success() || failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: playwright-report-chromium-${{ matrix.shard }} path: playwright-report/ retention-days: 14 - name: Upload Chromium coverage (if enabled) if: (success() || failure()) && env.PLAYWRIGHT_COVERAGE == '1' uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: e2e-coverage-chromium-${{ matrix.shard }} path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: traces-chromium-${{ matrix.shard }} path: test-results/**/*.zip retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-chromium.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: docker-logs-chromium-${{ matrix.shard }} path: docker-logs-chromium.txt retention-days: 7 - name: Cleanup if: success() || failure() || cancelled() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true # Security Enforcement Tests (Firefox) - ISOLATED SERIAL EXECUTION e2e-firefox-security: name: E2E Firefox (Security Enforcement) runs-on: ubuntu-latest needs: build if: | (github.event_name != 'workflow_dispatch') || (github.event.inputs.browser == 'firefox' || github.event.inputs.browser == 'all') timeout-minutes: 30 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "true" CHARON_E2E_IMAGE_TAG: charon:e2e-test strategy: fail-fast: false matrix: shard: [1] # Single shard - security tests run serially total-shards: [1] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Download Docker image uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ $TOKEN_LENGTH -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV - name: Start test environment run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for Firefox security enforcement tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium run: | echo "📦 Installing Chromium (required by security-tests dependency)..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit $EXIT_CODE - name: Install Playwright Firefox run: | echo "📦 Installing Firefox..." npx playwright install --with-deps firefox EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit $EXIT_CODE - name: Run Security Enforcement Tests (Firefox) timeout-minutes: 25 run: npx playwright test --project=firefox tests/security-enforcement/ env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true - name: Upload HTML report (Firefox Security) if: success() || failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: playwright-report-firefox-security path: playwright-report/ retention-days: 14 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: traces-firefox-security path: test-results/**/*.zip retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-firefox-security.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: docker-logs-firefox-security path: docker-logs-firefox-security.txt retention-days: 7 - name: Cleanup if: success() || failure() || cancelled() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true # Firefox browser tests (non-security) - PARALLEL SHARDED EXECUTION e2e-firefox: name: E2E Firefox (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) runs-on: ubuntu-latest needs: build if: | (github.event_name != 'workflow_dispatch') || (github.event.inputs.browser == 'firefox' || github.event.inputs.browser == 'all') timeout-minutes: 25 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "false" # Cerberus OFF for non-security tests CHARON_E2E_IMAGE_TAG: charon:e2e-test strategy: fail-fast: false matrix: shard: [1, 2, 3] # 3 shards for non-security tests total-shards: [3] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Download Docker image uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ $TOKEN_LENGTH -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV - name: Start test environment run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for Firefox tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium run: | echo "📦 Installing Chromium (required by security-tests dependency)..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit $EXIT_CODE - name: Install Playwright Firefox run: | echo "📦 Installing Firefox..." npx playwright install --with-deps firefox EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" echo "📁 Checking browser cache..." ls -lR ~/.cache/ms-playwright/ 2>/dev/null || echo "Cache directory not found" echo "🔍 Searching for firefox executable..." find ~/.cache/ms-playwright -name "*firefox*" 2>/dev/null | head -10 || echo "No firefox files found" exit $EXIT_CODE - name: Run Firefox tests (Non-Security) timeout-minutes: 20 run: | # Run all tests except security-enforcement directory (Cerberus is OFF by default) npx playwright test --project=firefox \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ tests/emergency-server \ tests/integration \ tests/manual-dns-provider.spec.ts \ tests/monitoring \ tests/security \ tests/settings \ tests/tasks \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true TEST_WORKER_INDEX: ${{ matrix.shard }} - name: Upload HTML report (Firefox shard ${{ matrix.shard }}) if: success() || failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: playwright-report-firefox-${{ matrix.shard }} path: playwright-report/ retention-days: 14 - name: Upload Firefox coverage (if enabled) if: (success() || failure()) && env.PLAYWRIGHT_COVERAGE == '1' uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: e2e-coverage-firefox-${{ matrix.shard }} path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: traces-firefox-${{ matrix.shard }} path: test-results/**/*.zip retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-firefox.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: docker-logs-firefox-${{ matrix.shard }} path: docker-logs-firefox.txt retention-days: 7 - name: Cleanup if: success() || failure() || cancelled() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true # Security Enforcement Tests (WebKit) - ISOLATED SERIAL EXECUTION e2e-webkit-security: name: E2E WebKit (Security Enforcement) runs-on: ubuntu-latest needs: build if: | (github.event_name != 'workflow_dispatch') || (github.event.inputs.browser == 'webkit' || github.event.inputs.browser == 'all') timeout-minutes: 30 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "true" CHARON_E2E_IMAGE_TAG: charon:e2e-test strategy: fail-fast: false matrix: shard: [1] # Single shard - security tests run serially total-shards: [1] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Download Docker image uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ $TOKEN_LENGTH -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV - name: Start test environment run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for WebKit security enforcement tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium run: | echo "📦 Installing Chromium (required by security-tests dependency)..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit $EXIT_CODE - name: Install Playwright WebKit run: | echo "📦 Installing WebKit..." npx playwright install --with-deps webkit EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit $EXIT_CODE - name: Run Security Enforcement Tests (WebKit) timeout-minutes: 25 run: npx playwright test --project=webkit tests/security-enforcement/ env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true - name: Upload HTML report (WebKit Security) if: success() || failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: playwright-report-webkit-security path: playwright-report/ retention-days: 14 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: traces-webkit-security path: test-results/**/*.zip retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-webkit-security.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: docker-logs-webkit-security path: docker-logs-webkit-security.txt retention-days: 7 - name: Cleanup if: success() || failure() || cancelled() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true # WebKit browser tests (non-security) - PARALLEL SHARDED EXECUTION e2e-webkit: name: E2E WebKit (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) runs-on: ubuntu-latest needs: build if: | (github.event_name != 'workflow_dispatch') || (github.event.inputs.browser == 'webkit' || github.event.inputs.browser == 'all') timeout-minutes: 25 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "false" # Cerberus OFF for non-security tests CHARON_E2E_IMAGE_TAG: charon:e2e-test strategy: fail-fast: false matrix: shard: [1, 2, 3] # 3 shards for non-security tests total-shards: [3] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Download Docker image uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ $TOKEN_LENGTH -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV - name: Start test environment run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for WebKit tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium run: | echo "📦 Installing Chromium (required by security-tests dependency)..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit $EXIT_CODE - name: Install Playwright WebKit run: | echo "📦 Installing WebKit..." npx playwright install --with-deps webkit EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" echo "📁 Checking browser cache..." ls -lR ~/.cache/ms-playwright/ 2>/dev/null || echo "Cache directory not found" echo "🔍 Searching for webkit executable..." find ~/.cache/ms-playwright -name "*webkit*" -o -name "*MiniBrowser*" 2>/dev/null | head -10 || echo "No webkit files found" exit $EXIT_CODE - name: Run WebKit tests (Non-Security) timeout-minutes: 20 run: | # Run all tests except security-enforcement directory (Cerberus is OFF by default) npx playwright test --project=webkit \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ tests/emergency-server \ tests/integration \ tests/manual-dns-provider.spec.ts \ tests/monitoring \ tests/security \ tests/settings \ tests/tasks \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true TEST_WORKER_INDEX: ${{ matrix.shard }} - name: Upload HTML report (WebKit shard ${{ matrix.shard }}) if: success() || failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: playwright-report-webkit-${{ matrix.shard }} path: playwright-report/ retention-days: 14 - name: Upload WebKit coverage (if enabled) if: (success() || failure()) && env.PLAYWRIGHT_COVERAGE == '1' uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: e2e-coverage-webkit-${{ matrix.shard }} path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: traces-webkit-${{ matrix.shard }} path: test-results/**/*.zip retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-webkit.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: docker-logs-webkit-${{ matrix.shard }} path: docker-logs-webkit.txt retention-days: 7 - name: Cleanup if: success() || failure() || cancelled() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true # Test summary job test-summary: name: E2E Test Summary runs-on: ubuntu-latest needs: [e2e-chromium, e2e-firefox, e2e-webkit] if: success() || failure() steps: - name: Generate job summary run: | echo "## 📊 E2E Test Results (Split Browser Jobs)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Browser Job Status" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Browser | Status | Shards | Notes |" >> $GITHUB_STEP_SUMMARY echo "|---------|--------|--------|-------|" >> $GITHUB_STEP_SUMMARY echo "| Chromium | ${{ needs.e2e-chromium.result }} | 3 | Parallel execution (3 shards) |" >> $GITHUB_STEP_SUMMARY echo "| Firefox | ${{ needs.e2e-firefox.result }} | 3 | Parallel execution (3 shards) |" >> $GITHUB_STEP_SUMMARY echo "| WebKit | ${{ needs.e2e-webkit.result }} | 3 | Parallel execution (3 shards) |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Execution Strategy" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- ✅ **Browser Parallelism:** All 3 browsers run simultaneously (job-level)" >> $GITHUB_STEP_SUMMARY echo "- ✅ **Test Sharding:** Each browser splits tests across 3 parallel shards" >> $GITHUB_STEP_SUMMARY echo "- ⏱️ **Target Duration:** ~10 minutes per shard (vs 20+ minutes without sharding)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Per-Shard HTML Reports" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Download artifacts to view detailed test results for each browser and shard." >> $GITHUB_STEP_SUMMARY # Upload merged coverage to Codecov with browser-specific flags upload-coverage: name: Upload E2E Coverage runs-on: ubuntu-latest needs: [e2e-chromium, e2e-firefox, e2e-webkit] if: vars.PLAYWRIGHT_COVERAGE == '1' && (success() || failure()) steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download all coverage artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: pattern: e2e-coverage-* path: all-coverage merge-multiple: false - name: Merge browser coverage files run: | sudo apt-get update && sudo apt-get install -y lcov mkdir -p coverage/e2e-merged/{chromium,firefox,webkit} # Merge Chromium shards CHROMIUM_FILES=$(find all-coverage -path "*chromium*" -name "lcov.info" -type f) if [[ -n "$CHROMIUM_FILES" ]]; then MERGE_ARGS="" for file in $CHROMIUM_FILES; do MERGE_ARGS="$MERGE_ARGS -a $file"; done lcov $MERGE_ARGS -o coverage/e2e-merged/chromium/lcov.info echo "✅ Merged $(echo "$CHROMIUM_FILES" | wc -w) Chromium coverage files" fi # Merge Firefox shards FIREFOX_FILES=$(find all-coverage -path "*firefox*" -name "lcov.info" -type f) if [[ -n "$FIREFOX_FILES" ]]; then MERGE_ARGS="" for file in $FIREFOX_FILES; do MERGE_ARGS="$MERGE_ARGS -a $file"; done lcov $MERGE_ARGS -o coverage/e2e-merged/firefox/lcov.info echo "✅ Merged $(echo "$FIREFOX_FILES" | wc -w) Firefox coverage files" fi # Merge WebKit shards WEBKIT_FILES=$(find all-coverage -path "*webkit*" -name "lcov.info" -type f) if [[ -n "$WEBKIT_FILES" ]]; then MERGE_ARGS="" for file in $WEBKIT_FILES; do MERGE_ARGS="$MERGE_ARGS -a $file"; done lcov $MERGE_ARGS -o coverage/e2e-merged/webkit/lcov.info echo "✅ Merged $(echo "$WEBKIT_FILES" | wc -w) WebKit coverage files" fi - name: Upload Chromium coverage to Codecov if: hashFiles('coverage/e2e-merged/chromium/lcov.info') != '' uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/e2e-merged/chromium/lcov.info flags: e2e-chromium name: e2e-coverage-chromium fail_ci_if_error: false - name: Upload Firefox coverage to Codecov if: hashFiles('coverage/e2e-merged/firefox/lcov.info') != '' uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/e2e-merged/firefox/lcov.info flags: e2e-firefox name: e2e-coverage-firefox fail_ci_if_error: false - name: Upload WebKit coverage to Codecov if: hashFiles('coverage/e2e-merged/webkit/lcov.info') != '' uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/e2e-merged/webkit/lcov.info flags: e2e-webkit name: e2e-coverage-webkit fail_ci_if_error: false - name: Upload merged coverage artifacts uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: e2e-coverage-merged path: coverage/e2e-merged/ retention-days: 30 # Comment on PR with results comment-results: name: Comment Test Results runs-on: ubuntu-latest needs: [e2e-chromium, e2e-firefox, e2e-webkit, test-summary] if: github.event_name == 'pull_request' && (success() || failure()) permissions: pull-requests: write steps: - name: Determine overall status id: status run: | CHROMIUM="${{ needs.e2e-chromium.result }}" FIREFOX="${{ needs.e2e-firefox.result }}" WEBKIT="${{ needs.e2e-webkit.result }}" if [[ "$CHROMIUM" == "success" && "$FIREFOX" == "success" && "$WEBKIT" == "success" ]]; then echo "emoji=✅" >> $GITHUB_OUTPUT echo "status=PASSED" >> $GITHUB_OUTPUT echo "message=All browser tests passed!" >> $GITHUB_OUTPUT else echo "emoji=❌" >> $GITHUB_OUTPUT echo "status=FAILED" >> $GITHUB_OUTPUT echo "message=Some browser tests failed. Each browser runs independently." >> $GITHUB_OUTPUT fi - name: Comment on PR uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const emoji = '${{ steps.status.outputs.emoji }}'; const status = '${{ steps.status.outputs.status }}'; const message = '${{ steps.status.outputs.message }}'; const chromium = '${{ needs.e2e-chromium.result }}'; const firefox = '${{ needs.e2e-firefox.result }}'; const webkit = '${{ needs.e2e-webkit.result }}'; const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const body = `## ${emoji} E2E Test Results: ${status} (Split Browser Jobs) ${message} ### Browser Results (Parallel Sharding) | Browser | Status | Shards | Execution | |---------|--------|--------|-----------| | Chromium | ${chromium === 'success' ? '✅ Passed' : chromium === 'failure' ? '❌ Failed' : '⚠️ ' + chromium} | 3 | Parallel (3 shards) | | Firefox | ${firefox === 'success' ? '✅ Passed' : firefox === 'failure' ? '❌ Failed' : '⚠️ ' + firefox} | 3 | Parallel (3 shards) | | WebKit | ${webkit === 'success' ? '✅ Passed' : webkit === 'failure' ? '❌ Failed' : '⚠️ ' + webkit} | 3 | Parallel (3 shards) | **Sharding Active:** Tests split across 3 shards per browser to stay under 25-minute timeout. [📊 View workflow run & download reports](${runUrl}) --- 🤖 Sharding enabled - Target: ~10 minutes per shard`; const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const botComment = comments.find(comment => comment.user.type === 'Bot' && comment.body.includes('E2E Test Results') ); if (botComment) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body: body }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: body }); } # Final status check e2e-results: name: E2E Test Results (Final) runs-on: ubuntu-latest needs: [e2e-chromium, e2e-firefox, e2e-webkit] if: success() || failure() steps: - name: Check test results run: | CHROMIUM="${{ needs.e2e-chromium.result }}" FIREFOX="${{ needs.e2e-firefox.result }}" WEBKIT="${{ needs.e2e-webkit.result }}" echo "Browser Results:" echo " Chromium: $CHROMIUM" echo " Firefox: $FIREFOX" echo " WebKit: $WEBKIT" # Allow skipped browsers (workflow_dispatch with specific browser) if [[ "$CHROMIUM" == "skipped" ]]; then CHROMIUM="success"; fi if [[ "$FIREFOX" == "skipped" ]]; then FIREFOX="success"; fi if [[ "$WEBKIT" == "skipped" ]]; then WEBKIT="success"; fi if [[ "$CHROMIUM" == "success" && "$FIREFOX" == "success" && "$WEBKIT" == "success" ]]; then echo "✅ All browser tests passed or were skipped" exit 0 else echo "❌ One or more browser tests failed" exit 1 fi