diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4b45db5..ff88680 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,11 +4,20 @@ updates: directory: /stacks/java-selenium-testng schedule: interval: weekly + commit-message: + prefix: build + include: scope - package-ecosystem: github-actions directory: / schedule: interval: weekly + commit-message: + prefix: build + include: scope - package-ecosystem: pip directory: /stacks/python-playwright schedule: interval: weekly + commit-message: + prefix: build + include: scope diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 6be0d9e..19f0338 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -35,16 +35,13 @@ jobs: - chrome - firefox - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: python3 tools/ci_containers.py start-website + - name: Set up JDK 25 uses: actions/setup-java@v5 with: @@ -76,7 +73,7 @@ jobs: - name: Upload Surefire XML if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: java-${{ matrix.browser }}-${{ github.run_id }}-surefire-xml path: artifacts/java/${{ github.run_id }}/${{ matrix.browser }}/ diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 93930cc..244dd33 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -22,22 +22,23 @@ jobs: strategy: fail-fast: false + max-parallel: 3 matrix: browser: - chrome - edge - firefox - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: >- + python3 tools/ci_containers.py + --initial-jitter-seconds 20 + start-website + - name: Set up JDK 25 uses: actions/setup-java@v5 with: @@ -69,7 +70,7 @@ jobs: - name: Upload Surefire XML if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: java-nightly-${{ matrix.browser }}-${{ github.run_id }}-surefire-xml path: artifacts/java/${{ github.run_id }}/nightly-${{ matrix.browser }}/ @@ -82,6 +83,7 @@ jobs: strategy: fail-fast: false + max-parallel: 3 matrix: include: - browser: chrome @@ -94,31 +96,17 @@ jobs: grid_node_image: selenium/node-firefox:4.45.0-20260606@sha256:d2ea8f4fa9c3178f9842fd16c51626cb934b303833ca70d9e3ce4aa5c539baf7 artifact_slice: grid-firefox - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - selenium-hub: - image: selenium/hub:4.45.0-20260606@sha256:684d163880d558f217b29d24e992c5a52e0b130381ab7e1f79e9bad0414f753b - ports: - - 4442:4442 - - 4443:4443 - - 4444:4444 - browser-node: - image: ${{ matrix.grid_node_image }} - options: --shm-size=2g - env: - SE_EVENT_BUS_HOST: selenium-hub - SE_EVENT_BUS_PUBLISH_PORT: 4442 - SE_EVENT_BUS_SUBSCRIBE_PORT: 4443 - SE_NODE_ENABLE_MANAGED_DOWNLOADS: "true" - SE_NODE_MAX_SESSIONS: "1" - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application and Selenium Grid + run: >- + python3 tools/ci_containers.py + --initial-jitter-seconds 20 + start-grid + --node-image '${{ matrix.grid_node_image }}' + - name: Set up JDK 25 uses: actions/setup-java@v5 with: @@ -126,30 +114,6 @@ jobs: java-version: '25' cache: maven - - name: Wait for Selenium Grid - run: | - python3 - <<'PY' - import json - import time - import urllib.request - - deadline = time.monotonic() + 90 - last_error = "" - while time.monotonic() < deadline: - try: - with urllib.request.urlopen("http://localhost:4444/status", timeout=5) as response: - payload = json.load(response) - value = payload.get("value", {}) - if value.get("ready") is True: - print(json.dumps(value, sort_keys=True)) - raise SystemExit(0) - last_error = json.dumps(value, sort_keys=True) - except Exception as exc: - last_error = str(exc) - time.sleep(3) - raise SystemExit(f"Selenium Grid was not ready: {last_error}") - PY - - name: Run Java Selenium Grid nightly regression run: >- mvn -B -f stacks/java-selenium-testng/pom.xml -P CLI_Parameters clean test @@ -174,7 +138,7 @@ jobs: - name: Upload Java Grid Surefire XML if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: java-${{ matrix.artifact_slice }}-${{ github.run_id }}-surefire-xml path: artifacts/java/${{ github.run_id }}/${{ matrix.artifact_slice }}/ @@ -187,6 +151,7 @@ jobs: strategy: fail-fast: false + max-parallel: 3 matrix: include: - project: chromium @@ -218,18 +183,18 @@ jobs: grep-invert: "@desktop|@http|@not-ci" artifact_slice: nightly-mobile-safari - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: >- + python3 tools/ci_containers.py + --initial-jitter-seconds 20 + start-website + - name: Set up Node 22 - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' cache: npm @@ -283,7 +248,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ts-${{ matrix.artifact_slice }}-${{ github.run_id }}-playwright path: artifacts/ts/${{ github.run_id }}/${{ matrix.artifact_slice }}/ @@ -296,6 +261,7 @@ jobs: strategy: fail-fast: false + max-parallel: 3 matrix: include: - project: chromium @@ -348,18 +314,18 @@ jobs: marker_expr: not desktop and not http and not not_ci artifact_slice: nightly-mobile-safari - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: >- + python3 tools/ci_containers.py + --initial-jitter-seconds 20 + start-website + - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.14' cache: pip @@ -437,7 +403,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: py-${{ matrix.artifact_slice }}-${{ github.run_id }}-playwright path: artifacts/py/${{ github.run_id }}/${{ matrix.artifact_slice }}/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ae54b53..f30dec3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,14 +24,23 @@ jobs: with: fetch-depth: 0 + - name: Test CI container helper + run: python3 -m unittest discover -s tools/tests -v + + - name: Pull CI utility images + run: >- + python3 tools/ci_containers.py pull + rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 + lycheeverse/lychee:0.24.2@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d + - name: Set up Node 22 - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' - name: Lint GitHub Actions workflows run: >- - docker run --rm + docker run --rm --pull=never -v "$PWD:/repo:ro" -w /repo rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 @@ -71,7 +80,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: >- git ls-files -z '*.md' | xargs -0 - docker run --rm + docker run --rm --pull=never -v "$PWD:/repo:ro" -w /repo -e GITHUB_TOKEN @@ -90,12 +99,17 @@ jobs: github.event_name == 'pull_request' && !contains(github.event.pull_request.title, '[ci-guide-exempt]') env: + ACTOR: ${{ github.actor }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | changed="$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA")" if printf '%s\n' "$changed" | grep -q '^\.github/workflows/' && ! printf '%s\n' "$changed" | grep -qx 'docs/ci-workflows.md'; then + if [ "$ACTOR" = "dependabot[bot]" ]; then + echo "Dependabot action-version updates do not change the documented job topology." + exit 0 + fi echo "::error file=docs/ci-workflows.md::This pull request changes .github/workflows/ but not docs/ci-workflows.md" echo "docs/ci-workflows.md mirrors the workflows at job and step granularity, and nothing else keeps it honest." echo "Update it in this pull request. If the change has no semantic effect on the guide, add [ci-guide-exempt] to the pull request title." @@ -122,16 +136,13 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 20 - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: python3 tools/ci_containers.py start-website + - name: Set up JDK 25 uses: actions/setup-java@v5 with: @@ -166,7 +177,7 @@ jobs: - name: Upload Surefire XML if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: java-smoke-${{ github.run_id }}-surefire-xml path: artifacts/java/${{ github.run_id }}/smoke/ @@ -177,18 +188,15 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: python3 tools/ci_containers.py start-website + - name: Set up Node 22 - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' cache: npm @@ -224,7 +232,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ts-smoke-${{ github.run_id }}-playwright path: artifacts/ts/${{ github.run_id }}/smoke/ @@ -235,18 +243,15 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: python3 tools/ci_containers.py start-website + - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.14' cache: pip @@ -290,7 +295,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: py-smoke-${{ github.run_id }}-playwright path: artifacts/py/${{ github.run_id }}/smoke/ diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 554c6a6..016485b 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -68,18 +68,15 @@ jobs: marker_expr: mobile_emulation and not desktop and not not_ci and not flaky_demo artifact_slice: mobile-safari - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: python3 tools/ci_containers.py start-website + - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.14' cache: pip @@ -143,7 +140,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: py-${{ matrix.artifact_slice }}-${{ github.run_id }}-playwright path: artifacts/py/${{ github.run_id }}/${{ matrix.artifact_slice }}/ diff --git a/.github/workflows/ts.yml b/.github/workflows/ts.yml index 828d032..c3d8df9 100644 --- a/.github/workflows/ts.yml +++ b/.github/workflows/ts.yml @@ -63,18 +63,15 @@ jobs: grep-invert: '@desktop|@not-ci|@flaky-demo' artifact_slice: mobile-safari - services: - website: - image: gprestes/the-internet:v2.6.5@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171 - ports: - - 7080:5000 - steps: - name: Checkout repository uses: actions/checkout@v7 + - name: Start test application + run: python3 tools/ci_containers.py start-website + - name: Set up Node 22 - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' cache: npm @@ -130,7 +127,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ts-${{ matrix.artifact_slice }}-${{ github.run_id }}-playwright path: artifacts/ts/${{ github.run_id }}/${{ matrix.artifact_slice }}/ diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 98f737e..13efda7 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -8,7 +8,7 @@ module.exports = { 'scope-enum': [ 2, 'always', - ['java', 'ts', 'py', 'catalog', 'docker', 'actions', 'deps', 'readme'], + ['java', 'ts', 'py', 'catalog', 'docker', 'actions', 'deps', 'deps-dev', 'readme'], ], 'subject-empty': [2, 'never'], 'subject-full-stop': [2, 'never', '.'], diff --git a/docs/ci-workflows.md b/docs/ci-workflows.md index 8415ff8..7766566 100644 --- a/docs/ci-workflows.md +++ b/docs/ci-workflows.md @@ -4,7 +4,7 @@ Use this guide to understand what runs in CI, when, and why: the trigger fan-out ## The one rule that shapes everything -There is no `needs:`, `workflow_run:`, `workflow_call:`, or `concurrency:` anywhere in the five workflows, so nothing chains, orders, or cancels anything else. Every trigger fans out to its matching workflows in parallel, and inside each workflow every job runs in parallel with the others. The only sequential execution is the ordered list of steps inside a single job. Read the step lists below top to bottom for order within a job, and treat everything else as concurrent. +There is no `needs:`, `workflow_run:`, `workflow_call:`, or `concurrency:` anywhere in the five workflows, so nothing chains, orders, or cancels anything else. Every trigger fans out to its matching workflows in parallel, and inside each workflow every top-level job runs in parallel with the others. Matrix legs are also concurrent, subject to the nightly limit of three legs per matrix. The only sequential execution is the ordered list of steps inside a single job. Read the step lists below top to bottom for order within a job, and treat everything else as concurrent. ## Trigger fan-out @@ -61,18 +61,18 @@ Steps run top to bottom within each job. A step marked (always) carries `if: alw | Job (`id`) | Intent | Steps (top to bottom) | File | | --- | --- | --- | --- | -| `repo-hygiene` | Lint workflows, commit messages, and Markdown, check documentation links, validate the README fast start, and require CI-guide updates alongside workflow changes | 1. Checkout repository, 2. Set up Node 22, 3. Lint GitHub Actions workflows, 4. Lint pull request commits (conditional), 5. Lint latest commit (conditional), 6. Lint Markdown, 7. Check Markdown links, 8. Validate README fast start, 9. Require CI guide updates with workflow changes (conditional) | [`pr.yml`](../.github/workflows/pr.yml) | +| `repo-hygiene` | Lint workflows, commit messages, and Markdown, check documentation links, validate the README fast start, and require CI-guide updates alongside workflow changes | 1. Checkout repository, 2. Test CI container helper, 3. Pull CI utility images, 4. Set up Node 22, 5. Lint GitHub Actions workflows, 6. Lint pull request commits (conditional), 7. Lint latest commit (conditional), 8. Lint Markdown, 9. Check Markdown links, 10. Validate README fast start, 11. Require CI guide updates with workflow changes (conditional) | [`pr.yml`](../.github/workflows/pr.yml) | | `scenario-catalog` | Fail if the generated scenario matrix or the README embed of it has drifted | 1. Checkout repository, 2. Check scenario catalog | [`pr.yml`](../.github/workflows/pr.yml) | -| `java-smoke` | Compile the Java tests and run the Chrome smoke suite | 1. Checkout repository, 2. Set up JDK 25, 3. Compile Java tests, 4. Run Java smoke, 5. Stage Java reports (always), 6. Upload Surefire XML (always) | [`pr.yml`](../.github/workflows/pr.yml) | -| `ts-smoke` | Type-check, lint, and format-check, then run the Chromium smoke suite | 1. Checkout repository, 2. Set up Node 22, 3. Install TypeScript stack dependencies, 4. Type-check TypeScript stack, 5. Lint TypeScript stack, 6. Check TypeScript stack formatting, 7. Install Chromium browser, 8. Run TypeScript Chromium smoke, 9. Upload Playwright artifacts (always) | [`pr.yml`](../.github/workflows/pr.yml) | -| `py-smoke` | Lint, format-check, and type-check, then run the Chromium smoke suite | 1. Checkout repository, 2. Set up Python 3.14, 3. Install Python stack dependencies, 4. Lint Python stack, 5. Check Python stack formatting, 6. Type-check Python stack, 7. Install Chromium browser, 8. Run Python Chromium smoke, 9. Upload Playwright artifacts (always) | [`pr.yml`](../.github/workflows/pr.yml) | -| `java-regression` | Run the Java regression suite per matrix browser | 1. Checkout repository, 2. Set up JDK 25, 3. Run Java regression, 4. Stage Java reports (always), 5. Upload Surefire XML (always) | [`java.yml`](../.github/workflows/java.yml) | -| `python-regression` | Plan and run each Python Playwright slice per matrix project | 1. Checkout repository, 2. Set up Python 3.14, 3. Install Python stack dependencies, 4. Plan Python Playwright slice, 5. Install Playwright browser (conditional), 6. Run Python Playwright slice (conditional), 7. Upload Playwright artifacts (always) | [`python.yml`](../.github/workflows/python.yml) | -| `typescript-regression` | Plan and run each TypeScript Playwright slice per matrix project | 1. Checkout repository, 2. Set up Node 22, 3. Install TypeScript stack dependencies, 4. Plan TypeScript Playwright slice, 5. Install Playwright browsers (conditional), 6. Run TypeScript Playwright slice (conditional), 7. Upload Playwright artifacts (always) | [`ts.yml`](../.github/workflows/ts.yml) | -| `java-nightly-local` | Run the Java regression suite with a local driver per matrix browser | 1. Checkout repository, 2. Set up JDK 25, 3. Run Java nightly regression, 4. Stage Java reports (always), 5. Upload Surefire XML (always) | [`nightly.yml`](../.github/workflows/nightly.yml) | -| `java-nightly-grid` | Run the Java regression suite against Selenium Grid per matrix browser | 1. Checkout repository, 2. Set up JDK 25, 3. Wait for Selenium Grid, 4. Run Java Selenium Grid nightly regression, 5. Stage Java Grid reports (always), 6. Upload Java Grid Surefire XML (always) | [`nightly.yml`](../.github/workflows/nightly.yml) | -| `typescript-nightly` | Plan and run each TypeScript nightly slice per matrix project | 1. Checkout repository, 2. Set up Node 22, 3. Install TypeScript stack dependencies, 4. Plan TypeScript nightly slice, 5. Install Playwright browser (conditional), 6. Run TypeScript nightly slice (conditional), 7. Upload Playwright artifacts (always) | [`nightly.yml`](../.github/workflows/nightly.yml) | -| `python-nightly` | Plan and run each Python nightly slice per matrix project | 1. Checkout repository, 2. Set up Python 3.14, 3. Install Python stack dependencies, 4. Plan Python nightly slice, 5. Install Playwright browser (conditional), 6. Run Python nightly slice (conditional), 7. Upload Playwright artifacts (always) | [`nightly.yml`](../.github/workflows/nightly.yml) | +| `java-smoke` | Compile the Java tests and run the Chrome smoke suite | 1. Checkout repository, 2. Start test application, 3. Set up JDK 25, 4. Compile Java tests, 5. Run Java smoke, 6. Stage Java reports (always), 7. Upload Surefire XML (always) | [`pr.yml`](../.github/workflows/pr.yml) | +| `ts-smoke` | Type-check, lint, and format-check, then run the Chromium smoke suite | 1. Checkout repository, 2. Start test application, 3. Set up Node 22, 4. Install TypeScript stack dependencies, 5. Type-check TypeScript stack, 6. Lint TypeScript stack, 7. Check TypeScript stack formatting, 8. Install Chromium browser, 9. Run TypeScript Chromium smoke, 10. Upload Playwright artifacts (always) | [`pr.yml`](../.github/workflows/pr.yml) | +| `py-smoke` | Lint, format-check, and type-check, then run the Chromium smoke suite | 1. Checkout repository, 2. Start test application, 3. Set up Python 3.14, 4. Install Python stack dependencies, 5. Lint Python stack, 6. Check Python stack formatting, 7. Type-check Python stack, 8. Install Chromium browser, 9. Run Python Chromium smoke, 10. Upload Playwright artifacts (always) | [`pr.yml`](../.github/workflows/pr.yml) | +| `java-regression` | Run the Java regression suite per matrix browser | 1. Checkout repository, 2. Start test application, 3. Set up JDK 25, 4. Run Java regression, 5. Stage Java reports (always), 6. Upload Surefire XML (always) | [`java.yml`](../.github/workflows/java.yml) | +| `python-regression` | Plan and run each Python Playwright slice per matrix project | 1. Checkout repository, 2. Start test application, 3. Set up Python 3.14, 4. Install Python stack dependencies, 5. Plan Python Playwright slice, 6. Install Playwright browser (conditional), 7. Run Python Playwright slice (conditional), 8. Upload Playwright artifacts (always) | [`python.yml`](../.github/workflows/python.yml) | +| `typescript-regression` | Plan and run each TypeScript Playwright slice per matrix project | 1. Checkout repository, 2. Start test application, 3. Set up Node 22, 4. Install TypeScript stack dependencies, 5. Plan TypeScript Playwright slice, 6. Install Playwright browsers (conditional), 7. Run TypeScript Playwright slice (conditional), 8. Upload Playwright artifacts (always) | [`ts.yml`](../.github/workflows/ts.yml) | +| `java-nightly-local` | Run the Java regression suite with a local driver per matrix browser | 1. Checkout repository, 2. Start test application, 3. Set up JDK 25, 4. Run Java nightly regression, 5. Stage Java reports (always), 6. Upload Surefire XML (always) | [`nightly.yml`](../.github/workflows/nightly.yml) | +| `java-nightly-grid` | Run the Java regression suite against Selenium Grid per matrix browser | 1. Checkout repository, 2. Start test application and Selenium Grid, 3. Set up JDK 25, 4. Run Java Selenium Grid nightly regression, 5. Stage Java Grid reports (always), 6. Upload Java Grid Surefire XML (always) | [`nightly.yml`](../.github/workflows/nightly.yml) | +| `typescript-nightly` | Plan and run each TypeScript nightly slice per matrix project | 1. Checkout repository, 2. Start test application, 3. Set up Node 22, 4. Install TypeScript stack dependencies, 5. Plan TypeScript nightly slice, 6. Install Playwright browser (conditional), 7. Run TypeScript nightly slice (conditional), 8. Upload Playwright artifacts (always) | [`nightly.yml`](../.github/workflows/nightly.yml) | +| `python-nightly` | Plan and run each Python nightly slice per matrix project | 1. Checkout repository, 2. Start test application, 3. Set up Python 3.14, 4. Install Python stack dependencies, 5. Plan Python nightly slice, 6. Install Playwright browser (conditional), 7. Run Python nightly slice (conditional), 8. Upload Playwright artifacts (always) | [`nightly.yml`](../.github/workflows/nightly.yml) | This guide is the one document CI checks against its own source. A pull request that changes anything under `.github/workflows/` without touching this file fails @@ -80,11 +80,28 @@ that changes anything under `.github/workflows/` without touching this file fail risk documentation in the repository: it stays plausible long after it stops being true. If a workflow change genuinely has no semantic effect here — a whitespace or comment edit — put the literal token `[ci-guide-exempt]` in the -pull request title to skip the check. +pull request title to skip the check. Dependabot GitHub Actions updates are also +exempt because that ecosystem can only replace action-version references; it +cannot change the documented job topology. + +## Resilient container bootstrap + +CI starts its pinned Docker images from an explicit **Start test application** +step instead of the job-level `services` phase. The helper at +[`tools/ci_containers.py`](../tools/ci_containers.py) bounds each pull, retries +only that idempotent external operation with exponential backoff and jitter, +starts containers with `--pull=never`, and polls application/Grid readiness +before language setup or tests begin. Exhausted retries include container +status and logs. Tests themselves are never retried. + +The 20 nightly matrix legs make 26 image-pull calls in total, so each leg starts +with a randomized delay of at most 20 seconds and each matrix runs at most three +legs concurrently. This reduces synchronized Docker Hub bursts without reducing +browser coverage. ## Matrix and tags -Each regression and nightly job fans out over a `strategy.matrix`, with `fail-fast` disabled so one failing leg never cancels the rest. +Each regression and nightly job fans out over a `strategy.matrix`, with `fail-fast` disabled so one failing leg never cancels the rest. Nightly matrices cap parallelism at three legs to avoid synchronized external-registry bursts. | Stack | Regression projects | Nightly projects | | --- | --- | --- | diff --git a/docs/runbooks/ci-failure-triage.md b/docs/runbooks/ci-failure-triage.md index 86ce469..b318ddf 100644 --- a/docs/runbooks/ci-failure-triage.md +++ b/docs/runbooks/ci-failure-triage.md @@ -4,8 +4,10 @@ A check went red. This is how to find out why without reading the whole workflow file, and how to reproduce it locally so you are debugging your change rather than the Actions UI. -Rule zero: **never fix a red gate by weakening it.** Widening a tag, adding a -retry, or relaxing a lint to get green converts a defect into a permanent lie. +Rule zero: **never fix a red gate by weakening it.** Widening a tag, retrying a +test, or relaxing a lint to get green converts a defect into a permanent lie. +Bounded retries are reserved for idempotent external setup operations such as +pulling a digest-pinned image; they do not change test outcomes. If a failure is genuinely not your change, say so in the pull request and fix the cause. @@ -164,6 +166,10 @@ mvn -B -f stacks/java-selenium-testng/pom.xml -P CLI_Parameters clean test \ Selenium Grid. Compare them before anything else, because the pair localizes the fault for free: +- **Start test application/Grid fails** — inspect the pull warnings and the + grouped container diagnostics. Checkout and tests have not run, so classify + an exhausted registry timeout as external infrastructure rather than a test + failure. - **Grid fails, local passes** — the fault is in the Grid path: session capabilities, the node image, or a client/node version mismatch. It is not your test logic. diff --git a/stacks/java-selenium-testng/pom.xml b/stacks/java-selenium-testng/pom.xml index e4c8dbb..c07408c 100644 --- a/stacks/java-selenium-testng/pom.xml +++ b/stacks/java-selenium-testng/pom.xml @@ -8,7 +8,7 @@ 1.0-SNAPSHOT 25 - 4.45.0 + 4.46.0 UTF-8 diff --git a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/driver/DriverFactory.java b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/driver/DriverFactory.java index 63ab2b2..81cca69 100644 --- a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/driver/DriverFactory.java +++ b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/driver/DriverFactory.java @@ -5,10 +5,12 @@ import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Paths; +import java.time.Duration; import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.NotNull; import org.openqa.selenium.Capabilities; +import org.openqa.selenium.MutableCapabilities; import org.openqa.selenium.PageLoadStrategy; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; @@ -21,6 +23,7 @@ public final class DriverFactory { public static final String DOWNLOADS_FOLDER = Paths.get("target/").toAbsolutePath().toString(); + static final Duration PAGE_LOAD_TIMEOUT = Duration.ofSeconds(30); public @NotNull RemoteWebDriver create(@NotNull TestRunConfig config) { return switch (config.browser()) { @@ -34,7 +37,7 @@ public final class DriverFactory { private @NotNull RemoteWebDriver createChromeDriver(@NotNull TestRunConfig config) { ChromeOptions chromeOptions = new ChromeOptions(); chromeOptions.setExperimentalOption("prefs", chromePreferences()); - chromeOptions.setPageLoadStrategy(PageLoadStrategy.NORMAL); + configureNavigation(chromeOptions); chromeOptions.addArguments("--window-size=1920,1200"); if (!config.useSeleniumGrid()) { // Ubuntu 24.04 CI runners restrict unprivileged user namespaces (AppArmor), @@ -59,6 +62,7 @@ public final class DriverFactory { firefoxOptions.addPreference("browser.download.folderList", 2); firefoxOptions.addPreference("browser.download.dir", DOWNLOADS_FOLDER); firefoxOptions.addPreference("browser.helperApps.neverAsk.saveToDisk", "text/plain,application/octet-stream"); + configureNavigation(firefoxOptions); if (config.headless() && !config.useSeleniumGrid()) { firefoxOptions.addArguments("-headless"); } @@ -74,11 +78,10 @@ public final class DriverFactory { private @NotNull RemoteWebDriver createEdgeDriver(@NotNull TestRunConfig config) { EdgeOptions edgeOptions = new EdgeOptions(); edgeOptions.setExperimentalOption("prefs", chromePreferences()); + configureNavigation(edgeOptions); edgeOptions.addArguments("--window-size=1920,1200"); if (!config.useSeleniumGrid()) { - // Ubuntu 24.04 CI runners restrict unprivileged user namespaces (AppArmor), - // which crashes the Chromium sandbox on launch; disable it for local runs. - edgeOptions.addArguments("--no-sandbox", "--disable-dev-shm-usage"); + configureLocalEdge(edgeOptions); } if (config.headless() && !config.useSeleniumGrid()) { edgeOptions.addArguments("--headless=new"); @@ -92,6 +95,18 @@ public final class DriverFactory { return new EdgeDriver(edgeOptions); } + /** Applies launch hardening required by ephemeral Linux Edge profiles. */ + static void configureLocalEdge(@NotNull EdgeOptions edgeOptions) { + // Ubuntu 24.04 restricts Chromium's sandbox; fresh Edge profiles can present first-run UI. + edgeOptions.addArguments("--no-first-run", "--no-sandbox", "--disable-dev-shm-usage"); + } + + /** Bounds navigation and lets page-object waits own application readiness. */ + static void configureNavigation(@NotNull MutableCapabilities options) { + options.setCapability("pageLoadStrategy", PageLoadStrategy.EAGER.toString()); + options.setCapability("timeouts", Map.of("pageLoad", PAGE_LOAD_TIMEOUT.toMillis())); + } + private static void applyBrowserVersion(@NotNull ChromeOptions options, @NotNull TestRunConfig config) { if (hasBrowserVersion(config)) { options.setBrowserVersion(config.browserVersion()); diff --git a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/driver/DriverFactoryTest.java b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/driver/DriverFactoryTest.java new file mode 100644 index 0000000..1c49eca --- /dev/null +++ b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/driver/DriverFactoryTest.java @@ -0,0 +1,38 @@ +package theinternetwebsite.ui.driver; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +import java.util.List; +import java.util.Map; +import org.openqa.selenium.edge.EdgeOptions; +import org.testng.annotations.Test; + +public final class DriverFactoryTest { + @Test(description = "Navigation returns after DOM readiness and has a bounded page-load timeout") + public void navigationPolicyIsBoundedAndUsesEagerReadiness() { + EdgeOptions options = new EdgeOptions(); + + DriverFactory.configureNavigation(options); + + assertEquals(options.getCapability("pageLoadStrategy"), "eager"); + Object timeouts = options.getCapability("timeouts"); + assertTrue(timeouts instanceof Map, "Driver capabilities should contain a timeout map"); + assertEquals(((Map) timeouts).get("pageLoad"), DriverFactory.PAGE_LOAD_TIMEOUT.toMillis()); + } + + @Test(description = "Local Edge sessions suppress the fresh-profile first-run experience") + public void localEdgeDisablesFirstRunExperience() { + EdgeOptions options = new EdgeOptions(); + + DriverFactory.configureLocalEdge(options); + + Object edgeOptions = options.getCapability(EdgeOptions.CAPABILITY); + assertTrue(edgeOptions instanceof Map, "Edge capabilities should contain an options map"); + Object arguments = ((Map) edgeOptions).get("args"); + assertTrue(arguments instanceof List, "Edge options should contain launch arguments"); + assertTrue(((List) arguments).containsAll(List.of( + "--no-first-run", "--no-sandbox", "--disable-dev-shm-usage")), + "Local Edge must skip first-run UI and retain Linux CI launch hardening"); + } +} diff --git a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/BasePage.java b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/BasePage.java index 416e085..f841122 100644 --- a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/BasePage.java +++ b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/BasePage.java @@ -7,6 +7,7 @@ import org.openqa.selenium.support.PageFactory; import org.openqa.selenium.support.ui.WebDriverWait; import theinternetwebsite.ui.UITest; +import theinternetwebsite.ui.support.BrowserActions; abstract class BasePage { private final UITest caller; @@ -19,7 +20,7 @@ protected BasePage(@NotNull UITest caller, @NotNull String path) { } public void open() { - driver().get(pageUrl); + navigateTo(pageUrl); waitUntilOpen(); } @@ -41,6 +42,10 @@ public boolean isPageOpen() { return pageUrl; } + protected final void navigateTo(@NotNull String url) { + BrowserActions.navigateTo(driver(), url); + } + protected final @NotNull WebDriverWait waitFor(@NotNull Duration timeout) { return caller.waits().waitFor(timeout); } diff --git a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/DynamicContentPage.java b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/DynamicContentPage.java index 3e56a2f..4c90a02 100644 --- a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/DynamicContentPage.java +++ b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/DynamicContentPage.java @@ -36,7 +36,7 @@ public void reloadPage() { public HashMap getContent(@NotNull Boolean partial) { String staticContentQueryString = "?with_content=static"; - driver().get(Boolean.TRUE.equals(partial) ? pageUrl() + staticContentQueryString : pageUrl()); + navigateTo(Boolean.TRUE.equals(partial) ? pageUrl() + staticContentQueryString : pageUrl()); waitUntilOpen(); List rawImages = driver().findElements(By.xpath("(//*[@class='large-2 columns'])")); diff --git a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/IFramePage.java b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/IFramePage.java index f013839..228343d 100644 --- a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/IFramePage.java +++ b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/pageobjects/IFramePage.java @@ -27,12 +27,8 @@ public IFramePage(@NotNull UITest caller) { } public void switchToIFrame() { - driver().switchTo().frame(this.iFrame); - // TinyMCE injects its default content asynchronously after the frame loads. - // Wait until it has rendered so reads don't race the editor boot: the test - // reads the text and writeIFrameTextAreaText re-reads it, and both snapshots - // must agree. (The tinymce global lives in the parent document, not this - // content frame, so probe the rendered body text instead.) + waitFor(Duration.ofSeconds(10)).until(ExpectedConditions.frameToBeAvailableAndSwitchToIt(iFrame)); + // TinyMCE injects its default content after the frame becomes available. waitFor(Duration.ofSeconds(10)).until(d -> !iFrameTextArea.getText().isEmpty()); } diff --git a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/support/BrowserActions.java b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/support/BrowserActions.java index 0c166a2..452e832 100644 --- a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/support/BrowserActions.java +++ b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/support/BrowserActions.java @@ -2,12 +2,58 @@ import org.jetbrains.annotations.NotNull; import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebDriverException; import org.openqa.selenium.WebElement; import org.openqa.selenium.remote.RemoteWebDriver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public final class BrowserActions { + private static final Logger LOGGER = LoggerFactory.getLogger(BrowserActions.class); + private static final int MAX_NAVIGATION_ATTEMPTS = 2; + private BrowserActions() {} + /** Opens a URL with one bounded retry for an idempotent timed-out navigation. */ + public static void navigateTo(@NotNull WebDriver driver, @NotNull String url) { + TimeoutException firstFailure = null; + for (int attempt = 1; attempt <= MAX_NAVIGATION_ATTEMPTS; attempt++) { + try { + driver.get(url); + return; + } catch (TimeoutException timeout) { + if (hasReachedTarget(driver, url, timeout)) { + LOGGER.warn("Navigation timed out after reaching {}; continuing with page readiness checks", url); + return; + } + if (attempt == MAX_NAVIGATION_ATTEMPTS) { + TimeoutException failure = new TimeoutException( + "Navigation to " + url + " timed out after " + MAX_NAVIGATION_ATTEMPTS + " attempts", + timeout); + if (firstFailure != null) { + failure.addSuppressed(firstFailure); + } + throw failure; + } + firstFailure = timeout; + LOGGER.warn("Navigation attempt {} of {} timed out for {}; retrying", + attempt, MAX_NAVIGATION_ATTEMPTS, url); + } + } + } + + private static boolean hasReachedTarget( + @NotNull WebDriver driver, @NotNull String url, @NotNull TimeoutException timeout) { + try { + return url.equals(driver.getCurrentUrl()); + } catch (WebDriverException currentUrlFailure) { + timeout.addSuppressed(currentUrlFailure); + return false; + } + } + public static void reloadPage(@NotNull RemoteWebDriver driver) { driver.navigate().refresh(); } diff --git a/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/support/BrowserActionsTest.java b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/support/BrowserActionsTest.java new file mode 100644 index 0000000..216e890 --- /dev/null +++ b/stacks/java-selenium-testng/src/test/java/theinternetwebsite/ui/support/BrowserActionsTest.java @@ -0,0 +1,73 @@ +package theinternetwebsite.ui.support; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.expectThrows; + +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.WebDriver; +import org.testng.annotations.Test; + +public final class BrowserActionsTest { + private static final String TARGET_URL = "http://localhost:7080/login"; + + @Test(description = "Retries one timed-out idempotent navigation") + public void navigateToRetriesOnce() { + AtomicInteger attempts = new AtomicInteger(); + AtomicReference currentUrl = new AtomicReference<>("about:blank"); + WebDriver driver = navigationDriver(attempts, currentUrl, 1); + + BrowserActions.navigateTo(driver, TARGET_URL); + + assertEquals(attempts.get(), 2); + assertEquals(currentUrl.get(), TARGET_URL); + } + + @Test(description = "Does not reload a target already reached before the timeout") + public void navigateToAcceptsReachedTarget() { + AtomicInteger attempts = new AtomicInteger(); + AtomicReference currentUrl = new AtomicReference<>(TARGET_URL); + WebDriver driver = navigationDriver(attempts, currentUrl, 1); + + BrowserActions.navigateTo(driver, TARGET_URL); + + assertEquals(attempts.get(), 1); + } + + @Test(description = "Surfaces an actionable failure after the bounded retry") + public void navigateToFailsAfterOneRetry() { + AtomicInteger attempts = new AtomicInteger(); + AtomicReference currentUrl = new AtomicReference<>("about:blank"); + WebDriver driver = navigationDriver(attempts, currentUrl, 2); + + TimeoutException failure = expectThrows( + TimeoutException.class, () -> BrowserActions.navigateTo(driver, TARGET_URL)); + + assertEquals(attempts.get(), 2); + assertTrue(failure.getMessage().contains("timed out after 2 attempts")); + assertEquals(failure.getSuppressed().length, 1); + } + + private static WebDriver navigationDriver( + AtomicInteger attempts, AtomicReference currentUrl, int failures) { + return (WebDriver) Proxy.newProxyInstance( + WebDriver.class.getClassLoader(), + new Class[] {WebDriver.class}, + (proxy, method, args) -> switch (method.getName()) { + case "get" -> { + String requestedUrl = (String) args[0]; + if (attempts.incrementAndGet() <= failures) { + throw new TimeoutException("simulated navigation timeout"); + } + currentUrl.set(requestedUrl); + yield null; + } + case "getCurrentUrl" -> currentUrl.get(); + case "toString" -> "navigation-driver"; + default -> throw new UnsupportedOperationException(method.getName()); + }); + } +} diff --git a/stacks/java-selenium-testng/src/test/resources/regression.xml b/stacks/java-selenium-testng/src/test/resources/regression.xml index f3df73b..17569d3 100644 --- a/stacks/java-selenium-testng/src/test/resources/regression.xml +++ b/stacks/java-selenium-testng/src/test/resources/regression.xml @@ -13,6 +13,16 @@ + + + + + + + + + + diff --git a/stacks/java-selenium-testng/src/test/resources/smoke.xml b/stacks/java-selenium-testng/src/test/resources/smoke.xml index 9121e6a..886ffdf 100644 --- a/stacks/java-selenium-testng/src/test/resources/smoke.xml +++ b/stacks/java-selenium-testng/src/test/resources/smoke.xml @@ -8,6 +8,16 @@ + + + + + + + + + + diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..11e46d9 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Repository validation and CI support tools.""" diff --git a/tools/ci_containers.py b/tools/ci_containers.py new file mode 100644 index 0000000..8ce4880 --- /dev/null +++ b/tools/ci_containers.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +"""Start CI test containers with bounded pull retries and readiness checks.""" + +from __future__ import annotations + +import argparse +import json +import random +import re +import subprocess +import time +import urllib.error +import urllib.request +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Final + +WEBSITE_IMAGE: Final = ( + "gprestes/the-internet:v2.6.5" + "@sha256:205b8fc712747ea5fa1a0d54b01fa02d65e3f67a448c7a5c2aba65b211def171" +) +SELENIUM_HUB_IMAGE: Final = ( + "selenium/hub:4.45.0-20260606" + "@sha256:684d163880d558f217b29d24e992c5a52e0b130381ab7e1f79e9bad0414f753b" +) +NETWORK_NAME: Final = "the-internet-tests-ci" +WEBSITE_CONTAINER: Final = "the-internet-tests-ci-website" +GRID_HUB_CONTAINER: Final = "the-internet-tests-ci-grid-hub" +GRID_NODE_CONTAINER: Final = "the-internet-tests-ci-grid-node" +CONTAINER_NAMES: Final = (WEBSITE_CONTAINER, GRID_NODE_CONTAINER, GRID_HUB_CONTAINER) +IMAGE_REFERENCE_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/@:+-]{0,511}$") + + +class ContainerStartupError(RuntimeError): + """Raised when a required CI container cannot be pulled or started.""" + + +@dataclass(frozen=True, slots=True) +class RetryPolicy: + """Bounds retries for an idempotent image pull.""" + + attempts: int = 4 + timeout_seconds: float = 90.0 + base_delay_seconds: float = 5.0 + max_delay_seconds: float = 30.0 + + def __post_init__(self) -> None: + """Validate retry bounds.""" + if self.attempts < 1: + raise ValueError("attempts must be at least 1") + for name, value in ( + ("timeout_seconds", self.timeout_seconds), + ("base_delay_seconds", self.base_delay_seconds), + ("max_delay_seconds", self.max_delay_seconds), + ): + if value <= 0: + raise ValueError(f"{name} must be greater than 0") + + +type CommandRunner = Callable[ + [Sequence[str], float | None, bool, bool], subprocess.CompletedProcess[str] +] + + +def run_command( + command: Sequence[str], + timeout_seconds: float | None = None, + check: bool = True, + quiet: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run a command without invoking a shell.""" + return subprocess.run( + list(command), + check=check, + stderr=subprocess.DEVNULL if quiet else None, + stdout=subprocess.DEVNULL if quiet else None, + text=True, + timeout=timeout_seconds, + ) + + +def validate_image_reference(image: str) -> str: + """Validate an image reference received from workflow matrix data.""" + if IMAGE_REFERENCE_PATTERN.fullmatch(image) is None: + raise ValueError( + "image reference must contain 1 to 512 Docker-reference characters " + "and cannot begin with an option prefix" + ) + return image + + +def pull_image( + image: str, + *, + policy: RetryPolicy, + runner: CommandRunner = run_command, + sleeper: Callable[[float], None] = time.sleep, + randomizer: Callable[[float, float], float] = random.uniform, +) -> None: + """Pull an image with bounded exponential backoff and full jitter.""" + validated_image = validate_image_reference(image) + last_failure: BaseException | None = None + + for attempt in range(1, policy.attempts + 1): + print( + f"Pulling {validated_image} (attempt {attempt}/{policy.attempts})", + flush=True, + ) + try: + runner( + ("docker", "pull", validated_image), + policy.timeout_seconds, + True, + False, + ) + return + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as failure: + last_failure = failure + if attempt == policy.attempts: + break + delay_cap = min( + policy.max_delay_seconds, + policy.base_delay_seconds * (2 ** (attempt - 1)), + ) + delay = randomizer(0.0, delay_cap) + print( + f"::warning title=Container pull retry::{validated_image} failed " + f"on attempt {attempt}; retrying in {delay:.1f}s", + flush=True, + ) + sleeper(delay) + + raise ContainerStartupError( + f"Failed to pull {validated_image} after {policy.attempts} attempts" + ) from last_failure + + +def pull_images( + images: Sequence[str], + *, + policy: RetryPolicy, + initial_jitter_seconds: float = 0.0, + runner: CommandRunner = run_command, + sleeper: Callable[[float], None] = time.sleep, + randomizer: Callable[[float, float], float] = random.uniform, +) -> None: + """Pull unique images after an optional anti-stampede delay.""" + if initial_jitter_seconds < 0 or initial_jitter_seconds > 120: + raise ValueError("initial_jitter_seconds must be between 0 and 120") + if initial_jitter_seconds: + delay = randomizer(0.0, initial_jitter_seconds) + print(f"Staggering container pulls by {delay:.1f}s", flush=True) + sleeper(delay) + + for image in dict.fromkeys(images): + pull_image( + image, + policy=policy, + runner=runner, + sleeper=sleeper, + randomizer=randomizer, + ) + + +def _fetch_json(url: str, timeout_seconds: float) -> object: + """Fetch and decode a JSON readiness response.""" + with urllib.request.urlopen(url, timeout=timeout_seconds) as response: + return json.load(response) + + +def wait_for_readiness( + description: str, + probe: Callable[[], bool], + *, + timeout_seconds: float = 90.0, + interval_seconds: float = 2.0, + monotonic: Callable[[], float] = time.monotonic, + sleeper: Callable[[float], None] = time.sleep, +) -> None: + """Poll a service until it is ready or the bounded deadline expires.""" + deadline = monotonic() + timeout_seconds + last_failure: BaseException | None = None + while monotonic() < deadline: + try: + if probe(): + print(f"{description} is ready", flush=True) + return + except (OSError, ValueError, urllib.error.URLError) as failure: + last_failure = failure + sleeper(interval_seconds) + + message = f"{description} was not ready within {timeout_seconds:.0f}s" + if last_failure is not None: + message = f"{message}: {last_failure}" + raise ContainerStartupError(message) + + +def website_is_ready() -> bool: + """Return whether the test application responds successfully.""" + with urllib.request.urlopen("http://localhost:7080/", timeout=5) as response: + status = response.status + if not isinstance(status, int): + raise ContainerStartupError( + "Test application returned an invalid HTTP status" + ) + return 200 <= status < 400 + + +def grid_is_ready() -> bool: + """Return whether Selenium Grid reports a registered, ready node.""" + payload = _fetch_json("http://localhost:4444/status", 5) + if not isinstance(payload, dict): + return False + value = payload.get("value") + return isinstance(value, dict) and value.get("ready") is True + + +def _ensure_network(runner: CommandRunner) -> None: + inspected = runner( + ("docker", "network", "inspect", NETWORK_NAME), 15.0, False, True + ) + if inspected.returncode != 0: + runner(("docker", "network", "create", NETWORK_NAME), 30.0, True, False) + + +def _remove_resources(runner: CommandRunner) -> None: + for container_name in CONTAINER_NAMES: + runner(("docker", "rm", "--force", container_name), 30.0, False, True) + runner(("docker", "network", "rm", NETWORK_NAME), 30.0, False, True) + + +def _print_diagnostics(runner: CommandRunner) -> None: + print("::group::CI container diagnostics", flush=True) + runner(("docker", "ps", "--all"), 30.0, False, False) + for container_name in CONTAINER_NAMES: + runner(("docker", "logs", "--tail", "200", container_name), 30.0, False, False) + print("::endgroup::", flush=True) + + +def _start_website(runner: CommandRunner) -> None: + runner( + ( + "docker", + "run", + "--detach", + "--name", + WEBSITE_CONTAINER, + "--network", + NETWORK_NAME, + "--network-alias", + "website", + "--publish", + "7080:5000", + "--pull", + "never", + WEBSITE_IMAGE, + ), + 60.0, + True, + False, + ) + + +def _start_grid(node_image: str, runner: CommandRunner) -> None: + runner( + ( + "docker", + "run", + "--detach", + "--name", + GRID_HUB_CONTAINER, + "--network", + NETWORK_NAME, + "--network-alias", + "selenium-hub", + "--publish", + "4442:4442", + "--publish", + "4443:4443", + "--publish", + "4444:4444", + "--pull", + "never", + SELENIUM_HUB_IMAGE, + ), + 60.0, + True, + False, + ) + runner( + ( + "docker", + "run", + "--detach", + "--name", + GRID_NODE_CONTAINER, + "--network", + NETWORK_NAME, + "--shm-size", + "2g", + "--env", + "SE_EVENT_BUS_HOST=selenium-hub", + "--env", + "SE_EVENT_BUS_PUBLISH_PORT=4442", + "--env", + "SE_EVENT_BUS_SUBSCRIBE_PORT=4443", + "--env", + "SE_NODE_ENABLE_MANAGED_DOWNLOADS=true", + "--env", + "SE_NODE_MAX_SESSIONS=1", + "--pull", + "never", + node_image, + ), + 60.0, + True, + False, + ) + + +def start_services( + *, + grid_node_image: str | None, + policy: RetryPolicy, + initial_jitter_seconds: float, + runner: CommandRunner = run_command, +) -> None: + """Pull, start, and verify the application and optional Selenium Grid.""" + images = [WEBSITE_IMAGE] + validated_node_image: str | None = None + if grid_node_image is not None: + validated_node_image = validate_image_reference(grid_node_image) + images.extend((SELENIUM_HUB_IMAGE, validated_node_image)) + + pull_images( + images, + policy=policy, + initial_jitter_seconds=initial_jitter_seconds, + runner=runner, + ) + _remove_resources(runner) + + try: + _ensure_network(runner) + _start_website(runner) + if validated_node_image is not None: + _start_grid(validated_node_image, runner) + wait_for_readiness("Test application", website_is_ready) + if validated_node_image is not None: + wait_for_readiness("Selenium Grid", grid_is_ready) + except BaseException: + _print_diagnostics(runner) + _remove_resources(runner) + raise + + +def _retry_policy_from_args(args: argparse.Namespace) -> RetryPolicy: + return RetryPolicy( + attempts=args.pull_attempts, + timeout_seconds=args.pull_timeout_seconds, + ) + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line parser.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pull-attempts", type=int, default=4) + parser.add_argument("--pull-timeout-seconds", type=float, default=90.0) + parser.add_argument("--initial-jitter-seconds", type=float, default=0.0) + subparsers = parser.add_subparsers(dest="command", required=True) + + pull_parser = subparsers.add_parser("pull", help="Pull images with retries") + pull_parser.add_argument("images", nargs="+") + + subparsers.add_parser("start-website", help="Start the test application") + grid_parser = subparsers.add_parser("start-grid", help="Start the app and Grid") + grid_parser.add_argument("--node-image", required=True) + subparsers.add_parser("cleanup", help="Remove CI containers and network") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the requested CI container operation.""" + args = build_parser().parse_args(argv) + policy = _retry_policy_from_args(args) + + if args.command == "pull": + pull_images( + args.images, + policy=policy, + initial_jitter_seconds=args.initial_jitter_seconds, + ) + elif args.command == "start-website": + start_services( + grid_node_image=None, + policy=policy, + initial_jitter_seconds=args.initial_jitter_seconds, + ) + elif args.command == "start-grid": + start_services( + grid_node_image=args.node_image, + policy=policy, + initial_jitter_seconds=args.initial_jitter_seconds, + ) + else: + _remove_resources(run_command) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_ci_containers.py b/tools/tests/test_ci_containers.py new file mode 100644 index 0000000..f14bef6 --- /dev/null +++ b/tools/tests/test_ci_containers.py @@ -0,0 +1,160 @@ +"""Tests for the bounded CI container bootstrap helper.""" + +from __future__ import annotations + +import subprocess +import unittest +from collections.abc import Sequence + +from tools import ci_containers + + +class RecordingRunner: + """Record commands and return configured results.""" + + def __init__(self, failures: int = 0) -> None: + self.commands: list[tuple[str, ...]] = [] + self.failures = failures + + def __call__( + self, + command: Sequence[str], + timeout_seconds: float | None, + check: bool, + quiet: bool, + ) -> subprocess.CompletedProcess[str]: + del timeout_seconds, check, quiet + recorded = tuple(command) + self.commands.append(recorded) + if self.failures: + self.failures -= 1 + raise subprocess.CalledProcessError(1, recorded) + return subprocess.CompletedProcess(recorded, 0) + + +class RetryPolicyTest(unittest.TestCase): + """Validate retry-policy contracts.""" + + def test_rejects_invalid_attempt_count(self) -> None: + with self.assertRaisesRegex(ValueError, "attempts"): + ci_containers.RetryPolicy(attempts=0) + + def test_rejects_non_positive_timeout(self) -> None: + with self.assertRaisesRegex(ValueError, "timeout_seconds"): + ci_containers.RetryPolicy(timeout_seconds=0) + + +class ImageReferenceTest(unittest.TestCase): + """Validate untrusted matrix image references.""" + + def test_accepts_digest_pinned_reference(self) -> None: + image = "registry.example/image:v1@sha256:" + ("a" * 64) + self.assertEqual(ci_containers.validate_image_reference(image), image) + + def test_rejects_whitespace(self) -> None: + with self.assertRaisesRegex(ValueError, "Docker-reference"): + ci_containers.validate_image_reference("image:latest\n--privileged") + + def test_rejects_empty_reference(self) -> None: + with self.assertRaisesRegex(ValueError, "1 to 512"): + ci_containers.validate_image_reference("") + + def test_rejects_option_prefix(self) -> None: + with self.assertRaisesRegex(ValueError, "option prefix"): + ci_containers.validate_image_reference("--platform=host") + + +class PullImageTest(unittest.TestCase): + """Verify bounded retry behavior.""" + + def test_retries_then_succeeds(self) -> None: + runner = RecordingRunner(failures=2) + delays: list[float] = [] + + ci_containers.pull_image( + "example/image:v1", + policy=ci_containers.RetryPolicy(attempts=3), + runner=runner, + sleeper=delays.append, + randomizer=lambda _minimum, maximum: maximum, + ) + + self.assertEqual(len(runner.commands), 3) + self.assertEqual(delays, [5.0, 10.0]) + + def test_surfaces_exhausted_retries(self) -> None: + runner = RecordingRunner(failures=2) + + with self.assertRaisesRegex( + ci_containers.ContainerStartupError, "after 2 attempts" + ): + ci_containers.pull_image( + "example/image:v1", + policy=ci_containers.RetryPolicy(attempts=2), + runner=runner, + sleeper=lambda _delay: None, + ) + + self.assertEqual(len(runner.commands), 2) + + def test_deduplicates_images(self) -> None: + runner = RecordingRunner() + + ci_containers.pull_images( + ("example/image:v1", "example/image:v1"), + policy=ci_containers.RetryPolicy(), + runner=runner, + ) + + self.assertEqual(runner.commands, [("docker", "pull", "example/image:v1")]) + + def test_applies_bounded_initial_jitter(self) -> None: + runner = RecordingRunner() + delays: list[float] = [] + + ci_containers.pull_images( + ("example/image:v1",), + policy=ci_containers.RetryPolicy(), + initial_jitter_seconds=20, + runner=runner, + sleeper=delays.append, + randomizer=lambda _minimum, maximum: maximum / 2, + ) + + self.assertEqual(delays, [10.0]) + + +class ReadinessTest(unittest.TestCase): + """Verify readiness polling contracts.""" + + def test_retries_until_ready(self) -> None: + outcomes = iter((False, False, True)) + clock = iter((0.0, 0.0, 1.0, 2.0)) + delays: list[float] = [] + + ci_containers.wait_for_readiness( + "service", + lambda: next(outcomes), + timeout_seconds=10, + interval_seconds=1, + monotonic=lambda: next(clock), + sleeper=delays.append, + ) + + self.assertEqual(delays, [1, 1]) + + def test_times_out_with_actionable_error(self) -> None: + clock = iter((0.0, 0.0, 2.0)) + + with self.assertRaisesRegex(ci_containers.ContainerStartupError, "not ready"): + ci_containers.wait_for_readiness( + "service", + lambda: False, + timeout_seconds=1, + monotonic=lambda: next(clock), + sleeper=lambda _delay: None, + ) + + +if __name__ == "__main__": + unittest.main()