The E2E workflow was failing during "Build frontend" because npm ci was only run at root level. The frontend directory has its own package.json with React, Tailwind, and other dependencies that were never installed. Add "Install frontend dependencies" step before build Update Node.js version from 18 to 20 (required by markdownlint-cli2) Fixes failing E2E tests in PR #550
565 lines
18 KiB
YAML
565 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
|
|
- develop
|
|
paths:
|
|
- 'frontend/**'
|
|
- 'backend/**'
|
|
- 'tests/**'
|
|
- 'playwright.config.js'
|
|
- '.github/workflows/e2e-tests.yml'
|
|
|
|
push:
|
|
branches:
|
|
- main
|
|
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.21'
|
|
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: Install frontend dependencies
|
|
run: npm ci
|
|
working-directory: frontend
|
|
|
|
- name: Build frontend
|
|
run: npm run build
|
|
working-directory: frontend
|
|
|
|
- name: Build backend
|
|
run: make build
|
|
working-directory: backend
|
|
|
|
- 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
|
|
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
|
|
docker compose -f .docker/compose/docker-compose.playwright.yml up -d --build
|
|
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: 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
|