Skip to content

Unit Tests

Unit Tests #1652

Workflow file for this run

name: Unit Tests
on:
push:
branches: [ main, develop ]
paths:
- 'src/**'
- 'tests/**'
- 'pyproject.toml'
- 'requirements*.txt'
- 'uv.lock'
- 'assets/**'
- 'rxconfig.py'
- '.github/workflows/ci-tests.yml'
pull_request:
branches: [ main, develop ]
paths:
- 'src/**'
- 'tests/**'
- 'pyproject.toml'
- 'requirements*.txt'
- 'uv.lock'
- 'assets/**'
- 'rxconfig.py'
- '.github/workflows/ci-tests.yml'
schedule:
- cron: '0 2 * * *'
# workflow_dispatch: allows re-running tests manually from the Actions UI
workflow_dispatch:
# Cancel in-progress runs when a new push arrives on the same branch/ref.
# Scheduled nightly runs use a stable group key so they never cancel each
# other (there is only ever one running at a time by schedule).
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
config:
name: Configuration
uses: ./.github/workflows/shared-config.yml
setup-cache:
name: Setup Test Cache
needs: config
uses: ./.github/workflows/cache-management.yml
with:
cache-type: dependencies
cache-key-base: test-deps
python-version: ${{ needs.config.outputs.default-python-version }}
# Parallel test-group matrix (T2613): splits the non-provider test corpus
# into independent legs that each run on their own runner with xdist within
# the leg. Legs: unit (tests/unit), infrastructure (tests/infrastructure),
# integration (tests/integration). Provider legs are handled by the
# providers-tests matrix below and remain driven by discovery.
#
# The standalone `tests`, `integration-tests`, and `infrastructure-tests` jobs
# that previously ran these same suites have been removed; test-groups is their
# replacement. artifact-prefix uses `ci-tests-group` (not `ci-tests`) so that
# actions/upload-artifact@v4 does not error on duplicate artifact names within
# the same run (each matrix leg's artifact is named `<prefix>-<test-type>-…`).
test-groups:
name: Test Groups (Parallel)
needs: [config, setup-cache]
strategy:
fail-fast: false
matrix:
test-group: [unit, infrastructure, integration]
uses: ./.github/workflows/reusable-test.yml
secrets: inherit
with:
test-type: ${{ matrix.test-group }}
python-version: ${{ needs.config.outputs.default-python-version }}
default-python-version: ${{ needs.config.outputs.default-python-version }}
continue-on-error: false
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
artifact-prefix: ci-tests-group
aws-region: ${{ needs.config.outputs.aws-region }}
aws-access-key: ${{ needs.config.outputs.aws-access-key }}
aws-secret-key: ${{ needs.config.outputs.aws-secret-key }}
environment: ${{ needs.config.outputs.environment }}
testing-flag: ${{ needs.config.outputs.testing-flag }}
# e2e-tests run against an in-process TestClient app — no live infrastructure
# required — so they gate like every other leg. (They previously ran with
# continue-on-error: true, which silently masked 23 genuine failures.)
e2e-tests:
name: End-to-End Tests (Default Python)
needs: [config, setup-cache]
uses: ./.github/workflows/reusable-test.yml
secrets: inherit
with:
test-type: e2e
python-version: ${{ needs.config.outputs.default-python-version }}
default-python-version: ${{ needs.config.outputs.default-python-version }}
continue-on-error: false
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
artifact-prefix: ci-tests
aws-region: ${{ needs.config.outputs.aws-region }}
aws-access-key: ${{ needs.config.outputs.aws-access-key }}
aws-secret-key: ${{ needs.config.outputs.aws-secret-key }}
environment: ${{ needs.config.outputs.environment }}
testing-flag: ${{ needs.config.outputs.testing-flag }}
# Per-provider matrix. One job per discovered provider subtree under
# tests/providers/. Discovery runs in the shared-config job: any directory
# that has a tests/providers/<name>/testconf.mk becomes a matrix entry.
# Adding a new provider directory with testconf.mk requires no workflow edit.
providers-tests:
name: Providers Tests
needs: [config, setup-cache]
strategy:
fail-fast: false
matrix:
# Dynamic: fromJSON(needs.config.outputs.providers) is populated by the
# shared-config discover step. The static fallback ['aws'] keeps CI
# valid in forks or contexts where discovery cannot run.
provider: ${{ fromJSON(needs.config.outputs.providers || '["aws"]') }}
uses: ./.github/workflows/reusable-test.yml
secrets: inherit
with:
test-type: providers
provider: ${{ matrix.provider }}
python-version: ${{ needs.config.outputs.default-python-version }}
default-python-version: ${{ needs.config.outputs.default-python-version }}
continue-on-error: false
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
artifact-prefix: ci-tests
# Provider suites are the heaviest legs (aws moto ≈ 270s of CPU-bound
# mocking). Run them on a 16-vCPU runner with one xdist worker per core.
os: ${{ vars.LARGE_RUNNER || 'ubuntu-latest-16-cores' }}
workers: auto
aws-region: ${{ needs.config.outputs.aws-region }}
aws-access-key: ${{ needs.config.outputs.aws-access-key }}
aws-secret-key: ${{ needs.config.outputs.aws-secret-key }}
environment: ${{ needs.config.outputs.environment }}
testing-flag: ${{ needs.config.outputs.testing-flag }}
# Serial-marked provider tests (currently the live subtree of each provider).
# The make target points pytest directly at ``tests/providers/<provider>/live``
# because ``norecursedirs`` excludes that path from auto-recursion. Without
# ``--live`` the root conftest stamps every live test with ``skip_live``, so
# the job collects the suite, reports SKIPPED, and exits 0 — that proves the
# collection wiring + skip mechanism still work end-to-end on every PR.
# When CI is wired up with real credentials, set ``PYTEST_LIVE=--live``
# in the workflow env to flip the suite into actual execution.
providers-tests-serial:
name: Providers Tests (Serial)
needs: [config, setup-cache, providers-tests]
strategy:
fail-fast: false
matrix:
# Same dynamic discovery as providers-tests above.
provider: ${{ fromJSON(needs.config.outputs.providers || '["aws"]') }}
uses: ./.github/workflows/reusable-test.yml
secrets: inherit
with:
test-type: providers-serial
provider: ${{ matrix.provider }}
python-version: ${{ needs.config.outputs.default-python-version }}
default-python-version: ${{ needs.config.outputs.default-python-version }}
continue-on-error: false
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
artifact-prefix: ci-tests
aws-region: ${{ needs.config.outputs.aws-region }}
aws-access-key: ${{ needs.config.outputs.aws-access-key }}
aws-secret-key: ${{ needs.config.outputs.aws-secret-key }}
environment: ${{ needs.config.outputs.environment }}
testing-flag: ${{ needs.config.outputs.testing-flag }}
ui-unit-tests:
name: UI Unit Tests
needs: [config, setup-cache]
uses: ./.github/workflows/reusable-test.yml
secrets: inherit
with:
test-type: ui-unit
python-version: ${{ needs.config.outputs.default-python-version }}
default-python-version: ${{ needs.config.outputs.default-python-version }}
continue-on-error: false
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
artifact-prefix: ci-tests
aws-region: ${{ needs.config.outputs.aws-region }}
aws-access-key: ${{ needs.config.outputs.aws-access-key }}
aws-secret-key: ${{ needs.config.outputs.aws-secret-key }}
environment: ${{ needs.config.outputs.environment }}
testing-flag: ${{ needs.config.outputs.testing-flag }}
ui-smoke:
name: UI Smoke
needs: [config, setup-cache]
uses: ./.github/workflows/reusable-test.yml
secrets: inherit
with:
test-type: ui-smoke
python-version: ${{ needs.config.outputs.default-python-version }}
default-python-version: ${{ needs.config.outputs.default-python-version }}
continue-on-error: false
# ui-smoke runs run_ui_smoke.sh (no pytest) so no coverage XML is produced.
upload-coverage: false
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
artifact-prefix: ci-tests
aws-region: ${{ needs.config.outputs.aws-region }}
aws-access-key: ${{ needs.config.outputs.aws-access-key }}
aws-secret-key: ${{ needs.config.outputs.aws-secret-key }}
environment: ${{ needs.config.outputs.environment }}
testing-flag: ${{ needs.config.outputs.testing-flag }}
build-and-package:
name: Build & Package
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [config, setup-cache]
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup UV with cache
uses: ./.github/actions/setup-uv-cached
with:
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
fail-on-cache-miss: false
- name: Build package
# This job builds a wheel artifact for CI validation only and has no
# bun/uv to compile the SPA (it never shipped one). Skip the UI build.
env:
ORB_SKIP_UI_BUILD: "1"
run: make build
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-artifacts-${{ needs.config.outputs.package-version }}
retention-days: 60
path: |
dist/
build/
test-report:
name: Generate Test Report
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [config, setup-cache, test-groups, e2e-tests, providers-tests, providers-tests-serial, ui-unit-tests, ui-smoke]
if: always()
permissions:
contents: read
issues: read
checks: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup UV with cache
uses: ./.github/actions/setup-uv-cached
with:
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
fail-on-cache-miss: false
- name: Download test results
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: test-results/
- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2
if: always()
continue-on-error: true
with:
files: "test-results/**/junit-*.xml"
check_name: "Test Results Summary"
comment_mode: "always"
job_summary: true
action_fail: false
fail_on: "nothing"
# Self-contained combined-coverage gate: merges the per-leg .coverage data
# (uploaded by reusable-test) and enforces the threshold locally, so the hard
# gate does not depend on Codecov's external service. Codecov still receives
# each leg's upload for the PR-comment diff view.
coverage-combine:
name: Coverage Gate (Combined)
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [config, setup-cache, test-groups, e2e-tests, providers-tests, ui-unit-tests]
if: always()
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup UV with cache
uses: ./.github/actions/setup-uv-cached
with:
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
fail-on-cache-miss: false
- name: Download coverage artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: coverage-artifacts/
- name: Gather per-leg .coverage data files
run: |
# Each leg uploads a .coverage data file inside its test-results-* dir.
# coverage combine expects them in CWD with unique .coverage.<suffix> names.
i=0
find coverage-artifacts -name '.coverage*' -type f | while read -r f; do
cp "$f" ".coverage.leg${i}"
i=$((i + 1))
done
ls -la .coverage* 2>/dev/null || echo "no .coverage data files found"
- name: Combine coverage and enforce combined threshold
id: coverage-gate
run: make ci-tests-coverage-check
# Single deterministic Codecov upload: the combined report from ALL legs is
# sent once here, instead of each leg uploading separately. Codecov then
# shows one stable number (no mid-run partial-merge flapping) and this needs
# no hardcoded leg count — it auto-scales to any number of provider legs.
# if: always() so the report uploads even when the threshold gate fails
# above (the job still fails on the gate; Codecov still gets the data).
- name: Upload combined coverage to Codecov
if: always()
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage-combined.xml
name: combined
# Overwrite any prior partial report for this commit with the full merge.
override_commit: ${{ github.event.pull_request.head.sha || github.sha }}
# Single stable required status check for branch protection. Replaces listing
# every individual job name (which drifts silently when jobs are renamed).
# Register "CI Passed" as the sole required check in branch protection.
ci-passed:
name: CI Passed
runs-on: ubuntu-latest
timeout-minutes: 5
if: always()
needs:
- config
- setup-cache
- test-groups
- e2e-tests
- providers-tests
- providers-tests-serial
- ui-unit-tests
- ui-smoke
- build-and-package
- coverage-combine
- test-report
steps:
- name: Check all required jobs passed
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
with:
jobs: ${{ toJSON(needs) }}