-
Notifications
You must be signed in to change notification settings - Fork 32
683 lines (628 loc) · 30.1 KB
/
Copy pathtests.yml
File metadata and controls
683 lines (628 loc) · 30.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
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"