2. Slow CI Pipeline
-
Author: @maik-hasler
-
Version: identified in v1.0.0
-
Date: 2026-07-18 (last re-measured 2026-08-20, issues #2145 and #2147)
-
Status: In Progress
|
Note
|
Product renamed from Einsatzbereit to Afunto. Dated entries below use the new names for things that carried the old one at the time: |
Description
The backend CI workflow (.github/workflows/dotnet.yml) is the slowest quality gate on the project. It runs on every pull request and every push to main that touches backend/, frontend/, or keycloak/**, and it must pass before a change can be merged or released. The workflow builds the backend and then runs four test projects sequentially in a single job; the two that boot the full Aspire stack under Docker - IntegrationTests and especially VisualTests (TUnit.Playwright) - dominate its runtime.
The debt is not that the pipeline is unacceptably slow today, but that its runtime grows steadily and is concentrated in a single, ever-expanding job with no mechanism to keep it bounded. Left unmanaged, feedback latency keeps rising as the VisualTests suite grows.
Empirical Timing
Measured from the GitHub Actions API over recent main and pull-request runs (July 2026).
| Workflow | Median | Range | Notes |
|---|---|---|---|
|
~9.6 min |
8.5 - 10.5 min |
PR/push quality gate; the bottleneck (n=23) |
|
~1.1 min |
1.0 - 1.3 min |
runs independently; not a concern |
|
~0.9 min |
0.7 - 4.2 min |
docs-only path filter |
|
~11.7 min (pre-split) |
on tag push only |
same test suite plus the Docker image build - now split the same way as |
Within a representative dotnet.yml run, the build-and-test job (~10.3 min) breaks down as follows.
| Step | Duration | Share |
|---|---|---|
Setup (.NET, Node, pnpm, NuGet cache restore, dependency install) |
~60 s |
~10 % |
Build backend |
~55 s |
~9 % |
Pre-pull Docker images |
~13 s |
~2 % |
|
~3 s |
<1 % |
|
~3 s |
<1 % |
|
~124 s |
~20 % |
|
~350 s |
~57 % |
VisualTests alone accounts for roughly 57 % of the job; together the two Aspire-backed test projects (IntegrationTests and VisualTests) account for about 77 % (~8 minutes). A separate format-check job runs in parallel in ~1.2 min and is not on the critical path.
Trend: the same workflow measured ~4.5 min in late May 2026, ~5-6 min in mid-June, and ~8-10 min in July - it has roughly doubled in about two months as end-to-end coverage grew.
|
Important
|
The table and breakdown above are the July 2026 measurement and are no longer current. A fresh pass on 2026-08-20 (56 runs, issue #2145) measured a median of 21 min 58 s with a 36.4 % completion rate - the ~9.6 min figure above, and the ~6 min figure the #773 note below reports, have both been overtaken. The |
|
Note
|
Earlier project documentation described this workflow as taking "~20-30 minutes". That figure does not match the measured data (median ~9.6 min) and has been corrected in |
Links
-
ADR-1: Monorepo - the path filters that decide which workflow runs stem from the monorepo strategy.
-
TDR-1: Rate Limiting - the previous backend quality-gate / robustness debt.
-
QualityGoal Maintainability - scenario QS-3.4 (the quality gate returns a verdict within a bounded time) is the quality target this debt threatens.
Impact
Technical Impact
-
Developer feedback loop: Every pull request and push waits ~10 minutes for the backend gate before it is known green; release candidates wait on
publish.yml(~12 min) for the same suite. This is the single largest source of iteration latency. -
Suite scalability: All four test projects run sequentially in one job with no sharding or parallelism, so runtime grows linearly with every new
VisualTestsscenario - the suite cannot be extended without directly extending the critical path. -
Maintainability: Because
VisualTestsboots the full Aspire stack under Docker, a slow or flaky run is expensive to reproduce and diagnose, and any re-run costs the full ~10 minutes.
Business Impact
-
Cost control: Longer runs consume more GitHub Actions minutes per pull request - relevant for a non-profit, volunteer-driven project with a minimal budget. Any parallelization that adds runners trades wall-clock time for total compute minutes.
-
Contributor experience: Slow feedback raises the barrier to casual weekend contributions (persona Contributor Caro), working against the project’s low-entry-barrier goal.
Symptoms
-
Dominant single step: One step (
VisualTests) consumes the majority of every backend CI run. -
Rising duration over time: Median run time roughly doubled over two months with no infrastructure change.
-
Full re-run cost: A single flaky end-to-end assertion forces a complete ~10 minute re-run.
-
Stale documentation: Earlier docs quoted a "20-30 minute" runtime that no longer reflected reality, a sign that pipeline cost was not being tracked.
Severity
High (raised from "Low to Medium" on 2026-08-20, see issue #2145).
The earlier rating rested on a ~10 min median that was "tolerable and well below the point of blocking work", with the debt framed as trajectory rather than present cost. The trajectory arrived: by 2026-08-20 the median was 21 min 58 s and only 36.4 % of runs completed at all. The thing that rose was not just runtime - visual-tests was failing 53 % of the time, which is the more important number, because a gate that is wrong every second run stops being a gate. The prediction in the original text ("severity rises toward High as the VisualTests suite expands") held exactly; the suite went from 117 tests at the time of #773 to 542.
Sharding (below) brings the wall-clock back under the original baseline. The severity stays High until the failure rate is measured back under 20 %, because runtime was only ever half of what made this debt expensive.
Proposed Solution
Ordered from cheapest quick win to larger strategic change. The project’s Appropriateness and minimal-maintenance goals argue for stopping at the first options that keep runtime bounded.
Quick wins (low effort, low risk):
-
Keep the fast checks first:
Application.UnitTestsandArchitectureTests(~3 s each) already give near-instant feedback and run before the expensive Docker steps; keep that ordering so obvious failures fail fast. -
Confirm caching is effective: NuGet (
actions/cache) and pnpm (setup-nodecache) are already configured. Verify cache hit rates in the logs; the setup phase (~60 s) is already small, so there is little further to gain - do not over-invest here. -
Skip Docker layer caching: Image pull is only ~13 s (pre-pull step), so Docker layer caching is not worth the added workflow complexity at current numbers.
Structural (medium effort):
-
Split the two Aspire test projects into parallel jobs: Run
IntegrationTestsandVisualTestsas separate jobs so their wall-clock overlaps instead of summing. The critical path drops from ~10 min to roughly ~8 min (the longer job plus setup), at the cost of roughly doubled compute minutes and a duplicated build per job (mitigate by sharing the build output as an artifact).NoteImplemented (issue #773):
-
dotnet.ymlsplit into 3 parallel jobs:fast-tests(Application.UnitTests+ArchitectureTests, scoped so it skipsVisualTests’ Playwright install), `integration-tests,visual-tests. Noneedsbetween them. -
Housekeeping: added a
concurrencygroup (cancels superseded runs on the same PR), addedtimeout-minutesto every job (there was none before - a hang would have run the full 360-minute default), and added the NuGetactions/cachesteppublish.ymlwas missing. -
Cost: $0. Public repo, plain
ubuntu-latest, nowhere near the ~20 concurrent-job cap. -
VisualTestslogin cost cut: 78 of 117 tests only needed a login as setup.AuthHelper.FastSignInAsyncmints a token directly (same mechanismIntegrationTestFixturealready used) and seeds it into browser storage instead of driving the login UI. Two logins stay real on purpose -JwtAudienceTestsand a new round-trip test inAuthGuardTests- so the actual login flow and the frontend/frontend-test mapper parity the fast path relies on stay under real coverage. -
First real run (2026-07-19, n=1):
format-check~1 min,fast-tests~1 min,integration-tests~4 min,visual-tests~6 min, all parallel → critical path ~6 min. Down from ~9.6 min, better than the ~8 min estimate above (that estimate predated the login-cost fix). Preliminary - re-measure at n=20+ before treating 6 min as the new baseline. -
publish.ymlgot the same split (backend-fast-tests/backend-integration-tests/backend-visual-tests→publish-backend).publish-backendis now just version-extract → GHCR login → build → push, gated on all three test jobs. Not yet observed on a real tag push.
NoteEliminated the actual repeated-download source instead of centralizing the build (2026-07-28):
-
Root-caused "gateway errors from downloading images repeatedly" against real CI logs rather than assuming the three
docker pullcalls in "Pre-pull Docker images" were the cause - across several recent runs (both success and failure) that step consistently completed in ~10-15s with no errors. The actual repeated large download wastests/VisualTests.csproj’s `InstallPlaywrightBrowsersMSBuild target (AfterTargets="Build"), which fetches Chrome for Testing + Chrome Headless Shell (~290 MiB combined) fromcdn.playwright.dev. Becauseintegration-testsandvisual-testsboth randotnet buildagainst the wholeAfunto.slnx,integration-testswas compiling (and therefore triggering the Playwright install for)VisualTests.csprojdespiteIntegrationTestsnever referencing it or using a browser - a pure wasted ~290 MiB download on every single run, on top of `visual-tests’ own (legitimate but also uncached) copy of the same download. -
Fix: scoped every test job’s restore/build to just its own project (
dotnet restore tests/<Project>/dotnet build tests/<Project> --no-restore) instead of the whole solution -integration-testsnever touchesVisualTests.csprojnow, so it no longer pays that download at all.visual-testsadditionally caches~/.cache/ms-playwrightviaactions/cache(keyed onDirectory.Packages.props, matching the existing NuGet cache key convention), so the download that remains legitimate only happens once per Playwright version rather than on every run. Applied to bothdotnet.ymlandpublish.yml. -
Initially proposed scoping each job’s build down instead of a single upstream
buildjob whose artifact every test job downloads, reasoning thatdotnet restoreis already cache-backed and cheap (the actual redundant network cost was the Playwright download above, not restore or the Docker pulls) and that a shared build crossing jobs viaactions/upload-artifact/download-artifactwould serialize setup+build (~2 min) before any test job starts, instead of today’s model where each job’s setup+build overlaps with the others in parallel - trading a faster critical path for fewer total compute-minutes, the wrong side of this project’s stated trade-off (public-repo compute is free; wall-clock feedback latency is not - see the Business Impact section above). -
Overridden on explicit request: adopted the single-
build-job/artifact-fan-out shape anyway. The repo owner weighed the wall-clock trade-off above and asked for it regardless -dotnet.ymlnow has onebuildjob (dotnet restore+dotnet buildfor the wholeAfunto.slnx) thatfast-tests/integration-tests/visual-testsall depend on (needs: build) and consume viaactions/upload-artifact/download-artifact(publish.ymlmirrors this withbackend-build).format-checkstays independent (it never needed a build, only a restore, and is the one job that would lose the most by waiting). Two things confirmed empirically before shipping this, not assumed: (1)dotnet run --project X --no-restore --no-buildstill needs~/.nuget/packagesphysically present even with the artifact’sbin/objrestored - without it, MSBuild project evaluation fails outright (OutputType is 'Library'instead of the test-SDK-contributedExe) because it can’t import the.props/.targetsfiles packages contribute - so every downstream job still restores the sameactions/cacheNuGet keybuildpopulated, just never runsdotnet restoreitself; (2) the artifact is genuinely large - the whole solution’sbin/objoutput measured ~567 MiB uncompressed / ~218 MiB zipped locally (each test project carries its own copy of shared framework dependencies likeTUnit/Aspire.Hosting.Testing), so this trades some upload/download time on every run for one restore+build instead of three. Re-measure wall-clock once this has run for real; if the artifact-transfer cost turns out to dominate, sharding (below) is the next lever to reach for instead of reverting this. Two more things surfaced live, not in advance, both on the first real run of this shape: (3)actions/upload-artifact/download-artifactzip the artifact and don’t preserve the Unix executable bit, so every downstream job’s native apphost binary (bin/…/<ProjectName>, no file extension - whatdotnet runlaunches directly) came back non-executable and failedfast-tests/integration-testswith "Permission denied"; (4)upload-artifactalso excludes dot-prefixed files/folders by default, which silently droppedtests/VisualTests’s entire `bin/…/.playwright/folder (the Playwright driver - a bundled Node.js runtime, distinct from the browser binaries in~/.cache/ms-playwright) from the artifact and failedvisual-testswith "Driver not found", a different symptom from (3) since the file was missing outright rather than merely non-executable. Confirmed (4) by downloading the actual failed run’s artifact and listing its contents rather than guessing. Fixed (3) with achmod +xpass on every file under anybin/folder right after eachdownload-artifactstep (an initial version scoped to files with no extension in their name incorrectly skippedApplication.UnitTests’s own apphost binary, since the project name itself contains a dot - dropped that filter entirely once discovered, since marking a `.dll/.jsonexecutable is harmless), and (4) by settinginclude-hidden-files: trueon theupload-artifactstep. -
VisualTestsparallel-limit tightened for flakiness, not speed:VisualTestsParallelLimit(AssemblyParallelLimit.cs) capped concurrent tests atEnvironment.ProcessorCount, which leaves zero spare CPU for the Aspire-hosted stack itself (backend/Postgres/Keycloak/frontend) once that many Chromium+axe-core tests are running - a plausible structural cause of the recurring "tips over the timeout" pattern already called out inAuthHelper.GoToOrgAppDashboardAsync’s comment (most recently: two `AccessibilityTestsmethods timed out on the sameGET /v1/organizationsround trip in the same 2026-07-28 run). Changed toEnvironment.ProcessorCount - 1(minimum 1) to reserve one core for the stack. This is a structural mitigation, not a fix confirmed by controlled experiment - Docker/Aspire can’t be run in a sandboxed session to load-test it locally, so treat it as a hypothesis to re-evaluate against real CI failure rates over the following runs; if flakiness persists, sharding (below) is the next lever, now for reliability as well as speed. -
Found a second, distinct flake live on this change’s own PR run:
HomePage_HasNoSeriousA11yViolationsfailed on acolor-contrastviolation againsttext-brand-200stats-row labels, which sit insideglobal.css’s `.animate-fade-up-d4entrance animation (opacity 0 → 1, staggered ~0.5s delay). Axe-core is deterministic given the DOM it’s handed, so this was not the CPU-contention timing pattern above - it was a race between the scan and a still-fading element being caught at partial opacity, reading a lower (alpha-blended) contrast than the settled UI ever shows. Fixed at the source rather than in the one page it happened to surface on:VisualTestBasenow overridesContextOptionsto setReducedMotion = ReducedMotion.Reduce, which skipsglobal.css’s `@media (prefers-reduced-motion: no-preference)-gated animations entirely for everyVisualTestsbrowser context - the same rendering a real reduced-motion user gets, and immune to this race by construction rather than by timing the scan around it.
-
-
Shard
VisualTests: Split the Playwright suite across N parallel shards, each on its own Aspire stack. This is the only lever that scales sub-linearly as the suite grows, but each shard multiplies Docker and compute cost - adopt only once a singleVisualTestsjob is the clear blocker.NoteImplemented (issue #2145, 2026-08-20). The "not warranted yet" note this replaces was written when
visual-testsran ~6 min. It became warranted.What the measurements actually said (56
dotnet.ymlruns over 15.5 h, 2026-08-20):Metric Value dotnet.ymlmedian wall-clock21 min 58 s
Completion rate
36.4 % (20 success / 20 cancelled / 15 failure)
visual-testsmedian19 min 25 s (~78 % of wall-clock)
visual-testsfailures9 of 17 usable runs (53 %)
integration-tests/build/format-check/fast-tests4 min 19 s / 2 min 37 s / 2 min 26 s / 1 min 25 s, none notable
This was never a "CI is slow" problem - 471 integration tests in 4 min 19 s is a good number. It was one job. The 20 cancelled runs are part of the finding rather than noise:
run_attempt > 1was 0 across 210 runs despite 15 failures, so contributors were not re-running a red job, they were pushing over it and lettingcancel-in-progressclean up. That is what a gate nobody trusts looks like.Root cause. 542 tests on a 4-vCPU
ubuntu-latestrunner, withAssemblyParallelLimit.cscapping concurrency atProcessorCount - 2(= 2), is ~271 sequential slots. 1165 s / 271 ~= 4.3 s per slot, which matches the measured median. The cap was not a mistake - it was tightened twice (ProcessorCount→-1in the 2026-07-28 note above, then-2) precisely because CPU contention between Playwright/axe-core sessions and the Aspire stack they all call into was causing timeouts. But reducing parallelism buys reliability with runtime, and by 542 tests that trade had gone net-negative: it bought 19 minutes and roughly the same failure rate.Why B (sharding) and not A (a bigger runner). A would have been a one-line change -
runs-on: ubuntu-latest-8-coresandProcessorCount - 2scales itself to 6 - and it addresses the measured cause head-on. It was rejected on cost, not on merit: only the defaultubuntu-latest/windows-latest/macos-latesttier is free and unlimited on a public repository, and every larger-runner tier is billed per minute regardless of repository visibility (GitHub Actions runner pricing). That is incompatible with the minimal-budget constraint in this record’s own Business Impact section. Sharding buys the same parallelism by adding runners rather than cores, and standard runners stay free, so it is $0 - the same reasoning #773 used.Measured fixed per-shard cost, which is what decides how many shards are worth having (run 32337102365,
visual-tests, 2026-08-20):Segment Duration Job setup (checkout → Docker pre-pull complete)
1 min 43 s
Aspire stack boot, inside the test step
~1 min (the first two tests were still flagged
[slow] still running after 1m 00swith 0 completed)Test work
~15 min 50 s
Test step total (TUnit self-reported 16 min 48 s)
16 min 50 s
So roughly 2 min 45 s per shard is fixed and does not divide. At 4 shards that is ~3 min 50 s of test work each, for a ~6.5-7 min job and a ~9.5 min critical path once
build(2 min 38 s) is added - under the 10 min target. Six shards would reach ~8 min end to end, and past that the fixed cost dominates entirely, so 4 is the point where the returns flatten rather than an arbitrary pick. The Aspire boot figure above had to be reconstructed from step timestamps and TUnit’s[slow]lines;AspireFixturenow times its own boot and both workflows print it (their "Report Aspire stack startup cost" steps), so the next revision of this table is a direct reading rather than an inference.How the shard split is expressed. TUnit 1.65.31 has no native sharding flag - its documented flag set is
--list-tests/--treenode-filter/--maximum-parallel-tests/etc., with nothing that splits a run across machines - so a shard is a Microsoft.Testing.Platform--treenode-filterover class names, which was #2145’s stated fallback. The split is computed by.github/scripts/visual-test-shard.shon every run rather than checked in: a hand-written list would silently stop running any newly added test class, since a class in no shard’s filter is simply never selected. Classes are packed longest-first into whichever shard is lightest, because sizes are extremely uneven -AccessibilityTestsalone is 90 of the 542 cases while most classes are 1-5 - which lands the four shards at 136/136/135/135 cases. The script fails loudly if a file’s class name ever stops matching its file name, since that is the one way a class could fall out of every filter.AssemblyParallelLimitwas deliberately left alone. Each shard is still a 4-vCPU runner, so concurrency stays 2 and per-instant CPU contention per stack is unchanged - the win here is four stacks, not a busier one. Changing the parallel limit in the same commit would have confounded the one measurement this change exists to produce.First sharded run (n=1, 2026-08-20, run
32345414446):Job Duration build2 min 45 s
visual-tests (1)6 min 57 s
visual-tests (2)6 min 07 s
visual-tests (3)7 min 07 s
visual-tests (4)7 min 34 s
integration-tests4 min 46 s
Critical path
~10 min 19 s (down from 21 min 58 s)
The Aspire boot instrumented above reported 63.0 s on shard 4, confirming the ~1 min the shard count was chosen from. Shard sizes came out at 136/136/135/135 test cases.
Wall-clock is therefore just over the < 10 min target rather than under it, and the residual is almost entirely fixed cost: ~2 min 45 s of
buildplus ~1 min 45 s of per-shard setup plus ~1 min of Aspire boot is ~5 min 30 s before a single test runs. More shards barely move that; the levers that would are the E2E rebalance (option C, issue #2148) and thebuild-artifact round trip this document’s 2026-07-28 note already flagged as re-measurable.One failure in that run, on shard 4:
JwtAudienceTests.Administration_AuthenticatedAdmin_LoadsOrganizationsWithoutAuthError, a 45 s timeout on the post-submit leg of a real Keycloak login. That is the contention flake this document has tracked since 2026-07-28, but sharding changes its distribution in a way worth recording: every shard now has a "first concurrent batch" against a cold Keycloak, where one 542-test session used to have exactly one, statistically absorbed byAccessibilityTests’ 90 cases - which is why the only `[Retry(2)]in the suite sits on exactly such a call site, with a comment naming that same cause.AuthHelper.LoginAsync’s own comment claimed callers carried that retry; only 1 of the 15 classes driving a real login actually did. The retry now lives inside the helper (scoped to the identity round trip, never to a product assertion, so #1321’s distinction holds) and `AspireFixturerenders the login page once during boot so the custom Freemarker theme compiles inside the initializer’s budget rather than inside a test’s.Second run, after the login fix (2026-08-20, run
32346711001): all four shards green,visual-tests (4)back to 135 tests / 0 failures, shards 6 min 41 s - 7 min 43 s, Aspire boot 64.9 s, critical path ~10 min 33 s. The same shape held on the release path (v1.0.0-rc.362, run32347940443), which is the only thing that exercises `publish.yml’s copy of the matrix.Still to re-measure. The above is n=2. Re-measure at n=20+ before treating ~10 min as the new figure, and treat the failure rate as the primary success criterion, not the wall-clock - runtime was only ever half of what made this debt expensive.
Strategic (larger change, defer until justified):
-
Move end-to-end tests off the per-PR critical path: Keep build, unit, architecture, and integration tests on every pull request (fast and deterministic) and run the full
VisualTestssuite on a merge queue or nightly schedule instead of every push. This is the largest feedback-time win and best fits the Maintainability goal, at the cost of slightly later end-to-end signal. -
Rebalance the test pyramid (issue #2148): Move the bulk of the
VisualTestssuite down intoIntegrationTestsandApplication.UnitTests, keeping end-to-end coverage only for what genuinely needs a browser. 542 end-to-end tests is roughly 24 % of the whole suite sitting in the slowest and most fragile layer, and sharding only divides that cost rather than removing it. The risk is deleting real coverage along with redundant coverage, which is why the guardrail below had to be recorded first.NoteMutation-score baseline recorded (issue #2147, 2026-08-20). Deliberately scoped to producing the number: no test changes, no threshold gate.
Why this and not line coverage. Line coverage cannot answer the question #2148 raises. A redundant end-to-end test and a load-bearing one look identical to it: delete either, and the lines they touched are still executed by something else, so the percentage barely moves. Mutation testing gives a falsifiable condition instead - record the score now, re-run it after each migration wave, and if the score does not drop, the coverage that was removed was redundant. Without a recorded number first, #2148 is unfalsifiable and should not start.
What was measured. Stryker.NET 4.16.0 mutating
backend/src/Domainandbackend/src/Application, one run per source project, both driven byApplication.UnitTests(1017 tests, no Docker). .NET SDK 10.0.400,mutation-level: Standard,concurrency: 1(see "The concurrency trap" below), on a 4-vCPU machine. Measured at commitc4a143b(ci: shard visual-tests across four runners and cut fixed waits (#2146)). The configuration is checked in atbackend/tests/Application.UnitTests/stryker-config.json, so the run is reproducible by anyone.Table 3. Mutation-score baseline, commit c4a143b, Stryker.NET 4.16.0, concurrency 1Project Generated Scored Killed Survived No coverage Score Wall-clock Domain872
764
510
173
81
66.75 %
3 min 58 s
Application1818
1466
1004
343
119
68.49 %
7 min 42 s
Combined
2690
2230
1514
516
200
67.89 %
11 min 40 s
"Generated" minus "scored" is 460 mutants Stryker excludes from the score itself: 333 ignored (blocks already covered), 126 that did not compile, 1 runtime error. The score is
(killed + timeout) / (killed + timeout + survived + no coverage); there was exactly one timeout across both runs.The concurrency trap - the most important finding here, because it decides whether the number is usable at all. The first pass ran at Stryker’s suggested
concurrency: 2and produced 71.60 % / 73.47 %. Repeating it at the same commit produced 70.68 % / 72.85 %. The mutant sets were identical both times, but individual verdicts churned: 47 of 872 Domain mutants and 99 of 1818 Application mutants changed status between two runs of unchanged code. Disabling Stryker’sbailandmix-mutantsoptimisations did not help (34 flips remained, at no cost in runtime), which ruled out mutant batching as the cause.Dropping to
concurrency: 1did. Two runs per project at concurrency 1 were bit-for-bit identical - zero flips out of 872 and out of 1818. The determinism survives a dependency change too: re-running both projects after merging `main’s TUnit 1.65.31 → 1.65.38 bump (#2144) reproduced every single verdict, 0 flips out of 2690 mutants and the same 66.75 % / 68.49 %. So the number tracks the test suite, not the machine or the engine underneath it. Comparing the concurrency-2 runs against that deterministic baseline shows the error is one-directional:Run Kills concurrency 1 does not report Kills concurrency 1 reports that it misses Domain, four separate concurrency-2 runs+37 / +30 / +40 / +38
0 / 0 / 0 / 0
Application, two separate concurrency-2 runs+73 / +64
0 / 0
Every parallel run invents kills and never loses one, and it invents a different set each time: across the four Domain runs the four spurious sets intersected in exactly zero mutants (75 distinct mutants were spuriously killed at least once). So concurrency inflates the score by roughly 5 points and jitters it by roughly 1. Stryker’s Microsoft.Testing.Platform runner is marked preview and warns as much on every run; this looks like its per-mutant attribution losing track of which test host reported which failure.
concurrency: 1is therefore load-bearing, not a performance setting - the whole value of this number is that a movement in it means something, and at concurrency 2 a 1-point drop is indistinguishable from running the tool twice.Wall-clock: cheap enough to run per migration wave. 11 min 40 s for both projects sequentially, and the two projects are independent, so
.github/workflows/mutation-tests.ymlruns them as a matrix on separate runners - CI wall-clock is the longer leg (~8 min) plus job setup, not the sum. That is affordable several times per wave. It is still ~150x thefast-testsjob whose 1017 tests it reuses (3 s), which is why it stays off the per-PR path.Decided: it runs in CI, but manually and report-only.
mutation-tests.ymlisworkflow_dispatchonly, so it is never a required check and cannot block a merge, andthresholds.breakis pinned to0so a low score exits 0 regardless. Same posture as the report-only coverage summary added tofast-testsin #1327: visibility is the point. Do not add a break threshold - a number whose whole purpose is to be free to move cannot double as a gate.What 67.89 % is actually made of, because the headline is misleading read alone:
Slice Score What it says Headline (everything scored)
67.89 %
The number to track across #2148’s waves. Compare like with like: only against another concurrency-1 run of Stryker 4.16.0.
Excluding the string mutator
81.13 %
330 of the 516 survivors (64 %) are string-literal mutations. Nothing else is close - statement removal is 10.9 %, equality 8.3 %, and every remaining mutator is under 6 %.
Covered code only
74.58 %
Drops the 200 mutants no unit test reaches at all. That is a coverage gap rather than a test-quality one, and much of it is
Api/Infrastructure-facing plumbing thatIntegrationTestsexercises instead.Both adjustments
86.86 %
Mutants that are both reachable and not a bare string swap are killed at roughly 87 %.
Domain and Application land within two points of each other on every one of those four slices (66.75/68.49, 80.39/81.52, 74.67/74.54, 85.89/87.37), which is a good sign the number measures a property of the test suite rather than of one layer.
The string survivors are not noise, and this is the single most actionable thing the run surfaced. Splitting the string-literal mutants by what the literal actually is:
Literal kind Killed Survived No coverage Kill rate Errorcode ("Engagement.NotPending")6
174
44
3 %
Prose description (
"Only pending engagements can be confirmed.")132
84
51
49 %
Other short literals
146
72
5
65 %
Error codes are essentially unasserted: 6 kills out of 224. The cause is
ResultFailureException(Error error) : Exception(error.Description)- assertions written asThrowAsync<ResultFailureException>().WithMessage("not found")pin the human-readable prose and leaveError.Codeuntouched, even thoughError.Codeis whatResultFailureExceptionHandlerputs on the wire aserrorCodeand what the frontend branches on. The contract that can be reworded freely is tested; the contract that cannot be changed without breaking clients is not. Those 174 survivors are 34 % of every survivor in both projects, so closing that one pattern alone would move the combined score from 67.9 % to roughly 75.7 %. Out of scope for #2147 (no test changes), worth its own issue.How to read the number, and what a "good" score here would be. Stryker’s own built-in thresholds (high 80, low 60) are generic defaults, and a single global target is the wrong instrument for this codebase - 64 % of the surviving mutants are string swaps, whose kill rate says more about assertion style than about test quality. Useful expectations, in the order they should be reached for:
-
For #2148, the only threshold that matters is "did it drop". The run is deterministic, so any drop at all is real. Treat a drop of more than ~0.5 points (~11 mutants) after a migration wave as coverage that was actually load-bearing, and go find which mutants stopped dying - the JSON report names them. A wave that holds the score flat has proven its case.
-
As a standing target, 75 % combined is the realistic next rung and is reachable without writing a single new test scenario - it is what asserting
Error.Codeinstead of (or alongside) exception message text would produce on its own. -
Above that, judge the adjusted slices, not the headline. "Reachable, non-string" mutants are already killed at 87 %, which is genuinely healthy; the headline is held down by literal mutations and by unreachable code, and neither is fixed by writing more of the same tests.
-
80 % headline is a reasonable long-run ambition and a bad short-run gate. Getting there means both fixing the error-code assertions and covering the 200 currently unreachable mutants, most of which belong to `IntegrationTests’ territory rather than to the unit suite.
Caveats on the number.
-
Stryker’s Microsoft.Testing.Platform runner is preview and prints a warning saying so on every run. It is not optional here: TUnit has no VSTest adapter, so Stryker’s default VSTest runner discovers zero tests and reports a vacuous pass rather than an error.
test-runner: mtpis pinned in the config for that reason, and the concurrency finding above is a concrete symptom of the runner’s preview status. -
The Stryker version is pinned (4.16.0) in the workflow and in the documented local command. A different version can add or drop mutators, which changes the denominator and makes the score incomparable with this baseline. Bumping it means re-recording this table.
-
Application.UnitTests.csprojgained a directProjectReferencetoDomain.csproj. It is redundant to the compiler (Application already pulls Domain in) and exists only because Stryker resolves the project it mutates from the test project’s direct reference list; without it the Domain half cannot run at all. -
Wall-clock was measured on a 4-vCPU sandbox, not a GitHub runner. Same core count as
ubuntu-latest, so it should transfer, but treat it as an estimate untilmutation-tests.ymlhas run for real.
NoteWave 1 - accessibility, migrated (issue #2148, 2026-08-20). The first of the waves #2148 calls for, taken in the order that issue suggested: the accessibility slice, because it was the single largest one (
AccessibilityTests.csalone held 90 of the 542 cases) and becausevitest-axeat component level is a well-trodden swap rather than a new invention.What moved.
AccessibilityTests.cswent from 82 test methods / 85 cases to 14 methods / 17 cases, and from 3000 lines to 533. In its place,frontend/gained a component-level a11y suite: 20 files, 99 cases, colocated assrc/*/.a11y.test.tsxand run by the existingpnpm testgate infrontend-checks.yml. The new dependencies arevitest-axe,axe-core,@testing-library/react,@testing-library/dom,@testing-library/user-eventand@testing-library/jest-dom; the shared harness lives infrontend/src/test/.The gate is deliberately identical at both altitudes - fail on any serious/critical axe violation plus the same escalated moderate rules - so a scan that moved down did not also quietly get easier. What changed is scope, and it changed in both directions.
What could not move, and why the ~10 page-level smokes are the number they are. Three classes of check are unavailable in jsdom, and they are not cosmetic:
-
Colour contrast. axe samples rendered pixels through a canvas. jsdom has no layout engine and no canvas, so
color-contrastcan only ever come back inconclusive there - and an inconclusive result never reaches a violations list, which means a component suite that "checks contrast" would pass on markup that fails it. The rule is therefore disabled outright infrontend/src/test/a11y.tsrather than left looking like a check, and the surviving page scans are the only place contrast is evaluated at all. -
Page-scope structure. Landmarks,
page-has-heading-one,heading-orderacross a whole document,bypass,html-has-lang- axe skips its page-level rules entirely when handed a fragment, and a component genuinely cannot answer them. -
Real focus and pointer behaviour. The two skip links moving focus into
#main-content(one per layout), the Leaflet marker’s accessible name, and DOM order deciding which control a Tab reaches first.
So what stayed is one scan per distinct layout and palette - public landing, browse, opportunity detail, signed-in volunteer profile, my-signups, org app dashboard, engagement management, administration (4 sections), static legal page - plus the five browser-only assertions above. What left is every scan whose only difference from another was which component happened to be mounted: 68 methods, each of which booted the Aspire stack, signed a user in, seeded data and drove a browser to reach a dialog or a dropdown panel that renders from props.
Residual coverage gap, stated rather than glossed. Contrast inside states only reachable behind a modal or an edit-mode toggle - the org-settings edit form, the dashboard widget colour dialogs, the cancel dialogs - is no longer checked automatically; those states keep their structural and semantic coverage at component level and lose their contrast coverage. Two things bound that:
DateRangeContrastTestsandEngagementStatusContrastTestsremain as dedicated, deterministic contrast regressions, and contrast in this codebase is a property of a small set of design tokens defined once inglobal.css, with the floors documented infrontend/AGENTS.md. If the gap turns out to bite, the answer is a token-level contrast test, not 68 restored Aspire boots.Mutation score after the wave: unchanged. Re-run at concurrency 1 against the same Stryker 4.16.0 configuration as the #2147 baseline:
Domain66.75 % (872 generated / 764 scored / 510 killed / 173 survived / 81 no coverage, 4 min 47 s),Application68.49 % (1818 / 1466 / 1004 / 343 / 119, 8 min 45 s), combined 67.89 % - every one of those figures identical to the recorded baseline, mutant for mutant. That is the expected result rather than a surprising one, and worth saying plainly: this wave changed no file underbackend/src, and the mutation run mutatesDomain/Applicationdriven byApplication.UnitTests, which it also did not touch. The number is reported because #2148’s acceptance criteria ask for it after every wave and because a wave that did move backend coverage would be indistinguishable from this one without it - not because it is evidence about the a11y scans themselves. For this wave the falsifiable check is the one CI runs: the 14 surviving page scans still pass, and the 99 component cases assert the same rule set the deleted ones did.Shard count deliberately not changed yet. #2148’s acceptance criteria call for shrinking the
visual-testsshard count once the ~2 min 45 s of fixed per-shard cost starts dominating. It plausibly now does - the suite is down to 474 cases - but the numbers that would decide it come from a real run of this branch, not from an estimate, and this document has twice had to correct a figure that was reasoned about rather than measured. Re-read the shard timings from this change’s owndotnet.ymlrun before touching the matrix.Next waves follow #2148’s own table - see the waves 2-5 note below for what they turned into.
NoteWaves 2-5 - the rest of the rebalance, and what the target ratio actually turned out to be (issue #2148, 2026-08-20).
What moved. 84 more cases left
VisualTestsafter wave 1’s 68, in four waves:Wave What moved, and where Cases 2 - pure UI behaviour
Static content pages, the create-opportunity wizard’s react-hook-form behaviour, the design-system markup assertions (
RequiredFieldMarker,SharedFormClasses,DangerZonePanel), the rendered-offline states, live regions and loading skeletons, the administration routes, the member-search empty states, and six of the seven org-shell error states - all to colocated RTL suites underfrontend/src/68
3 - auth/session
Four of
SessionExpiryTests’ five to a hook test, `AccountConsoleLinkTestsand oneAuthGuardTestscase to component tests, andJwtAudienceTests’ admin half to `IntegrationTests7
4 - email
EmailDeliveryTests’ Keycloak-realm-SMTP case to `IntegrationTests- it never opened a browser even as a visual test1
5 - API behaviour
The three classes that used no browser API at all (
EngagementCheckInStatusCodeTests,MilestoneAchievementTests,LoginStreakConcurrencyTests), toIntegrationTests6
The measured shape, before and after. Counting every executable case in each project (
Application.UnitTests+ArchitectureTests+frontend’s Vitest suite as "unit", `IntegrationTestsas "integration",VisualTestsas "E2E"):Layer Before After Change #2148’s target Unit
1766 (63.5 %)
1953 (69.2 %)
+187
~1400 (69 %)
Integration
471 (16.9 %)
480 (17.0 %)
+9
~570 (28 %)
E2E
542 (19.5 %)
391 (13.8 %)
-151
~50 (2.5 %)
So: the unit share hit its target exactly, and the E2E share did not come close. That is the finding, and it is worth stating plainly rather than filing as unfinished work.
Why ~2.5 % E2E is not reachable here. Classifying every one of the 391 surviving cases by what it actually depends on - not by which file it sits in:
Cases Depends on Can it move? ~131
Rendered layout or real input:
SetViewportSize,getComputedStyle,BoundingBox, drag/resize, keyboard, focus,Context.SetOfflineNo. jsdom has no layout engine and no canvas. This is the same limit wave 1 documented for
color-contrast, and it is whyKeycloakThemeTests(19 cases over a Freemarker theme asserted through computed styles) never even entered a wave.~47
A real Keycloak round trip - the login form, the registration endpoint, an SSO session across tabs, a locale stored on the account
No, and #2148 says to keep these.
~15
A page-level axe scan - landmarks, heading order, colour contrast
No, per wave 1’s own reasoning.
~198
API/domain behaviour, reached through the UI
Yes, in principle - this is wave 5’s remaining pool.
Moving every one of the remaining ~198 - which would mean rewriting the two largest classes in the suite (
VolunteerOpportunityTests,OrganizationTests) plus a long tail - lands E2E at roughly 190 cases, or ~7 %. The floor is set by the ~193 that need a browser, not by appetite. Reaching 50 would mean deleting layout, theme and real-auth regressions that have no lower-altitude equivalent - which is the exact failure #2147’s mutation baseline exists to prevent, just at a layer that baseline cannot see.Recommendation: treat ~7 % as the realistic target and close #2148 against it, or reopen the question as "should layout regressions be covered by visual-diff snapshots instead of Playwright assertions", which is a different decision with a different cost.
The cheapest win was not the case count.
HomePageOrgCtaTestscalledfixture.ResetAsync()before every test and carried[NotInParallel("visualtests-db")]so it could guarantee vera organized nothing. Three of its four cases moved, and with them the database reset and the serialization - that class no longer blocks every other DB-touching class in the suite. Three more classes (EngagementCheckInStatusCodeTests,MilestoneAchievementTests,LoginStreakConcurrencyTests) were paying for a Chromium context, a frontend and Playwright’s ~290 MiB browser download to make plain HTTP calls. Neither shows up as much in a case count and both cost real wall-clock.Mutation score after waves 2-5: 66.75 % / 68.49 % / 67.89 %, bit-for-bit identical to the #2147 baseline. Re-run at the end of this branch on the same concurrency-1 Stryker 4.16.0 configuration:
Domain872 generated / 764 scored / 510 killed / 173 survived / 81 no-coverage;Application1818 / 1466 / 1004 (1003 killed plus one timeout) / 343 / 119. That is the wanted result rather than a weak one, and it is worth being precise about why: no wave changed a file underbackend/src- wave 5 changed only which project three test classes live in - and the run mutatesDomain/Applicationdriven byApplication.UnitTests, which the waves also did not touch. A movement would have meant something quite specific: that the number does not measure what it claims to, so any drop would have been a tooling problem rather than lost coverage. What this confirms is narrow and should be read as such - it says the moved cases took noApplication/Domaincoverage with them, not that the Vitest andIntegrationTestsreplacements are as strong as the Playwright originals. Nothing in this repo measures that second thing today.Shard count: still four, and now from a direct reading rather than an estimate. This branch’s own green run (
32405319770, 2026-08-20, 391 cases) puts the four shards at 5 min 22 s - 6 min 39 s of wall clock, with the instrumented Aspire boot reading 53.6 s / 63.1 s / 68.3 s / 68.9 s. Splitting each shard into what scales and what does not: fixed ~2 min 36 s (1 min 12 s - 1 min 55 s of job setup, restore and image pulls, plus the 63.5 s mean Aspire boot inside the test run) and variable ~3 min 25 s (820 s of real test work over 391 cases, or ~2.1 s per case). That givesshard ~= 156 s + 2.1 s * (391 / N), which predicts a 6 min 02 s mean at N = 4 against that run’s observed 6 min 02 s.Read that fit with care, because a second run of the identical suite arrived before this paragraph was a day old. Run
32407729992(commita0c972e, docs-only, so the same 391 cases) came in at a 6 min 30 s mean - 28 s above the model, not the exact match the first run suggested. What did reproduce is the number the decision actually turns on: the slowest shard was 6 min 39 s then 6 min 44 s, and the critical path 9 min 05 s then 9 min 06 s. The mean moves because the fast shards vary (run 1 spanned 5 min 22 s - 6 min 39 s, run 2 a tighter 6 min 09 s - 6 min 44 s); the ceiling barely does. So the model estimates the tail well and the middle loosely, which is the right way round for sizing a matrix - but the single-run exact fit was luck, and this document has been burned by exactly that before.Extrapolating from the model, and holding the observed slowest-vs-mean spread: three shards would run ~7 min 10 s each for a ~10 min critical path, and five would run ~5 min 20 s for ~8 min 20 s, against the measured 9 min 05 s / 9 min 06 s at four. So dropping a runner costs roughly a minute of critical path and adding one buys roughly 45 s - three shards is not neutral, which is what the wave-1 estimate in this document assumed before the run existed to check it. Four stays. The threshold worth watching is the fixed share: at 43 % of a shard’s wall clock it is already the larger half of the residual, and removing another ~100 cases would push it past 50 %, which is the point where folding shards starts to pay. The matrix is worth touching once, at whatever the final case count turns out to be, rather than re-tuned per wave to land in the same place.
AssemblyParallelLimit’s `ProcessorCount - 2is likewise unchanged. #2148 suggested revisiting it "once the suite is much smaller". It is 28 % smaller, not much smaller, and the contention it guards against (Playwright and axe-core sessions competing with the Aspire stack they all call into) is unchanged per-shard. Raising it is a controlled experiment against real CI failure rates, not a follow-on edit.NoteWaves 6-13 - the remaining pool cleared, and the answer to "how low does E2E go" (issue #2148, 2026-08-21).
The waves 2-5 note above closed with an estimate: ~198 cases were movable "in principle", which would land E2E at roughly 190 cases or ~7 %. That estimate has now been replaced by a measurement, and the estimate was close but arrived at from the wrong direction.
How the remaining pool was established. Rather than reasoning per class, every one of the then-surviving cases was read in full with its helper calls resolved, classified, and then handed to a second reviewer instructed to refute the classification and to default to "cannot move" under any uncertainty. That refutation pass is the part worth keeping: it rejected a third of the proposed moves, and several of the rejections were cases that would have passed in Vitest while asserting nothing - a
queryByRole("button", { name: "Remove" })that matches nothing whether or not a Remove button exists, because the accessible name is anaria-labelreading "Remove Vera Volunteer". Playwright matches accessible names by substring and RTL matches exactly; a port that does not account for that difference is a green test with no subject.What the pass produced. 134 cases confirmed movable - 120 to Vitest/RTL, 14 to
IntegrationTests. All 134 have moved, in waves 6-13. Two further cases were deleted rather than moved, becauseIntegrationTestsalready covered exactly what they asserted (`EngagementReadRepositoryTests’ checked-in bucket rule and `EngagementTests’ gone-opportunity reclassification).The measured final shape.
Layer Before #2148 After Change #2148’s target Unit (
Application.UnitTests+ArchitectureTests+ frontend Vitest)1766 (63.3 %)
2143 (74.1 %)
+377
~1400 (69 %)
Integration
483 (17.3 %)
518 (17.9 %)
+35
~570 (28 %)
E2E (
VisualTests)542 (19.4 %)
232 (8.0 %)
-310
~50 (2.5 %)
Every figure counts cases, not
[Test]methods: a method carrying[Arguments]expands to one case per argument set, which is what CI runs and whatvisual-test-shard.shbalances.So the unit layer overshot its target and E2E stopped at 8 %, not 2.5 %. Categorising the 232 survivors mechanically - by which browser-only API the case or its helpers actually reach, checked in order:
Cases What it reaches 119
Rendered layout or real input:
SetViewportSize,getComputedStyle,BoundingBox,ToHaveClass, drag/resize, keyboard, focus53
A real navigation or a multi-page journey - notification click-through, breadcrumb return paths, unpublish cascading to an engagement, a QR scan resolving against a live engagement. None of these is one component’s output
26
A real Keycloak round trip - the login form, the registration endpoint, the terms-of-use gate
19
A page-level axe scan - landmarks, heading order, colour contrast
15
Network interception or real offline (
RouteAsync,SetOfflineAsync)The first, third, fourth and fifth rows are the same four categories the waves 2-5 note called a floor, and they still are. What that note underestimated is the second row: cases whose subject is a journey rather than a rendering, which are not movable for a different reason than layout is - not "jsdom cannot measure this" but "there is nothing here for a component test to be about".
Recommendation, restated with the arithmetic behind it. ~8 % is the reachable target for this suite as it stands. Reaching 2.5 % would mean deleting layout, theme, real-auth and journey regressions that have no lower-altitude equivalent - the exact failure #2147’s mutation baseline exists to prevent, at a layer that baseline cannot see. Either adopt ~8 % and close #2148 against it, or reopen the question as "should layout regressions be visual-diff snapshots rather than Playwright assertions", which is a different decision with a different cost.
What the case count does not show. Fifteen whole classes are gone, several of which were paying a disproportionate share of the suite’s fixed cost:
AvatarAndLogoDisplayTestsforced[NotInParallel("visualtests-vera-avatar")]on its own upload case because its DELETE made vera’s avatar vanish mid-assertion, andEngagementReactivationTests,EngagementTimezoneTestsand theVisualTestsAdminReportsTestseach booted a Chromium context and a frontend to make plain HTTP calls -EngagementTimezoneTestsopened a page solely to scrape an OIDC token out ofsessionStorage.Three moved cases came out stronger than the originals, which is worth recording because the usual risk of this work is the opposite.
SocialPreviewImageTestsbooted a browser, an Aspire stack and a frontend to read two hardcoded strings out of a staticindex.html- and despite its name never opened the image, so a declaration disagreeing with the file passed; as a Vitest case both are build inputs, so it now parses the PNG’s IHDR and actually compares.OrganizationSwitcher_SelectingAnOrgRow_…carriedSkip.When(rowCount < 2)and silently did nothing unless the signed-in user happened to belong to two organizations; a two-org prop list removes the skip.MiniCalendar’s past-day guard is now asserted as behaviour (`onChangenever fires) rather than only as thearia-disabledattribute - which matters, because that attribute deliberately leaves the button clickable so arrow keys can cross it, so the attribute alone was never the guard.One finding filed rather than fixed.
UndoCheckInEngagementEndpointreturns 409 forEngagement.AlreadyTerminatedandEngagement.CheckInNotActive, both of which have user-facing translations, but declares only 400/401/403/404 - so NSwag generates no typed branch for 409 in either generated client and the rejection arrives as the non-genericApiException. Not user-facing (the frontend readserrorCodeoff the body generically), but the OpenAPI document is inaccurate. Filed as afunto#2158; not fixed here because the one-line.ProducesProblem(…)regenerates both API clients, which does not belong in a test-rebalance change.Mutation score after waves 6-13: 66.75 % / 68.49 % / 67.89 %, still bit-for-bit identical to the #2147 baseline. Re-run on this branch’s final head at concurrency 1 on the same Stryker 4.16.0 configuration:
Domain872 generated / 764 scored / 510 killed / 173 survived / 81 no-coverage;Application1818 / 1466 / 1004 (1003 killed plus one timeout) / 343 / 119. The reading is the same one the waves 2-5 note gave and should stay narrow: it says the moved cases took noApplication/Domaincoverage with them, not that the Vitest andIntegrationTestsreplacements are as strong as the Playwright originals. Nothing in this repo measures that second thing today.Frontend coverage, which does move.
vitest.config.ts’s `coverage.includewassrc/lib//.ts- correct while that suite was only the pure-function tests, and blind to everything #2148 moved down. Widened tosrc//.{ts,tsx}(excluding the tests themselves, thesrc/test/harness, the NSwag-generated client and composition roots), the same measurement reads 7.22 % statements before, 60.94 % after - 4.45 % to 60.02 % branches, 7.32 % to 56.85 % functions. That is the number that actually moved, and it moved because coverage of components and pages now exists at a layer something measures.A gap this document should name: nothing measures the moved tests' own strength. The mutation score above is Stryker.NET mutating
Domain/ApplicationunderApplication.UnitTests. It is silent about the Vitest suite by construction, so "identical score" says the moved cases took no backend coverage with them and nothing more.StrykerJS (
@stryker-mutator/core+@stryker-mutator/vitest-runner) closes exactly that gap, and #2148 adds it:frontend/stryker.config.json,pnpm mutation, and amutation-tests-frontendjob on the existing manualmutation-tests.yml. Findings worth recording before anyone changes its scope:-
The first numbers are low, and mostly real.
OpportunityCard.tsxscores 39.68 % (42.37 % of covered) against the full suite - 50 killed, 68 survived, 8 with no coverage. Only 5 of 76 survivors sit on aclassNameline, so this is not Tailwind-string noise: the survivors are unasserted branches (capacity.isFull, theunlimitedcase, theFEW_SPOTS_THRESHOLDtone). -
Scope changes the number less than expected. The same file against only its own test suite scores 34.92 %, against the whole 709-case suite 39.68 % - so the colocated suite carries most of the weight, and a per-file run is a fair cheap proxy.
-
It cannot answer whether this PR lost coverage. There is no before-baseline: Stryker.NET cannot drive Playwright against mutated frontend source, and StrykerJS cannot run the C# suite, so the Playwright originals' own kill rate is unknowable. StrykerJS is a forward-looking gate, not a retrospective one - the same position #2147 put the backend in before #2148 leaned on it.
The cost is why the committed scope is
src/lib/and notsrc/. Instrumenting the whole frontend produces 14 851 mutants over 182 files - 5.5x the backend’s 2 690, against a suite that renders components in jsdom rather than calling pure functions.src/lib/**is 37 files / 897 mutants of pure logic: the tier that is both cheap to mutate and directly comparable toDomain/Application. The component tier stays on demand (pnpm mutation --mutate "src/components/Foo.tsx"), which is the useful granularity anyway - a component’s survivors name specific unasserted branches, a whole-app average names nothing.The baseline, recorded so later waves have something to compare against. This is the forward-looking equivalent of #2147’s table: it says nothing about what this PR moved, and everything about whether the next change to
src/libweakens the suite covering it.Table 4. Frontend mutation-score baseline, #2148 head, StrykerJS 10.0.0, concurrency: 2,ignoreStatic: trueScope Generated Scored Killed Survived No coverage Score Wall-clock src/lib/**(37 files)897
840
680
126
34
80.95 %
28 min 59 s
Wall-clock is from CI run
32479000847, the first real execution ofmutation-tests.ymland of themutation-tests-frontendjob specifically; the same commit took 36 min 24 s on a contended 4-core sandbox. That run also re-confirmed the two .NET legs on a GitHub runner rather than locally:Domain66.75 % in 4 min 23 s,Application68.49 % in 7 min 34 s, both identical to the #2147 baseline. So the frontend leg alone costs more than twice the entire backend matrix, which is the single most useful fact for deciding where to run it.concurrency: 2is reproducible for StrykerJS, and this is worth stating explicitly because the backend concluded the opposite. The 80.95 % above was produced twice - once on a contended 4-core sandbox, once on a cleanubuntu-latest- with a bit-for-bit identical per-file table, atconcurrency: 2both times. Stryker.NET, by contrast, needsconcurrency: 1because its preview MTP runner invents 30-73 kills per run at 2 (see "The concurrency trap" above). These are different runners with different defects; nobody should propagate the .NET constraint to the JS config by analogy, and nobody should assume the JS result vindicates raising the .NET one. Two agreeing runs is evidence, not proof, but it is the evidence there is.The 57-mutant gap between generated and scored is entirely
ignoreStaticexclusions; there were no compile errors. At 80.95 % this tier scores materially better than the backend’s 66.75 % / 68.49 %, which is what one would expect of pure functions with colocated unit tests rather than evidence that the frontend is better tested - the tier was chosen for being the easy one. The survivors concentrate in four files:organizationFormSchema.ts(37 of 107),format.ts(28 of 145),imageCrop.ts(27 of 59) andimageUpload.ts(18 of 68). As on the backend, string-literal mutations lead the survivor mix (45 of 126), then conditional expressions (26) and equality operators (14) - so the same caution applies: read the survivors per file, do not chase the headline.Two costs that are not obvious until you run it, and one estimate that did not hold. Every run pays a 3 min 21 s instrumented dry run before a single mutant is tested, against ~50 s for the same suite uninstrumented - fixed overhead larger than the whole
checks / testjob (1 min 39 s). And the run is slow in absolute terms: 36 min 24 s for the cheap tier, against ~12 minutes for the backend’s entire Domain + Application matrix. That gap is the whole reason the component tier stays on demand.The estimate that did not hold is worth recording, because it was the basis for a configuration choice. Stryker warns that 111 static mutants (12 % of the 897) are estimated to take 69 % of the run, and recommends
ignoreStatic. Taking that recommendation: the flag excluded 57 mutants rather than 111, and the run still took 36 minutes. The pass without it was killed at 35 minutes before finishing, so the saving is real but its size is unmeasured - what is measured is that it does not make the run cheap. The flag stays (module-scope initialisation is excluded from the denominator as a result, which is the trade), but nobody should plan against that 69 % as recoverable headroom.Three practical notes: under pnpm the plugin must be listed explicitly in
pluginsor Stryker reports no test runner found;.stryker-tmp/is a full copy of the project, so ESLint needs it inignoresorpnpm lintfails on its vendored coverage scripts after any local run; and the version is pinned for the same reasondotnet-strykeris - a new Stryker can add or drop mutators, and the score is only comparable across runs if the mutant set is.Shard count and
AssemblyParallelLimit: still four and stillProcessorCount - 2, and the reason is a correction to this document’s own framing.Measured on this branch’s green run at 232 cases:
build2 min 12 s, shards 4 min 36 s / 5 min 21 s / 4 min 51 s / 5 min 04 s (mean 4 min 58 s),integration-tests4 min 23 s, critical path 7 min 33 s - against 9 min 05 s at 391 cases and ~9 min 50 s at 474. The model (shard ~= 156 s + 2.1 s * (cases / N)) predicts 4 min 38 s at N = 4 against an observed 4 min 58 s mean, so it still under-predicts the middle by ~20 s and tracks the tail; unchanged in character from the two runs above.The fixed share is now 52 % of a shard’s wall clock (156 s of the 298 s mean), so the 50 % threshold recorded above has indeed been crossed. It does not mean what this document said it meant. A high fixed share means adding shards has stopped paying - not that removing them starts to. Folding a shard moves its cases onto the survivors, which are running in parallel, so as long as variable work is positive the critical path always gets worse:
Shards Modelled shard Modelled critical path 2
6 min 40 s
~9 min 35 s
3
5 min 19 s
~8 min 13 s
4 (today)
4 min 38 s
7 min 33 s (observed)
So folding to three costs ~40 s of critical path and saves one runner; folding to two costs ~2 minutes. The trade is runners against wall clock, and it never was a speed win - the earlier framing ("only then does folding shards start to pay") was wrong, and the arithmetic that produced it was reasoned about rather than measured, which is the third time this document has had to record that exact mistake. Four shards stay unless the runner budget becomes the binding constraint.
-
-
Larger or self-hosted runners: GitHub-hosted larger runners are paid (conflicts with the minimal-budget constraint); a self-hosted runner fits the self-hosting principle (ADR-3 rationale) but adds operational burden that the single-maintainer constraint discourages. Revisit only if the suite outgrows the hosted two-core runner.
NoteThe #2159 residue, reconciled (issue #2162, 2026-08-23). #2152 closed #2159 against its headline number - 134 confirmed-movable cases - but #2159’s own summary table and its per-case detail section disagreed on the count: the table budgeted "25 further classes | 1 each", the detail section named roughly 44 distinct single-case classes. #2152 executed against the table and left 16 classes / 23
[Test]methods never moved and never re-refuted in writing. This note is that refutation pass, run the same way #2159’s own method describes: each class read in full with helpers resolved, then classified adversarially rather than trusted at face value.The result is not "move all 16". Nine of the sixteen classes turned out to need the same adversarial second look #2152 gave everything else - some were correctly movable, some were wrongly classified as movable by #2159’s rushed detail pass, and two classes each split into a case that moves and a case that cannot.
Class Cases Disposition EngagementCalendarTests1
Split. Calendar-link rendering (Google/Outlook/Apple/download hrefs) →
F/components/AddToCalendarMenu.test.tsx(new). ICS content, headers and filename →IntegrationTests/EngagementTests.cs(new case), hitting the real/v1/engagements/{id}/calendarendpoint - the string-building itself was already unit-tested, this closes the Api-layer wiring gap.MyEngagementsTests2
Split. The org-name-link case →
ActivitySection.test.tsx. The sr-only-heading case stays: it measures a rendered box height (BoundingBoxAsync), which jsdom cannot do.EngagementManagementFiltersTests1
Moved →
EngagementManagementPage.test.tsx. The status filter drives a new API call per selection; nothing here needs a browser.MyEngagementsTimeSlotTests1
Moved →
ActivitySection.test.tsx. Pure rendering of an already-fetched engagement’s time slot.OpportunityApplicationStateTests3
Split, and the one case #2159 never separately counted turns out to be the interesting one. The hard-navigation case → an absence assertion added next to the existing positive "Confirmed"/"Pending" renders in
VolunteerOpportunityDetailPage.test.tsx(#2159’s own hazard 1: pair every absence with a positive proving the branch was reached). The duplicate-sign-up case’s frontend half (409 maps to "already signed up", not "Unknown error") →SignUpModal.test.tsx; its backend half was already covered byIntegrationTests’ `CreateEngagement_ShouldReturn409_WhenVolunteerAlreadySignedUp, so nothing new was needed there. The race-condition case stays: it depends on a real anonymous-then-authenticated fetch ordering during OIDC hydration that the RTL harness’s synchronousauthprop cannot reproduce -renderWithProvidersnever models a transient anonymous phase.OpportunityUnpublishCancelTests2
Split, both cases the same way. The UI section-transition and action-menu-visibility half →
OrgOpportunitiesPage.test.tsx. The cascade-cancel half (does unpublishing/cancelling really flip a confirmed engagement toCancelled, with the organizer’s reason on cancel) → two newIntegrationTests/EngagementTests.cscases using the real outbox pipeline (fixture.WaitForOutboxMessageProcessedAsync, the same patternNotificationTests.csalready uses) - previously only this E2E class exercised that cascade through a real HTTP call; the handlers were unit-tested with mocks but the wiring was not.OpportunityDetailStatusCardTimeSlotTests1
Moved →
VolunteerOpportunityDetailPage.test.tsx. Pure rendering, same shape as the existing pending/confirmed status-card tests.PageHealthTests1
Stays, correcting #2159. Boots the real frontend against the real backend and fails on any console error or failed request. No mocked-API RTL test can reproduce what this catches - a mocked call never fails the way a real misconfiguration does.
SmokeTests2
Deleted as redundant - see below, this is the SmokeTests/EngagementCoreJourneysTests contradiction the issue asked to resolve explicitly.
SignInAccountLocaleTests1
Stays, correcting #2159. Real Keycloak login for a throwaway user, asserting a real post-login locale-sync side effect. This is exactly #2152’s own "26 cases: a real Keycloak round trip → No" row; #2159’s detail-pass classified it movable in evident tension with the table its own PR published.
InitialLocaleResolutionTests1
Stays, correcting #2159. Asserts locale determinism across repeated real hard navigations (full page loads). The risk is a fresh-boot i18next detection race that only exists across a real reload cycle -
renderWithProvidersbuilds one already-initialized i18n instance per render and never re-runs that boot sequence.CheckInModalDeletedOpportunityTests1
Moved →
CheckInModal.test.tsx. The component already renders the same genericloadErrormessage on any failed detail fetch; the "opportunity deleted after the list loaded" framing was one way to produce a 404, not something the assertion depends on.LocalizedCheckInPinErrorTests1
Moved →
CheckInModal.test.tsx. Pure i18n-mapping of a check-in rejection to localized copy.CheckInPinOrganizerSetTests2
Moved (UI half only) →
CreateVolunteerOpportunityModal.test.tsx, asserting the wizard sends the typed/generated PIN exactly. The "backend persists it exactly, doesn’t regenerate" half was already covered byApplication.UnitTests’ `Handle_ShouldUseGivenCheckInPin.EngagementCoreJourneysTests2
Moved, both cases - see below, this is the other half of the contradiction.
OrgDashboardSettingsIconWidgetTests1
Moved →
SettingsIconWidget.test.tsx(new, colocated the wayCalendarWidget.test.tsx/QuickCheckInWidget.test.tsxalready are). Pure rendering: the label span renders unconditionally regardless of widget size.The part that actually needed a decision, not just re-classification. #2159 flagged both
SmokeTests.Login_AsVera_CompletesAndReturnsToHomeand all ofEngagementCoreJourneysTestsas movable; the issue that carried this residue forward (#2162) pointed out that both read, by name, like exactly the critical journeys #2148 says must stay E2E - and that #2148 and #2159 had never been reconciled on this point. Reading the bodies rather than the names resolves it in both directions, not by splitting the difference:-
SmokeTests.Login_AsVera_CompletesAndReturnsToHomedoes not test a login journey at all. It callsAuthHelper.FastSignInAsync, which mints a token directly intosessionStorageto skip the Keycloak round trip - the same shortcut every other precondition-only test in this suite uses. The only thing it asserts is that the post-"login" URL is/.AuthGuardTests.SignIn_WithValidCredentials_ReachesAuthenticatedHomePagealready drives the real Keycloak form and reaches the same authenticated home page, so this case is redundant with a case that actually exercises the journey it claims to.SmokeTests.HomePage_LoadsForAnonymousUseris redundant the same way, against two survivors instead of one:PageHealthTests(above) for "does the real stack boot cleanly", and the already-movedNavigationTests.HomePage_HasMainHeading(#2148 wave) for "does the heading render". BothSmokeTestscases are deleted, not moved - there was nothing left for either to uniquely cover. -
EngagementCoreJourneysTestsnames journeys but tests single-page state transitions.ConfirmButton_MovesApplicationToConfirmedandWithdrawButton_MovesEngagementFromUpcomingToPastScopeare both one authenticated user, one page, one click, seeded via API the way most of this suite’s survivors are not. Neither crosses a real page boundary or a real Keycloak round trip - the two things #2148’s "stay E2E" rule actually protects. Both moved cleanly to RTL (EngagementManagementPage.test.tsx,ActivitySection.test.tsx), each ported with a positive assertion pairing (visible status text before and after the click) per #2159’s absence-assertion hazard.
#2148’s outstanding acceptance criterion - "critical user journeys explicitly listed and kept as real E2E" - is met by this list, not by the class name
EngagementCoreJourneysTeststhat turned out not to be one. Grepping this repository for that phrase before #2162 returned nothing; these are the E2E cases actually carrying that weight, by what their bodies do rather than what their classes are called:Journey Real E2E coverage Sign-in (real Keycloak round trip)
AuthGuardTests.SignIn_WithValidCredentials_ReachesAuthenticatedHomePage,.Header_SignIn_FromNonHomePage_ReturnsToOriginatingPage,.MyEngagements_Anonymous_RedirectsToKeycloakSelf-registration (real Keycloak round trip)
SelfRegistrationDefaultRoleTests.Register_CompletesForm_GrantsUserRoleForAuthenticatedApiCallsAccount-locale-aware sign-in
SignInAccountLocaleTests.SignIn_AccountLocaleDiffersFromSessionLanguage_ShowsToastAndSwitchesLanguageOrganizer creates and publishes an opportunity
CreateOpportunityRetryTests.PublishScheduledSlots_TimeSlotFailureMidPublish_RetryUpdatesSameDraftInsteadOfDuplicating- the retry/failure-recovery edge is what keeps this one E2E rather than the happy path aloneReal-browser check-in (QR scan / camera)
QRScannerModalTests.QRScannerModal_ResumesScanning_AfterFailedCheckInand siblings - a real scan resolving against a live engagement, #2152’s own "cannot move" row 2Read this table narrowly, the same way the mutation-score readings above ask to be read. "Search → apply → check in" as one continuous, named E2E case does not exist in this suite, and this list does not pretend otherwise - what exists instead is each step protected independently (apply via the real modal in the surviving
OpportunityApplicationStateTestsrace-condition case; check-in via the real scanner above), each seeded through the API rather than clicked through from scratch. That is a deliberate cost-minimization already established by #2148/#2152 for every other survivor, not a gap this note is introducing.The final shape. 328 cases have now moved out of
VisualTestsin total (310 from #2148/#2152, 18 more from this reconciliation), 2 were deleted outright as redundant (SmokeTests), leaving 214.visual-test-shard.shrepacked cleanly across all four shards with no orphaned class (54/54/53/53 cases, 18/19/18/18 classes) - confirming the "a class stops matching its file name" failure mode #2148 warned about did not happen here. -
Consequences of Delay
Deferring is acceptable in the short term - the pipeline is not yet a hard blocker. However:
-
Runtime keeps climbing with each new end-to-end scenario, and the fix becomes more disruptive the larger the single job grows.
-
Contributor feedback latency erodes the low-entry-barrier goal precisely as the project tries to attract more contributors.
-
The gap between documented ("20-30 min") and actual (~10 min) runtime showed that pipeline cost was going untracked; without a tracked budget, regressions go unnoticed until they hurt.
Dependencies
-
Test isolation: Sharding or parallelizing depends on
VisualTestsscenarios being independent and safe to run against separate Aspire stacks. -
Budget: Any option that adds runners or minutes depends on the project’s tolerance for additional GitHub Actions cost.