#50 — Tech debt: parallelize the E2E CI job across Playwright projects #50
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#50
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tech debt: parallelize the E2E CI job across Playwright projects
Reported by: QA Agent (found during
#46code review)Priority: Could — no correctness impact, but a growing tax on every push/PR's feedback loop
Date: 2026-07-11
Problem
#46added two Playwright projects (Desktop HD,Mobile Chrome (Pixel 8a)) alongside theexisting
chromium/firefox. The.gitea/workflows/ci.ymle2ejob still runs all fourprojects through a single
npx playwright testinvocation on one runner, withworkers: isCI ? 1 : undefined(serialized — seeplaywright.config.ts's comment: heavymulti-context specs like
invitation.spec.ts/transfer-ownership.spec.tscaused intermittenttimeouts under concurrent load against the shared backend). Verified via
npx playwright test --list: the suite went from 48 serialized test-runs (2 projects × 24 specs) to 83 (4 projects,weighted by scope) — a ~73% growth in the
e2ejob's wall-clock time, with the heaviest specsnow running 3× instead of 2× (chromium, firefox, Desktop HD all execute the full desktop suite).
Proposal
Split the single
e2ejob into a CI matrix overproject: [chromium, firefox, 'Desktop HD', 'Mobile Chrome (Pixel 8a)'], each with its ownpostgres/redisservice containers andnpx playwright test --project="${{ matrix.project }}".This keeps
workers: 1within each project (preserving the documented anti-contentionproperty) while letting the 4 projects run concurrently across runners, turning the wall-clock
cost from the sum of all test-runs into roughly the cost of the single slowest project instead
of their total.
Acceptance criteria (PO-finalized 2026-07-12)
e2eCI job runs each Playwright project (chromium,firefox,Desktop HD,Mobile Chrome (Pixel 8a)) in its own matrix leg with its ownpostgres/redisservicecontainers
— this is the story's actual purpose; confirm on the first real CI run post-merge
fail-fast: false), so a single flakyproject doesn't hide results from the other three
workers: 1(or equivalent per-leg serialization) is preserved within each project — noreintroduction of the concurrent-context contention
#46's config comment documentsplaywright-report,e2e-screenshots) still upload per leg, with aunique artifact name per leg (project names contain spaces/parens, not valid as-is in an
artifact name) so a failure is traceable to the right project
dockerjob'sneeds: [backend, frontend]is unaffected (it does not depend one2etoday and must not start depending on it as a side effect of this change)
Blockers
None — purely a CI workflow change, independent of any feature work.
design (
50_e2e_ci_matrix_parallelization_design.md)Architect design —
#50E2E CI matrix parallelizationDesign
Replace the single
e2ejob in.gitea/workflows/ci.ymlwith astrategy.matrixover the fourPlaywright project names,
fail-fast: false. Gitea Actions (GitHub Actions-compatible) spins upone independent job instance per matrix leg, each getting its own
services:containers — sopostgres/redisisolation is free, not something we have to hand-roll.Each leg runs
npx playwright test --project="${{ matrix.project }}"instead of the barenpx playwright test.playwright.config.tsneeds no change:workers: isCI ? 1 : undefinedalready serializes within a single project's run, and a matrix leg only ever runs one project, so
the anti-contention property from
#46is preserved automatically — the contention that commentguards against was cross-spec-file contention within one project's shared backend, not
cross-project (each leg gets its own backend + Postgres + Redis via its own
webServer/servicesblock, so there's no shared state between legs to contend over).
Artifact naming
Project names (
Desktop HD,Mobile Chrome (Pixel 8a)) contain spaces and parentheses, which arenot safe verbatim in
actions/upload-artifact'sname:(and would collide across legs if leftunparameterized — v3 upload-artifact does not append a leg-unique suffix on its own the way v4
does). Introduce
matrix.project_slugalongsidematrix.projectin the matrix definition(explicit include list, not a computed slug, since Gitea Actions' expression language has no
string-replace function to derive one from
matrix.projectinline) and use it in both artifactname:fields:playwright-report-${{ matrix.project_slug }}/e2e-screenshots-${{ matrix.project_slug }}.Matrix definition
What doesn't change
dockerjob'sneeds: [backend, frontend]— it never depended one2e, so nothing to do here;called out in the acceptance criteria only because matrix-izing
e2eis exactly the kind ofchange where a stray
needs: [e2e]could get copy-pasted in by habit.playwright.config.ts— no edits. The per-projectworkers: 1behavior this story preserves isalready a property of the config, not the workflow.
npx playwright install chromium firefox --with-deps) installs bothdesktop browsers regardless of which project a leg runs — cheap enough (~seconds vs. minutes for
the test run itself) that splitting it per-project isn't worth the added matrix complexity.
Known risks (unconfirmable in the sandbox, watch the first real CI run)
playwright.config.ts'swebServerbindsfixed host ports (backend
:5000, frontend:5173) withreuseExistingServer: !isCI(false inCI). Pre-
#50only onee2ejob instance ever ran at a time, so this was never a conflict. Post-#50,up to 4 matrix legs bind those same fixed ports concurrently. This relies on each matrix leg
getting a fully isolated job runner (its own container/network namespace) — the standard
Actions/act_runner execution model, and already implicitly relied on today by
backendandfrontendrunning as separate concurrent jobs. Not re-architected to use dynamic per-leg portshere: that's real added complexity to defend against a risk this repo's runner model should
already rule out by default, and the fix (if the assumption turns out wrong) is well understood
(parameterize the port from
matrix.project_slug) — cheaper to apply once actually observed thanto build speculatively. If a leg ever fails CI with "port already in use," that's the signal.
webServerindependently runsdotnet run --project CqsTodo.WebApi— a Debug-mode cold build plus a full EF migration run against a brandnew Postgres container (the 120s timeout in
playwright.config.tsexists because this wasalready observed to take close to 60s serially). Post-
#50this happens 4x concurrently instead ofonce. This is the accepted cost side of the trade this story makes: more total compute in exchange
for the wall-clock win the first acceptance criterion measures — not a defect, but not previously
written down, so noting it here for whoever reviews the actual CI resource usage after this lands.
Security pre-review
No new secrets, no new network exposure, no change to what code runs — this is a CI topology
change only (same test code, same service images, same connection strings, now split across
parallel jobs instead of one sequential job).
REGISTRY_TOKENusage in thedockerjob isuntouched. Approved without further action.
qa (
50_e2e_ci_matrix_parallelization_qa.md)QA notes —
#50E2E CI matrix parallelizationWhat was verified locally
The sandbox has no Docker/Postgres/Redis (documented limitation — see
ai/roles/memory/06_qa_agent_memory.md), so the actual matrix job execution can only be confirmedon the real Gitea CI runner. What's verifiable locally:
.gitea/workflows/ci.ymlparses as valid YAML (checked viajs-yaml) and thejobsmappingstill has the expected four top-level jobs (
backend,frontend,e2e,docker).--projectvalues used in the new matrix (chromium,firefox,'Desktop HD','Mobile Chrome (Pixel 8a)') was run againstnpx playwright test --project=... --listand correctly filtered to that project's own spec subset (25 / 25 / 27 / 6 tests respectively) —
confirms the quoting and project names in the workflow's
matrix.includeexactly matchplaywright.config.ts'sprojects[].namevalues (a mismatch here would silently run 0 tests fora leg instead of erroring).
dockerjob'sneeds: [backend, frontend]is unchanged (diff confirms only thee2ejob blockwas touched) — acceptance criterion "docker job unaffected" holds by inspection.
playwright-report-${{ matrix.project_slug }},e2e-screenshots-${{ matrix.project_slug }}) are now unique per leg and contain onlyalphanumerics/hyphens, avoiding the space/parenthesis characters in the raw project names that
upload-artifact@v3would otherwise receive.Confirmed on real CI (run 272, commit
19d7dad, 2026-07-12)All 7 jobs green:
Frontend1m47s,Backend3m12s,Docker9s, and all four E2E legs —Mobile Chrome (Pixel 8a)3m42s,Desktop HD4m24s,chromium4m42s,firefox4m54s.failure — the fixed-port risk flagged in the design doc's "Known risks" section did not
materialize. Per-leg runner isolation holds as assumed.
workers: 1contention-free, as expected (unchanged config, already proven pre-#50).Proposal section hoped for — the runner pool only executed 2 E2E legs concurrently, not 4
(observed via polling:
Desktop HD+Mobile Chromeran first,chromium+firefoxstartedonly once those finished). E2E wall-clock was therefore roughly two back-to-back waves
(~4m24s + ~4m54s ≈ 9m18s) rather than one (~4m54s, the slowest single leg). Still a real
improvement over the pre-
#50serial baseline (all 4 projects' full test-runs summed in one job,no concurrency at all — Problem section estimated a ~73% wall-clock growth from adding the 3rd
and 4th projects), just smaller than the design doc's best case, which implicitly assumed
unlimited concurrent runners. Not a defect in this story's approach — worth a follow-up note in
case the runner pool's concurrency limit is itself worth raising as separate infra work, out of
this story's scope.
Security final review
No new attack surface: same test code, same images, same secrets (none added), only the job
topology changed. Approved.