533 lines
20 KiB
YAML
533 lines
20 KiB
YAML
# E2E Tests Workflow
|
|
# Runs Playwright E2E tests with sharding for faster execution
|
|
# and collects frontend code coverage via @bgotink/playwright-coverage
|
|
#
|
|
# Test Execution Architecture:
|
|
# - Parallel Sharding: Tests split across 4 shards for speed
|
|
# - Per-Shard HTML Reports: Each shard generates its own HTML report
|
|
# - No Merging Needed: Smaller reports are easier to debug
|
|
# - Trace Collection: Failure traces captured for debugging
|
|
#
|
|
# 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
|
|
# - Coverage disabled by default (requires PLAYWRIGHT_COVERAGE=1)
|
|
#
|
|
# 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, upload per-shard HTML reports
|
|
# 3. test-summary: Generate summary with links to shard reports
|
|
# 4. comment-results: Post test results as PR comment
|
|
# 5. upload-coverage: Merge and upload E2E coverage to Codecov (if enabled)
|
|
# 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'
|
|
- '.github/workflows/e2e-tests.yml'
|
|
|
|
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
|
|
PLAYWRIGHT_COVERAGE: ${{ vars.PLAYWRIGHT_COVERAGE || '0' }}
|
|
# Enhanced debugging environment variables
|
|
DEBUG: 'charon:*,charon-test:*'
|
|
PLAYWRIGHT_DEBUG: '1'
|
|
CI_LOG_LEVEL: 'verbose'
|
|
|
|
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
|
|
outputs:
|
|
image_digest: ${{ steps.build-image.outputs.digest }}
|
|
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
|
|
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
|
|
|
|
# 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 }}
|
|
# Enable security-focused endpoints and test gating
|
|
CHARON_EMERGENCY_SERVER_ENABLED: "true"
|
|
CHARON_SECURITY_TESTS_ENABLED: "true"
|
|
CHARON_E2E_IMAGE_TAG: charon:e2e-test
|
|
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: 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 in repository settings"
|
|
echo "::error::Navigate to: Repository Settings → Secrets and Variables → Actions"
|
|
echo "::error::Create secret: CHARON_EMERGENCY_TOKEN"
|
|
echo "::error::Generate value with: openssl rand -hex 32"
|
|
echo "::error::See docs/github-setup.md for detailed instructions"
|
|
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 (current: $TOKEN_LENGTH)"
|
|
echo "::error::Generate new token with: openssl rand -hex 32"
|
|
exit 1
|
|
fi
|
|
|
|
# Mask token in output (show first 8 chars only)
|
|
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: |
|
|
# 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 docker-compose.playwright-ci.yml for CI (no .env file, uses GitHub Secrets)
|
|
# Note: Using pre-built image loaded from artifact - no rebuild needed
|
|
docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d
|
|
echo "✅ Container started via docker-compose.playwright-ci.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-ci.yml logs
|
|
exit 1
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Cache Playwright browsers
|
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # 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: Run E2E tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }})
|
|
run: |
|
|
echo "════════════════════════════════════════════════════════════"
|
|
echo "E2E Test Shard ${{ matrix.shard }}/${{ matrix.total-shards }}"
|
|
echo "Browser: ${{ matrix.browser }}"
|
|
echo "Start Time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
|
echo ""
|
|
echo "Reporter: HTML (per-shard reports)"
|
|
echo "Output: playwright-report/ directory"
|
|
echo "════════════════════════════════════════════════════════════"
|
|
|
|
SHARD_START=$(date +%s)
|
|
|
|
npx playwright test \
|
|
--project=${{ matrix.browser }} \
|
|
--shard=${{ matrix.shard }}/${{ matrix.total-shards }}
|
|
|
|
SHARD_END=$(date +%s)
|
|
SHARD_DURATION=$((SHARD_END - SHARD_START))
|
|
|
|
echo ""
|
|
echo "════════════════════════════════════════════════════════════"
|
|
echo "Shard ${{ matrix.shard }} Complete | Duration: ${SHARD_DURATION}s"
|
|
echo "════════════════════════════════════════════════════════════"
|
|
env:
|
|
# Test directly against Docker container (no coverage)
|
|
PLAYWRIGHT_BASE_URL: http://localhost:8080
|
|
CI: true
|
|
TEST_WORKER_INDEX: ${{ matrix.shard }}
|
|
|
|
- name: Upload HTML report (per-shard)
|
|
if: always()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: playwright-report-shard-${{ matrix.shard }}
|
|
path: playwright-report/
|
|
retention-days: 14
|
|
|
|
- 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-ci.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-ci.yml down -v 2>/dev/null || true
|
|
|
|
# Summarize test results from all shards (no merging needed)
|
|
test-summary:
|
|
name: E2E Test Summary
|
|
runs-on: ubuntu-latest
|
|
needs: e2e-tests
|
|
if: always()
|
|
|
|
steps:
|
|
- name: Generate job summary with per-shard links
|
|
run: |
|
|
echo "## 📊 E2E Test Results" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "### Per-Shard HTML Reports" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "Each shard generates its own HTML report for easier debugging:" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "| Shard | HTML Report | Traces (on failure) |" >> $GITHUB_STEP_SUMMARY
|
|
echo "|-------|-------------|---------------------|" >> $GITHUB_STEP_SUMMARY
|
|
echo "| 1 | \`playwright-report-shard-1\` | \`traces-chromium-shard-1\` |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| 2 | \`playwright-report-shard-2\` | \`traces-chromium-shard-2\` |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| 3 | \`playwright-report-shard-3\` | \`traces-chromium-shard-3\` |" >> $GITHUB_STEP_SUMMARY
|
|
echo "| 4 | \`playwright-report-shard-4\` | \`traces-chromium-shard-4\` |" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "### How to View Reports" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "1. Download the shard HTML report artifact (zip file)" >> $GITHUB_STEP_SUMMARY
|
|
echo "2. Extract and open \`index.html\` in your browser" >> $GITHUB_STEP_SUMMARY
|
|
echo "3. Or run: \`npx playwright show-report path/to/extracted-folder\`" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "### Debugging Tips" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Failed tests?** Download the shard report that failed. Each shard has a focused subset of tests." >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Traces**: Available in trace artifacts (only on failure)" >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Docker Logs**: Backend errors available in docker-logs-shard-N artifacts" >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Local repro**: \`npx playwright test --grep=\"test name\"\`" >> $GITHUB_STEP_SUMMARY
|
|
|
|
# Comment on PR with results
|
|
comment-results:
|
|
name: Comment Test Results
|
|
runs-on: ubuntu-latest
|
|
needs: [e2e-tests, test-summary]
|
|
if: github.event_name == 'pull_request' && always()
|
|
permissions:
|
|
pull-requests: write
|
|
|
|
steps:
|
|
- 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. Check artifacts for per-shard reports." >> $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} |
|
|
|
|
**Per-Shard HTML Reports** (easier to debug):
|
|
- \`playwright-report-shard-1\` through \`playwright-report-shard-4\`
|
|
|
|
[📊 View workflow run & download reports](${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
|
|
# Coverage is only produced when PLAYWRIGHT_COVERAGE=1 (requires Vite dev server)
|
|
if: vars.PLAYWRIGHT_COVERAGE == '1'
|
|
|
|
|
|
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
|