Collect coverage from every browser, not just one - #32
Merged
Merged
Conversation
testem runs N browsers in parallel (ember-exam's --parallel, testem's own parallel option), but the middleware kept a single CDP connection and a single coverage session. Only one browser was ever instrumented, so the report described roughly 1/N of the suite while looking complete. Browsers cannot share a fixed --remote-debugging-port, and testem resolves browser_args once per browser *name* rather than per instance, so a pinned port means the second Chrome exposes no CDP endpoint at all. Parallel runs therefore have to use --remote-debugging-port=0, and Chrome reports the port it actually bound in <user-data-dir>/DevToolsActivePort. This polls for those files and connects to each browser it finds. Pinned ports are unaffected: Chrome only writes DevToolsActivePort for an ephemeral port, so chrome.remoteDebuggingPort (still defaulting to 9222) is seeded directly as before. The two paths are complementary. Per-browser state replaces the module-level session, accumulator and reload gate. Each browser's V8 snapshot is folded together with mergeProcessCovs, the same primitive already used to merge one browser's periodic deltas. Each browser's HTTP response is held open until the merged report is written, so no Chrome is torn down while another is still collecting; a browser that dies without reporting is bounded by chrome.stragglerTimeout. The runtime now sends testem's browser id (the numeric path prefix testem serves each browser's test page under) so a /_coverage request is matched to the browser that sent it. Adds a parallel scenario that splits the suite across two browsers by QUnit filter, so neither browser sees both files under test. The assertions only hold if both browsers' coverage was collected and merged.
Contributor
Coverage reports
Each artifact is the scenario's From this CI run for 62f1f14. |
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Made with Claude Code. Apologies in advance if I've misread how you intended any of this to work — this touches the CDP connection lifecycle, which is the most subtle part of the middleware, so please push back hard on anything that looks off. Happy to rework or drop it entirely.
The problem
When testem runs several browsers at once (ember-exam's
--parallel, or testem's ownparalleloption), the middleware keeps onecdpClientand one coverage accumulator. Only the first browser is instrumented. The run succeeds, a report is written, and it silently describes roughly 1/N of the suite.I hit this trying to turn coverage on for an app whose CI runs ~8 browsers per machine.
Why discovery, and why it does not replace the fixed port
Browsers can't share a fixed
--remote-debugging-port, and testem resolvesbrowser_argsonce per browser name, not per instance (browser-args.js) — so with a pinned port the second Chrome exposes no CDP endpoint at all. Parallel runs must use--remote-debugging-port=0.With an ephemeral port Chrome writes the port it actually bound into
<user-data-dir>/DevToolsActivePort, and testem gives every launcher its own--user-data-dir(Launcher#setupBrowserTmpDir). So the ports are discoverable: this polls for those files and connects to each browser it finds.Worth being explicit, because I got this wrong at first and it cost me a while: Chrome writes
DevToolsActivePortonly for an ephemeral port. With a pinned port there's nothing to announce, so no file appears:So discovery can't replace the existing path —
chrome.remoteDebuggingPort(still defaulting to9222) is still seeded directly. The two mechanisms are complementary: pinned ports via the seed, ephemeral ports via discovery. PassingremoteDebuggingPort: 0opts out of the seed.What changed
mergeProcessCovs— the same primitive already used to merge one browser's periodic deltas, since a second browser is just more deltas. No change toreport.js./_coverageresponse is held open until the merged report is written. testem doesn't kill a browser until itsafterTestshook callsnext(), so this is what stops an early-finishing browser being torn down while another is still collecting. Bounded by a newchrome.stragglerTimeout(default 30s) so one dead browser can't hang the run./_coveragerequest is matched to the browser that sent it. Falls back to the sole browser when there's only one.os.tmpdir()is shared, so onlytestem-*dirs created after the middleware started are considered — otherwise a concurrent testem run's leftovers get pulled into the report. There were 23h-old ones on my machine while I was working on this.Testing
New scenario (
testem-parallel.cjs+tests/vite-app-parallel-js.test.js) runs two browsers over the same app, split by QUnit filter so neither browser sees both files under test:The assertions only pass if both browsers' coverage was collected and merged. Observed on the merged report:
and the log shows two distinct ephemeral ports discovered:
It also asserts the merge doesn't turn untested code green —
format-score.js's untestedscore < 0branch andcounter.gjs's never-calledclampedCount/countAsStringstay uncovered.Full suite: 4 files / 28 tests passing,
oxlint+oxfmtclean. I reused the existingvite-app-jsapp rather than adding a fourth scenario directory, since only the testem config differs.Things I'm unsure about
os.tmpdir()as the discovery root assumes testem's defaultuser_data_dir. I exposedchrome.userDataDirfor the non-default case rather than trying to read testem's config, since the middleware only receives the expressapp— but you may know a better hook.readdirSync/statSyncso it should be fine, but I can't claim it's tested.connectionTimeoutwarning text saying "coverage disabled" per-browser; with N browsers that's now per-browser rather than global, which may want rewording.