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: Afunto.slnx was Einsatzbereit.slnx.

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).

Table 1. Workflow wall-clock duration
Workflow Median Range Notes

dotnet.yml (backend build + test)

~9.6 min

8.5 - 10.5 min

PR/push quality gate; the bottleneck (n=23)

frontend.yml (lint + build)

~1.1 min

1.0 - 1.3 min

runs independently; not a concern

docs.yml (AsciiDoc + Pages)

~0.9 min

0.7 - 4.2 min

docs-only path filter

publish.yml (full suite + image build/push)

~11.7 min (pre-split)

on tag push only

same test suite plus the Docker image build - now split the same way as dotnet.yml (see note below), not yet re-measured

Within a representative dotnet.yml run, the build-and-test job (~10.3 min) breaks down as follows.

Table 2. build-and-test job step breakdown (representative run)
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 %

Application.UnitTests

~3 s

<1 %

ArchitectureTests

~3 s

<1 %

IntegrationTests (Aspire + Docker)

~124 s

~20 %

VisualTests (Aspire + Docker + Playwright)

~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 build-and-test step breakdown is doubly historical: that single job no longer exists, having been split by #773 and then sharded by #2145. See the Severity section and the sharding note under Proposed Solution for the current numbers and what was done about them; this section is kept as the record of where the trend started.

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 .github/AGENTS.md alongside this record. The real risk is the growth trajectory, not the present absolute duration.

  • 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 VisualTests scenario - the suite cannot be extended without directly extending the critical path.

  • Maintainability: Because VisualTests boots 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.UnitTests and ArchitectureTests (~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-node cache) 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 IntegrationTests and VisualTests as 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).

    Note

    Implemented (issue #773):

    • dotnet.yml split into 3 parallel jobs: fast-tests (Application.UnitTests + ArchitectureTests, scoped so it skips VisualTests’ Playwright install), `integration-tests, visual-tests. No needs between them.

    • Housekeeping: added a concurrency group (cancels superseded runs on the same PR), added timeout-minutes to every job (there was none before - a hang would have run the full 360-minute default), and added the NuGet actions/cache step publish.yml was missing.

    • Cost: $0. Public repo, plain ubuntu-latest, nowhere near the ~20 concurrent-job cap.

    • VisualTests login cost cut: 78 of 117 tests only needed a login as setup. AuthHelper.FastSignInAsync mints a token directly (same mechanism IntegrationTestFixture already used) and seeds it into browser storage instead of driving the login UI. Two logins stay real on purpose - JwtAudienceTests and a new round-trip test in AuthGuardTests - 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.yml got the same split (backend-fast-tests/backend-integration-tests/backend-visual-tests → publish-backend). publish-backend is now just version-extract → GHCR login → build → push, gated on all three test jobs. Not yet observed on a real tag push.

    Note

    Eliminated 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 pull calls 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 was tests/VisualTests.csproj’s `InstallPlaywrightBrowsers MSBuild target (AfterTargets="Build"), which fetches Chrome for Testing + Chrome Headless Shell (~290 MiB combined) from cdn.playwright.dev. Because integration-tests and visual-tests both ran dotnet build against the whole Afunto.slnx, integration-tests was compiling (and therefore triggering the Playwright install for) VisualTests.csproj despite IntegrationTests never 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-tests never touches VisualTests.csproj now, so it no longer pays that download at all. visual-tests additionally caches ~/.cache/ms-playwright via actions/cache (keyed on Directory.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 both dotnet.yml and publish.yml.

    • Initially proposed scoping each job’s build down instead of a single upstream build job whose artifact every test job downloads, reasoning that dotnet restore is 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 via actions/upload-artifact/download-artifact would 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.yml now has one build job (dotnet restore + dotnet build for the whole Afunto.slnx) that fast-tests/integration-tests/visual-tests all depend on (needs: build) and consume via actions/upload-artifact/download-artifact (publish.yml mirrors this with backend-build). format-check stays 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-build still needs ~/.nuget/packages physically present even with the artifact’s bin/obj restored - without it, MSBuild project evaluation fails outright (OutputType is 'Library' instead of the test-SDK-contributed Exe) because it can’t import the .props/.targets files packages contribute - so every downstream job still restores the same actions/cache NuGet key build populated, just never runs dotnet restore itself; (2) the artifact is genuinely large - the whole solution’s bin/obj output measured ~567 MiB uncompressed / ~218 MiB zipped locally (each test project carries its own copy of shared framework dependencies like TUnit/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-artifact zip the artifact and don’t preserve the Unix executable bit, so every downstream job’s native apphost binary (bin/…​/<ProjectName>, no file extension - what dotnet run launches directly) came back non-executable and failed fast-tests/integration-tests with "Permission denied"; (4) upload-artifact also excludes dot-prefixed files/folders by default, which silently dropped tests/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 failed visual-tests with "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 a chmod +x pass on every file under any bin/ folder right after each download-artifact step (an initial version scoped to files with no extension in their name incorrectly skipped Application.UnitTests’s own apphost binary, since the project name itself contains a dot - dropped that filter entirely once discovered, since marking a `.dll/.json executable is harmless), and (4) by setting include-hidden-files: true on the upload-artifact step.

    • VisualTests parallel-limit tightened for flakiness, not speed: VisualTestsParallelLimit (AssemblyParallelLimit.cs) capped concurrent tests at Environment.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 in AuthHelper.GoToOrgAppDashboardAsync’s comment (most recently: two `AccessibilityTests methods timed out on the same GET /v1/organizations round trip in the same 2026-07-28 run). Changed to Environment.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_HasNoSeriousA11yViolations failed on a color-contrast violation against text-brand-200 stats-row labels, which sit inside global.css’s `.animate-fade-up-d4 entrance 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: VisualTestBase now overrides ContextOptions to set ReducedMotion = ReducedMotion.Reduce, which skips global.css’s `@media (prefers-reduced-motion: no-preference)-gated animations entirely for every VisualTests browser 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 single VisualTests job is the clear blocker.

    Note

    Implemented (issue #2145, 2026-08-20). The "not warranted yet" note this replaces was written when visual-tests ran ~6 min. It became warranted.

    What the measurements actually said (56 dotnet.yml runs over 15.5 h, 2026-08-20):

    Metric Value

    dotnet.yml median wall-clock

    21 min 58 s

    Completion rate

    36.4 % (20 success / 20 cancelled / 15 failure)

    visual-tests median

    19 min 25 s (~78 % of wall-clock)

    visual-tests failures

    9 of 17 usable runs (53 %)

    integration-tests / build / format-check / fast-tests

    4 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 > 1 was 0 across 210 runs despite 15 failures, so contributors were not re-running a red job, they were pushing over it and letting cancel-in-progress clean up. That is what a gate nobody trusts looks like.

    Root cause. 542 tests on a 4-vCPU ubuntu-latest runner, with AssemblyParallelLimit.cs capping concurrency at ProcessorCount - 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 → -1 in 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-cores and ProcessorCount - 2 scales itself to 6 - and it addresses the measured cause head-on. It was rejected on cost, not on merit: only the default ubuntu-latest/windows-latest/macos-latest tier 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 00s with 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; AspireFixture now 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-filter over class names, which was #2145’s stated fallback. The split is computed by .github/scripts/visual-test-shard.sh on 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 - AccessibilityTests alone 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.

    AssemblyParallelLimit was 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

    build

    2 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-tests

    4 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 build plus ~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 the build-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 by AccessibilityTests’ 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 `AspireFixture renders 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, run 32347940443), 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 VisualTests suite 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 VisualTests suite down into IntegrationTests and Application.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.

    Note

    Mutation-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/Domain and backend/src/Application, one run per source project, both driven by Application.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 commit c4a143b (ci: shard visual-tests across four runners and cut fixed waits (#2146)). The configuration is checked in at backend/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 1
    Project Generated Scored Killed Survived No coverage Score Wall-clock

    Domain

    872

    764

    510

    173

    81

    66.75 %

    3 min 58 s

    Application

    1818

    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: 2 and 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’s bail and mix-mutants optimisations did not help (34 flips remained, at no cost in runtime), which ruled out mutant batching as the cause.

    Dropping to concurrency: 1 did. 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: 1 is 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.yml runs 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 the fast-tests job 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.yml is workflow_dispatch only, so it is never a required check and cannot block a merge, and thresholds.break is pinned to 0 so a low score exits 0 regardless. Same posture as the report-only coverage summary added to fast-tests in #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 that IntegrationTests exercises 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

    Error code ("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 as ThrowAsync<ResultFailureException>().WithMessage("not found") pin the human-readable prose and leave Error.Code untouched, even though Error.Code is what ResultFailureExceptionHandler puts on the wire as errorCode and 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:

    1. 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.

    2. 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.Code instead of (or alongside) exception message text would produce on its own.

    3. 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.

    4. 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: mtp is 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.csproj gained a direct ProjectReference to Domain.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 until mutation-tests.yml has run for real.

    Note

    Wave 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.cs alone held 90 of the 542 cases) and because vitest-axe at component level is a well-trodden swap rather than a new invention.

    What moved. AccessibilityTests.cs went 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 as src/*/.a11y.test.tsx and run by the existing pnpm test gate in frontend-checks.yml. The new dependencies are vitest-axe, axe-core, @testing-library/react, @testing-library/dom, @testing-library/user-event and @testing-library/jest-dom; the shared harness lives in frontend/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-contrast can 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 in frontend/src/test/a11y.ts rather 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-order across 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: DateRangeContrastTests and EngagementStatusContrastTests remain as dedicated, deterministic contrast regressions, and contrast in this codebase is a property of a small set of design tokens defined once in global.css, with the floors documented in frontend/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: Domain 66.75 % (872 generated / 764 scored / 510 killed / 173 survived / 81 no coverage, 4 min 47 s), Application 68.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 under backend/src, and the mutation run mutates Domain/Application driven by Application.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-tests shard 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 own dotnet.yml run before touching the matrix.

    Next waves follow #2148’s own table - see the waves 2-5 note below for what they turned into.

    Note

    Waves 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 VisualTests after 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 under frontend/src/

    68

    3 - auth/session

    Four of SessionExpiryTests’ five to a hook test, `AccountConsoleLinkTests and one AuthGuardTests case to component tests, and JwtAudienceTests’ admin half to `IntegrationTests

    7

    4 - email

    EmailDeliveryTests’ Keycloak-realm-SMTP case to `IntegrationTests - it never opened a browser even as a visual test

    1

    5 - API behaviour

    The three classes that used no browser API at all (EngagementCheckInStatusCodeTests, MilestoneAchievementTests, LoginStreakConcurrencyTests), to IntegrationTests

    6

    The measured shape, before and after. Counting every executable case in each project (Application.UnitTests + ArchitectureTests + frontend’s Vitest suite as "unit", `IntegrationTests as "integration", VisualTests as "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.SetOffline

    No. jsdom has no layout engine and no canvas. This is the same limit wave 1 documented for color-contrast, and it is why KeycloakThemeTests (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. HomePageOrgCtaTests called fixture.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: Domain 872 generated / 764 scored / 510 killed / 173 survived / 81 no-coverage; Application 1818 / 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 under backend/src - wave 5 changed only which project three test classes live in - and the run mutates Domain/Application driven by Application.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 no Application/Domain coverage with them, not that the Vitest and IntegrationTests replacements 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 gives shard ~= 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 (commit a0c972e, 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 - 2 is 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.

    Note

    Waves 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 an aria-label reading "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, because IntegrationTests already 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 what visual-test-shard.sh balances.

    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, focus

    53

    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: AvatarAndLogoDisplayTests forced [NotInParallel("visualtests-vera-avatar")] on its own upload case because its DELETE made vera’s avatar vanish mid-assertion, and EngagementReactivationTests, EngagementTimezoneTests and the VisualTests AdminReportsTests each booted a Chromium context and a frontend to make plain HTTP calls - EngagementTimezoneTests opened a page solely to scrape an OIDC token out of sessionStorage.

    Three moved cases came out stronger than the originals, which is worth recording because the usual risk of this work is the opposite. SocialPreviewImageTests booted a browser, an Aspire stack and a frontend to read two hardcoded strings out of a static index.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_…​ carried Skip.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 (`onChange never fires) rather than only as the aria-disabled attribute - 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. UndoCheckInEngagementEndpoint returns 409 for Engagement.AlreadyTerminated and Engagement.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-generic ApiException. Not user-facing (the frontend reads errorCode off 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: Domain 872 generated / 764 scored / 510 killed / 173 survived / 81 no-coverage; Application 1818 / 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 no Application/Domain coverage with them, not that the Vitest and IntegrationTests replacements 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.include was src/lib//.ts - correct while that suite was only the pure-function tests, and blind to everything #2148 moved down. Widened to src//.{ts,tsx} (excluding the tests themselves, the src/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/Application under Application.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 a mutation-tests-frontend job on the existing manual mutation-tests.yml. Findings worth recording before anyone changes its scope:

    • The first numbers are low, and mostly real. OpportunityCard.tsx scores 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 a className line, so this is not Tailwind-string noise: the survivors are unasserted branches (capacity.isFull, the unlimited case, the FEW_SPOTS_THRESHOLD tone).

    • 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 not src/. 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 to Domain/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/lib weakens the suite covering it.

    Table 4. Frontend mutation-score baseline, #2148 head, StrykerJS 10.0.0, concurrency: 2, ignoreStatic: true
    Scope 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 of mutation-tests.yml and of the mutation-tests-frontend job 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: Domain 66.75 % in 4 min 23 s, Application 68.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: 2 is 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 clean ubuntu-latest - with a bit-for-bit identical per-file table, at concurrency: 2 both times. Stryker.NET, by contrast, needs concurrency: 1 because 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 ignoreStatic exclusions; 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) and imageUpload.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 / test job (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 plugins or Stryker reports no test runner found; .stryker-tmp/ is a full copy of the project, so ESLint needs it in ignores or pnpm lint fails on its vendored coverage scripts after any local run; and the version is pinned for the same reason dotnet-stryker is - 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 still ProcessorCount - 2, and the reason is a correction to this document’s own framing.

    Measured on this branch’s green run at 232 cases: build 2 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-tests 4 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.

    Note

    The #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

    EngagementCalendarTests

    1

    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}/calendar endpoint - the string-building itself was already unit-tested, this closes the Api-layer wiring gap.

    MyEngagementsTests

    2

    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.

    EngagementManagementFiltersTests

    1

    Moved → EngagementManagementPage.test.tsx. The status filter drives a new API call per selection; nothing here needs a browser.

    MyEngagementsTimeSlotTests

    1

    Moved → ActivitySection.test.tsx. Pure rendering of an already-fetched engagement’s time slot.

    OpportunityApplicationStateTests

    3

    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 by IntegrationTests’ `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 synchronous auth prop cannot reproduce - renderWithProviders never models a transient anonymous phase.

    OpportunityUnpublishCancelTests

    2

    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 to Cancelled, with the organizer’s reason on cancel) → two new IntegrationTests/EngagementTests.cs cases using the real outbox pipeline (fixture.WaitForOutboxMessageProcessedAsync, the same pattern NotificationTests.cs already 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.

    OpportunityDetailStatusCardTimeSlotTests

    1

    Moved → VolunteerOpportunityDetailPage.test.tsx. Pure rendering, same shape as the existing pending/confirmed status-card tests.

    PageHealthTests

    1

    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.

    SmokeTests

    2

    Deleted as redundant - see below, this is the SmokeTests/EngagementCoreJourneysTests contradiction the issue asked to resolve explicitly.

    SignInAccountLocaleTests

    1

    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.

    InitialLocaleResolutionTests

    1

    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 - renderWithProviders builds one already-initialized i18n instance per render and never re-runs that boot sequence.

    CheckInModalDeletedOpportunityTests

    1

    Moved → CheckInModal.test.tsx. The component already renders the same generic loadError message 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.

    LocalizedCheckInPinErrorTests

    1

    Moved → CheckInModal.test.tsx. Pure i18n-mapping of a check-in rejection to localized copy.

    CheckInPinOrganizerSetTests

    2

    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 by Application.UnitTests’ `Handle_ShouldUseGivenCheckInPin.

    EngagementCoreJourneysTests

    2

    Moved, both cases - see below, this is the other half of the contradiction.

    OrgDashboardSettingsIconWidgetTests

    1

    Moved → SettingsIconWidget.test.tsx (new, colocated the way CalendarWidget.test.tsx/QuickCheckInWidget.test.tsx already 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_CompletesAndReturnsToHome and all of EngagementCoreJourneysTests as 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_CompletesAndReturnsToHome does not test a login journey at all. It calls AuthHelper.FastSignInAsync, which mints a token directly into sessionStorage to 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_ReachesAuthenticatedHomePage already 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_LoadsForAnonymousUser is redundant the same way, against two survivors instead of one: PageHealthTests (above) for "does the real stack boot cleanly", and the already-moved NavigationTests.HomePage_HasMainHeading (#2148 wave) for "does the heading render". Both SmokeTests cases are deleted, not moved - there was nothing left for either to uniquely cover.

    • EngagementCoreJourneysTests names journeys but tests single-page state transitions. ConfirmButton_MovesApplicationToConfirmed and WithdrawButton_MovesEngagementFromUpcomingToPastScope are 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 EngagementCoreJourneysTests that 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_RedirectsToKeycloak

    Self-registration (real Keycloak round trip)

    SelfRegistrationDefaultRoleTests.Register_CompletesForm_GrantsUserRoleForAuthenticatedApiCalls

    Account-locale-aware sign-in

    SignInAccountLocaleTests.SignIn_AccountLocaleDiffersFromSessionLanguage_ShowsToastAndSwitchesLanguage

    Organizer 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 alone

    Real-browser check-in (QR scan / camera)

    QRScannerModalTests.QRScannerModal_ResumesScanning_AfterFailedCheckIn and siblings - a real scan resolving against a live engagement, #2152’s own "cannot move" row 2

    Read 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 OpportunityApplicationStateTests race-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 VisualTests in total (310 from #2148/#2152, 18 more from this reconciliation), 2 were deleted outright as redundant (SmokeTests), leaving 214. visual-test-shard.sh repacked 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 VisualTests scenarios 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.