From afb666e0da77a18a6bcb7a2b42158b6565a56c23 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Thu, 29 Jan 2026 17:51:25 +0100 Subject: [PATCH] feat(ci): add test impact analysis for selective test execution (#9844) --- .github/scripts/test-impact.py | 257 +++++++++++++ .github/test-impact.yml | 399 +++++++++++++++++++++ .github/workflows/test-impact-analysis.yml | 112 ++++++ .github/workflows/ui-e2e-tests-v2.yml | 238 ++++++++++++ 4 files changed, 1006 insertions(+) create mode 100755 .github/scripts/test-impact.py create mode 100644 .github/test-impact.yml create mode 100644 .github/workflows/test-impact-analysis.yml create mode 100644 .github/workflows/ui-e2e-tests-v2.yml diff --git a/.github/scripts/test-impact.py b/.github/scripts/test-impact.py new file mode 100755 index 0000000000..f97848f6b5 --- /dev/null +++ b/.github/scripts/test-impact.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +Test Impact Analysis Script + +Analyzes changed files and determines which tests need to run. +Outputs GitHub Actions compatible outputs. + +Usage: + python test-impact.py + python test-impact.py --from-stdin # Read files from stdin (one per line) + +Outputs (for GitHub Actions): + - run-all: "true" if critical paths changed + - sdk-tests: Space-separated list of SDK test paths + - api-tests: Space-separated list of API test paths + - ui-e2e: Space-separated list of UI E2E test paths + - modules: Comma-separated list of affected module names +""" + +import fnmatch +import os +import sys +from pathlib import Path + +import yaml + + +def load_config() -> dict: + """Load test-impact.yml configuration.""" + config_path = Path(__file__).parent.parent / "test-impact.yml" + with open(config_path) as f: + return yaml.safe_load(f) + + +def matches_pattern(file_path: str, pattern: str) -> bool: + """Check if file path matches a glob pattern.""" + # Normalize paths + file_path = file_path.strip("/") + pattern = pattern.strip("/") + + # Handle ** patterns + if "**" in pattern: + # Convert glob pattern to work with fnmatch + # e.g., "prowler/lib/**" matches "prowler/lib/check/foo.py" + base = pattern.replace("/**", "") + if file_path.startswith(base): + return True + # Also try standard fnmatch + return fnmatch.fnmatch(file_path, pattern) + + return fnmatch.fnmatch(file_path, pattern) + + +def filter_ignored_files( + changed_files: list[str], ignored_paths: list[str] +) -> list[str]: + """Filter out files that match ignored patterns.""" + filtered = [] + for file_path in changed_files: + is_ignored = False + for pattern in ignored_paths: + if matches_pattern(file_path, pattern): + print(f" [IGNORED] {file_path} matches {pattern}", file=sys.stderr) + is_ignored = True + break + if not is_ignored: + filtered.append(file_path) + return filtered + + +def check_critical_paths(changed_files: list[str], critical_paths: list[str]) -> bool: + """Check if any changed file matches critical paths.""" + for file_path in changed_files: + for pattern in critical_paths: + if matches_pattern(file_path, pattern): + print(f" [CRITICAL] {file_path} matches {pattern}", file=sys.stderr) + return True + return False + + +def find_affected_modules( + changed_files: list[str], modules: list[dict] +) -> dict[str, dict]: + """Find which modules are affected by changed files.""" + affected = {} + + for file_path in changed_files: + for module in modules: + module_name = module["name"] + match_patterns = module.get("match", []) + + for pattern in match_patterns: + if matches_pattern(file_path, pattern): + if module_name not in affected: + affected[module_name] = { + "tests": set(), + "e2e": set(), + "matched_files": [], + } + affected[module_name]["matched_files"].append(file_path) + + # Add test patterns + for test_pattern in module.get("tests", []): + affected[module_name]["tests"].add(test_pattern) + + # Add E2E patterns + for e2e_pattern in module.get("e2e", []): + affected[module_name]["e2e"].add(e2e_pattern) + + break # File matched this module, move to next file + + return affected + + +def categorize_tests( + affected_modules: dict[str, dict], +) -> tuple[set[str], set[str], set[str]]: + """Categorize tests into SDK, API, and UI E2E.""" + sdk_tests = set() + api_tests = set() + ui_e2e = set() + + for module_name, data in affected_modules.items(): + for test_path in data["tests"]: + if test_path.startswith("tests/"): + sdk_tests.add(test_path) + elif test_path.startswith("api/"): + api_tests.add(test_path) + + for e2e_path in data["e2e"]: + ui_e2e.add(e2e_path) + + return sdk_tests, api_tests, ui_e2e + + +def set_github_output(name: str, value: str): + """Set GitHub Actions output.""" + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as f: + # Handle multiline values + if "\n" in value: + import uuid + + delimiter = uuid.uuid4().hex + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + else: + f.write(f"{name}={value}\n") + # Print for debugging (without deprecated format) + print(f" {name}={value}", file=sys.stderr) + + +def main(): + # Parse arguments + if "--from-stdin" in sys.argv: + changed_files = [line.strip() for line in sys.stdin if line.strip()] + else: + changed_files = [f for f in sys.argv[1:] if f and not f.startswith("-")] + + if not changed_files: + print("No changed files provided", file=sys.stderr) + set_github_output("run-all", "false") + set_github_output("sdk-tests", "") + set_github_output("api-tests", "") + set_github_output("ui-e2e", "") + set_github_output("modules", "") + set_github_output("has-tests", "false") + return + + print(f"Analyzing {len(changed_files)} changed files...", file=sys.stderr) + for f in changed_files[:10]: # Show first 10 + print(f" - {f}", file=sys.stderr) + if len(changed_files) > 10: + print(f" ... and {len(changed_files) - 10} more", file=sys.stderr) + + # Load configuration + config = load_config() + + # Filter out ignored files (docs, configs, etc.) + ignored_paths = config.get("ignored", {}).get("paths", []) + changed_files = filter_ignored_files(changed_files, ignored_paths) + + if not changed_files: + print("\nAll changed files are ignored (docs, configs, etc.)", file=sys.stderr) + print("No tests needed.", file=sys.stderr) + set_github_output("run-all", "false") + set_github_output("sdk-tests", "") + set_github_output("api-tests", "") + set_github_output("ui-e2e", "") + set_github_output("modules", "none-ignored") + set_github_output("has-tests", "false") + return + + print( + f"\n{len(changed_files)} files remain after filtering ignored paths", + file=sys.stderr, + ) + + # Check critical paths + critical_paths = config.get("critical", {}).get("paths", []) + if check_critical_paths(changed_files, critical_paths): + print("\nCritical path changed - running ALL tests", file=sys.stderr) + set_github_output("run-all", "true") + set_github_output("sdk-tests", "tests/") + set_github_output("api-tests", "api/src/backend/") + set_github_output("ui-e2e", "ui/tests/") + set_github_output("modules", "all") + set_github_output("has-tests", "true") + return + + # Find affected modules + modules = config.get("modules", []) + affected = find_affected_modules(changed_files, modules) + + if not affected: + print("\nNo test-mapped modules affected", file=sys.stderr) + set_github_output("run-all", "false") + set_github_output("sdk-tests", "") + set_github_output("api-tests", "") + set_github_output("ui-e2e", "") + set_github_output("modules", "") + set_github_output("has-tests", "false") + return + + # Report affected modules + print(f"\nAffected modules: {len(affected)}", file=sys.stderr) + for module_name, data in affected.items(): + print(f" [{module_name}]", file=sys.stderr) + for f in data["matched_files"][:3]: + print(f" - {f}", file=sys.stderr) + if len(data["matched_files"]) > 3: + print( + f" ... and {len(data['matched_files']) - 3} more files", + file=sys.stderr, + ) + + # Categorize tests + sdk_tests, api_tests, ui_e2e = categorize_tests(affected) + + # Output results + print("\nTest paths to run:", file=sys.stderr) + print(f" SDK: {sdk_tests or 'none'}", file=sys.stderr) + print(f" API: {api_tests or 'none'}", file=sys.stderr) + print(f" E2E: {ui_e2e or 'none'}", file=sys.stderr) + + set_github_output("run-all", "false") + set_github_output("sdk-tests", " ".join(sorted(sdk_tests))) + set_github_output("api-tests", " ".join(sorted(api_tests))) + set_github_output("ui-e2e", " ".join(sorted(ui_e2e))) + set_github_output("modules", ",".join(sorted(affected.keys()))) + set_github_output( + "has-tests", "true" if (sdk_tests or api_tests or ui_e2e) else "false" + ) + + +if __name__ == "__main__": + main() diff --git a/.github/test-impact.yml b/.github/test-impact.yml new file mode 100644 index 0000000000..3f90ad3d3a --- /dev/null +++ b/.github/test-impact.yml @@ -0,0 +1,399 @@ +# Test Impact Analysis Configuration +# Defines which tests to run based on changed files +# +# Usage: Changes to paths in 'critical' always run all tests. +# Changes to paths in 'modules' run only the mapped tests. +# Changes to paths in 'ignored' don't trigger any tests. + +# Ignored paths - changes here don't trigger any tests +# Documentation, configs, and other non-code files +ignored: + paths: + # Documentation + - docs/** + - "*.md" + - "**/*.md" + - mkdocs.yml + + # Config files that don't affect runtime + - .gitignore + - .gitattributes + - .editorconfig + - .pre-commit-config.yaml + - .backportrc.json + - CODEOWNERS + - LICENSE + + # IDE/Editor configs + - .vscode/** + - .idea/** + + # Examples and contrib (not production code) + - examples/** + - contrib/** + + # Skills (AI agent configs, not runtime) + - skills/** + + # Permissions docs + - permissions/** + +# Critical paths - changes here run ALL tests +# These are foundational/shared code that can affect anything +critical: + paths: + # SDK Core + - prowler/lib/** + - prowler/config/** + - prowler/exceptions/** + - prowler/providers/common/** + + # API Core + - api/src/backend/api/models.py + - api/src/backend/config/** + - api/src/backend/conftest.py + + # UI Core + - ui/lib/** + - ui/types/** + - ui/config/** + - ui/middleware.ts + + # CI/CD changes + - .github/workflows/** + - .github/test-impact.yml + +# Module mappings - path patterns to test patterns +modules: + # ============================================ + # SDK - Providers (each provider is isolated) + # ============================================ + - name: sdk-aws + match: + - prowler/providers/aws/** + - prowler/compliance/aws/** + tests: + - tests/providers/aws/** + e2e: [] + + - name: sdk-azure + match: + - prowler/providers/azure/** + - prowler/compliance/azure/** + tests: + - tests/providers/azure/** + e2e: [] + + - name: sdk-gcp + match: + - prowler/providers/gcp/** + - prowler/compliance/gcp/** + tests: + - tests/providers/gcp/** + e2e: [] + + - name: sdk-kubernetes + match: + - prowler/providers/kubernetes/** + - prowler/compliance/kubernetes/** + tests: + - tests/providers/kubernetes/** + e2e: [] + + - name: sdk-github + match: + - prowler/providers/github/** + - prowler/compliance/github/** + tests: + - tests/providers/github/** + e2e: [] + + - name: sdk-m365 + match: + - prowler/providers/m365/** + - prowler/compliance/m365/** + tests: + - tests/providers/m365/** + e2e: [] + + - name: sdk-alibabacloud + match: + - prowler/providers/alibabacloud/** + - prowler/compliance/alibabacloud/** + tests: + - tests/providers/alibabacloud/** + e2e: [] + + - name: sdk-cloudflare + match: + - prowler/providers/cloudflare/** + - prowler/compliance/cloudflare/** + tests: + - tests/providers/cloudflare/** + e2e: [] + + - name: sdk-oraclecloud + match: + - prowler/providers/oraclecloud/** + - prowler/compliance/oraclecloud/** + tests: + - tests/providers/oraclecloud/** + e2e: [] + + - name: sdk-mongodbatlas + match: + - prowler/providers/mongodbatlas/** + - prowler/compliance/mongodbatlas/** + tests: + - tests/providers/mongodbatlas/** + e2e: [] + + - name: sdk-nhn + match: + - prowler/providers/nhn/** + - prowler/compliance/nhn/** + tests: + - tests/providers/nhn/** + e2e: [] + + - name: sdk-iac + match: + - prowler/providers/iac/** + - prowler/compliance/iac/** + tests: + - tests/providers/iac/** + e2e: [] + + - name: sdk-llm + match: + - prowler/providers/llm/** + - prowler/compliance/llm/** + tests: + - tests/providers/llm/** + e2e: [] + + # ============================================ + # SDK - Lib modules + # ============================================ + - name: sdk-lib-check + match: + - prowler/lib/check/** + tests: + - tests/lib/check/** + e2e: [] + + - name: sdk-lib-outputs + match: + - prowler/lib/outputs/** + tests: + - tests/lib/outputs/** + e2e: [] + + - name: sdk-lib-scan + match: + - prowler/lib/scan/** + tests: + - tests/lib/scan/** + e2e: [] + + - name: sdk-lib-cli + match: + - prowler/lib/cli/** + tests: + - tests/lib/cli/** + e2e: [] + + - name: sdk-lib-mutelist + match: + - prowler/lib/mutelist/** + tests: + - tests/lib/mutelist/** + e2e: [] + + # ============================================ + # API - Views, Serializers, Tasks + # ============================================ + - name: api-views + match: + - api/src/backend/api/v1/views.py + tests: + - api/src/backend/api/tests/test_views.py + e2e: + # API view changes can break UI + - ui/tests/** + + - name: api-serializers + match: + - api/src/backend/api/v1/serializers.py + - api/src/backend/api/v1/serializer_utils/** + tests: + - api/src/backend/api/tests/** + e2e: + # Serializer changes affect API responses → UI + - ui/tests/** + + - name: api-filters + match: + - api/src/backend/api/filters.py + tests: + - api/src/backend/api/tests/** + e2e: [] + + - name: api-rbac + match: + - api/src/backend/api/rbac/** + tests: + - api/src/backend/api/tests/** + e2e: + - ui/tests/roles/** + + - name: api-tasks + match: + - api/src/backend/tasks/** + tests: + - api/src/backend/tasks/tests/** + e2e: [] + + - name: api-attack-paths + match: + - api/src/backend/api/attack_paths/** + tests: + - api/src/backend/api/tests/test_attack_paths.py + e2e: [] + + # ============================================ + # UI - Components and Features + # ============================================ + - name: ui-providers + match: + - ui/components/providers/** + - ui/actions/providers/** + - ui/app/**/providers/** + tests: [] + e2e: + - ui/tests/providers/** + + - name: ui-findings + match: + - ui/components/findings/** + - ui/actions/findings/** + - ui/app/**/findings/** + tests: [] + e2e: + - ui/tests/findings/** + + - name: ui-scans + match: + - ui/components/scans/** + - ui/actions/scans/** + - ui/app/**/scans/** + tests: [] + e2e: + - ui/tests/scans/** + + - name: ui-compliance + match: + - ui/components/compliance/** + - ui/actions/compliances/** + - ui/app/**/compliance/** + tests: [] + e2e: + - ui/tests/compliance/** + + - name: ui-auth + match: + - ui/components/auth/** + - ui/actions/auth/** + - ui/app/(auth)/** + tests: [] + e2e: + - ui/tests/sign-in/** + - ui/tests/sign-up/** + + - name: ui-invitations + match: + - ui/components/invitations/** + - ui/actions/invitations/** + - ui/app/**/invitations/** + tests: [] + e2e: + - ui/tests/invitations/** + + - name: ui-roles + match: + - ui/components/roles/** + - ui/actions/roles/** + - ui/app/**/roles/** + tests: [] + e2e: + - ui/tests/roles/** + + - name: ui-users + match: + - ui/components/users/** + - ui/actions/users/** + - ui/app/**/users/** + tests: [] + e2e: + - ui/tests/users/** + + - name: ui-integrations + match: + - ui/components/integrations/** + - ui/actions/integrations/** + - ui/app/**/integrations/** + tests: [] + e2e: + - ui/tests/integrations/** + + - name: ui-resources + match: + - ui/components/resources/** + - ui/actions/resources/** + - ui/app/**/resources/** + tests: [] + e2e: + - ui/tests/resources/** + + - name: ui-profile + match: + - ui/app/**/profile/** + tests: [] + e2e: + - ui/tests/profile/** + + - name: ui-lighthouse + match: + - ui/components/lighthouse/** + - ui/actions/lighthouse/** + - ui/app/**/lighthouse/** + - ui/lib/lighthouse/** + tests: [] + e2e: + - ui/tests/lighthouse/** + + - name: ui-overview + match: + - ui/components/overview/** + - ui/actions/overview/** + tests: [] + e2e: + - ui/tests/home/** + + - name: ui-shadcn + match: + - ui/components/shadcn/** + - ui/components/ui/** + tests: [] + e2e: + # Shared components can affect any E2E + - ui/tests/** + + - name: ui-attack-paths + match: + - ui/components/attack-paths/** + - ui/actions/attack-paths/** + - ui/app/**/attack-paths/** + tests: [] + e2e: + - ui/tests/attack-paths/** diff --git a/.github/workflows/test-impact-analysis.yml b/.github/workflows/test-impact-analysis.yml new file mode 100644 index 0000000000..7f750ede2b --- /dev/null +++ b/.github/workflows/test-impact-analysis.yml @@ -0,0 +1,112 @@ +name: Test Impact Analysis + +on: + workflow_call: + outputs: + run-all: + description: "Whether to run all tests (critical path changed)" + value: ${{ jobs.analyze.outputs.run-all }} + sdk-tests: + description: "SDK test paths to run" + value: ${{ jobs.analyze.outputs.sdk-tests }} + api-tests: + description: "API test paths to run" + value: ${{ jobs.analyze.outputs.api-tests }} + ui-e2e: + description: "UI E2E test paths to run" + value: ${{ jobs.analyze.outputs.ui-e2e }} + modules: + description: "Comma-separated list of affected modules" + value: ${{ jobs.analyze.outputs.modules }} + has-tests: + description: "Whether there are any tests to run" + value: ${{ jobs.analyze.outputs.has-tests }} + has-sdk-tests: + description: "Whether there are SDK tests to run" + value: ${{ jobs.analyze.outputs.has-sdk-tests }} + has-api-tests: + description: "Whether there are API tests to run" + value: ${{ jobs.analyze.outputs.has-api-tests }} + has-ui-e2e: + description: "Whether there are UI E2E tests to run" + value: ${{ jobs.analyze.outputs.has-ui-e2e }} + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run-all: ${{ steps.impact.outputs.run-all }} + sdk-tests: ${{ steps.impact.outputs.sdk-tests }} + api-tests: ${{ steps.impact.outputs.api-tests }} + ui-e2e: ${{ steps.impact.outputs.ui-e2e }} + modules: ${{ steps.impact.outputs.modules }} + has-tests: ${{ steps.impact.outputs.has-tests }} + has-sdk-tests: ${{ steps.set-flags.outputs.has-sdk-tests }} + has-api-tests: ${{ steps.set-flags.outputs.has-api-tests }} + has-ui-e2e: ${{ steps.set-flags.outputs.has-ui-e2e }} + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + + - name: Setup Python + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: '3.12' + + - name: Install PyYAML + run: pip install pyyaml + + - name: Analyze test impact + id: impact + run: | + echo "Changed files:" + echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' + echo "" + python .github/scripts/test-impact.py ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Set convenience flags + id: set-flags + run: | + if [[ -n "${{ steps.impact.outputs.sdk-tests }}" ]]; then + echo "has-sdk-tests=true" >> $GITHUB_OUTPUT + else + echo "has-sdk-tests=false" >> $GITHUB_OUTPUT + fi + + if [[ -n "${{ steps.impact.outputs.api-tests }}" ]]; then + echo "has-api-tests=true" >> $GITHUB_OUTPUT + else + echo "has-api-tests=false" >> $GITHUB_OUTPUT + fi + + if [[ -n "${{ steps.impact.outputs.ui-e2e }}" ]]; then + echo "has-ui-e2e=true" >> $GITHUB_OUTPUT + else + echo "has-ui-e2e=false" >> $GITHUB_OUTPUT + fi + + - name: Summary + run: | + echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ steps.impact.outputs.run-all }}" == "true" ]]; then + echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY + else + echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY + echo "\`${{ steps.impact.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY + echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| SDK Tests | \`${{ steps.impact.outputs.sdk-tests || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| API Tests | \`${{ steps.impact.outputs.api-tests || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| UI E2E | \`${{ steps.impact.outputs.ui-e2e || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml new file mode 100644 index 0000000000..8de1e9d9fa --- /dev/null +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -0,0 +1,238 @@ +name: UI - E2E Tests (Optimized) + +# This is an optimized version that runs only relevant E2E tests +# based on changed files. Falls back to running all tests if +# critical paths are changed or if impact analysis fails. + +on: + pull_request: + branches: + - master + - "v5.*" + paths: + - '.github/workflows/ui-e2e-tests-v2.yml' + - '.github/test-impact.yml' + - 'ui/**' + - 'api/**' # API changes can affect UI E2E + +jobs: + # First, analyze which tests need to run + impact-analysis: + if: github.repository == 'prowler-cloud/prowler' + uses: ./.github/workflows/test-impact-analysis.yml + + # Run E2E tests based on impact analysis + e2e-tests: + needs: impact-analysis + if: | + github.repository == 'prowler-cloud/prowler' && + (needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true') + runs-on: ubuntu-latest + env: + AUTH_SECRET: 'fallback-ci-secret-for-testing' + AUTH_TRUST_HOST: true + NEXTAUTH_URL: 'http://localhost:3000' + NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1' + E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} + E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} + E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} + E2E_AWS_PROVIDER_ACCESS_KEY: ${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }} + E2E_AWS_PROVIDER_SECRET_KEY: ${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }} + E2E_AWS_PROVIDER_ROLE_ARN: ${{ secrets.E2E_AWS_PROVIDER_ROLE_ARN }} + E2E_AZURE_SUBSCRIPTION_ID: ${{ secrets.E2E_AZURE_SUBSCRIPTION_ID }} + E2E_AZURE_CLIENT_ID: ${{ secrets.E2E_AZURE_CLIENT_ID }} + E2E_AZURE_SECRET_ID: ${{ secrets.E2E_AZURE_SECRET_ID }} + E2E_AZURE_TENANT_ID: ${{ secrets.E2E_AZURE_TENANT_ID }} + E2E_M365_DOMAIN_ID: ${{ secrets.E2E_M365_DOMAIN_ID }} + E2E_M365_CLIENT_ID: ${{ secrets.E2E_M365_CLIENT_ID }} + E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }} + E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }} + E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }} + E2E_KUBERNETES_CONTEXT: 'kind-kind' + E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config + E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }} + E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} + E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} + E2E_GITHUB_BASE64_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_BASE64_APP_PRIVATE_KEY }} + E2E_GITHUB_USERNAME: ${{ secrets.E2E_GITHUB_USERNAME }} + E2E_GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_PERSONAL_ACCESS_TOKEN }} + E2E_GITHUB_ORGANIZATION: ${{ secrets.E2E_GITHUB_ORGANIZATION }} + E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN }} + E2E_ORGANIZATION_ID: ${{ secrets.E2E_ORGANIZATION_ID }} + E2E_OCI_TENANCY_ID: ${{ secrets.E2E_OCI_TENANCY_ID }} + E2E_OCI_USER_ID: ${{ secrets.E2E_OCI_USER_ID }} + E2E_OCI_FINGERPRINT: ${{ secrets.E2E_OCI_FINGERPRINT }} + E2E_OCI_KEY_CONTENT: ${{ secrets.E2E_OCI_KEY_CONTENT }} + E2E_OCI_REGION: ${{ secrets.E2E_OCI_REGION }} + E2E_NEW_USER_PASSWORD: ${{ secrets.E2E_NEW_USER_PASSWORD }} + # Pass E2E paths from impact analysis + E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }} + RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }} + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Show test scope + run: | + echo "## E2E Test Scope" >> $GITHUB_STEP_SUMMARY + if [[ "${{ env.RUN_ALL_TESTS }}" == "true" ]]; then + echo "Running **ALL** E2E tests (critical path changed)" >> $GITHUB_STEP_SUMMARY + else + echo "Running tests matching: \`${{ env.E2E_TEST_PATHS }}\`" >> $GITHUB_STEP_SUMMARY + fi + echo "" + echo "Affected modules: \`${{ needs.impact-analysis.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY + + - name: Create k8s Kind Cluster + uses: helm/kind-action@v1 + with: + cluster_name: kind + + - name: Modify kubeconfig + run: | + kubectl config set-cluster kind-kind --server=https://kind-control-plane:6443 + kubectl config view + + - name: Add network kind to docker compose + run: | + yq -i '.networks.kind.external = true' docker-compose.yml + yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml + + - name: Fix API data directory permissions + run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data + + - name: Add AWS credentials for testing + run: | + echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env + echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env + + - name: Start API services + run: | + export PROWLER_API_VERSION=latest + docker compose up -d api worker worker-beat + + - name: Wait for API to be ready + run: | + echo "Waiting for prowler-api..." + timeout=150 + elapsed=0 + while [ $elapsed -lt $timeout ]; do + if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then + echo "Prowler API is ready!" + exit 0 + fi + echo "Waiting... (${elapsed}s elapsed)" + sleep 5 + elapsed=$((elapsed + 5)) + done + echo "Timeout waiting for prowler-api" + exit 1 + + - name: Load database fixtures + run: | + docker compose exec -T api sh -c ' + for fixture in api/fixtures/dev/*.json; do + if [ -f "$fixture" ]; then + echo "Loading $fixture" + poetry run python manage.py loaddata "$fixture" --database admin + fi + done + ' + + - name: Setup Node.js + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: '24.13.0' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Get pnpm store directory + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm and Next.js cache + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + with: + path: | + ${{ env.STORE_PATH }} + ./ui/node_modules + ./ui/.next/cache + key: ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-${{ hashFiles('ui/**/*.ts', 'ui/**/*.tsx', 'ui/**/*.js', 'ui/**/*.jsx') }} + restore-keys: | + ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}- + ${{ runner.os }}-pnpm-nextjs- + + - name: Install UI dependencies + working-directory: ./ui + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Build UI application + working-directory: ./ui + run: pnpm run build + + - name: Cache Playwright browsers + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install Playwright browsers + working-directory: ./ui + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm run test:e2e:install + + - name: Run E2E tests + working-directory: ./ui + run: | + if [[ "${{ env.RUN_ALL_TESTS }}" == "true" ]]; then + echo "Running ALL E2E tests..." + pnpm run test:e2e + else + echo "Running targeted E2E tests: ${{ env.E2E_TEST_PATHS }}" + # Convert glob patterns to playwright test paths + # e.g., "ui/tests/providers/**" -> "tests/providers" + TEST_PATHS="${{ env.E2E_TEST_PATHS }}" + # Remove ui/ prefix and convert ** to empty (playwright handles recursion) + TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u | tr '\n' ' ') + echo "Resolved test paths: $TEST_PATHS" + pnpm exec playwright test $TEST_PATHS + fi + + - name: Upload test reports + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + if: failure() + with: + name: playwright-report + path: ui/playwright-report/ + retention-days: 30 + + - name: Cleanup services + if: always() + run: | + docker compose down -v || true + + # Skip job - provides clear feedback when no E2E tests needed + skip-e2e: + needs: impact-analysis + if: | + github.repository == 'prowler-cloud/prowler' && + needs.impact-analysis.outputs.has-ui-e2e != 'true' && + needs.impact-analysis.outputs.run-all != 'true' + runs-on: ubuntu-latest + steps: + - name: No E2E tests needed + run: | + echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No UI E2E tests needed for this change." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Affected modules: \`${{ needs.impact-analysis.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> $GITHUB_STEP_SUMMARY