Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
764 changes: 533 additions & 231 deletions src/testem/middleware.js

Large diffs are not rendered by default.

28 changes: 27 additions & 1 deletion src/testem/runtime.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
/* global Testem */
import { REPORT_TO_MIDDLEWARE_PATH } from "#utils";

/**
* testem serves each browser's test page under a numeric id prefix
* (`/<id>/tests/index.html` — see testem's server route
* `/^\/(?:-?[0-9]+)(\/.+)$/`). When testem runs several browsers in parallel
* each one gets its own id, and that id is the only thing on the page that
* tells the middleware WHICH browser is reporting — so it can take coverage
* from that browser's own CDP session rather than another browser's.
*
* Returns "" when the page isn't served under an id prefix (a page opened
* directly, say), in which case the middleware falls back to the sole
* connected browser.
*/
function browserId() {
const first = String(location.pathname).split("/")[1];

return /^-?[0-9]+$/.test(first) ? first : "";
}

function reportUrl() {
const id = browserId();

if (!id) return REPORT_TO_MIDDLEWARE_PATH;

return `${REPORT_TO_MIDDLEWARE_PATH}?id=${encodeURIComponent(id)}`;
}

export function setupCoverage() {
// Testem will not be defined at dev-time
if (typeof Testem === "undefined") return;
Expand Down Expand Up @@ -32,7 +58,7 @@ export function setupCoverage() {
Testem.afterTests(async function (err, data, next) {
const keepAlive = setInterval(function () {}, 50);
try {
await fetch(REPORT_TO_MIDDLEWARE_PATH);
await fetch(reportUrl());
clearInterval(keepAlive);
next();
} catch (fetchErr) {
Expand Down
1 change: 1 addition & 0 deletions test-scenarios/vite-app-js/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@

# broccoli-debug
/DEBUG/
coverage-parallel/
4 changes: 3 additions & 1 deletion test-scenarios/vite-app-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"start": "vite",
"test:build": "vite build --mode development",
"test:run": "testem ci --port 0",
"test": "pnpm test:build && pnpm test:run"
"test": "pnpm test:build && pnpm test:run",
"test:parallel:run": "testem ci --file testem-parallel.cjs --port 0",
"test:parallel": "pnpm test:build && pnpm test:parallel:run"
},
"devDependencies": {
"@babel/core": "^7.29.0",
Expand Down
54 changes: 54 additions & 0 deletions test-scenarios/vite-app-js/testem-parallel.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

// Parallel-browser variant of testem.cjs.
//
// testem launches one browser per entry in `test_page`, so the two entries below
// produce two simultaneous Chromes. The QUnit `filter` splits the suite so that
// NEITHER browser sees the whole app:
//
// browser 1 (filter=Unit) exercises app/utils/format-score.js
// browser 2 (filter=Integration) exercises app/components/counter.gjs
//
// A merged report therefore has to contain covered lines from both files. If the
// middleware only ever collected from one browser — the behaviour before
// per-browser sessions existed — one of those files would show zero covered
// lines, and the assertions in tests/vite-app-parallel-js.test.js fail.
if (typeof module !== 'undefined') {
module.exports = {
test_page: ['tests/index.html?hidepassed&filter=Unit', 'tests/index.html?hidepassed&filter=Integration'],
cwd: 'dist',
parallel: 2,
disable_watching: true,
launch_in_ci: ['Chrome'],
launch_in_dev: ['Chrome'],
browser_start_timeout: 120,
middleware: [
require('testem-code-coverage').middleware({
outputFolder: 'coverage-parallel',
reporters: ['json-summary'],
chrome: {
// 0 disables the fixed-port seed. Two browsers cannot share one port,
// so this run relies entirely on discovering the ephemeral ports that
// Chrome reports via <user-data-dir>/DevToolsActivePort.
remoteDebuggingPort: 0,
},
}),
],
browser_args: {
Chrome: {
ci: [
// --no-sandbox is needed when running Chrome inside a container
process.env.CI ? '--no-sandbox' : null,
'--headless',
'--disable-dev-shm-usage',
'--disable-software-rasterizer',
'--mute-audio',
// Ephemeral, so both browsers get a port of their own.
'--remote-debugging-port=0',
'--window-size=1440,900',
].filter(Boolean),
dev: ['--remote-debugging-port=0'],
},
},
};
}
13 changes: 8 additions & 5 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { readFileSync, existsSync } from "node:fs";
import { join, relative } from "node:path";

/**
* Run `pnpm test` inside a scenario directory.
* Run `pnpm <script>` (default: `test`) inside a scenario directory.
* Throws with stdout+stderr attached if the command exits non-zero.
*/
export function runScenario(scenarioDir: string): void {
const result = spawnSync("pnpm", ["test"], {
export function runScenario(scenarioDir: string, script = "test"): void {
const result = spawnSync("pnpm", [script], {
cwd: scenarioDir,
encoding: "utf8",
// Pipe both streams so we can include them in error messages.
Expand Down Expand Up @@ -69,8 +69,11 @@ function dropBranches(entry: unknown): unknown {
* Branch counts are omitted — they are non-deterministic across runs due to
* V8's tiered JIT compilation.
*/
export function readCoverageSummary(scenarioDir: string): Record<string, unknown> {
const summaryPath = join(scenarioDir, "coverage", "coverage-summary.json");
export function readCoverageSummary(
scenarioDir: string,
coverageDir = "coverage",
): Record<string, unknown> {
const summaryPath = join(scenarioDir, coverageDir, "coverage-summary.json");

if (!existsSync(summaryPath)) {
throw new Error(`coverage-summary.json not found at ${summaryPath}`);
Expand Down
62 changes: 62 additions & 0 deletions tests/vite-app-parallel-js.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, test, expect, beforeAll } from "vitest";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { runScenario, readCoverageSummary } from "./helpers.ts";

const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const scenarioDir = join(repoRoot, "test-scenarios", "vite-app-js");

/**
* Two browsers, each running half the suite (see testem-parallel.cjs):
*
* browser 1 (filter=Unit) → app/utils/format-score.js
* browser 2 (filter=Integration) → app/components/counter.gjs
*
* Neither browser covers both files, so these assertions only hold if coverage
* was collected from BOTH browsers and merged. They are the regression test for
* one-session-per-browser: with a single shared session, whichever browser lost
* the race contributes nothing and its file reports zero covered lines.
*/
let summary;

beforeAll(() => {
runScenario(scenarioDir, "test:parallel");
summary = readCoverageSummary(scenarioDir, "coverage-parallel");
});

function find(suffix) {
const key = Object.keys(summary).find((k) => k.endsWith(suffix));

return key ? summary[key] : undefined;
}

test("both browsers' files are present in the merged report", () => {
expect(find("app/utils/format-score.js"), "format-score.js (Unit browser)").toBeDefined();
expect(find("app/components/counter.gjs"), "counter.gjs (Integration browser)").toBeDefined();
});

describe("merged coverage", () => {
test("the Unit-only browser contributed covered lines", () => {
const formatScore = find("app/utils/format-score.js");

expect(formatScore.lines.covered, "format-score.js has covered lines").toBeGreaterThan(0);
});

test("the Integration-only browser contributed covered lines", () => {
const counter = find("app/components/counter.gjs");

expect(counter.lines.covered, "counter.gjs has covered lines").toBeGreaterThan(0);
});

test("uncovered branches are still reported as uncovered", () => {
// format-score.js deliberately leaves the `score < 0` branch untested, and
// counter.gjs never calls clampedCount/countAsString. Merging must not turn
// untested code green — mergeProcessCovs sums counts, so a function absent
// from both browsers stays at zero.
const formatScore = find("app/utils/format-score.js");
const counter = find("app/components/counter.gjs");

expect(formatScore.lines.pct, "format-score.js below 100%").toBeLessThan(100);
expect(counter.functions.pct, "counter.gjs functions below 100%").toBeLessThan(100);
});
});
Loading