From 47f0bdc6a5a21e72d7c33f9965aaaff5b0c2e989 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 29 Apr 2026 11:02:51 +0200 Subject: [PATCH] Use Istanbul instrumentation for unittestcli code coverage --- external/ccov/coverage_format.mjs | 40 ++++++ .../coverage_search.mjs | 0 gulpfile.mjs | 121 +++++++++++++----- package-lock.json | 82 ------------ package.json | 1 - test/test.mjs | 28 +--- test/unit/clitests_helper.js | 12 ++ 7 files changed, 142 insertions(+), 142 deletions(-) create mode 100644 external/ccov/coverage_format.mjs rename external/{coverage_search => ccov}/coverage_search.mjs (100%) diff --git a/external/ccov/coverage_format.mjs b/external/ccov/coverage_format.mjs new file mode 100644 index 000000000..d936a8e01 --- /dev/null +++ b/external/ccov/coverage_format.mjs @@ -0,0 +1,40 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const COVERAGE_FORMAT_TO_REPORTER = { + info: "lcovonly", + html: "html", + json: "json", + text: "text", + cobertura: "cobertura", + clover: "clover", +}; + +function parseCoverageFormats(str) { + const formats = new Set(); + for (const fmt of (str ?? "").split(",")) { + const name = fmt.trim(); + if (name && COVERAGE_FORMAT_TO_REPORTER[name]) { + formats.add(name); + } else if (name) { + console.warn( + `### Unknown coverage format "${name}", valid values: ${Object.keys(COVERAGE_FORMAT_TO_REPORTER).join(", ")}` + ); + } + } + return formats.size > 0 ? formats : new Set(["info"]); +} + +export { COVERAGE_FORMAT_TO_REPORTER, parseCoverageFormats }; diff --git a/external/coverage_search/coverage_search.mjs b/external/ccov/coverage_search.mjs similarity index 100% rename from external/coverage_search/coverage_search.mjs rename to external/ccov/coverage_search.mjs diff --git a/gulpfile.mjs b/gulpfile.mjs index 829a0f352..40c8247df 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -18,6 +18,10 @@ import { babelPluginStripSrcPath, preprocessPDFJSCode, } from "./external/builder/babel-plugin-pdfjs-preprocessor.mjs"; +import { + COVERAGE_FORMAT_TO_REPORTER, + parseCoverageFormats, +} from "./external/ccov/coverage_format.mjs"; import { exec, execSync, spawn, spawnSync } from "child_process"; import autoprefixer from "autoprefixer"; import babel from "@babel/core"; @@ -27,7 +31,10 @@ import { finished } from "stream/promises"; import fs from "fs"; import gulp from "gulp"; import hljs from "highlight.js"; +import istanbulCoverage from "istanbul-lib-coverage"; +import istanbulReportGenerator from "istanbul-reports"; import layouts from "@metalsmith/layouts"; +import libReport from "istanbul-lib-report"; import markdown from "@metalsmith/markdown"; import Metalsmith from "metalsmith"; import ordered from "ordered-read-streams"; @@ -788,7 +795,7 @@ function runTests(testsName, { bot = false } = {}) { const result = spawnSync( "node", [ - path.join(__dirname, "external/coverage_search/coverage_search.mjs"), + path.join(__dirname, "external/ccov/coverage_search.mjs"), `--code=${codeArg}`, `--coverage-dir=${coverageDir}`, ], @@ -948,7 +955,7 @@ gulp.task("coverage_search", function (done) { const result = spawnSync( "node", [ - path.join(__dirname, "external/coverage_search/coverage_search.mjs"), + path.join(__dirname, "external/ccov/coverage_search.mjs"), `--code=${codeArg}`, `--coverage-dir=${coverageDir}`, ], @@ -1714,6 +1721,7 @@ function buildLibHelper(bundleDefines, inputStream, outputDir) { }, }; const enableSourceMaps = bundleDefines.TESTING; + const enableCoverage = bundleDefines.COVERAGE; function preprocessLib(file, _enc, callback) { const skipBabel = bundleDefines.SKIP_BABEL; @@ -1733,7 +1741,32 @@ function buildLibHelper(bundleDefines, inputStream, outputDir) { // Calculate relative path from output directory to source file const relativeSourcePath = path.relative(outputFileDir, file.path); + const plugins = [ + [babelPluginPDFJSPreprocessor, ctx], + [babelPluginStripSrcPath], + ]; + if (enableCoverage) { + plugins.push([ + "babel-plugin-istanbul", + { + cwd: __dirname, + include: ["src/**/*.js", "web/**/*.js"], + }, + ]); + } + plugins.push([ + "add-header-comment", + { + header: licenseHeader, + }, + ]); + const result = babel.transform(file.contents.toString(), { + ...(enableCoverage && { + filename: file.path, + babelrc: false, + configFile: false, + }), sourceType: "module", presets: skipBabel ? undefined @@ -1743,16 +1776,7 @@ function buildLibHelper(bundleDefines, inputStream, outputDir) { { ...BABEL_PRESET_ENV_OPTS, loose: false, modules: false }, ], ], - plugins: [ - [babelPluginPDFJSPreprocessor, ctx], - [babelPluginStripSrcPath], - [ - "add-header-comment", - { - header: licenseHeader, - }, - ], - ], + plugins, targets: BABEL_TARGETS, sourceMaps: enableSourceMaps, sourceFileName: relativeSourcePath, @@ -1789,6 +1813,10 @@ function buildLib(defines, dir) { BUNDLE_VERSION: versionInfo.version, BUNDLE_BUILD: versionInfo.commit, TESTING: defines.TESTING ?? process.env.TESTING === "true", + COVERAGE: + defines.COVERAGE ?? + (process.argv.includes("--coverage") || + process.argv.includes("--coverage-per-test")), DEFAULT_FTL: getDefaultFtl(), }; @@ -2164,40 +2192,63 @@ gulp.task( }, function runUnitTestCli(done) { const useCoverage = process.argv.includes("--coverage"); + const coverageDir = + getArgValue("--coverage-output") || BUILD_DIR + "coverage"; + const coverageFormats = parseCoverageFormats( + getArgValue("--coverage-formats") + ); + const coverageFile = path.join( + __dirname, + BUILD_DIR, + "tmp", + "unittestcli-coverage.json" + ); + const env = { ...process.env }; if (useCoverage) { console.log("\n### Running unit tests with code coverage"); + env.UNITTESTCLI_COVERAGE_FILE = coverageFile; + fs.rmSync(coverageFile, { force: true }); } - let jasmineProcess; - if (useCoverage) { - const options = [ - "node_modules/c8/bin/c8.js", - "node", - "--max-http-header-size=80000", - "node_modules/jasmine/bin/jasmine", - "JASMINE_CONFIG_PATH=test/unit/clitests.json", - ]; - jasmineProcess = spawn("node", options, { stdio: "inherit" }); - } else { - const options = [ - "--enable-source-maps", - "node_modules/jasmine/bin/jasmine", - "JASMINE_CONFIG_PATH=test/unit/clitests.json", - ]; - jasmineProcess = startNode(options, { stdio: "inherit" }); - } + const options = [ + "--enable-source-maps", + "node_modules/jasmine/bin/jasmine", + "JASMINE_CONFIG_PATH=test/unit/clitests.json", + ]; + const jasmineProcess = startNode(options, { stdio: "inherit", env }); jasmineProcess.on("close", function (code) { + if (useCoverage) { + if (fs.existsSync(coverageFile)) { + const rawCoverage = JSON.parse( + fs.readFileSync(coverageFile, "utf8") + ); + const coverageMap = istanbulCoverage.createCoverageMap(rawCoverage); + const context = libReport.createContext({ + dir: coverageDir, + coverageMap, + }); + for (const fmt of coverageFormats) { + istanbulReportGenerator + .create(COVERAGE_FORMAT_TO_REPORTER[fmt], { + projectRoot: __dirname, + }) + .execute(context); + } + console.log( + `\n### Code coverage report generated in ${coverageDir} directory` + ); + } else { + console.warn( + `\n### No coverage data found at ${coverageFile}. Did the build include 'babel-plugin-istanbul'?` + ); + } + } if (code !== 0) { done(new Error("Unit tests failed.")); return; } - if (useCoverage) { - console.log( - "\n### Code coverage report generated in ./build/coverage directory" - ); - } done(); }); } diff --git a/package-lock.json b/package-lock.json index a65c67a16..6df1b2c20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "babel-loader": "^10.1.1", "babel-plugin-add-header-comment": "^1.0.3", "babel-plugin-istanbul": "^8.0.0", - "c8": "^11.0.0", "cached-iterable": "^0.3.0", "caniuse-lite": "^1.0.30001791", "core-js": "^3.49.0", @@ -1598,16 +1597,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@cacheable/memory": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", @@ -2930,13 +2919,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4450,55 +4432,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/c8": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", - "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.1", - "@istanbuljs/schema": "^0.1.3", - "find-up": "^5.0.0", - "foreground-child": "^3.1.1", - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.1.6", - "test-exclude": "^8.0.0", - "v8-to-istanbul": "^9.0.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1" - }, - "bin": { - "c8": "bin/c8.js" - }, - "engines": { - "node": "20 || >=22" - }, - "peerDependencies": { - "monocart-coverage-reports": "^2" - }, - "peerDependenciesMeta": { - "monocart-coverage-reports": { - "optional": true - } - } - }, - "node_modules/c8/node_modules/test-exclude": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", - "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^13.0.6", - "minimatch": "^10.2.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/cacheable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz", @@ -12224,21 +12157,6 @@ "dev": true, "license": "MIT" }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/v8flags": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz", diff --git a/package.json b/package.json index bf8e10b81..68bb88bdb 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "babel-loader": "^10.1.1", "babel-plugin-add-header-comment": "^1.0.3", "babel-plugin-istanbul": "^8.0.0", - "c8": "^11.0.0", "cached-iterable": "^0.3.0", "caniuse-lite": "^1.0.30001791", "core-js": "^3.49.0", diff --git a/test/test.mjs b/test/test.mjs index ac74e1c41..85805b9e6 100644 --- a/test/test.mjs +++ b/test/test.mjs @@ -15,6 +15,10 @@ /* eslint-disable no-var */ import { copySubtreeSync, ensureDirSync } from "./testutils.mjs"; +import { + COVERAGE_FORMAT_TO_REPORTER, + parseCoverageFormats, +} from "../external/ccov/coverage_format.mjs"; import { downloadManifestFiles, verifyManifestFiles, @@ -1107,30 +1111,6 @@ function getSession(browser) { return sessions.find(session => session.name === browser); } -const COVERAGE_FORMAT_TO_REPORTER = { - info: "lcovonly", - html: "html", - json: "json", - text: "text", - cobertura: "cobertura", - clover: "clover", -}; - -function parseCoverageFormats(str) { - const formats = new Set(); - for (const fmt of str.split(",")) { - const name = fmt.trim(); - if (name && COVERAGE_FORMAT_TO_REPORTER[name]) { - formats.add(name); - } else if (name) { - console.warn( - `### Unknown coverage format "${name}", valid values: ${Object.keys(COVERAGE_FORMAT_TO_REPORTER).join(", ")}` - ); - } - } - return formats.size > 0 ? formats : new Set(["info"]); -} - function accumulatePerTestCoverage(testId, counts) { let testIdx = perTestIdMap.get(testId); if (testIdx === undefined) { diff --git a/test/unit/clitests_helper.js b/test/unit/clitests_helper.js index d86eeacbb..77270d221 100644 --- a/test/unit/clitests_helper.js +++ b/test/unit/clitests_helper.js @@ -18,6 +18,8 @@ import { setVerbosityLevel, VerbosityLevel, } from "../../src/shared/util.js"; +import fs from "fs"; +import path from "path"; // Sets longer timeout, similar to `jasmine-boot.js`. jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; @@ -32,3 +34,13 @@ if (!isNodeJS) { // Reduce the amount of console "spam", by ignoring `info`/`warn` calls, // when running the unit-tests in Node.js/Travis. setVerbosityLevel(VerbosityLevel.ERRORS); + +const coverageFile = process.env.UNITTESTCLI_COVERAGE_FILE; +if (coverageFile) { + process.on("exit", () => { + if (globalThis.__coverage__) { + fs.mkdirSync(path.dirname(coverageFile), { recursive: true }); + fs.writeFileSync(coverageFile, JSON.stringify(globalThis.__coverage__)); + } + }); +}