Files
Charon/.github/workflows/e2e-tests.yml
GitHub Actions ac803fd411 fix(ci): add CHARON_EMERGENCY_TOKEN to E2E test workflows
Add missing emergency token environment variable to all E2E test workflows to
fix security teardown failures in CI. Without this token, the emergency reset
endpoint returns 501 "not configured", causing test teardown to fail and
leaving ACL enabled, which blocks 83 subsequent tests.

Changes:

Add CHARON_EMERGENCY_TOKEN to docker-build.yml test-image job
Add CHARON_EMERGENCY_TOKEN to e2e-tests.yml e2e-tests job
Add CHARON_EMERGENCY_TOKEN to playwright.yml playwright job
Verified:

Docker build strategy already optimal (build once, push to both GHCR + Docker Hub)
Testing strategy correct (test once by digest, validates both registries)
All workflows now have environment parity with local development setup
Requires GitHub repository secret:

Name: CHARON_EMERGENCY_TOKEN
Value: 64-char hex token (e.g., from openssl rand -hex 32)
Related:

Emergency endpoint rate limiting removal (proper fix)
Local emergency token configuration (.env, docker-compose.local.yml)
Security test suite teardown mechanism
Refs #550
2026-01-26 20:03:30 +00:00

567 lines
18 KiB
YAML

# E2E Tests Workflow
# Runs Playwright E2E tests with sharding for faster execution
# and collects frontend code coverage via @bgotink/playwright-coverage
#
# Coverage Architecture:
# - Backend: Docker container at localhost:8080 (API)
# - Frontend: Vite dev server at localhost:3000 (serves source files)
# - Tests hit Vite, which proxies API calls to Docker
# - V8 coverage maps directly to source files for accurate reporting
#
# Triggers:
# - Pull requests to main/develop (with path filters)
# - Push to main branch
# - Manual dispatch with browser selection
#
# Jobs:
# 1. build: Build Docker image and upload as artifact
# 2. e2e-tests: Run tests in parallel shards with coverage
# 3. merge-reports: Combine HTML reports from all shards
# 4. comment-results: Post test results as PR comment
# 5. upload-coverage: Merge and upload E2E coverage to Codecov
# 6. e2e-results: Status check to block merge on failure
name: E2E Tests
on:
pull_request:
branches:
- main
- development
- 'feature/**'
paths:
- 'frontend/**'
- 'backend/**'
- 'tests/**'
- 'playwright.config.js'
- '.github/workflows/e2e-tests.yml'
push:
branches:
- main
- development
- 'feature/**'
paths:
- 'frontend/**'
- 'backend/**'
- 'tests/**'
- 'playwright.config.js'
workflow_dispatch:
inputs:
browser:
description: 'Browser to test'
required: false
default: 'chromium'
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
concurrency:
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Build application once, share across test shards
build:
name: Build Application
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- 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@8b402f58fbc84540c8b491a91e594a4576fec3d7 # 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
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
# Run tests in parallel shards
e2e-tests:
name: E2E Tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }})
runs-on: ubuntu-latest
needs: build
timeout-minutes: 30
env:
# Required for security teardown (emergency reset fallback when ACL blocks API)
CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }}
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
total-shards: [4]
browser: [chromium]
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 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: Load Docker image
run: |
docker load -i charon-e2e-image.tar
docker images | grep charon
- name: Generate ephemeral encryption key
run: |
# Generate a unique, ephemeral encryption key for this CI run
# Key is 32 bytes, base64-encoded as required by CHARON_ENCRYPTION_KEY
echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV
echo "✅ Generated ephemeral encryption key for E2E tests"
- name: Start test environment
run: |
# Use the committed docker-compose.playwright.yml for E2E testing
# Note: Using pre-built image loaded from artifact - no rebuild needed
docker compose -f .docker/compose/docker-compose.playwright.yml up -d
echo "✅ Container started via docker-compose.playwright.yml"
- 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://localhost:8080/api/v1/health > /dev/null 2>&1; then
echo "✅ Charon is healthy!"
curl -s http://localhost:8080/api/v1/health | jq .
exit 0
fi
sleep 2
done
echo "❌ Health check failed"
docker compose -f .docker/compose/docker-compose.playwright.yml logs
exit 1
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }}
restore-keys: playwright-${{ matrix.browser }}-
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Install Frontend Dependencies
run: |
echo "📦 Installing frontend dependencies..."
cd frontend
npm ci
- name: Start Vite dev server for coverage
run: |
echo "🚀 Starting Vite dev server for E2E coverage..."
cd frontend
# Use port 5173 (Vite default) with --strictPort to fail if busy
VITE_PORT=5173
npx vite --port ${VITE_PORT} --strictPort > /tmp/vite.log 2>&1 &
VITE_PID=$!
echo "VITE_PID=${VITE_PID}" >> $GITHUB_ENV
echo "VITE_PORT=${VITE_PORT}" >> $GITHUB_ENV
# Wait for Vite to be ready
echo "⏳ Waiting for Vite to start on port ${VITE_PORT}..."
MAX_WAIT=60
WAITED=0
while [[ ${WAITED} -lt ${MAX_WAIT} ]]; do
if curl -sf http://localhost:${VITE_PORT} > /dev/null 2>&1; then
echo "✅ Vite dev server ready at http://localhost:${VITE_PORT}"
exit 0
fi
sleep 1
WAITED=$((WAITED + 1))
done
echo "❌ Vite failed to start"
cat /tmp/vite.log
exit 1
- name: Run E2E tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }})
run: |
npx playwright test \
--project=${{ matrix.browser }} \
--shard=${{ matrix.shard }}/${{ matrix.total-shards }} \
--reporter=html,json,github
env:
# Use Vite dev server for coverage (proxies API to Docker at 8080)
PLAYWRIGHT_BASE_URL: http://localhost:${{ env.VITE_PORT }}
CI: true
TEST_WORKER_INDEX: ${{ matrix.shard }}
- name: Stop Vite dev server
if: always()
run: |
if [[ -n "${VITE_PID}" ]]; then
kill ${VITE_PID} 2>/dev/null || true
fi
- name: Upload test results
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: test-results-${{ matrix.browser }}-shard-${{ matrix.shard }}
path: |
playwright-report/
test-results/
retention-days: 7
- name: Upload E2E coverage artifact
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-coverage-shard-${{ 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-${{ matrix.browser }}-shard-${{ matrix.shard }}
path: test-results/**/*.zip
retention-days: 7
- name: Collect Docker logs on failure
if: failure()
run: |
echo "📋 Container logs:"
docker compose -f .docker/compose/docker-compose.playwright.yml logs > docker-logs-shard-${{ matrix.shard }}.txt 2>&1
- name: Upload Docker logs on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: docker-logs-shard-${{ matrix.shard }}
path: docker-logs-shard-${{ matrix.shard }}.txt
retention-days: 7
- name: Cleanup
if: always()
run: |
docker compose -f .docker/compose/docker-compose.playwright.yml down -v 2>/dev/null || true
# Merge reports from all shards
merge-reports:
name: Merge Test Reports
runs-on: ubuntu-latest
needs: e2e-tests
if: always()
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Download all test results
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: test-results-*
path: all-results
merge-multiple: false
- name: Merge Playwright HTML reports
run: |
# Create directory for merged results
mkdir -p merged-results
# Find and copy all blob reports
find all-results -name "*.zip" -exec cp {} merged-results/ \; 2>/dev/null || true
# Merge reports if blobs exist
if ls merged-results/*.zip 1> /dev/null 2>&1; then
npx playwright merge-reports --reporter html merged-results
else
echo "No blob reports found, copying individual reports"
cp -r all-results/test-results-chromium-shard-1/playwright-report playwright-report 2>/dev/null || mkdir -p playwright-report
fi
- name: Upload merged report
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: merged-playwright-report
path: playwright-report/
retention-days: 30
- name: Generate job summary
run: |
echo "## E2E Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Count results from all shards
TOTAL=0
PASSED=0
FAILED=0
for dir in all-results/test-results-*/; do
if [[ -f "${dir}test-results/.last-run.json" ]]; then
SHARD_STATS=$(cat "${dir}test-results/.last-run.json" 2>/dev/null || echo '{}')
# Parse stats if available
fi
done
echo "| Shard | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
for i in 1 2 3 4; do
if [[ -d "all-results/test-results-chromium-shard-${i}" ]]; then
echo "| Shard ${i} | ✅ Complete |" >> $GITHUB_STEP_SUMMARY
else
echo "| Shard ${i} | ❌ Failed |" >> $GITHUB_STEP_SUMMARY
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "[View full Playwright report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
# Comment on PR with results
comment-results:
name: Comment Test Results
runs-on: ubuntu-latest
needs: [e2e-tests, merge-reports]
if: github.event_name == 'pull_request' && always()
permissions:
pull-requests: write
steps:
- name: Download merged report
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: merged-playwright-report
path: playwright-report
continue-on-error: true
- name: Determine test status
id: status
run: |
if [[ "${{ needs.e2e-tests.result }}" == "success" ]]; then
echo "emoji=✅" >> $GITHUB_OUTPUT
echo "status=PASSED" >> $GITHUB_OUTPUT
echo "message=All E2E tests passed!" >> $GITHUB_OUTPUT
elif [[ "${{ needs.e2e-tests.result }}" == "failure" ]]; then
echo "emoji=❌" >> $GITHUB_OUTPUT
echo "status=FAILED" >> $GITHUB_OUTPUT
echo "message=Some E2E tests failed. Please review the failures." >> $GITHUB_OUTPUT
else
echo "emoji=⚠️" >> $GITHUB_OUTPUT
echo "status=UNKNOWN" >> $GITHUB_OUTPUT
echo "message=E2E tests did not complete successfully." >> $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 runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## ${emoji} E2E Test Results: ${status}
${message}
| Metric | Result |
|--------|--------|
| Browser | Chromium |
| Shards | 4 |
| Status | ${status} |
[📊 View full Playwright report](${runUrl})
---
<sub>🤖 This comment was automatically generated by the E2E Tests workflow.</sub>`;
// Find existing comment
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
});
}
# Upload merged E2E coverage to Codecov
upload-coverage:
name: Upload E2E Coverage
runs-on: ubuntu-latest
needs: e2e-tests
if: always() && needs.e2e-tests.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: e2e-coverage-*
path: all-coverage
merge-multiple: false
- name: Merge LCOV coverage files
run: |
# Install lcov for merging
sudo apt-get update && sudo apt-get install -y lcov
# Create merged coverage directory
mkdir -p coverage/e2e-merged
# Find all lcov.info files and merge them
LCOV_FILES=$(find all-coverage -name "lcov.info" -type f)
if [[ -n "$LCOV_FILES" ]]; then
# Build merge command
MERGE_ARGS=""
for file in $LCOV_FILES; do
MERGE_ARGS="$MERGE_ARGS -a $file"
done
lcov $MERGE_ARGS -o coverage/e2e-merged/lcov.info
echo "✅ Merged $(echo "$LCOV_FILES" | wc -w) coverage files"
else
echo "⚠️ No coverage files found to merge"
exit 0
fi
- name: Upload E2E coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/e2e-merged/lcov.info
flags: e2e
name: e2e-coverage
fail_ci_if_error: false
- name: Upload merged coverage artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-coverage-merged
path: coverage/e2e-merged/
retention-days: 30
# Final status check - blocks merge if tests fail
e2e-results:
name: E2E Test Results
runs-on: ubuntu-latest
needs: e2e-tests
if: always()
steps:
- name: Check test results
run: |
if [[ "${{ needs.e2e-tests.result }}" == "success" ]]; then
echo "✅ All E2E tests passed"
exit 0
elif [[ "${{ needs.e2e-tests.result }}" == "skipped" ]]; then
echo "⏭️ E2E tests were skipped"
exit 0
else
echo "❌ E2E tests failed or were cancelled"
echo "Result: ${{ needs.e2e-tests.result }}"
exit 1
fi