diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e2403b1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: "/" + schedule: + interval: weekly + day: monday + time: "05:00" + open-pull-requests-limit: 5 + labels: [dependencies, java] + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "05:00" + open-pull-requests-limit: 5 + labels: [dependencies, ci] + + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + day: monday + time: "05:00" + open-pull-requests-limit: 3 + labels: [dependencies, docker] diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..fdac4ac --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,266 @@ +name: CD (mock AWS) + +# DEMO PIPELINE. Every AWS identifier below is a documented placeholder (account +# 123456789012, demo-* names, us-east-1). Nothing authenticates or mutates AWS unless the +# repository sets vars.AWS_MOCK_MODE to 'false' AND provides a real OIDC role — see the +# `Guard` steps and docs/CICD.md. Under the default mock configuration every AWS-touching +# step logs the command it *would* run and exits 0. + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + environment: + description: "Target environment" + type: choice + default: staging + options: [staging, production] + +concurrency: + group: cd-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + # --- Mocked AWS identifiers (override with Actions variables to go real) --- + AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} + AWS_ACCOUNT_ID: ${{ vars.AWS_ACCOUNT_ID || '123456789012' }} + ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY || 'demo/selenium-testng-harness' }} + REPORTS_BUCKET: ${{ vars.REPORTS_BUCKET || 'demo-mock-test-reports' }} + # 'true' (default) keeps the pipeline hermetic: build only, no AWS calls. + AWS_MOCK_MODE: ${{ vars.AWS_MOCK_MODE || 'true' }} + +jobs: + build-image: + name: Build harness image + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} + steps: + - uses: actions/checkout@v4.2.2 + + - name: Compute image tag + id: meta + run: echo "image_tag=${GITHUB_SHA::12}" >> "${GITHUB_OUTPUT}" + + - uses: docker/setup-buildx-action@v3.8.0 + + # Always builds for real (no AWS involved) and exports a local tarball so the + # downstream deploy jobs have something concrete to "push" in mock mode. + - name: Build image + uses: docker/build-push-action@v6.13.0 + with: + context: . + file: ./Dockerfile + push: false + load: false + outputs: type=docker,dest=/tmp/harness-image.tar + tags: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.image_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Upload image tarball + uses: actions/upload-artifact@v4.6.0 + with: + name: harness-image + path: /tmp/harness-image.tar + retention-days: 3 + + deploy-staging: + name: Deploy to staging (demo-staging) + needs: build-image + if: github.event_name == 'push' || inputs.environment == 'staging' || inputs.environment == 'production' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + # GitHub Environment 'staging' — add reviewers there if staging should also gate. + environment: + name: staging + permissions: + contents: read + id-token: write # required for OIDC role assumption (no long-lived keys) + env: + ENV_NAME: staging + K8S_NAMESPACE: demo-staging + ECS_CLUSTER: ${{ vars.ECS_CLUSTER_STAGING || 'demo-staging-cluster' }} + ECS_TASK_FAMILY: ${{ vars.ECS_TASK_FAMILY_STAGING || 'demo-staging-selenium-harness' }} + ECS_SUBNETS: ${{ vars.ECS_SUBNETS_STAGING || 'subnet-0abc123456789def0' }} + ECS_SECURITY_GROUPS: ${{ vars.ECS_SECURITY_GROUPS_STAGING || 'sg-0abc123456789def0' }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_STAGING || 'arn:aws:iam::123456789012:role/demo-staging-github-oidc' }} + TASK_EXECUTION_ROLE_ARN: ${{ vars.TASK_EXECUTION_ROLE_ARN_STAGING || 'arn:aws:iam::123456789012:role/demo-ecsTaskExecutionRole' }} + steps: + - uses: actions/checkout@v4.2.2 + + - name: Download image tarball + uses: actions/download-artifact@v4.1.8 + with: + name: harness-image + path: /tmp + + # ---- Guard: everything below no-ops while AWS_MOCK_MODE is 'true' ---- + - name: Configure AWS credentials (OIDC) + if: env.AWS_MOCK_MODE != 'true' + uses: aws-actions/configure-aws-credentials@v4.0.2 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + role-session-name: gha-cd-${{ github.run_id }} + + - name: Login to Amazon ECR + if: env.AWS_MOCK_MODE != 'true' + uses: aws-actions/amazon-ecr-login@v2.0.1 + + - name: Push image to ECR + env: + IMAGE_URI: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ needs.build-image.outputs.image_tag }} + run: | + set -euo pipefail + docker load --input /tmp/harness-image.tar + if [ "${AWS_MOCK_MODE}" = "true" ]; then + echo "::notice::MOCK MODE — would run: docker push ${IMAGE_URI}" + exit 0 + fi + docker push "${IMAGE_URI}" + + - name: Render ECS task definition + env: + IMAGE_URI: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ needs.build-image.outputs.image_tag }} + run: | + set -euo pipefail + sed -e "s|__IMAGE_URI__|${IMAGE_URI}|g" \ + -e "s|__ENV_NAME__|${ENV_NAME}|g" \ + -e "s|__TASK_FAMILY__|${ECS_TASK_FAMILY}|g" \ + -e "s|__EXECUTION_ROLE_ARN__|${TASK_EXECUTION_ROLE_ARN}|g" \ + -e "s|__AWS_REGION__|${AWS_REGION}|g" \ + deploy/ecs-task-definition.json > /tmp/task-definition.json + cat /tmp/task-definition.json + + - name: Register task definition and run harness task + run: | + set -euo pipefail + NETWORK="awsvpcConfiguration={subnets=[${ECS_SUBNETS}],securityGroups=[${ECS_SECURITY_GROUPS}],assignPublicIp=ENABLED}" + if [ "${AWS_MOCK_MODE}" = "true" ]; then + echo "::notice::MOCK MODE — would run: aws ecs register-task-definition --cli-input-json file:///tmp/task-definition.json" + echo "::notice::MOCK MODE — would run: aws ecs run-task --cluster ${ECS_CLUSTER} --task-definition ${ECS_TASK_FAMILY} --launch-type FARGATE --network-configuration ${NETWORK}" + echo "::notice::MOCK MODE — EKS equivalent: kubectl -n ${K8S_NAMESPACE} create job selenium-harness-${GITHUB_SHA::12} --image=" + exit 0 + fi + aws ecs register-task-definition --cli-input-json file:///tmp/task-definition.json + aws ecs run-task \ + --cluster "${ECS_CLUSTER}" \ + --task-definition "${ECS_TASK_FAMILY}" \ + --launch-type FARGATE \ + --network-configuration "${NETWORK}" + + - name: Publish reports to S3 + run: | + set -euo pipefail + DEST="s3://${REPORTS_BUCKET}/${ENV_NAME}/${GITHUB_RUN_ID}/" + if [ "${AWS_MOCK_MODE}" = "true" ]; then + echo "::notice::MOCK MODE — would run: aws s3 sync ExtentReports/ ${DEST}" + exit 0 + fi + aws s3 sync ExtentReports/ "${DEST}" + + deploy-production: + name: Promote to production (demo-prod) + needs: [build-image, deploy-staging] + if: github.event_name == 'push' || inputs.environment == 'production' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + # Manual approval gate: configure required reviewers on the 'production' + # GitHub Environment. The job stays queued until an approver releases it. + environment: + name: production + permissions: + contents: read + id-token: write + env: + ENV_NAME: production + K8S_NAMESPACE: demo-prod + ECS_CLUSTER: ${{ vars.ECS_CLUSTER_PRODUCTION || 'demo-prod-cluster' }} + ECS_TASK_FAMILY: ${{ vars.ECS_TASK_FAMILY_PRODUCTION || 'demo-prod-selenium-harness' }} + ECS_SUBNETS: ${{ vars.ECS_SUBNETS_PRODUCTION || 'subnet-0fed987654321cba0' }} + ECS_SECURITY_GROUPS: ${{ vars.ECS_SECURITY_GROUPS_PRODUCTION || 'sg-0fed987654321cba0' }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_PRODUCTION || 'arn:aws:iam::123456789012:role/demo-prod-github-oidc' }} + TASK_EXECUTION_ROLE_ARN: ${{ vars.TASK_EXECUTION_ROLE_ARN_PRODUCTION || 'arn:aws:iam::123456789012:role/demo-ecsTaskExecutionRole' }} + steps: + - uses: actions/checkout@v4.2.2 + + - name: Download image tarball + uses: actions/download-artifact@v4.1.8 + with: + name: harness-image + path: /tmp + + - name: Configure AWS credentials (OIDC) + if: env.AWS_MOCK_MODE != 'true' + uses: aws-actions/configure-aws-credentials@v4.0.2 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + role-session-name: gha-cd-prod-${{ github.run_id }} + + - name: Login to Amazon ECR + if: env.AWS_MOCK_MODE != 'true' + uses: aws-actions/amazon-ecr-login@v2.0.1 + + # Promotion re-tags the exact image validated in staging; it is never rebuilt. + - name: Promote staging image tag to production + env: + SRC_URI: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ needs.build-image.outputs.image_tag }} + PROD_URI: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:prod-${{ needs.build-image.outputs.image_tag }} + run: | + set -euo pipefail + docker load --input /tmp/harness-image.tar + docker tag "${SRC_URI}" "${PROD_URI}" + if [ "${AWS_MOCK_MODE}" = "true" ]; then + echo "::notice::MOCK MODE — would run: docker push ${PROD_URI}" + exit 0 + fi + docker push "${PROD_URI}" + + - name: Render ECS task definition + env: + IMAGE_URI: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:prod-${{ needs.build-image.outputs.image_tag }} + run: | + set -euo pipefail + sed -e "s|__IMAGE_URI__|${IMAGE_URI}|g" \ + -e "s|__ENV_NAME__|${ENV_NAME}|g" \ + -e "s|__TASK_FAMILY__|${ECS_TASK_FAMILY}|g" \ + -e "s|__EXECUTION_ROLE_ARN__|${TASK_EXECUTION_ROLE_ARN}|g" \ + -e "s|__AWS_REGION__|${AWS_REGION}|g" \ + deploy/ecs-task-definition.json > /tmp/task-definition.json + cat /tmp/task-definition.json + + - name: Register task definition and run harness task + run: | + set -euo pipefail + NETWORK="awsvpcConfiguration={subnets=[${ECS_SUBNETS}],securityGroups=[${ECS_SECURITY_GROUPS}],assignPublicIp=ENABLED}" + if [ "${AWS_MOCK_MODE}" = "true" ]; then + echo "::notice::MOCK MODE — would run: aws ecs register-task-definition --cli-input-json file:///tmp/task-definition.json" + echo "::notice::MOCK MODE — would run: aws ecs run-task --cluster ${ECS_CLUSTER} --task-definition ${ECS_TASK_FAMILY} --launch-type FARGATE --network-configuration ${NETWORK}" + echo "::notice::MOCK MODE — EKS equivalent: kubectl -n ${K8S_NAMESPACE} create job selenium-harness-${GITHUB_SHA::12} --image=" + exit 0 + fi + aws ecs register-task-definition --cli-input-json file:///tmp/task-definition.json + aws ecs run-task \ + --cluster "${ECS_CLUSTER}" \ + --task-definition "${ECS_TASK_FAMILY}" \ + --launch-type FARGATE \ + --network-configuration "${NETWORK}" + + - name: Publish reports to S3 + run: | + set -euo pipefail + DEST="s3://${REPORTS_BUCKET}/${ENV_NAME}/${GITHUB_RUN_ID}/" + if [ "${AWS_MOCK_MODE}" = "true" ]; then + echo "::notice::MOCK MODE — would run: aws s3 sync ExtentReports/ ${DEST}" + exit 0 + fi + aws s3 sync ExtentReports/ "${DEST}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c5bcf9f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel superseded runs on the same ref. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # The pom targets Java 1.8 bytecode; Selenium 4.25 needs a JDK 11+ toolchain to run. + JAVA_VERSION: "21" + MAVEN_ARGS: "-B -ntp" + +jobs: + build: + name: Build & static validation + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4.2.2 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4.7.0 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + cache: maven + + - name: Compile and package (no tests) + run: mvn ${MAVEN_ARGS} verify -DskipTests + + # This repo configures no Checkstyle/Spotless/PMD plugin in pom.xml, so there is + # nothing to invoke here beyond the compiler. These two checks are the portable + # substitute: dependency/plugin resolution correctness and well-formed suite XML. + - name: Validate POM and resolve plugins + run: mvn ${MAVEN_ARGS} validate dependency:resolve-plugins -DskipTests + + - name: Validate TestNG suite XML is well formed + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libxml2-utils + for suite in src/test/resources/suites/*.xml; do + echo "Checking ${suite}" + xmllint --noout --nonet "${suite}" + done + + - name: Upload build artifact + uses: actions/upload-artifact@v4.6.0 + with: + name: test-harness-jar + path: target/*.jar + if-no-files-found: warn + retention-days: 7 + + smoke-tests: + name: Headless smoke suite + runs-on: ubuntu-24.04 + timeout-minutes: 30 + needs: build + steps: + - uses: actions/checkout@v4.2.2 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4.7.0 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + cache: maven + + # Pin the browser + matching driver so WebDriverManager never has to resolve a + # driver over the network: CHROMEWEBDRIVER/chromedriver is exported by this action + # and picked up through the webdriver.chrome.driver system property below. + - name: Set up Chrome + id: setup-chrome + uses: browser-actions/setup-chrome@v1.7.3 + with: + chrome-version: stable + install-chromedriver: true + + - name: Run smoke suite (headless Chrome) + env: + # WebDriverManager resolution order: an explicit driver path wins, so the run + # stays offline-safe even if the chromedriver CDN is unreachable from CI. + WDM_CHROMEDRIVERPATH: ${{ steps.setup-chrome.outputs.chromedriver-path }} + run: | + mvn ${MAVEN_ARGS} test \ + -Dsurefire.suiteXmlFiles=src/test/resources/suites/smoke.xml \ + -Dwebdriver.chrome.driver="${WDM_CHROMEDRIVERPATH}" \ + -Dwdm.chromeDriverPath="${WDM_CHROMEDRIVERPATH}" \ + -Dwebdriver.chrome.whitelistedIps= + + # Scope note (no continue-on-error anywhere in this job): the committed full suite + # (suites/testng.xml) also contains FaceBookLoginTest, which ends in + # Assert.assertTrue(false, ...) by design and drives against facebook.com. CI runs + # the smoke suite (GoogleSearchTest only) so a red X always means a real regression. + # The full suite runs in nightly.yml, where the known failure is expected. + + - name: Upload ExtentReports HTML + if: always() + uses: actions/upload-artifact@v4.6.0 + with: + name: extent-report-smoke + path: | + ExtentReports/** + logfile.log + if-no-files-found: warn + retention-days: 14 + + - name: Upload Surefire / TestNG output + if: always() + uses: actions/upload-artifact@v4.6.0 + with: + name: surefire-testng-output-smoke + path: | + target/surefire-reports/** + test-output/** + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..edd4bae --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,99 @@ +name: Nightly full suite + +on: + schedule: + # 02:30 UTC every day. + - cron: "30 2 * * *" + workflow_dispatch: + inputs: + suite: + description: "TestNG suite file to run" + type: choice + default: src/test/resources/suites/testng.xml + options: + - src/test/resources/suites/testng.xml + - src/test/resources/suites/smoke.xml + +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + JAVA_VERSION: "21" + MAVEN_ARGS: "-B -ntp" + +jobs: + full-suite: + name: Full TestNG suite (headless) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4.2.2 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4.7.0 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + cache: maven + + - name: Set up Chrome + id: setup-chrome + uses: browser-actions/setup-chrome@v1.7.3 + with: + chrome-version: stable + install-chromedriver: true + + # continue-on-error is deliberate and scoped to this single step: the committed + # suite includes FaceBookLoginTest, whose body ends in Assert.assertTrue(false, ...) + # and which drives against facebook.com (login page markup changes constantly and + # the site actively blocks datacenter traffic). A nightly full-suite run is therefore + # expected to end in BUILD FAILURE today; the artifacts below are the actual output. + # Remove this flag once the demo failure is fixed or the external target is stubbed. + - name: Run full suite + id: full + continue-on-error: true + env: + WDM_CHROMEDRIVERPATH: ${{ steps.setup-chrome.outputs.chromedriver-path }} + SUITE: ${{ inputs.suite || 'src/test/resources/suites/testng.xml' }} + run: | + mvn ${MAVEN_ARGS} test \ + -Dsurefire.suiteXmlFiles="${SUITE}" \ + -Dwebdriver.chrome.driver="${WDM_CHROMEDRIVERPATH}" \ + -Dwdm.chromeDriverPath="${WDM_CHROMEDRIVERPATH}" + + - name: Upload ExtentReports HTML + if: always() + uses: actions/upload-artifact@v4.6.0 + with: + name: extent-report-nightly-${{ github.run_id }} + path: | + ExtentReports/** + logfile.log + if-no-files-found: warn + retention-days: 30 + + - name: Upload Surefire / TestNG output + if: always() + uses: actions/upload-artifact@v4.6.0 + with: + name: surefire-testng-output-nightly-${{ github.run_id }} + path: | + target/surefire-reports/** + test-output/** + if-no-files-found: warn + retention-days: 30 + + - name: Summarize outcome + if: always() + run: | + { + echo "### Nightly suite result" + echo "" + echo "- Maven outcome: \`${{ steps.full.outcome }}\`" + echo "- Known-failing demo test: \`FaceBookLoginTest#facebookLoginTest\` (asserts false by design)" + echo "- Reports uploaded as run artifacts." + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a550352 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Container image of the UI test harness. It is not a service: the entrypoint runs the +# TestNG suite once and exits, which is what the ECS task / EKS job below executes. +# +# Single stage on the Maven+JDK image on purpose: Surefire's `test` phase re-runs +# `testCompile`, so the runtime needs a JDK (not a JRE), and the Maven that primes +# ~/.m2 must be the same version that runs later — a different Maven binds different +# default lifecycle plugin versions and offline (`-o`) resolution then fails. +FROM maven:3.9.9-eclipse-temurin-21 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg jq unzip \ + && curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends google-chrome-stable \ + && rm -rf /var/lib/apt/lists/* + +# Bake a chromedriver matching the installed Chrome into the Selenium cache layout that +# WebDriverManager reads, so no driver is downloaded at run time (the ECS task may run in +# a private subnet with no egress). +RUN set -eux; \ + CHROME_FULL="$(google-chrome --version | awk '{print $3}')"; \ + CHROME_BUILD="$(echo "${CHROME_FULL}" | cut -d. -f1-3)"; \ + DRIVER_VERSION="$(curl -fsSL https://googlechromelabs.github.io/chrome-for-testing/latest-patch-versions-per-build.json \ + | jq -r --arg b "${CHROME_BUILD}" '.builds[$b].version')"; \ + curl -fsSL -o /tmp/chromedriver.zip \ + "https://storage.googleapis.com/chrome-for-testing-public/${DRIVER_VERSION}/linux64/chromedriver-linux64.zip"; \ + unzip -q -j /tmp/chromedriver.zip chromedriver-linux64/chromedriver -d /usr/local/bin; \ + chmod +x /usr/local/bin/chromedriver; \ + mkdir -p "/root/.cache/selenium/chromedriver/linux64/${DRIVER_VERSION}"; \ + cp /usr/local/bin/chromedriver "/root/.cache/selenium/chromedriver/linux64/${DRIVER_VERSION}/chromedriver"; \ + rm -f /tmp/chromedriver.zip + +WORKDIR /harness +COPY pom.xml ./ +RUN mvn -B -ntp dependency:go-offline +COPY src ./src +# Prime the local repository by really executing the suite once: dependency:go-offline does +# not fetch the default lifecycle plugins, and Surefire resolves its provider +# (surefire-testng) only when tests actually run — skipping execution leaves it missing and +# the offline entrypoint then fails. Failures here are ignored: this layer exists to warm +# ~/.m2, not to gate the build (the suite drives an external site). +RUN mvn -B -ntp test-compile \ + && mvn -B -ntp test \ + -Dsurefire.suiteXmlFiles=src/test/resources/suites/smoke.xml \ + -Dwebdriver.chrome.driver=/usr/local/bin/chromedriver \ + -Dmaven.test.failure.ignore=true \ + && rm -rf target/surefire-reports test-output ExtentReports logfile.log + +ENV SUITE_FILE=src/test/resources/suites/smoke.xml +# Deliberately not named MAVEN_ARGS: Maven 3.9 auto-consumes that variable, which would +# apply these flags to every mvn invocation in the image. +ENV HARNESS_MAVEN_ARGS="-B -ntp -o" + +ENTRYPOINT ["/bin/sh", "-c", "mvn ${HARNESS_MAVEN_ARGS} test -Dsurefire.suiteXmlFiles=${SUITE_FILE} -Dwebdriver.chrome.driver=/usr/local/bin/chromedriver"] diff --git a/deploy/ecs-task-definition.json b/deploy/ecs-task-definition.json new file mode 100644 index 0000000..e6baef2 --- /dev/null +++ b/deploy/ecs-task-definition.json @@ -0,0 +1,28 @@ +{ + "family": "__TASK_FAMILY__", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "2048", + "memory": "4096", + "executionRoleArn": "__EXECUTION_ROLE_ARN__", + "containerDefinitions": [ + { + "name": "selenium-testng-harness", + "image": "__IMAGE_URI__", + "essential": true, + "environment": [ + { "name": "ENV_NAME", "value": "__ENV_NAME__" }, + { "name": "SUITE_FILE", "value": "src/test/resources/suites/smoke.xml" } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/demo/selenium-testng-harness", + "awslogs-region": "__AWS_REGION__", + "awslogs-stream-prefix": "harness", + "awslogs-create-group": "true" + } + } + } + ] +} diff --git a/docs/CICD.md b/docs/CICD.md new file mode 100644 index 0000000..67306cc --- /dev/null +++ b/docs/CICD.md @@ -0,0 +1,121 @@ +# CI/CD + +Three GitHub Actions workflows. This repository is a Selenium/TestNG **test harness**, not a +deployable service, so "deployment" means shipping the harness image and publishing its +reports — not running a long-lived service. + +| Workflow | File | Triggers | What it does | +| --- | --- | --- | --- | +| CI | `.github/workflows/ci.yml` | push to `main`, pull_request to `main` | `mvn verify -DskipTests`, POM/plugin resolution, suite-XML lint, then the headless smoke suite. Uploads jar, ExtentReports HTML, surefire/TestNG output. | +| Nightly | `.github/workflows/nightly.yml` | cron `30 2 * * *`, `workflow_dispatch` | Full `suites/testng.xml` in headless Chrome; publishes report artifacts (30-day retention). | +| CD (mock AWS) | `.github/workflows/cd.yml` | push to `main`, `workflow_dispatch` (`environment` input) | Builds the harness container, "pushes" to ECR, registers/runs an ECS Fargate task, syncs reports to S3 — staging first, then production behind a manual approval gate. | + +Common standards: `concurrency` groups per workflow/ref, `~/.m2` caching via +`actions/setup-java` (`cache: maven`), least-privilege `permissions:` (`contents: read`, +plus `id-token: write` only on deploy jobs), and pinned action versions. + +## Java / suite configuration + +`pom.xml` targets Java **1.8** bytecode (`maven.compiler.source/target`), but Selenium 4.25 +requires a JDK 11+ toolchain, so CI builds on **Temurin 21** (matching the sandbox where this +was validated) while still emitting 1.8 bytecode. Surefire's default suite is +`src/test/resources/suites/testng.xml`; CI overrides it with `-Dsurefire.suiteXmlFiles=...`. + +### Suite scoping (why CI is smoke-scoped) + +The committed suite contains two classes: + +- `GoogleSearchTest` — drives `google.co.in`, passes headlessly (verified locally). +- `FaceBookLoginTest` — drives `facebook.com` and ends with `Assert.assertTrue(false, ...)`, + i.e. it **always fails by design** (demo content), and the login markup it targets no + longer exists. + +So a full `mvn test` is expected to end in BUILD FAILURE. To keep a red CI check meaningful, +PR/push CI runs `src/test/resources/suites/smoke.xml` (added by this setup — `GoogleSearchTest` +only) with **no** `continue-on-error`. The nightly workflow runs the full suite and marks only +that one step `continue-on-error: true`, with an inline comment explaining the known failure; +its artifacts and job summary carry the real result. Remove that flag once the demo failure is +fixed or the external target is stubbed. + +Both jobs reach public internet targets (Google/Facebook), which GitHub-hosted runners can +do; results still depend on those third-party sites, so treat nightly failures as +"investigate the external target first". + +`GoogleSearchTest` is also mildly **flaky in containers**: in ~2 of 5 runs inside the harness +image, Google served an interstitial and the assertion `Title doesn't contain abc` failed +(host runs were 3/3 green). Treat isolated smoke failures with that message as environmental +and re-run; the durable fix is a `WebDriverWait` on the title inside the test, which is out of +scope for this CI change. + +### Driver resolution in CI + +`browser-actions/setup-chrome` installs Chrome **and** a matching chromedriver; the driver +path is passed to Maven as `-Dwebdriver.chrome.driver` / `-Dwdm.chromeDriverPath`, so +WebDriverManager uses the local binary instead of downloading one — the run works without +egress to the driver CDN. The container image (`Dockerfile`) bakes Chrome in for the same +reason, bakes a Chrome-for-Testing chromedriver into the Selenium cache, and runs Maven +offline (`-o`) against a `~/.m2` primed by executing the suite once at build time — verified +with `docker run --network none`, where Chrome and chromedriver start with zero egress. + +## MOCK AWS configuration — nothing here is real + +**All AWS identifiers are placeholders.** Account `123456789012`, roles +`arn:aws:iam::123456789012:role/demo-*`, ECR repo `demo/selenium-testng-harness`, clusters +`demo-staging-cluster` / `demo-prod-cluster`, bucket `demo-mock-test-reports`, region +`us-east-1`, namespaces `demo-staging` / `demo-prod`, subnets/SGs `subnet-0abc…`/`sg-0abc…`. + +The kill switch is the repository variable **`AWS_MOCK_MODE`** (default `true` when unset): + +- `true` — no credentials are configured, no ECR login happens, and every push/deploy/S3 + step prints `MOCK MODE — would run: …` and exits 0. The container image is still built for + real and uploaded as a workflow artifact, so the pipeline is genuinely exercised. +- `false` — the real `aws-actions/configure-aws-credentials` OIDC assumption, ECR login, + `docker push`, `aws ecs register-task-definition` / `run-task`, and `aws s3 sync` execute. + +There are **no long-lived AWS keys anywhere**: authentication is GitHub OIDC role assumption +(`id-token: write` on the deploy jobs only). + +### Actions variables (Settings → Secrets and variables → Actions → Variables) + +| Variable | Mock default | Notes | +| --- | --- | --- | +| `AWS_MOCK_MODE` | `true` | Set to `false` to actually talk to AWS. | +| `AWS_REGION` | `us-east-1` | | +| `AWS_ACCOUNT_ID` | `123456789012` | | +| `ECR_REPOSITORY` | `demo/selenium-testng-harness` | Must exist before a real push. | +| `REPORTS_BUCKET` | `demo-mock-test-reports` | Report destination for `aws s3 sync`. | +| `AWS_ROLE_ARN_STAGING` / `AWS_ROLE_ARN_PRODUCTION` | `arn:aws:iam::123456789012:role/demo-{staging,prod}-github-oidc` | Role trusted for this repo's OIDC subject. | +| `TASK_EXECUTION_ROLE_ARN_STAGING` / `_PRODUCTION` | `arn:aws:iam::123456789012:role/demo-ecsTaskExecutionRole` | ECS task execution role. | +| `ECS_CLUSTER_STAGING` / `ECS_CLUSTER_PRODUCTION` | `demo-staging-cluster` / `demo-prod-cluster` | | +| `ECS_TASK_FAMILY_STAGING` / `_PRODUCTION` | `demo-{staging,prod}-selenium-harness` | | +| `ECS_SUBNETS_STAGING` / `_PRODUCTION` | `subnet-0abc123456789def0` / `subnet-0fed987654321cba0` | Comma-separated. | +| `ECS_SECURITY_GROUPS_STAGING` / `_PRODUCTION` | `sg-0abc123456789def0` / `sg-0fed987654321cba0` | Comma-separated. | + +No repository **secrets** are required — OIDC replaces them. (The harness's optional email +reporting via `simple-java-mail` would need SMTP settings in `src/test/resources/config/test.properties`; +supply those as secrets if you enable it.) + +### GitHub Environments + +Create two environments: + +- `staging` — no reviewers required (add some if you want a gate there too). +- `production` — **required reviewers** configured. This is the manual approval gate: the + promotion job queues until an approver releases it. Promotion re-tags the exact image that + staging validated (`prod-`); it never rebuilds. + +## Going from mock to real + +1. Create the ECR repo, ECS cluster(s) (or an EKS cluster with namespaces `demo-staging`/`demo-prod`), + the reports S3 bucket, and CloudWatch log group `/demo/selenium-testng-harness`. +2. Create the two IAM roles with a GitHub OIDC trust policy scoped to this repository and the + `staging` / `production` environments; grant only ECR push, `ecs:RegisterTaskDefinition`, + `ecs:RunTask`, `iam:PassRole` for the task roles, and `s3:PutObject` on the bucket prefix. +3. Set all the variables above to real values and flip `AWS_MOCK_MODE` to `false`. +4. Configure required reviewers on the `production` environment. +5. Review `deploy/ecs-task-definition.json` (cpu/memory, log group, `SUITE_FILE`). + +## Dependabot + +`.github/dependabot.yml` tracks Maven dependencies, GitHub Actions versions, and the +Dockerfile base images weekly. diff --git a/src/test/resources/suites/smoke.xml b/src/test/resources/suites/smoke.xml new file mode 100644 index 0000000..4f4c661 --- /dev/null +++ b/src/test/resources/suites/smoke.xml @@ -0,0 +1,15 @@ + + + + + + + + + + +