name: E2E Fresh Install Tests on: workflow_dispatch: # Allows manual triggering pull_request: branches: - '**' # paths filter removed to always trigger push: branches: - main # paths filter removed to always trigger jobs: fresh-install-e2e: runs-on: ubuntu-latest timeout-minutes: 60 # Set a timeout for the job steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 # Fetches all history for all branches and tags, required for tj-actions/changed-files #-------------------------------------------------------------- # Detect whether the proposed changes require running the # (slow) E2E installation tests. Skip if only non-application # files changed (e.g., Helm charts, docs, scripts). #-------------------------------------------------------------- - name: Check for relevant file changes id: changed_files_check uses: tj-actions/changed-files@v46 # Updated from v42 to fix CVE-2025-30066 with: # List of paths that, when modified, SHOULD trigger the heavy # E2E job. Paths NOT listed here (helm/, docs/, scripts/, sdk/) # will not trigger the tests. files: | .github/workflows/e2e-fresh-install-tests.yaml docker-compose.base.yaml docker-compose.ce.yaml docker-compose.prod.yaml Dockerfile Dockerfile.build server/** setup/** shared/** packages/** services/** ee/** e2e-tests/** package.json package-lock.json tsconfig.base.json # Short-circuit the job if no relevant files changed - name: Skip job -- no application files changed if: steps.changed_files_check.outputs.any_changed == 'false' run: | echo "No application files were modified – skipping E2E Fresh Install Tests." echo "No further steps will be executed." # Nothing else to do, the job will report success because the # previous steps (including this one) have succeeded. - name: Set up test environment secrets and .env if: steps.changed_files_check.outputs.any_changed == 'true' run: | # Create secrets directory mkdir -p secrets # Create sample secrets (placeholders, real values not needed for this test if services are self-contained) # Using similar placeholders as pr-checks.yaml for consistency # Use printf instead of echo to avoid trailing newlines printf "placeholder-password" > secrets/postgres_password printf "placeholder-password" > secrets/db_password_server printf "placeholder-password" > secrets/db_password_hocuspocus printf "placeholder-password" > secrets/redis_password printf "placeholder-key-32-chars-long-01" > secrets/alga_auth_key printf "placeholder-key-32-chars-long-02" > secrets/crypto_key printf "placeholder-key-32-chars-long-03" > secrets/token_secret_key printf "placeholder-key-32-chars-long-04" > secrets/nextauth_secret printf "placeholder-password" > secrets/email_password printf "placeholder-id" > secrets/google_oauth_client_id printf "placeholder-secret" > secrets/google_oauth_client_secret # Set permissions chmod 600 secrets/* # Copy and configure environment file cp .env.example .env # Configure required environment variables for the test # Set APP_ENV to production for production build testing cat >> .env << EOL APP_VERSION=1.0.0-e2e APP_NAME=alga-e2e-test APP_ENV=production APP_HOST=0.0.0.0 APP_PORT=3000 APP_EDITION=community # Database Configuration (will be overridden by docker-compose services but good to have) DB_TYPE=postgres DB_USER_ADMIN=postgres # Logging Configuration LOG_LEVEL=INFO LOG_IS_FORMAT_JSON=false LOG_IS_FULL_DETAILS=false # Email Configuration (disabled for tests) EMAIL_ENABLE=false # Authentication Configuration NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=placeholder-key-32-chars-long-04 NEXTAUTH_SESSION_EXPIRES=86400 AUTH_TRUST_HOST=true # Optional Configuration REQUIRE_HOCUSPOCUS=false # Secret Provider Configuration (override production defaults) SECRET_READ_CHAIN=env,filesystem SECRET_WRITE_PROVIDER=filesystem EOL shell: bash - name: Temporarily rename root docker-compose.yaml to avoid conflict in act if: env.ACT run: | if [ -f docker-compose.yaml ]; then echo "Temporarily renaming root docker-compose.yaml to docker-compose.yaml.ignored" sudo mv docker-compose.yaml docker-compose.yaml.ignored fi shell: bash - name: Install Docker Compose v1.29.2 via curl if: steps.changed_files_check.outputs.any_changed == 'true' id: install_docker_compose # Add an ID for dependent steps run: | COMPOSE_VERSION="v2.36.0" COMPOSE_URL="https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" DEST_PATH="/usr/local/bin/docker-compose" # Standard location echo "Downloading Docker Compose from ${COMPOSE_URL} to ${DEST_PATH}" sudo rm -f "${DEST_PATH}" # Remove existing to avoid conflicts # Use curl with -fS (fail silently on server errors, show client errors) and -L (follow redirects) sudo curl -fSL "${COMPOSE_URL}" -o "${DEST_PATH}" # Verify download was successful and file is not empty and is executable if [ ! -s "${DEST_PATH}" ]; then echo "Error: Downloaded docker-compose is empty. URL ${COMPOSE_URL} might be incorrect or file not found." exit 1 fi if ! file "${DEST_PATH}" | grep -q "executable"; then echo "Error: Downloaded file at ${DEST_PATH} is not an executable. It might be an HTML error page." echo "Downloaded content (first 5 lines):" sudo head -n 5 "${DEST_PATH}" exit 1 fi sudo chmod +x "${DEST_PATH}" echo "Docker Compose version:" docker-compose --version # Verify installation shell: bash - name: Free up disk space if: steps.install_docker_compose.outcome == 'success' run: | echo "Freeing up disk space before build..." df -h # Remove large pre-installed toolchains this job never uses. Each is # guarded with `|| true` so a missing path can't fail the step (the # shell runs with `set -e`). The previous run started with only ~19G # free and the four --no-cache image builds exhausted it; this frees # ~15-20G more on the root volume. sudo rm -rf /usr/share/dotnet || true sudo rm -rf /usr/local/lib/android || true sudo rm -rf /opt/ghc /usr/local/.ghcup || true sudo rm -rf /opt/hostedtoolcache/CodeQL || true sudo rm -rf /usr/local/share/boost || true sudo rm -rf /usr/share/swift || true # Drop apt caches. sudo apt-get clean || true # Reclaim all Docker space (images, build cache, volumes). docker system prune -a -f --volumes || true echo "Disk space after cleanup:" df -h shell: bash - name: Build images with no cache if: steps.install_docker_compose.outcome == 'success' run: | echo "Building all required images with --no-cache to ensure fresh build" # Build all images needed for e2e tests upfront # Excludes: workflow-worker (EE only, not needed for CE e2e) # Excludes: hocuspocus (built separately below with repo-root context) # Note: setup is built here using docker-compose.setup-ubuntu.override.yaml so # it produces the alga-setup:ubuntu image used by the later "Start setup # service (Ubuntu build override)" step. That step then starts the # container without --build to avoid a redundant rebuild. docker-compose -p alga-e2e-test \ -f docker-compose.base.yaml \ -f docker-compose.ce.yaml \ -f docker-compose.prod.yaml \ -f docker-compose.setup-ubuntu.override.yaml \ build --no-cache \ server setup email-service shell: bash - name: Reclaim disk space before hocuspocus build if: steps.install_docker_compose.outcome == 'success' run: | echo "Disk space before reclaim:" df -h / # The previous --no-cache build leaves dangling intermediate # (multi-stage) layers and BuildKit cache behind. Reclaim them so the # hocuspocus build has headroom -- the earlier run ran out of space # mid-`apt-get` precisely here, on the last image of the job. docker image prune -f || true docker builder prune -af || true echo "Disk space after reclaim:" df -h / shell: bash - name: Build hocuspocus image with repo-root context if: steps.install_docker_compose.outcome == 'success' run: | # hocuspocus/Dockerfile expects repo-root as build context (it does # `COPY hocuspocus/package.json ...`), but docker-compose.ce.yaml sets # `context: ./hocuspocus`, which breaks the build. Build it directly # here with the correct context and tag it with the name compose v2 # auto-generates (`-:latest`) so subsequent # `docker-compose up` calls reuse this image instead of rebuilding. docker build --no-cache \ -f hocuspocus/Dockerfile \ -t alga-e2e-test-hocuspocus:latest \ . shell: bash - name: Start foundational services (postgres and redis) if: steps.install_docker_compose.outcome == 'success' run: | echo "Starting foundational services: postgres and redis" docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml up --build -d postgres redis shell: bash - name: Wait for postgres to be ready if: steps.install_docker_compose.outcome == 'success' run: | echo "Waiting for postgres to be ready..." # Wait for postgres to be healthy echo "Checking postgres health..." MAX_ATTEMPTS=30 ATTEMPT_NUM=1 until docker-compose -p alga-e2e-test exec -T postgres pg_isready -U postgres; do if [ $ATTEMPT_NUM -ge $MAX_ATTEMPTS ]; then echo "Timeout: Postgres did not become ready within the allocated time." docker-compose -p alga-e2e-test logs postgres exit 1 fi echo "Attempt $ATTEMPT_NUM/$MAX_ATTEMPTS: Postgres not ready. Waiting 5 seconds..." sleep 5 ATTEMPT_NUM=$((ATTEMPT_NUM+1)) done echo "Postgres is ready." shell: bash - name: Start pgbouncer service if: steps.install_docker_compose.outcome == 'success' run: | echo "Starting pgbouncer service" docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml up --build -d pgbouncer shell: bash - name: Wait for pgbouncer to be ready if: steps.install_docker_compose.outcome == 'success' run: | echo "Waiting for pgbouncer to be ready..." # Wait for pgbouncer port to be open echo "Checking pgbouncer port availability..." MAX_ATTEMPTS=30 ATTEMPT_NUM=1 until docker run --rm --network alga-e2e-test_app-network busybox nc -z pgbouncer 6432; do if [ $ATTEMPT_NUM -ge $MAX_ATTEMPTS ]; then echo "Timeout: PgBouncer port did not become available within the allocated time." docker-compose -p alga-e2e-test logs pgbouncer exit 1 fi echo "Attempt $ATTEMPT_NUM/$MAX_ATTEMPTS: PgBouncer port not ready. Waiting 5 seconds..." sleep 5 ATTEMPT_NUM=$((ATTEMPT_NUM+1)) done echo "PgBouncer port is ready." # Additional wait for pgbouncer to fully initialize echo "Waiting additional 5 seconds for pgbouncer to fully initialize..." sleep 5 shell: bash - name: Start setup service (Ubuntu build override) if: steps.install_docker_compose.outcome == 'success' run: | echo "Starting setup service" # Image (alga-setup:ubuntu) was already built fresh in the earlier # "Build images with no cache" step using the same override file, so # we start without --build here to avoid a duplicate ~5–10 min rebuild # that previously pushed the job past its 60-minute timeout. # Set DB_HOST_ADMIN and DB_PORT_ADMIN to connect directly to postgres for admin operations # while keeping DB_HOST and DB_PORT pointing to pgbouncer for migrations/seeds DB_HOST_ADMIN=postgres \ DB_PORT_ADMIN=5432 \ docker-compose -p alga-e2e-test \ -f docker-compose.base.yaml \ -f docker-compose.ce.yaml \ -f docker-compose.prod.yaml \ -f docker-compose.setup-ubuntu.override.yaml \ up -d setup shell: bash - name: Wait for Setup service to complete (Ubuntu build override) if: steps.install_docker_compose.outcome == 'success' id: wait_for_setup run: | echo "Waiting for setup service to complete..." MAX_ATTEMPTS=60 # 10 minutes (60 attempts * 10 seconds) ATTEMPT_NUM=1 # Get the container ID before the loop, it might be gone after exit SETUP_CONTAINER_ID=$(docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml ps -q setup) if [ -z "$SETUP_CONTAINER_ID" ]; then echo "Critical: Could not get initial container ID for setup service. Assuming it failed to start." docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml logs setup echo "status=failure" >> $GITHUB_OUTPUT exit 1 fi echo "Monitoring Setup Container ID: $SETUP_CONTAINER_ID" while [ $ATTEMPT_NUM -le $MAX_ATTEMPTS ]; do # Check if the setup container is still running if ! docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml ps setup | grep -q "Up"; then # Container has exited, check its exit code using the stored ID echo "Setup container (ID: $SETUP_CONTAINER_ID) is no longer 'Up'. Checking exit code." # Use the stored SETUP_CONTAINER_ID if [ -z "$SETUP_CONTAINER_ID" ]; then # Should not happen if initial check passed echo "Error: Lost setup container ID." # Should be redundant due to initial check SETUP_EXIT_CODE="1" else echo "Inspecting container ID: $SETUP_CONTAINER_ID" INSPECT_OUTPUT=$(docker inspect -f '{{.State.ExitCode}} {{.State.Error}}' "$SETUP_CONTAINER_ID" 2>/dev/null) echo "Inspect output: '$INSPECT_OUTPUT'" SETUP_EXIT_CODE=$(echo "$INSPECT_OUTPUT" | awk '{print $1}') # If SETUP_EXIT_CODE is empty or not a number, default to 1 if ! [[ "$SETUP_EXIT_CODE" =~ ^[0-9]+$ ]]; then echo "Failed to parse exit code from inspect output, defaulting to 1." SETUP_EXIT_CODE="1" fi fi echo "Reported Setup Exit Code: $SETUP_EXIT_CODE" if [ "$SETUP_EXIT_CODE" -eq 0 ]; then echo "Setup service completed successfully." echo "status=success" >> $GITHUB_OUTPUT exit 0 else echo "Setup service failed with exit code $SETUP_EXIT_CODE." docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml logs setup echo "status=failure" >> $GITHUB_OUTPUT exit 1 fi fi # Check logs for completion message as a secondary check (optional, primary is exit code) if docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml logs setup | grep -q "Setup completed!"; then echo "Setup completion message found in logs. Waiting a bit for container to exit." sleep 5 # Give it a moment to exit gracefully # Re-check exit status using the stored ID if ! docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml ps setup | grep -q "Up"; then # Use the stored SETUP_CONTAINER_ID INSPECT_OUTPUT_LOG_CHECK=$(docker inspect -f '{{.State.ExitCode}}' "$SETUP_CONTAINER_ID" 2>/dev/null) SETUP_EXIT_CODE_LOG_CHECK=$(echo "$INSPECT_OUTPUT_LOG_CHECK" | awk '{print $1}') if ! [[ "$SETUP_EXIT_CODE_LOG_CHECK" =~ ^[0-9]+$ ]]; then echo "Failed to parse exit code from inspect output (log check), defaulting to 1." SETUP_EXIT_CODE_LOG_CHECK="1" fi if [ "$SETUP_EXIT_CODE_LOG_CHECK" -eq 0 ]; then echo "Setup service completed successfully after log check (Exit Code: $SETUP_EXIT_CODE_LOG_CHECK)." echo "status=success" >> $GITHUB_OUTPUT exit 0 # Successful exit from the script else echo "Setup service showed completion log but exited with code $SETUP_EXIT_CODE_LOG_CHECK." docker-compose -p alga-e2e-test logs setup echo "status=failure" >> $GITHUB_OUTPUT exit 1 # Failed exit from the script fi fi fi echo "Attempt $ATTEMPT_NUM/$MAX_ATTEMPTS: Setup service still running. Waiting 10 seconds..." sleep 10 ATTEMPT_NUM=$((ATTEMPT_NUM+1)) done echo "Timeout: Setup service did not complete within the allocated time." docker-compose -p alga-e2e-test logs echo "status=failure" >> $GITHUB_OUTPUT exit 1 shell: bash - name: Start remaining services after setup completion if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success' run: | echo "Setup completed successfully. Starting remaining services..." # Start CE services needed for e2e tests (images already built earlier) # Use --no-recreate to avoid restarting setup which already completed # Use --no-deps to avoid waiting on setup dependency again docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.imap-test.yaml up -d \ --no-recreate --no-deps \ server email-service imap-test-server hocuspocus shell: bash - name: Collect initial container logs if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success' run: | echo "=== CONTAINER STATUS ===" docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml ps echo -e "\n=== ALL CONTAINER LOGS ===" docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml logs --tail=100 echo -e "\n=== SERVER LOGS (detailed) ===" docker-compose -p alga-e2e-test logs server --tail=200 shell: bash - name: Wait for Server service to be healthy if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success' run: | echo "Waiting for server service to be healthy..." MAX_ATTEMPTS=30 # 5 minutes (30 attempts * 10 seconds) ATTEMPT_NUM=1 # Using APP_PORT from .env, default to 3000 if not found (though it should be there) APP_PORT_VALUE=$(grep APP_PORT .env | cut -d '=' -f2 | head -n 1 || echo "3000") HEALTH_CHECK_URL="http://localhost:${APP_PORT_VALUE}/api/health" # Assuming a health endpoint exists # Fallback if /api/health is not standard, try base URL # HEALTH_CHECK_URL="http://localhost:${APP_PORT_VALUE}/" until curl --output /dev/null --silent --head --fail $HEALTH_CHECK_URL; do if [ $ATTEMPT_NUM -ge $MAX_ATTEMPTS ]; then echo "Timeout: Server service did not become healthy at $HEALTH_CHECK_URL within the allocated time." echo "=== FINAL SERVER LOGS ===" docker-compose -p alga-e2e-test logs server --tail=500 echo "=== ALL CONTAINER STATUS ===" docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml ps exit 1 fi echo "Attempt $ATTEMPT_NUM/$MAX_ATTEMPTS: Server not yet healthy at $HEALTH_CHECK_URL. Waiting 10 seconds..." # Show server logs every 5 attempts to debug issues if [ $((ATTEMPT_NUM % 5)) -eq 0 ]; then echo "=== SERVER LOGS (attempt $ATTEMPT_NUM) ===" docker-compose -p alga-e2e-test logs server --tail=50 fi sleep 10 ATTEMPT_NUM=$((ATTEMPT_NUM+1)) done echo "Server service is healthy at $HEALTH_CHECK_URL." shell: bash - name: Trigger Login Page to Generate Credentials if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success' run: | echo "Attempting to trigger login page to generate credentials in server logs..." APP_PORT_VALUE=$(grep APP_PORT .env | cut -d '=' -f2 | head -n 1 || echo "3000") LOGIN_PAGE_URL="http://localhost:${APP_PORT_VALUE}/auth/signin" # Perform a curl request to the login page. We don't need the output, just the action of hitting the page. # Allow failure as the page might not return 200 immediately or might redirect, # but the act of requesting it should trigger the log. curl -s -o /dev/null -w "%{http_code}" "${LOGIN_PAGE_URL}" || echo "Curl to login page finished (ignore exit code here)." echo "Login page triggered. Waiting a few seconds for logs to propagate..." sleep 3 # Wait for server to process the request and log credentials shell: bash - name: Capture Credentials from Server Logs if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success' id: capture_creds run: | echo "Attempting to capture credentials from server logs..." # Wait a few seconds for logs to flush if needed sleep 5 SERVER_LOGS=$(docker-compose -p alga-e2e-test logs server) # Extract email (should be glinda@emeraldcity.oz) USER_EMAIL=$(echo "$SERVER_LOGS" | grep -oP 'User Email is -> \[ \K[^ ]+' || echo "") # Extract password USER_PASSWORD=$(echo "$SERVER_LOGS" | grep -oP 'Password is -> \[ \K[^ ]+' || echo "") if [ -z "$USER_EMAIL" ] || [ -z "$USER_PASSWORD" ]; then echo "Failed to extract credentials from server logs." echo "Server Logs:" echo "$SERVER_LOGS" exit 1 fi echo "Successfully extracted credentials." echo "e2e_user_email=$USER_EMAIL" >> $GITHUB_OUTPUT # Use a delimiter to safely pass passwords with special characters EOF_DELIM=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "e2e_user_password<<$EOF_DELIM" >> $GITHUB_OUTPUT echo "$USER_PASSWORD" >> $GITHUB_OUTPUT echo "$EOF_DELIM" >> $GITHUB_OUTPUT # Mask the password in logs echo "::add-mask::$USER_PASSWORD" shell: bash - name: Warm up authentication system if: steps.wait_for_setup.outputs.status == 'success' && steps.capture_creds.outputs.e2e_user_email != '' run: | echo "Warming up authentication system to prime database connections..." APP_PORT_VALUE=$(grep APP_PORT .env | cut -d '=' -f2 | head -n 1 || echo "3000") # Make a few requests to warm up the auth system and connection pool # First, hit the CSRF endpoint to initialize NextAuth curl -s -o /dev/null -w "CSRF endpoint: %{http_code}\n" \ "http://localhost:${APP_PORT_VALUE}/api/auth/csrf" || true # Then make a login attempt with invalid credentials to warm up the full auth path # This primes: database connections, NextAuth session machinery, lazy-loaded modules CSRF_TOKEN=$(curl -s "http://localhost:${APP_PORT_VALUE}/api/auth/csrf" | grep -oP '"csrfToken":"\K[^"]+' || echo "") curl -s -o /dev/null -w "Auth warmup: %{http_code}\n" \ -X POST "http://localhost:${APP_PORT_VALUE}/api/auth/callback/credentials" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "email=warmup@test.invalid&password=warmup&csrfToken=${CSRF_TOKEN}" || true echo "Auth system warmed up. Waiting for connections to stabilize..." sleep 3 shell: bash - name: Set up Node.js if: steps.wait_for_setup.outputs.status == 'success' uses: actions/setup-node@v3 with: node-version: '18' # Or your project's Node version - name: Create E2E test directory and package.json if: steps.wait_for_setup.outputs.status == 'success' run: | mkdir -p e2e-tests/tests cat << EOF > e2e-tests/package.json { "name": "alga-e2e-tests", "version": "1.0.0", "description": "E2E tests for Alga PSA", "main": "index.js", "scripts": { "test": "playwright test" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@playwright/test": "^1.40.0" } } EOF shell: bash - name: Install Playwright npm packages if: steps.wait_for_setup.outputs.status == 'success' working-directory: ./e2e-tests run: npm install shell: bash - name: Get Playwright version if: steps.wait_for_setup.outputs.status == 'success' id: playwright_version working-directory: ./e2e-tests run: | PLAYWRIGHT_VERSION=$(npx playwright --version | awk '{print $2}') echo "version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT echo "Playwright version: $PLAYWRIGHT_VERSION" shell: bash - name: Cache Playwright browsers if: steps.wait_for_setup.outputs.status == 'success' id: playwright_cache uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-browsers-${{ steps.playwright_version.outputs.version }} - name: Install Playwright browsers if: steps.wait_for_setup.outputs.status == 'success' && steps.playwright_cache.outputs.cache-hit != 'true' working-directory: ./e2e-tests run: npx playwright install shell: bash - name: Install Playwright system dependencies if: steps.wait_for_setup.outputs.status == 'success' working-directory: ./e2e-tests run: npx playwright install-deps shell: bash - name: Create Playwright config file if: steps.wait_for_setup.outputs.status == 'success' run: | cat << EOF > e2e-tests/playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'html', timeout: 120000, // 2 minutes global timeout use: { baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', // Default to localhost:3000 trace: 'on-first-retry', actionTimeout: 60000, // 60 seconds for actions navigationTimeout: 90000, // 90 seconds for navigation }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], }); EOF shell: bash - name: Create E2E login test spec if: steps.wait_for_setup.outputs.status == 'success' run: | # Ensure APP_PORT is available for constructing the baseURL if not set via E2E_BASE_URL APP_PORT_VALUE_FROM_ENV=$(grep '^APP_PORT=' ./.env | cut -d '=' -f2 | head -n 1) APP_PORT_VALUE=${APP_PORT_VALUE_FROM_ENV:-3000} BASE_URL="http://localhost:${APP_PORT_VALUE}" echo "Resolved BASE_URL for Playwright spec: ${BASE_URL}" cat << EOF > e2e-tests/tests/login.spec.ts import { test, expect } from '@playwright/test'; test.describe('Login Functionality', () => { test('should allow a user to log in and redirect to dashboard', async ({ page }) => { const email = process.env.E2E_USER_EMAIL; const password = process.env.E2E_USER_PASSWORD; const baseUrl = process.env.E2E_BASE_URL || '${BASE_URL}'; // Use env var or default if (!email || !password) { throw new Error('E2E_USER_EMAIL or E2E_USER_PASSWORD environment variables are not set.'); } // Navigate to the login page await page.goto(\`\${baseUrl}/auth/signin\`); // Fill in the email and password try { console.log('Waiting for email field...'); await page.waitForSelector('input[id="msp-email-field"]', { timeout: 15000 }); console.log(\`Filling email: \${email}\`); await page.fill('input[id="msp-email-field"]', email); console.log('Waiting for password field...'); await page.waitForSelector('input[id="msp-password-field"]', { timeout: 5000 }); console.log('Filling password...'); await page.fill('input[id="msp-password-field"]', password); console.log('Credentials filled successfully'); } catch (e) { console.error('Error filling form fields. Current page HTML:', await page.content()); throw e; } // Submit the login form // Note: Using keyboard Enter instead of button click because React form // onSubmit handlers are sometimes not triggered by Playwright's click() try { console.log('Waiting for sign-in button...'); await page.waitForSelector('button[id="msp-sign-in-button"]', { timeout: 5000 }); console.log('Submitting form via Enter key on password field...'); // Focus the password field and press Enter to submit the form // This is more reliable than clicking the submit button for React forms await page.focus('input[id="msp-password-field"]'); // Wait for the credentials POST request while pressing Enter // NextAuth credentials login POSTs to /api/auth/callback/credentials const [credentialsResponse] = await Promise.all([ page.waitForResponse( response => { const isCredentialsPost = response.url().includes('/api/auth/callback') && response.request().method() === 'POST'; if (isCredentialsPost) { console.log(\`Auth callback response: \${response.status()} \${response.request().method()} \${response.url()}\`); } return isCredentialsPost; }, { timeout: 30000 } ).catch(e => { console.log('No auth callback POST detected - trying button click as fallback'); return null; }), page.keyboard.press('Enter') ]); // If Enter didn't work, try clicking the button if (!credentialsResponse) { console.log('Enter key did not trigger form submit, trying button click...'); await Promise.all([ page.waitForResponse( response => { const isCredentialsPost = response.url().includes('/api/auth/callback') && response.request().method() === 'POST'; if (isCredentialsPost) { console.log(\`Auth callback response (via click): \${response.status()} \${response.request().method()} \${response.url()}\`); } return isCredentialsPost; }, { timeout: 30000 } ).catch(e => { console.log('Still no auth callback POST after button click'); return null; }), page.click('button[id="msp-sign-in-button"]') ]); } console.log('Form submitted, waiting for page response...'); } catch (e) { console.error('Error submitting login form:', e); console.error('Current page HTML:', await page.content()); throw e; } // Wait for authentication to complete console.log('Waiting for network to become idle after login...'); await page.waitForLoadState('networkidle', { timeout: 60000 }); // Check current state after networkidle const currentUrl = page.url(); console.log(\`Current URL after networkidle: \${currentUrl}\`); // If still on login page, capture diagnostic info if (currentUrl.includes('/auth/')) { console.log('Still on auth page - checking for errors...'); // Check for any visible error messages const pageText = await page.textContent('body'); if (pageText.toLowerCase().includes('error') || pageText.toLowerCase().includes('invalid')) { console.error('Error text found on page:', pageText.substring(0, 1000)); } // Check for alert elements const alertVisible = await page.locator('[role="alert"]').isVisible().catch(() => false); if (alertVisible) { const alertText = await page.locator('[role="alert"]').textContent(); console.error('Alert message found:', alertText); } // Log the visible form state const emailValue = await page.inputValue('input[id="msp-email-field"]').catch(() => 'N/A'); const passwordValue = await page.inputValue('input[id="msp-password-field"]').catch(() => 'N/A'); console.log(\`Form state - Email field value: \${emailValue}, Password filled: \${passwordValue ? 'yes' : 'no'}\`); // Take a screenshot for debugging await page.screenshot({ path: 'login-debug-screenshot.png', fullPage: true }); console.log('Debug screenshot saved to login-debug-screenshot.png'); } // Wait for navigation to the dashboard console.log(\`Waiting for URL: \${baseUrl}/msp/dashboard with 90s timeout.\`); await page.waitForURL(\`\${baseUrl}/msp/dashboard\`, { timeout: 90000 }); // Assert that the URL is the dashboard URL expect(page.url()).toBe(\`\${baseUrl}/msp/dashboard\`); // Optional: Add more assertions, e.g., check for a welcome message or specific element // await expect(page.locator('h1')).toContainText('Dashboard'); }); }); EOF working-directory: ./ shell: bash - name: Run Playwright E2E tests if: steps.wait_for_setup.outputs.status == 'success' && steps.capture_creds.outputs.e2e_user_email != '' working-directory: ./e2e-tests env: E2E_USER_EMAIL: ${{ steps.capture_creds.outputs.e2e_user_email }} E2E_USER_PASSWORD: ${{ steps.capture_creds.outputs.e2e_user_password }} run: npx playwright test shell: bash - name: Collect all container logs on failure if: failure() && steps.install_docker_compose.outcome == 'success' timeout-minutes: 5 run: | echo "=== COLLECTING ALL LOGS DUE TO FAILURE ===" echo "=== CONTAINER STATUS ===" timeout 60 docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml ps || echo "Could not get container status" echo -e "\n=== DOCKER SYSTEM INFO ===" # `docker system df` can hang for minutes against a disk-full/wedged # daemon (it previously stalled here until the step was SIGTERM'd -> # exit 143), so cap it with `timeout`. timeout 60 docker system df || echo "docker system df timed out or failed" timeout 60 docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}" || echo "docker images timed out or failed" echo -e "\n=== ALL CONTAINER LOGS ===" docker-compose -p alga-e2e-test logs --tail=1000 || echo "Could not get compose logs" echo -e "\n=== INDIVIDUAL SERVICE LOGS ===" for service in server setup postgres redis pgbouncer hocuspocus workflow-worker; do echo "--- $service logs ---" docker-compose -p alga-e2e-test logs $service --tail=200 2>/dev/null || echo "No logs for $service" done echo -e "\n=== DOCKER INSPECT (running containers) ===" docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep alga-e2e-test || echo "No running containers found" # Try to get logs from any containers that might have the project name echo -e "\n=== DIRECT DOCKER LOGS ===" for container in $(docker ps -a --filter "name=alga-e2e-test" --format "{{.Names}}"); do echo "--- Direct logs for $container ---" docker logs $container --tail=100 2>/dev/null || echo "Could not get logs for $container" done shell: bash - name: Save container logs as artifacts if: always() && steps.install_docker_compose.outcome == 'success' run: | mkdir -p logs # Copy any Playwright screenshots to logs directory cp e2e-tests/*.png logs/ 2>/dev/null || echo "No Playwright screenshots found" cp e2e-tests/test-results/**/*.png logs/ 2>/dev/null || echo "No test-results screenshots found" # Save logs for each service for service in server setup postgres redis pgbouncer hocuspocus workflow-worker; do echo "Saving logs for $service..." docker-compose -p alga-e2e-test logs $service --no-color > "logs/${service}.log" 2>/dev/null || echo "No logs for $service" > "logs/${service}.log" done # Save combined logs docker-compose -p alga-e2e-test logs --no-color > "logs/all-services.log" 2>/dev/null || echo "Could not save combined logs" # Save container status docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml ps > "logs/container-status.txt" 2>/dev/null || echo "Could not save container status" # Save environment info echo "Environment variables:" > logs/environment.txt env | grep -E "(APP_|NODE_|DB_|REDIS_)" >> logs/environment.txt || echo "Could not save environment" ls -la logs/ shell: bash - name: Upload logs as artifacts if: always() && steps.install_docker_compose.outcome == 'success' && github.actor != 'nektos/act' uses: actions/upload-artifact@v4 with: name: container-logs path: logs/ retention-days: 7 # - name: Upload Playwright Test Report # if: always() && steps.changed_files_check.outputs.any_changed == 'true' && steps.wait_for_setup.outputs.status == 'success' && !env.ACT # Run even if tests fail, but setup was ok. Skip in ACT due to token issues. # uses: actions/upload-artifact@v4 # with: # name: playwright-report # path: e2e-tests/playwright-report/ # retention-days: 7 - name: Cleanup Docker Compose if: always() && steps.install_docker_compose.outcome == 'success' # Ensure docker-compose was attempted to be installed run: | echo "Cleaning up Docker Compose environment..." # Check if docker-compose command is available before trying to use it if command -v docker-compose &> /dev/null; then docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml down -v --remove-orphans || echo "Docker Compose cleanup failed, but continuing." else echo "docker-compose command not found, skipping cleanup." fi shell: bash - name: Restore root docker-compose.yaml if: always() && env.ACT run: | if [ -f docker-compose.yaml.ignored ]; then echo "Restoring root docker-compose.yaml" sudo mv docker-compose.yaml.ignored docker-compose.yaml fi shell: bash