fix(i18n): resolve ISO 639-2/B language codes (ger, fre, dut) instead of showing "Unknown" #2411
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Test Suite | |
| on: | |
| push: | |
| # Only fire on direct pushes to main/dev (auto-merge to main, hotfixes). | |
| # PR branches get CI via the `pull_request:` trigger below — listing both | |
| # `branches: ['**']` here AND `pull_request:` causes dual-trigger CI runs | |
| # on the same SHA, which doubles the surface for the xdist worker-IPC | |
| # hang documented in notes/xdist-worker-ipc-hang-followup-2026-05-21.md. | |
| branches: [main, dev] | |
| # Version tags run the SPA e2e release gate (the e2e-tests job pulls the | |
| # tag's GHCR image once it publishes). Without this, the job's tag | |
| # condition is dead code — `branches:` alone filters out tag events. | |
| tags: ['v*'] | |
| pull_request: | |
| branches: [main, dev] # Run Job 1 on PRs to main/dev | |
| workflow_dispatch: # Manual trigger button in Actions tab | |
| inputs: | |
| run_integration: | |
| description: 'Run integration tests' | |
| required: false | |
| type: boolean | |
| default: false | |
| run_e2e: | |
| description: 'Run E2E tests' | |
| required: false | |
| type: boolean | |
| default: false | |
| # Cancel in-progress runs when new commit pushed | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # ============================================================================ | |
| # JOB 1: Fast Tests (Smoke + Unit) | |
| # Runs on: Every push, every PR | |
| # Duration: ~2 minutes | |
| # Purpose: Catch basic errors immediately | |
| # ============================================================================ | |
| fast-tests: | |
| name: Fast Tests (Smoke + Unit) | |
| runs-on: ubuntu-latest | |
| env: | |
| PYTHONDONTWRITEBYTECODE: '1' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Remove Python bytecode and pycache | |
| run: | | |
| echo "Removing any leftover .pyc and pycache" | |
| find . -name '__pycache__' -type d -print -exec rm -rf {} + | |
| find . -name '*.pyc' -type f -print -delete | |
| - name: Set up Python 3.13 | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.13' | |
| cache: 'pip' | |
| - name: Install system dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev gettext | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r requirements.txt | |
| pip install -r requirements-dev.txt | |
| - name: Create test environment structure | |
| run: | | |
| sudo mkdir -p /config /books/import /books/ingest /config/processed_books | |
| sudo chmod -R 777 /config /books | |
| touch /config/epub-fixer.log | |
| touch /config/converter.log | |
| touch /config/cwa.db | |
| - name: Run smoke and unit tests | |
| timeout-minutes: 10 # Last-resort kill; --timeout below is the real gate. | |
| env: | |
| PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/scripts | |
| run: | | |
| # --timeout=120 + --timeout-method=thread converts hung tests into named | |
| # test failures instead of opaque step-level 10-min step timeouts. Without | |
| # this, a stuck test on one xdist worker only manifests as a step timeout | |
| # with no indication of which test or worker stalled. | |
| # | |
| # --dist=loadfile pins all tests in a file to a single worker. This | |
| # eliminates the work-stealing race that's been hanging fast-tests | |
| # runs (one of two parallel triggers stalls at ~9 minutes with no | |
| # currently-executing test for pytest-timeout to interrupt). See | |
| # notes/xdist-worker-ipc-hang-followup-2026-05-21.md for diagnostic | |
| # log signature + hypothesis. Fewer worker handoffs ≈ fewer races. | |
| # | |
| # faulthandler_timeout (INI option, set via -o — there is no CLI | |
| # flag): last line of defense for whole-process freezes (e.g. the | |
| # GIL↔sqlite-mutex AB-BA deadlock, see | |
| # notes/fix-udf-gil-deadlock-DESIGN.md). faulthandler's watchdog | |
| # writes every thread's stack WITHOUT needing the GIL, so a freeze | |
| # that starves pytest-timeout still produces named stacks in the | |
| # log instead of a silent 10-minute step wall. 90 < the 120s | |
| # pytest-timeout on purpose: a hung test logs stacks first, then | |
| # pytest-timeout attempts the interrupt. | |
| pytest -m "smoke or unit" \ | |
| -n auto \ | |
| --dist=loadfile \ | |
| --maxfail=3 \ | |
| -v \ | |
| --tb=short \ | |
| --timeout=120 \ | |
| --timeout-method=thread \ | |
| -o faulthandler_timeout=90 \ | |
| --cov=cps \ | |
| --cov-report=xml \ | |
| --cov-report=term-missing | |
| - name: Upload coverage to Codecov | |
| uses: codecov/codecov-action@v7 | |
| if: always() | |
| with: | |
| files: ./coverage.xml | |
| flags: unittests | |
| name: fast-tests | |
| fail_ci_if_error: false | |
| - name: Comment PR with results | |
| uses: actions/github-script@v9 | |
| if: github.event_name == 'pull_request' && failure() | |
| with: | |
| script: | | |
| github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body: '⚠️ **Fast tests failed!** Please check the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) and fix before merging.' | |
| }) | |
| # ============================================================================ | |
| # JOB 1b: Frontend Build (SPA bundle) | |
| # Runs on: Every push, every PR | |
| # Duration: ~1 minute | |
| # Purpose: Fast TypeScript + Vite build feedback for the React SPA, so a | |
| # broken bundle surfaces immediately instead of only inside the ~15-min | |
| # Docker integration build. Mirrors the Dockerfile `frontend-build` stage. | |
| # ============================================================================ | |
| frontend-build: | |
| name: Frontend Build (SPA bundle) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Set up Node.js 22 | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| cache: 'npm' | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Install dependencies (npm ci) | |
| working-directory: frontend | |
| run: npm ci | |
| - name: Build SPA (tsc -b && vite build) | |
| working-directory: frontend | |
| run: npm run build | |
| - name: Assert bundle emitted to cps/static/app | |
| run: | | |
| test -f cps/static/app/index.html || { echo "::error::SPA bundle missing index.html"; exit 1; } | |
| test -d cps/static/app/assets || { echo "::error::SPA bundle missing assets/"; exit 1; } | |
| echo "✅ SPA bundle present at cps/static/app" | |
| # ============================================================================ | |
| # JOB 2: Integration Tests (Docker) | |
| # Runs on: Merge to main/dev, manual trigger | |
| # Duration: ~15-20 minutes | |
| # Purpose: Validate real-world behavior in Docker container | |
| # ============================================================================ | |
| integration-tests: | |
| name: Integration Tests (Docker) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: read # pull the private pbs-cache mirror images the Dockerfile COPYs from | |
| # Tier-2 auto-merge requires Integration Tests as a hard gate — small | |
| # code changes still need to clear the actual ingest/Calibre flow. | |
| # We keep the broad-PR exemption: tier-1 (.po / *.md) PRs and PRs | |
| # without a tier label don't pay the 15-minute integration cost. | |
| # On main / dev pushes the job stays advisory so flaky-network failures | |
| # don't block downstream auto-revert decisions. On tier-2 PRs the job | |
| # is mandatory. | |
| continue-on-error: ${{ !contains(github.event.pull_request.labels.*.name, 'safe-tier-2') }} | |
| # Run on: | |
| # - merge to main / dev (advisory) | |
| # - manual dispatch with flag | |
| # - any PR labeled safe-tier-2 (gating) | |
| if: | | |
| github.ref == 'refs/heads/main' || | |
| github.ref == 'refs/heads/dev' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_integration) || | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'safe-tier-2')) | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Set up Python 3.13 | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.13' | |
| cache: 'pip' | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Log in to GHCR (pbs-cache mirror pull) | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| # GH_PAT (read:packages) so BuildKit can COPY --from our private | |
| # pbs-cache mirror; falls back to GITHUB_TOKEN where the PAT | |
| # secret isn't in scope. | |
| password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| - name: Install system dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev gettext | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r requirements.txt | |
| pip install -r requirements-dev.txt | |
| - name: Build Docker image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| push: false | |
| load: true | |
| tags: crocodilestick/calibre-web-automated:latest | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| # PBS_SOURCE=ghcr pulls Python/kepubify from our GHCR mirror rather | |
| # than the release CDN, which intermittently 404s the Actions egress. | |
| # Contributors build without it and fall back to the CDN (see Dockerfile). | |
| build-args: | | |
| PBS_SOURCE=ghcr | |
| - name: Set test UID/GID | |
| run: | | |
| echo "CWA_TEST_PUID=$(id -u)" >> $GITHUB_ENV | |
| echo "CWA_TEST_PGID=$(id -g)" >> $GITHUB_ENV | |
| - name: Run Docker integration tests | |
| run: | | |
| # CRITICAL: No -n flag! Docker tests must run sequentially | |
| pytest tests/docker/ tests/integration/ \ | |
| -v \ | |
| --tb=long \ | |
| --durations=10 \ | |
| --junitxml=integration-results.xml | |
| env: | |
| CWA_TEST_PORT: "8083" # CI uses production default port | |
| CWA_TEST_IMAGE: "crocodilestick/calibre-web-automated:latest" | |
| timeout-minutes: 30 # Kill if stuck | |
| - name: Upload test results | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: integration-test-results | |
| path: | | |
| integration-results.xml | |
| tests/logs/ | |
| tests/tmp/ | |
| retention-days: 7 | |
| - name: Notify on failure | |
| uses: sarisia/actions-status-discord@v1 | |
| if: failure() && env.DISCORD_WEBHOOK != '' | |
| env: | |
| DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} | |
| with: | |
| webhook: ${{ secrets.DISCORD_WEBHOOK }} | |
| title: "❌ Integration tests failed on ${{ github.ref_name }}" | |
| description: | | |
| **Commit**: ${{ github.sha }} | |
| **Author**: ${{ github.actor }} | |
| **Branch**: ${{ github.ref_name }} | |
| [View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) | |
| color: 0xFF0000 | |
| username: GitHub Actions | |
| # ============================================================================ | |
| # JOB 2b: Which parts of the tree did this PR touch? | |
| # Runs on: pull requests only. Duration: ~20s. | |
| # Purpose: let the SPA e2e job gate frontend PRs without charging a .po-only | |
| # or docs-only PR half an hour for a suite that cannot tell it anything. | |
| # Deliberately plain checkout + git rather than a paths-filter action — | |
| # this repo does not take new third-party dependencies for convenience. | |
| # ============================================================================ | |
| changed_paths: | |
| name: Detect changed paths | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| timeout-minutes: 5 | |
| outputs: | |
| frontend: ${{ steps.detect.outputs.frontend }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Detect | |
| id: detect | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| # Three-dot: what this branch changed relative to the merge base, so a | |
| # busy main does not make every PR look like it touched everything. | |
| files="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}" || git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")" | |
| echo "$files" | |
| if echo "$files" | grep -qE '^(frontend/|cps/static/app/)'; then | |
| echo "frontend=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "frontend=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # ============================================================================ | |
| # JOB 3: E2E Tests (Full Stack) | |
| # Runs on: Manual trigger, all release tags (v3.2.0, v3.2.0-rc1, etc.) | |
| # Duration: ~30-45 minutes | |
| # Purpose: Validate complete user workflows | |
| # NOTE: Tests run AFTER tag creation. Check results before publishing release! | |
| # ============================================================================ | |
| e2e-tests: | |
| name: E2E Tests (SPA) | |
| runs-on: ubuntu-latest | |
| # `always()` matters: changed_paths only runs on PRs, and without it a | |
| # skipped dependency would drag the tag and dispatch runs down with it — | |
| # silently turning the release gate off, which is the exact class of | |
| # failure this whole change exists to remove. | |
| needs: [changed_paths] | |
| # Release gate + manual dispatch. The SPA e2e matrix (Layer 2 of the | |
| # verification system, frontend/e2e/) is the "earn the release" gate; | |
| # /CWNG_verify runs the same harness on-demand in dev. Tag runs test the | |
| # tag's own GHCR image (requires `on.push.tags` above — a `branches:`-only | |
| # push trigger never delivers tag events). The dev-branch condition is | |
| # forward wiring: no dev branch exists today, so per-merge canary coverage | |
| # is a follow-up (workflow_run after the :dev image build). | |
| # ADVISORY: a failure warns in test-summary + alerts Discord on tag runs, | |
| # but does not block. Promote to a hard gate once several tag runs are green. | |
| # | |
| # PRs touching frontend/** now run too (#953). They cannot be handled the | |
| # same way: the resolver below tests a PUBLISHED image, and a pull request | |
| # has none. Running the suite against :dev on a PR would test main's build | |
| # regardless of what the PR did to the SPA — green either way, and worse | |
| # than skipping because it would look like a gate. | |
| # | |
| # So PR runs take the API from :dev and overlay the PR's OWN SPA bundle | |
| # into the container. The specs here assert frontend behaviour and the SPA | |
| # is a static bundle under cps/static/app, so this gates exactly what they | |
| # cover, with no from-source image build (the resolver comment explains why | |
| # that path is avoided). Limit, stated rather than glossed: it will not | |
| # catch a regression that needs the PR's BACKEND changes. It is an SPA | |
| # gate, not a full-stack one — and the alternative today is no gate at all. | |
| if: | | |
| always() && ( | |
| startsWith(github.ref, 'refs/tags/v') || | |
| github.ref == 'refs/heads/dev' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_e2e) || | |
| (github.event_name == 'pull_request' && needs.changed_paths.outputs.frontend == 'true') | |
| ) | |
| # Tag runs may wait up to ~40 min for the GHCR release build to publish | |
| # the image (see the pull loop below) before the ~15 min harness run. | |
| # Advisory on PRs until several runs are green, matching the ADVISORY | |
| # stance above. Promote to a hard gate once it has earned it. | |
| continue-on-error: ${{ github.event_name == 'pull_request' }} | |
| timeout-minutes: 75 | |
| permissions: | |
| contents: read | |
| packages: read # pull the private pbs-cache mirror images the Dockerfile COPYs from | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| cache: 'npm' | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Log in to GHCR (to pull the app image) | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve image to test | |
| id: img | |
| run: | | |
| # Test the image users actually get (Class 2/4: verify the shipped | |
| # artifact, not a locally-rebuilt one). Version tag → that tag's image; | |
| # otherwise → the :dev canary. Avoids the from-source build, which | |
| # cold-pulls the private pbs-cache mirror and is fragile in CI. | |
| if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then | |
| echo "image=ghcr.io/new-usemame/calibre-web-nextgen:${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" | |
| else | |
| # PRs land here too: :dev supplies the API, and the overlay step | |
| # below replaces its SPA with the one built from this PR (#953). | |
| echo "image=ghcr.io/new-usemame/calibre-web-nextgen:dev" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Start container (SPA enabled) | |
| env: | |
| IMAGE: ${{ steps.img.outputs.image }} | |
| run: | | |
| echo "Testing image: $IMAGE" | |
| # On tag pushes this job races the GHCR release build, which publishes | |
| # the tag's image 15–35 min after the tag lands (v4.0.169 lesson) — | |
| # poll instead of failing on the first pull. :dev / dispatch runs hit | |
| # an already-published image and pull on the first attempt. | |
| for i in $(seq 1 80); do | |
| docker pull "$IMAGE" && break | |
| echo "image not pullable yet (attempt $i/80); retrying in 30s..." | |
| sleep 30 | |
| done | |
| if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then | |
| echo "::error::$IMAGE never became pullable — check the GHCR build for this ref" | |
| exit 1 | |
| fi | |
| mkdir -p /tmp/cwn/config /tmp/cwn/ingest | |
| docker run -d --name cwn-e2e \ | |
| -e PUID=1000 -e PGID=1000 -e TZ=UTC -e CWNG_SPA=1 \ | |
| -p 8083:8083 \ | |
| -v /tmp/cwn/config:/config \ | |
| -v /tmp/cwn/ingest:/cwa-book-ingest \ | |
| "$IMAGE" | |
| echo "Waiting for the app to answer..." | |
| timeout 240 bash -c 'until curl -fsS http://localhost:8083/ -o /dev/null; do sleep 5; done' | |
| - name: Seed books and wait for ingest | |
| run: | | |
| # Drop public-domain EPUBs into the ingest folder; auto-ingest imports | |
| # them. The container's init chowns the bind-mounted /cwa-book-ingest | |
| # to uid 1000, so the runner can't write to it from the host. Use | |
| # `docker cp` (root inside the container) and hand each file to uid | |
| # 1000, which the ingest service needs to process and then remove it. | |
| # | |
| # THREE books, not one. A single-book library silently failed 24 specs | |
| # on every run of this job — "the catalog fixture needs at least two | |
| # books" — and because the job is advisory, nobody ever read it. A | |
| # release gate that cannot tell a real failure from a fixture that was | |
| # never big enough is not a gate. pride_and_prejudice.epub is left out | |
| # deliberately: at 24 MB it dominates ingest time for no extra | |
| # coverage. | |
| for book in alice_in_wonderland christmas_carol metamorphosis; do | |
| docker cp "tests/fixtures/sample_books/${book}.epub" \ | |
| "cwn-e2e:/cwa-book-ingest/${book}.epub" | |
| docker exec -u 0 cwn-e2e chown 1000:1000 "/cwa-book-ingest/${book}.epub" | |
| done | |
| echo "Polling for the imported book via the API..." | |
| jar=$(mktemp); total=0 | |
| for i in $(seq 1 60); do | |
| csrf=$(curl -s -c "$jar" http://localhost:8083/api/v1/auth/csrf | python3 -c 'import sys,json;print(json.load(sys.stdin)["csrf_token"])' 2>/dev/null || true) | |
| [ -n "$csrf" ] && curl -s -b "$jar" -c "$jar" -X POST http://localhost:8083/api/v1/auth/login \ | |
| -H 'Content-Type: application/json' -H "X-CSRFToken: $csrf" \ | |
| -d '{"username":"admin","password":"admin123"}' -o /dev/null || true | |
| total=$(curl -s -b "$jar" 'http://localhost:8083/api/v1/books?limit=1' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("total",0))' 2>/dev/null || echo 0) | |
| echo " attempt $i: books total=$total" | |
| # Wait for ALL of them: breaking at the first import is what left | |
| # the library at one book while the step reported success. | |
| [ "${total:-0}" -ge 3 ] && break | |
| sleep 5 | |
| done | |
| if [ "${total:-0}" -lt 3 ]; then | |
| echo "::error::ingest imported only ${total:-0} of 3 seed books — dumping ingest logs" | |
| docker logs cwn-e2e 2>&1 | grep -iE 'ingest|convert|import|error|watch' | tail -40 || true | |
| exit 1 | |
| fi | |
| - name: Seed shelves and series | |
| run: | | |
| # The library the harness gets had three books and NO shelves, so | |
| # every spec asserting shelf grouping failed on the fixture rather | |
| # than on anything real (#1130). Reuse the session the ingest poll | |
| # above already logged in with. | |
| # | |
| # Verification fixture only: these shelves exist so the sidebar has | |
| # something to group. Nothing here changes product behaviour, and the | |
| # e2e job stays advisory on PRs. | |
| jar=$(mktemp) | |
| csrf=$(curl -s -c "$jar" http://localhost:8083/api/v1/auth/csrf \ | |
| | python3 -c 'import sys,json;print(json.load(sys.stdin)["csrf_token"])') | |
| curl -s -b "$jar" -c "$jar" -X POST http://localhost:8083/api/v1/auth/login \ | |
| -H 'Content-Type: application/json' -H "X-CSRFToken: $csrf" \ | |
| -d '{"username":"admin","password":"admin123"}' -o /dev/null | |
| csrf=$(curl -s -b "$jar" -c "$jar" http://localhost:8083/api/v1/auth/csrf \ | |
| | python3 -c 'import sys,json;print(json.load(sys.stdin)["csrf_token"])') | |
| for shelf in "Reading now" "To read"; do | |
| code=$(curl -s -o /tmp/shelf.json -w '%{http_code}' \ | |
| -b "$jar" -c "$jar" -X POST http://localhost:8083/api/v1/shelves \ | |
| -H 'Content-Type: application/json' -H "X-CSRFToken: $csrf" \ | |
| -d "{\"name\": \"$shelf\"}") | |
| echo " create shelf '$shelf' -> $code" | |
| # 409 is fine (already there); anything else is a broken fixture and | |
| # should fail loudly rather than resurface as confusing spec failures. | |
| if [ "$code" != "201" ] && [ "$code" != "409" ]; then | |
| echo "::error::could not create shelf '$shelf' (HTTP $code)"; cat /tmp/shelf.json; exit 1 | |
| fi | |
| done | |
| total=$(curl -s -b "$jar" http://localhost:8083/api/v1/shelves \ | |
| | python3 -c 'import sys,json;d=json.load(sys.stdin);print(len(d.get("items",d if isinstance(d,list) else [])))' 2>/dev/null || echo 0) | |
| echo "shelves now: $total" | |
| [ "${total:-0}" -ge 1 ] || { echo "::error::no shelves after seeding"; exit 1; } | |
| # Give two books a series. Without one, no book CARD renders a series | |
| # line — and the card series line is where #1135's contrast failure | |
| # lives, so the a11y gate could only catch that class of bug by luck | |
| # of fixture. A gate that depends on the seed happening to contain the | |
| # right shape of data is not a gate. | |
| csrf=$(curl -s -b "$jar" -c "$jar" http://localhost:8083/api/v1/auth/csrf \ | |
| | python3 -c 'import sys,json;print(json.load(sys.stdin)["csrf_token"])') | |
| ids=$(curl -s -b "$jar" 'http://localhost:8083/api/v1/books?per_page=2' \ | |
| | python3 -c 'import sys,json;print(" ".join(str(b["id"]) for b in json.load(sys.stdin)["items"]))') | |
| idx=1 | |
| for bid in $ids; do | |
| code=$(curl -s -o /tmp/md.json -w '%{http_code}' \ | |
| -b "$jar" -c "$jar" -X POST "http://localhost:8083/api/v1/books/$bid/metadata" \ | |
| -H 'Content-Type: application/json' -H "X-CSRFToken: $csrf" \ | |
| -d "{\"series\": \"E2E Fixture Series\", \"series_index\": $idx}") | |
| echo " book $bid series -> $code" | |
| if [ "$code" != "200" ]; then | |
| echo "::error::could not set series on book $bid (HTTP $code)"; cat /tmp/md.json; exit 1 | |
| fi | |
| idx=$((idx + 1)) | |
| done | |
| series_count=$(curl -s -b "$jar" http://localhost:8083/api/v1/series \ | |
| | python3 -c 'import sys,json;d=json.load(sys.stdin);print(len(d.get("items",[])))' 2>/dev/null || echo 0) | |
| echo "series now: $series_count" | |
| [ "${series_count:-0}" -ge 1 ] || { echo "::error::no series after seeding"; exit 1; } | |
| - name: Install Playwright | |
| working-directory: frontend | |
| run: | | |
| npm ci | |
| npx playwright install --with-deps chromium | |
| # The whole point of running on a PR: without this the container serves | |
| # main's SPA and the suite says nothing about the change under review. | |
| - name: Overlay this PR's SPA bundle into the container | |
| if: github.event_name == 'pull_request' | |
| working-directory: frontend | |
| run: | | |
| npm ci | |
| npm run build # writes ../cps/static/app | |
| docker cp ../cps/static/app/. cwn-e2e:/app/calibre-web-automated/cps/static/app/ | |
| # The bundle is static; no restart needed. Confirm the shell still | |
| # answers so a broken overlay fails here rather than as 30 confusing | |
| # spec failures. | |
| curl -fsS http://localhost:8083/app/ -o /dev/null | |
| echo "PR bundle in place" | |
| - name: Run SPA e2e harness | |
| working-directory: frontend | |
| env: | |
| CI: 'true' | |
| E2E_BASE_URL: http://localhost:8083 | |
| run: npm run test:e2e | |
| - name: Upload Playwright report | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: playwright-report | |
| path: | | |
| frontend/e2e/.report | |
| frontend/e2e/.results | |
| retention-days: 14 | |
| - name: Dump container logs | |
| if: always() | |
| run: docker logs cwn-e2e > cwn-e2e-logs.txt 2>&1 || true | |
| - name: Upload container logs | |
| uses: actions/upload-artifact@v7 | |
| if: always() | |
| with: | |
| name: cwn-e2e-logs | |
| path: cwn-e2e-logs.txt | |
| retention-days: 14 | |
| - name: Notify on failure | |
| uses: sarisia/actions-status-discord@v1 | |
| if: failure() && startsWith(github.ref, 'refs/tags/v') && env.DISCORD_WEBHOOK != '' | |
| env: | |
| DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} | |
| with: | |
| webhook: ${{ secrets.DISCORD_WEBHOOK }} | |
| title: "⛔ SPA e2e failed for ${{ github.ref_name }}" | |
| description: | | |
| **DO NOT RELEASE** until fixed! | |
| **Tag**: ${{ github.ref_name }} · **Commit**: ${{ github.sha }} | |
| [View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) | |
| color: 0xFF0000 | |
| username: GitHub Actions | |
| - name: Cleanup | |
| if: always() | |
| run: docker rm -f cwn-e2e || true | |
| # ============================================================================ | |
| # Summary Job (Optional) | |
| # Shows overall status in GitHub UI | |
| # ============================================================================ | |
| test-summary: | |
| name: Test Suite Summary | |
| runs-on: ubuntu-latest | |
| needs: [fast-tests, frontend-build, integration-tests, e2e-tests] | |
| if: always() | |
| # The unified gate that branch protection requires. auto-merge.yml | |
| # leans on this job being strict-for-tier-2 instead of polling | |
| # Integration Tests itself: when this summary is green, GitHub | |
| # auto-merge fires. | |
| steps: | |
| - name: Check test results | |
| env: | |
| # contains() evaluates to a 'true' / 'false' string; we pass | |
| # it through env to keep the shell predicate readable. | |
| IS_TIER2_PR: ${{ contains(github.event.pull_request.labels.*.name, 'safe-tier-2') }} | |
| run: | | |
| echo "Fast Tests: ${{ needs.fast-tests.result }}" | |
| echo "Frontend Build: ${{ needs.frontend-build.result }}" | |
| echo "Integration Tests: ${{ needs.integration-tests.result }}" | |
| echo "E2E Tests: ${{ needs.e2e-tests.result }}" | |
| echo "Is tier-2 PR: $IS_TIER2_PR" | |
| if [[ "${{ needs.fast-tests.result }}" == "failure" ]]; then | |
| echo "❌ Fast tests failed" | |
| exit 1 | |
| fi | |
| # The SPA bundle is a hard gate: a broken TypeScript/Vite build ships | |
| # an empty/broken frontend, so it blocks the merge like fast-tests. | |
| if [[ "${{ needs.frontend-build.result }}" == "failure" ]]; then | |
| echo "❌ Frontend build failed" | |
| exit 1 | |
| fi | |
| # Integration Tests is a hard gate for safe-tier-2 PRs (the | |
| # whole point of the tier — small code change must clear the | |
| # actual ingest / Calibre flow). For tier-1 PRs and pushes to | |
| # main/dev the suite is advisory (continue-on-error in the | |
| # job definition keeps it from blocking auto-revert decisions). | |
| if [[ "${{ needs.integration-tests.result }}" == "failure" ]]; then | |
| if [[ "$IS_TIER2_PR" == "true" ]]; then | |
| echo "❌ Integration tests failed on safe-tier-2 PR — auto-merge gate" | |
| exit 1 | |
| fi | |
| echo "⚠️ Integration tests failed (advisory; not a tier-2 PR)" | |
| fi | |
| if [[ "${{ needs.e2e-tests.result }}" == "failure" ]]; then | |
| echo "⛔ E2E tests failed - do not release!" | |
| # Don't fail the summary, just warn. | |
| fi | |
| echo "✅ Test suite completed" |