fix(test): make browser-project tests contribute to coverage measurement #425
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Coverage is currently only measured against the
serverproject (vitest run --coverage --project=server). Theclient(browser/Playwright) project — which tests all Svelte components viavitest-browser-svelte— contributes nothing to the coverage number.This means the 80% branch threshold is gating a narrow slice of the codebase (shared utilities, server helpers, document utilities) while the bulk of the application code (every
.sveltecomponent and its companion.tsstate files) is entirely invisible to the coverage tool.Running
npm run test -- --coverageacross both projects consistently fails to produce a report due to a race condition: both theclientandserverprocesses attempt to clean up the samecoverage/.tmpdirectory simultaneously, and one of them fails withENOTEMPTY. No coverage table is printed and nocoverage-summary.jsonis written.Goal
Browser-project tests should contribute to the combined coverage report. The 80% threshold should gate the full application — not just server-side utilities.
Investigation paths
1. Fix the
.tmprace conditionVitest supports per-project coverage directories. Setting
coverage.reportsDirectoryto a unique path per project (e.g.coverage/serverandcoverage/client) would prevent the cleanup conflict. Coverage can then be merged vialcov-result-mergeror a similar tool.2. Enable browser coverage via Istanbul
The v8 provider cannot instrument code running inside Chromium. Vitest browser mode supports Istanbul-based coverage when configured on the browser project:
This requires
@vitest/coverage-istanbulas a dev dependency. Istanbul injects instrumentation at transpile time, so it works inside the browser sandbox.3. Run projects sequentially
Adding
--sequence.concurrent=falseto the coverage run forces projects to run one after the other, eliminating the.tmprace condition without requiring separate directories. This is the lowest-effort fix but doubles the runtime.4. Use a single merged threshold
Once both projects produce reports, the combined
lcov.infofiles can be merged and a single threshold applied to the union. This gives a true picture of application coverage.Acceptance criteria
npm run test:coverage(or a newtest:coverage:fullscript) produces a combined coverage report covering both server-side TypeScript utilities and browser-rendered Svelte componentsENOTEMPTYoncoverage/.tmp) no longer occursRelated
test:coveragehas always been--project=server🏗️ Markus Keller — Application Architect
Observations
coverageblock invite.config.tsapplies only to theserverproject because theclientproject has no independentcoverageconfiguration and shares the samecoverage/.tmpscratch directory. Both projects racing to clean the same temp directory is the predictable outcome.includelist in the coverage block (src/lib/shared/utils/**,src/lib/shared/server/**,src/lib/shared/discussion/**,src/lib/document/**) is intentionally narrow — good discipline. But it means the 80% branch threshold is calibrated to a subset of the codebase and gives no signal about Svelte component behaviour.--sequence.concurrent=false) solves the race condition with zero new dependencies. However it doubles CI coverage time; for a solo developer on a NAS runner this is a legitimate concern.Recommendations
coverage.reportsDirectoryper project (coverage/serverandcoverage/client) as the first step. This eliminates the race without any sequencing overhead and costs one config line.@vitest/coverage-istanbulas a dev dependency and configure it on theclientproject block. The v8 provider is silently a no-op inside Chromium; Istanbul is the supported mechanism for browser-mode coverage. The issue description already identifies this correctly.--sequence.concurrent=falseas the final answer — it is an acceptable workaround while the Istanbul integration is being configured, but should not become the permanent solution. Sequential projects hide the root cause and penalise CI runtime indefinitely.lcov-result-mergeruntil both projects produce valid reports. Merge tooling should be added only when there is data worth merging.clientcoverage.Open Decisions
clientproject should be included in the coverageincludelist? The current list only covers utility and server code. If all.sveltecomponents are included, the bar will start much lower than 80%. Consider a phased rollout: add components to the include list incrementally as their test coverage rises, rather than gating the entire component layer on day one.👨💻 Felix Brandt — Fullstack Developer
Observations
vite.config.ts. Thecoverageblock lives at the top-leveltestobject, which means it is inherited by both projects viaextends: './vite.config.ts'. Theclientproject adds browser config but nocoverageoverride — so both projects use the sameprovider: 'v8'and the samereportsDirectory(defaultcoverage/). The race oncoverage/.tmpis structural.test:coveragescript isvitest run --coverage --project=server. This explicitly runs only theserverproject, which is why the race never manifests during the normal coverage workflow. The issue title says "browser-project tests contribute nothing to coverage measurement" — that's exactly the consequence of--project=server.@vitest/coverage-v8is already indevDependencies.@vitest/coverage-istanbulis not. Adding it is a one-linepackage.jsonchange plus installing it.exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**']in the coverage block explicitly excludes Svelte files from the server project's coverage — which is correct, since those files cannot be instrumented by v8 in a Node environment. The browser project, with Istanbul, would not need this exclusion.*.svelte.spec.tsnaming pattern and are routed to theclientproject viainclude: ['src/**/*.svelte.{test,spec}.{js,ts}']. The test infrastructure is ready; only the coverage instrumentation is missing.Recommendations
vite.config.ts, write a test that asserts the expected coverage report file exists atcoverage/client/lcov.infoafter running the browser project. This gives you a concrete failing signal.clientproject block:test:coverageinpackage.jsonto run both projects and then merge:&&prevents the race entirely without--sequence.concurrent=false.@vitest/coverage-istanbul— do not install it as@vitest/coverage-v8replacement; both should coexist since the server project continues using v8.--project=serverguard from the currenttest:coveragescript until the Istanbul setup is confirmed to produce a valid report. Keep the old script astest:coverage:serverfor regression testing during the migration.Open Decisions
coverage/client/be added to.gitignore, or is there a reason to commit coverage artifacts? (Currentcoverage/directory appears to already be ignored — worth verifying.)🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
.gitea/workflows/ci.yml) runsnpm test(which maps tovitest runacross both projects) but does not runnpm run test:coverage. Coverage is therefore not gated in CI at all today — the 80% branch threshold only applies if someone manually runstest:coveragelocally. This is the deeper problem the issue exposes.unit-testsjob usesmcr.microsoft.com/playwright:v1.58.2-nobleas the container image, which is sensible — Playwright and Chromium are pre-installed. This image will also support Istanbul-instrumented browser coverage without any additional system-level dependencies.package-lock.jsonis currently untracked (visible ingit statusat the start of this session as?? package-lock.json). Without a committed lock file thenpm cistep in CI is effectivelynpm installwith version drift risk. That should be fixed regardless of this issue.--sequence.concurrent=false) would add wall-clock time to theunit-testsCI job. At current test scale this is probably 30–60 seconds extra, acceptable but not free.actions/upload-artifact@v4is already being used — good, no deprecated action concerns here.Recommendations
test:coveragestep to theunit-testsCI job immediately after the test run step. Even before the browser project is fixed, having server coverage gated in CI is better than the current situation (no gate):package-lock.json— this is a prerequisite for reliablenpm ciin CI. The untracked status is a latent reliability risk unrelated to but more urgent than this issue.--sequence.concurrent=falseas a CI strategy. It serialises all projects globally; adding a third project later doubles the pain again. SeparatereportsDirectoryvalues solve the race structurally.vitest run --coverageexits non-zero when thresholds are not met — make sure the CI step is not wrapped inif: always()which would mask the failure.Open Decisions
lcov.infobe uploaded to a code coverage service (Codecov, Coveralls, SonarQube)? Given the self-hosted philosophy, a local HTML report artifact is sufficient — but worth deciding before building the merge pipeline.📋 Elicit — Requirements Engineer
Observations
npm run test:coverage(or a newtest:coverage:fullscript)" — the parenthetical "or" means the final script name is undecided. Any implementation will need to choose one, and the issue should specify which.Recommendations
test:coverage:fullas the new script (leavingtest:coveragepointing at the existing server-only command for backward compatibility during migration), then rename once browser coverage is stable.unit-testsCI job." Without this, the sequential approach (Approach 3) could be accepted even if it adds 5 minutes to every PR.Open Decisions
clientproject on day one of measurement? Starting at 80% may be unachievable without a large test-writing effort. Setting a realistic baseline (whatever the first measurement shows) and ratcheting upward avoids a situation where the gate is immediately broken by the act of enabling it.🔒 Nora Steiner (NullX) — Security Engineer
Observations
WRITE_ALL). The server enforces these permissions via@RequirePermission, but if a component-level conditional is wrong — say, an edit button visible to a user who lacksWRITE_ALL— that is a UX security flaw that only a browser-executed component test can catch. With zero browser coverage measurement, there is no way to know if these branches are tested.--project=serverfilter means that all*.svelte.spec.tsfiles (which test rendered component state, including conditional UI based on permissions) contribute nothing to the coverage gate. A developer could remove a permission check from a Svelte template and the coverage threshold would not move.lcov-result-mergeror@vitest/coverage-istanbulthemselves. Both are well-maintained packages with no known CVEs at this time.Recommendations
includelist from day one.${{ secrets.CODECOV_TOKEN }}) follows the same pattern as other CI secrets. Coverage reports can reveal file paths, function names, and dead code that aids attackers in mapping the application surface.@vitest/coverage-istanbuldoes not accidentally instrumentsrc/lib/server/files in the browser project context. Server-only code (auth cookie handling, session management) should remain in the server project's coverage scope only. Theexcludelist in the client project's coverage block should explicitly includesrc/lib/server/**andsrc/hooks/**.🧪 Sara Holt — QA Engineer & Test Strategist
Observations
*.svelte.spec.tsfiles) exists and runs, but its output is not measured. The 80% branch threshold is therefore gating only the base of the pyramid (shared utilities, server helpers) while the widest coverage gap — Svelte component branches — goes undetected.src/lib/document/directory, there are component spec files for most major UI components (BulkDocumentEditLayout.svelte.spec.ts,UploadZone.svelte.spec.ts,DocumentRow.svelte.spec.ts, etc.). These tests exist and are running in theclientproject. The issue is not that coverage is missing — it is that the tool is not measuring it.ENOTEMPTYoncoverage/.tmp) only manifests when both projects run simultaneously without separatereportsDirectoryvalues. Thetest:coveragescript currently sidesteps this by explicitly targeting only the server project, so the race has likely never been observed in practice on this codebase. The issue is correct that it would surface if someone ranvitest run --coverageacross both projects.Recommendations
clientcoverage threshold to whatever the first measurement produces, then create a follow-up issue to raise it incrementally. A coverage gate that starts at 40% and ratchets to 80% over several sprints is better than a gate that is immediately broken and bypassed.test:coverage:fullto produce a combined report, but without CI enforcement the gate is advisory only. A quality gate that does not gate is not a gate.clientproject'sexcludelist mirrors theserverproject's exclusions for generated code:src/lib/paraglide/**,src/lib/generated/**. Generated files should not count toward or against the coverage threshold.Open Decisions
clientcoverage threshold be enforced as a hard CI failure gate from day one, or should it start as a reporting-only metric? Given that the initial number is unknown, starting as a report-only metric for one sprint is pragmatic — but set a calendar date for when it becomes a hard gate, not a "when we get there."🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist
Observations
vitest-browser-sveltetests run in a real Chromium instance, which means they execute against the real DOM including CSS layout and ARIA tree. This makes them uniquely valuable for catching accessibility regressions that JSDOM-based tests miss. Bringing these tests into coverage measurement reinforces their importance.Recommendations
includelist is defined, make sure it explicitly covers components insrc/lib/shared/primitives/(BackButton, modal dialogs, form fields) — these are the components that touch focus management, ARIA roles, and keyboard navigation. They should be among the first components with meaningful branch coverage.aria-*attributes, focus trap logic, error state announcement viaaria-live) as high-priority test targets — not just for the coverage number, but because those branches represent real user failure modes for keyboard and screen reader users.No open decisions from a UX perspective — this is firmly an infrastructure call.
Decision Queue — Cross-Persona Open Items
Items raised across the review that need a human judgment call before or during implementation.
Theme 1: Client coverage
includescope and initial thresholdRaised by: Markus, Sara, Elicit
The
clientproject's coverageincludelist needs a scope decision. Two options:.svelteand.svelte.tsfiles immediately; accept that the gate will fail on day one and fix coverage as a follow-up. More honest picture, more immediate pain.Recommendation from review: option A. The gate being immediately broken is worse than a lower-but-honest threshold that tightens over time.
Theme 2: Combined vs. independent coverage thresholds
Raised by: Markus, Elicit
Once both server and client projects produce reports, the threshold enforcement can go two ways:
lcov-result-mergercombines both lcov files; one 80% threshold on the union. Simpler to configure; harder to diagnose which project regressed.Recommendation from review: option A (independent). The two project types test fundamentally different things; their coverage should be measured and gated separately.
Theme 3: CI enforcement timing for the client gate
Raised by: Sara, Tobias
Should the
clientcoverage threshold be a hard CI failure gate from day one of this issue landing, or a reporting-only metric for an initial period?Recommendation from review: option B, but with a fixed date committed in the issue (not "when we get there").
Theme 4: Script naming strategy
Raised by: Elicit
The acceptance criteria list "
npm run test:coverage(or a newtest:coverage:fullscript)" as the target. The naming needs to be decided before implementation touchespackage.json:test:coverageto cover both projects (breaking change for anyone using the old script).test:coverage:fullas a new script; leavetest:coveragepointing at server-only (backward compatible during migration; rename in a follow-up).Recommendation from review: option B. Backward compatibility during the transition is worth the two-step rename.
Theme 5: Coverage report persistence / hosting
Raised by: Tobias, Nora
Where should the combined coverage report live after CI runs?
Recommendation from review: option A. Consistent with the self-hosted philosophy; HTML artifacts in Gitea are sufficient for a solo project.
Theme 1: All at once
Theme 2: A (but both 80%)
Theme 3: A Hard gate
Theme 4: A test:coverage
Theme 5: CI Artifact
Implementation complete — PR #495
What was built
4 commits on
feat/issue-425-browser-coverage:1a28e311build(deps): add @vitest/coverage-istanbul for browser-project coveragebb374bf2feat(test): add Istanbul browser coverage via standalone client config16f69ffffeat(test): update test:coverage to run both server and client projectseccecf35ci: add combined coverage gate to unit-tests jobKey implementation note
Vitest 4 silently ignores
coverageoverrides placed inside individualtest.projectsentries — the global coverage block always wins. The fix is a standalonevitest.client-coverage.config.tswhere the Istanbul coverage block sits at the roottestlevel and is actually honoured.vite.config.tsretains the v8 block for the server project (coverage/server); the standalone config writes tocoverage/client.Acceptance criteria status
npm run test:coverageproducescoverage/server/lcov.info(v8)npm run test:coverageproducescoverage/client/lcov.info(Istanbul)coverage/client/lcov.infocovers.sveltecomponentsENOTEMPTYoncoverage/.tmp) eliminated&&)The client coverage starting below 80% is the expected consequence of choosing "all at once" + "hard gate immediately". A follow-up issue to raise component test coverage to 80% is the natural next step.