diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf464ed6b..38856e895 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,5 +128,134 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: var/coverage/clover.xml + flags: unit fail_ci_if_error: false verbose: true + + e2e: + name: E2E Tests (${{ matrix.shard }}/${{ strategy.job-total }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: lint + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 + + - name: Pull or build E2E image + run: | + # Try to pull pre-built image, fall back to building + docker pull ghcr.io/netresearch/timetracker:e2e || docker buildx bake app-e2e --load + + - name: Install dependencies + env: + COMPOSE_PROFILES: e2e + run: | + # Create directories with proper permissions for non-root container user + mkdir -p vendor var/cache var/log bin var/coverage/e2e playwright-report test-results + chmod -R 777 vendor var bin playwright-report test-results + docker compose run --rm app-e2e composer install --no-interaction --prefer-dist --ignore-platform-req=php + docker compose run --rm app-e2e npm install --legacy-peer-deps + docker compose run --rm app-e2e npm run build + + - name: Start E2E stack with coverage + env: + COMPOSE_PROFILES: e2e + COVERAGE_ENABLED: '1' + XDEBUG_MODE: coverage + run: | + # Start the E2E stack + docker compose up -d + + # Wait for database + docker compose exec -T db-e2e mariadb-admin ping -h 127.0.0.1 -uroot -pglobal123 --wait --connect-timeout=60 + + # Clear and warmup Symfony cache for test environment + docker compose exec -T app-e2e php bin/console cache:clear --env=test || true + docker compose exec -T app-e2e php bin/console cache:warmup --env=test || true + + # Show running containers for debugging + docker compose ps + + # Wait for app to be ready (increased timeout, will fail if not ready) + echo "Waiting for app to be ready..." + for i in {1..30}; do + HTTP_CODE=$(curl -s -o /tmp/response.html -w "%{http_code}" http://localhost:8766/login 2>&1) + if [ "$HTTP_CODE" = "200" ]; then + echo "App is ready after $i attempts (HTTP $HTTP_CODE)" + break + fi + if [ $i -eq 30 ]; then + echo "App failed to start within timeout (last HTTP code: $HTTP_CODE)" + echo "=== Last response body ===" + cat /tmp/response.html 2>/dev/null | head -100 || true + echo "=== PHP-FPM logs ===" + docker compose logs app-e2e | tail -50 + echo "=== Nginx logs ===" + docker compose logs httpd-e2e | tail -20 + echo "=== Symfony error log ===" + docker compose exec -T app-e2e cat var/log/test.log 2>/dev/null | tail -100 || echo "No test.log found" + exit 1 + fi + echo "Attempt $i: HTTP $HTTP_CODE, waiting..." + sleep 3 + done + + # Verify coverage is enabled + curl -s http://localhost:8766/coverage.php?action=status || echo "Coverage endpoint not responding" + + - name: Run E2E tests (shard ${{ matrix.shard }}/3) + env: + COMPOSE_PROFILES: e2e + run: | + # Run Playwright from inside the container (browsers pre-installed) + # Use internal Docker network: httpd-e2e is the service name, port 80 is internal + docker compose run --rm \ + -e E2E_BASE_URL=http://httpd-e2e:80 \ + -e CI=true \ + app-e2e npx playwright test --shard=${{ matrix.shard }}/3 -x + + - name: Collect E2E coverage + if: always() + run: | + # Fetch coverage report from the app + curl -s "http://localhost:8766/coverage.php?action=report&format=clover" > var/coverage/e2e-clover-${{ matrix.shard }}.xml || true + # Check if coverage file has content + if [ -s var/coverage/e2e-clover-${{ matrix.shard }}.xml ]; then + echo "E2E coverage collected successfully for shard ${{ matrix.shard }}" + else + echo "No E2E coverage data collected for shard ${{ matrix.shard }}" + fi + + - name: Upload E2E coverage to Codecov + if: always() + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: var/coverage/e2e-clover-${{ matrix.shard }}.xml + flags: e2e + fail_ci_if_error: false + verbose: true + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: e2e-artifacts-shard-${{ matrix.shard }} + path: | + playwright-report/ + test-results/ + var/coverage/ + retention-days: 7 + + - name: Stop E2E stack + if: always() + env: + COMPOSE_PROFILES: e2e + run: docker compose down diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c8fa0e7f6..a697f3513 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -48,7 +48,7 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push + - name: Build and push production image uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0 with: source: . @@ -61,3 +61,14 @@ jobs: set: | *.cache-from=type=gha *.cache-to=type=gha,mode=max + + - name: Build and push E2E image + uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0 + with: + source: . + targets: app-e2e + files: docker-bake.hcl + push: ${{ github.event_name != 'pull_request' }} + set: | + *.cache-from=type=gha + *.cache-to=type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 037d1e407..7e277a1eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -110,7 +110,10 @@ RUN composer install --no-dev --no-scripts --no-autoloader --ignore-platform-req COPY --chown=app:app . . # Finish composer install (autoloader, scripts) +# Set APP_ENV=prod to prevent loading dev-only bundles during cache warmup +# (MakerBundle etc. are not installed with --no-dev but bundles.php tries to load them in dev mode) ENV CAPTAINHOOK_DISABLE=true +ENV APP_ENV=prod RUN composer dump-autoload --optimize --classmap-authoritative \ && composer run-script post-install-cmd --no-interaction || true @@ -175,6 +178,41 @@ RUN git config --global --add safe.directory '*' ENV APP_ENV=dev ENV APP_DEBUG=1 +# ============================================================================= +# E2E - Development image with Playwright and browsers pre-installed +# ============================================================================= +FROM dev AS e2e + +# Install Playwright system dependencies +RUN set -ex \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + # Playwright chromium dependencies + libnss3 \ + libnspr4 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libdbus-1-3 \ + libxkbcommon0 \ + libatspi2.0-0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libpango-1.0-0 \ + libcairo2 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install Playwright and browsers +RUN npx playwright install chromium --with-deps + +ENV APP_ENV=test + # ============================================================================= # PRODUCTION - Minimal secure image # ============================================================================= diff --git a/compose.yml b/compose.yml index df72da470..02ec4e533 100644 --- a/compose.yml +++ b/compose.yml @@ -193,6 +193,9 @@ services: - APP_FROZEN_TIME=2024-01-15 12:00:00 # Use separate E2E database to avoid conflicts with dev - DATABASE_URL=mysql://timetracker:timetracker@db-e2e:3306/timetracker?serverVersion=mariadb-12.1.0&charset=utf8mb4 + # Coverage collection (set COVERAGE_ENABLED=1 XDEBUG_MODE=coverage to enable) + - COVERAGE_ENABLED=${COVERAGE_ENABLED:-0} + - XDEBUG_MODE=${XDEBUG_MODE:-off} volumes: - .:/var/www/html - ./public:/var/www/html/public diff --git a/docker-bake.hcl b/docker-bake.hcl index 1133006b6..9e17ddcd5 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -106,10 +106,10 @@ target "app-tools" { ] } -# E2E test image +# E2E test image (with Playwright and browsers pre-installed) target "app-e2e" { inherits = ["_common"] - target = "dev" + target = "e2e" tags = [ "${REGISTRY}/${IMAGE_NAME}:e2e", ] diff --git a/playwright.config.ts b/playwright.config.ts index 3f5ef64d8..bc2115bd8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,7 +20,8 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + // In CI with sharding, use 2 workers per shard for parallelism + workers: process.env.CI ? 2 : undefined, reporter: [ ['html', { open: 'never' }], ['list'], diff --git a/public/coverage.php b/public/coverage.php new file mode 100644 index 000000000..4ea52c133 --- /dev/null +++ b/public/coverage.php @@ -0,0 +1,308 @@ + 'Coverage not enabled. Set COVERAGE_ENABLED=1']); + exit; + } + handleCoverageRequest(); + exit; +} + +/** + * Start coverage collection for this request. + */ +function startCoverageCollection(): void +{ + if (!function_exists('xdebug_start_code_coverage')) { + return; + } + + if (!is_dir(COVERAGE_DIR)) { + if (!@mkdir(COVERAGE_DIR, 0755, true) && !is_dir(COVERAGE_DIR)) { + error_log('Coverage: Failed to create directory ' . COVERAGE_DIR); + return; + } + } + + // Start collecting coverage with dead code analysis + xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); + + // Register shutdown function to save coverage + register_shutdown_function('saveCoverageData'); +} + +/** + * Save coverage data at the end of the request. + */ +function saveCoverageData(): void +{ + if (!function_exists('xdebug_get_code_coverage')) { + return; + } + + $coverage = xdebug_get_code_coverage(); + xdebug_stop_code_coverage(); + + if (empty($coverage)) { + return; + } + + // Filter out dead code (-2) and non-src files before saving + $filteredCoverage = []; + foreach ($coverage as $file => $lines) { + if (!str_contains($file, '/src/')) { + continue; + } + $filteredLines = array_filter($lines, fn($hits) => $hits !== -2); + if (!empty($filteredLines)) { + $filteredCoverage[$file] = $filteredLines; + } + } + + if (empty($filteredCoverage)) { + return; + } + + // Generate unique filename using random bytes for better uniqueness + $uniqueId = bin2hex(random_bytes(16)); + $filename = COVERAGE_DIR . '/coverage_' . $uniqueId . '.json'; + + $result = @file_put_contents($filename, json_encode($filteredCoverage, JSON_THROW_ON_ERROR)); + if ($result === false) { + error_log('Coverage: Failed to write coverage file ' . $filename); + } +} + +/** + * Handle coverage API requests. + */ +function handleCoverageRequest(): void +{ + header('Content-Type: application/json'); + + $action = $_GET['action'] ?? 'status'; + + // Validate action parameter + if (!in_array($action, ['status', 'report', 'clear'], true)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid action. Use: status, report, clear']); + return; + } + + switch ($action) { + case 'status': + $files = is_dir(COVERAGE_DIR) ? (glob(COVERAGE_DIR . '/*.json') ?: []) : []; + echo json_encode([ + 'enabled' => function_exists('xdebug_start_code_coverage'), + 'xdebug_mode' => ini_get('xdebug.mode'), + 'coverage_dir' => COVERAGE_DIR, + 'files' => count($files), + ]); + break; + + case 'report': + $format = $_GET['format'] ?? 'clover'; + if (!in_array($format, ['clover', 'json'], true)) { + $format = 'clover'; + } + generateCoverageReport($format); + break; + + case 'clear': + clearCoverageData(); + echo json_encode(['status' => 'cleared']); + break; + } +} + +/** + * Generate coverage report from collected data. + */ +function generateCoverageReport(string $format): void +{ + if (!is_dir(COVERAGE_DIR)) { + http_response_code(404); + echo json_encode(['error' => 'No coverage data found']); + return; + } + + // Merge all coverage files + $mergedCoverage = []; + $files = glob(COVERAGE_DIR . '/*.json') ?: []; + + foreach ($files as $file) { + $content = @file_get_contents($file); + if ($content === false) { + error_log('Coverage: Failed to read file ' . $file); + continue; + } + + $data = json_decode($content, true); + if (!is_array($data)) { + error_log('Coverage: Invalid JSON in file ' . $file); + continue; + } + + foreach ($data as $filename => $lines) { + if (!isset($mergedCoverage[$filename])) { + $mergedCoverage[$filename] = []; + } + foreach ($lines as $line => $hits) { + // Skip dead code + if ($hits === -2) { + continue; + } + + if (!isset($mergedCoverage[$filename][$line])) { + $mergedCoverage[$filename][$line] = 0; + } + + // Xdebug: 1 = executed, -1 = not executed but executable + if ($hits === 1) { + $mergedCoverage[$filename][$line] = 1; + } elseif ($mergedCoverage[$filename][$line] !== 1 && $hits === -1) { + $mergedCoverage[$filename][$line] = -1; + } + } + } + } + + if ($format === 'clover') { + header('Content-Type: application/xml'); + echo generateCloverXml($mergedCoverage); + } else { + echo json_encode([ + 'files' => count($mergedCoverage), + 'coverage' => $mergedCoverage, + ]); + } +} + +/** + * Generate Clover XML format coverage report. + */ +function generateCloverXml(array $coverage): string +{ + $timestamp = time(); + $xml = new XMLWriter(); + $xml->openMemory(); + $xml->setIndent(true); + $xml->startDocument('1.0', 'UTF-8'); + + $xml->startElement('coverage'); + $xml->writeAttribute('generated', (string) $timestamp); + + $xml->startElement('project'); + $xml->writeAttribute('timestamp', (string) $timestamp); + $xml->writeAttribute('name', 'timetracker-e2e'); + + $totalStatements = 0; + $coveredStatements = 0; + + foreach ($coverage as $filename => $lines) { + $xml->startElement('file'); + $xml->writeAttribute('name', $filename); + + $fileStatements = 0; + $fileCovered = 0; + + foreach ($lines as $line => $hits) { + $xml->startElement('line'); + $xml->writeAttribute('num', (string) $line); + $xml->writeAttribute('type', 'stmt'); + $xml->writeAttribute('count', $hits === 1 ? '1' : '0'); + $xml->endElement(); + + $fileStatements++; + if ($hits === 1) { + $fileCovered++; + } + } + + $xml->startElement('metrics'); + $xml->writeAttribute('statements', (string) $fileStatements); + $xml->writeAttribute('coveredstatements', (string) $fileCovered); + $xml->endElement(); + + $xml->endElement(); // file + + $totalStatements += $fileStatements; + $coveredStatements += $fileCovered; + } + + $xml->startElement('metrics'); + $xml->writeAttribute('statements', (string) $totalStatements); + $xml->writeAttribute('coveredstatements', (string) $coveredStatements); + $xml->writeAttribute('files', (string) count($coverage)); + $xml->endElement(); + + $xml->endElement(); // project + $xml->endElement(); // coverage + + return $xml->outputMemory(); +} + +/** + * Clear all coverage data. + */ +function clearCoverageData(): void +{ + if (!is_dir(COVERAGE_DIR)) { + return; + } + + $files = glob(COVERAGE_DIR . '/*.json') ?: []; + foreach ($files as $file) { + if (!@unlink($file)) { + error_log('Coverage: Failed to delete file ' . $file); + } + } +} + +// Auto-start coverage if this file is included and coverage is enabled +if (!isDirectCoverageRequest() && isCoverageEnabled()) { + startCoverageCollection(); +} diff --git a/public/index.php b/public/index.php index c1453612c..e79b81ee2 100644 --- a/public/index.php +++ b/public/index.php @@ -10,6 +10,12 @@ /* @var Composer\Autoload\ClassLoader */ require dirname(__DIR__) . '/vendor/autoload.php'; +// Enable E2E coverage collection when COVERAGE_ENABLED=1 (test environment only) +$appEnv = $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? 'prod'; +if (($appEnv === 'test' || $appEnv === 'dev') && (!empty($_SERVER['COVERAGE_ENABLED']) || !empty($_ENV['COVERAGE_ENABLED']))) { + require __DIR__ . '/coverage.php'; +} + // Load cached env vars if the .env.local.php file exists // Run "composer dump-env prod" to create it (requires symfony/flex >=1.2) if (is_array($env = @include dirname(__DIR__) . '/.env.local.php') && (! isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) {