From e2af2b83c30b31fe37b75eeb30c5c9e69c876399 Mon Sep 17 00:00:00 2001 From: calixteman Date: Wed, 18 Feb 2026 21:43:11 +0100 Subject: [PATCH] Add code coverage for font tests --- .github/workflows/font_tests.yml | 13 ++ gulpfile.mjs | 9 ++ package-lock.json | 237 +++++++++++++++++++++++++++++++ package.json | 4 + test/test.mjs | 87 ++++++++++++ test/webserver.mjs | 51 ++++++- 6 files changed, 398 insertions(+), 3 deletions(-) diff --git a/.github/workflows/font_tests.yml b/.github/workflows/font_tests.yml index 445dc124f..c44422048 100644 --- a/.github/workflows/font_tests.yml +++ b/.github/workflows/font_tests.yml @@ -59,3 +59,16 @@ jobs: - name: Run font tests run: npx gulp fonttest --headless + + - name: Run font tests with code coverage + run: npx gulp fonttest --headless --coverage --coverage-output build/coverage/font + + - name: Upload results to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true + files: ./build/coverage/font/lcov.info + flags: fonttest + name: codecov-umbrella + verbose: true \ No newline at end of file diff --git a/gulpfile.mjs b/gulpfile.mjs index 87d975738..0373b0797 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -731,6 +731,15 @@ function runTests(testsName, { bot = false, xfaOnly = false } = {}) { if (process.argv.includes("--headless")) { args.push("--headless"); } + if (process.argv.includes("--coverage")) { + args.push("--coverage"); + } + if (process.argv.includes("--coverage-output")) { + args.push( + "--coverage-output", + process.argv[process.argv.indexOf("--coverage-output") + 1] + ); + } const testProcess = startNode(args, { cwd: TEST_DIR, stdio: "inherit" }); testProcess.on("close", function (code) { diff --git a/package-lock.json b/package-lock.json index 4f44e354f..110a3b183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "autoprefixer": "^10.4.24", "babel-loader": "^10.0.0", "babel-plugin-add-header-comment": "^1.0.3", + "babel-plugin-istanbul": "^7.0.1", "c8": "^10.1.3", "cached-iterable": "^0.3.0", "caniuse-lite": "^1.0.30001769", @@ -42,6 +43,9 @@ "gulp-sourcemaps": "^3.0.0", "gulp-zip": "^6.1.0", "highlight.js": "^11.11.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "jasmine": "^5.13.0", "jsdoc": "^4.0.5", "jstransformer-nunjucks": "^1.2.0", @@ -2382,6 +2386,113 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -4037,6 +4148,26 @@ "dev": true, "license": "MIT" }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.15", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", @@ -7109,6 +7240,13 @@ "node": ">=10.13.0" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7223,6 +7361,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -8248,6 +8396,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8921,6 +9081,36 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -10592,6 +10782,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -13314,6 +13514,43 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-decoder": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", diff --git a/package.json b/package.json index 03ca97ee2..051d51a5d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "autoprefixer": "^10.4.24", "babel-loader": "^10.0.0", "babel-plugin-add-header-comment": "^1.0.3", + "babel-plugin-istanbul": "^7.0.1", "c8": "^10.1.3", "cached-iterable": "^0.3.0", "caniuse-lite": "^1.0.30001769", @@ -37,6 +38,9 @@ "gulp-sourcemaps": "^3.0.0", "gulp-zip": "^6.1.0", "highlight.js": "^11.11.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "jasmine": "^5.13.0", "jsdoc": "^4.0.5", "jstransformer-nunjucks": "^1.2.0", diff --git a/test/test.mjs b/test/test.mjs index ee9cf7340..a32106401 100644 --- a/test/test.mjs +++ b/test/test.mjs @@ -21,6 +21,9 @@ import { verifyManifestFiles, } from "./downloadutils.mjs"; import fs from "fs"; +import istanbulCoverage from "istanbul-lib-coverage"; +import istanbulReportGenerator from "istanbul-reports"; +import libReport from "istanbul-lib-report"; import os from "os"; import path from "path"; import puppeteer from "puppeteer"; @@ -29,6 +32,8 @@ import { translateFont } from "./font/ttxdriver.mjs"; import { WebServer } from "./webserver.mjs"; import yargs from "yargs"; +const __dirname = import.meta.dirname; + function parseOptions() { const parsedArgs = yargs(process.argv) .usage("Usage: $0") @@ -117,6 +122,16 @@ function parseOptions() { describe: "Error if verifying the manifest files fails.", type: "boolean", }) + .option("coverage", { + default: false, + describe: "Enable code coverage collection.", + type: "boolean", + }) + .option("coverageOutput", { + default: "build/coverage", + describe: "The directory where to store code coverage data.", + type: "string", + }) .option("testfilter", { alias: "t", default: [], @@ -1057,6 +1072,7 @@ function startServer() { host, port: options.port, cacheExpirationTime: 3600, + coverageEnabled: global.coverageEnabled || false, }); server.start(); } @@ -1069,17 +1085,79 @@ function getSession(browser) { return sessions.find(session => session.name === browser); } +async function writeCoverageData(outputDirectory) { + try { + console.log("\n### Writing code coverage data"); + + // Merge coverage from all sessions + const mergedCoverage = istanbulCoverage.createCoverageMap(); + for (const session of sessions) { + if (session.coverage) { + mergedCoverage.merge( + istanbulCoverage.createCoverageMap(session.coverage) + ); + } + } + + // create a context for report generation + const context = libReport.createContext({ + dir: path.join(__dirname, "..", outputDirectory), + coverageMap: mergedCoverage, + }); + + const report = istanbulReportGenerator.create("lcovonly", { + projectRoot: path.join(__dirname, ".."), + }); + report.execute(context); + + console.log(`Total files covered: ${Object.keys(mergedCoverage).length}`); + } catch (err) { + console.error("Failed to write coverage data:", err); + } +} + async function closeSession(browser) { for (const session of sessions) { if (session.name !== browser) { continue; } if (session.browser !== undefined) { + // Collect coverage before closing (works with both Chrome and Firefox) + if (global.coverageEnabled) { + try { + const pages = await session.browser.pages(); + if (pages.length > 0) { + const page = pages[0]; + + // Extract window.__coverage__ which is populated by + // babel-plugin-istanbul + const coverage = await page.evaluate(() => window.__coverage__); + + if (coverage && Object.keys(coverage).length > 0) { + session.coverage = coverage; + console.log( + `Collected coverage from ${browser}: ${Object.keys(coverage).length} files` + ); + } + } + } catch (err) { + console.warn( + `Failed to collect coverage for ${browser}:`, + err.message + ); + } + } + await session.browser.close(); } session.closed = true; const allClosed = sessions.every(s => s.closed); if (allClosed) { + // Write coverage data if enabled + if (global.coverageEnabled) { + await writeCoverageData(global.coverageOutput); + } + if (tempDir) { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -1113,6 +1191,15 @@ async function main() { stats = []; } + if (options.coverage) { + global.coverageEnabled = true; + console.log("\n### Code coverage enabled for browser tests"); + if (options.coverageOutput) { + global.coverageOutput = options.coverageOutput; + console.log(`### Code coverage output file: ${options.coverageOutput}`); + } + } + try { if (options.downloadOnly) { await ensurePDFsDownloaded(); diff --git a/test/webserver.mjs b/test/webserver.mjs index 52511ed63..70b270ee0 100644 --- a/test/webserver.mjs +++ b/test/webserver.mjs @@ -17,6 +17,7 @@ // PLEASE NOTE: This code is intended for development purposes only and // should NOT be used in production environments. +import babel from "@babel/core"; import fs from "fs"; import fsPromises from "fs/promises"; import http from "http"; @@ -43,7 +44,7 @@ const MIME_TYPES = { const DEFAULT_MIME_TYPE = "application/octet-stream"; class WebServer { - constructor({ root, host, port, cacheExpirationTime }) { + constructor({ root, host, port, cacheExpirationTime, coverageEnabled }) { const cwdURL = pathToFileURL(process.cwd()) + "/"; this.rootURL = new URL(`${root || "."}/`, cwdURL); this.host = host || "localhost"; @@ -52,6 +53,7 @@ class WebServer { this.verbose = false; this.cacheExpirationTime = cacheExpirationTime || 0; this.disableRangeRequests = false; + this.coverageEnabled = coverageEnabled || false; this.hooks = { GET: [crossOriginHandler, redirectHandler], POST: [], @@ -190,7 +192,7 @@ class WebServer { if (this.verbose) { console.log(url); } - this.#serveFile(response, localURL, fileSize); + await this.#serveFile(response, localURL, fileSize, url); } async #serveDirectoryIndex(response, url, localUrl) { @@ -288,7 +290,50 @@ class WebServer { response.end(""); } - #serveFile(response, fileURL, fileSize) { + async #serveFile(response, fileURL, fileSize, url) { + // Check if we should instrument this file for coverage + const shouldInstrument = + this.coverageEnabled && + url && + /\.m?js$/.test(fileURL.pathname) && + (url.pathname.startsWith("/src/") || url.pathname.startsWith("/web/")) && + !url.pathname.includes("/test/"); + + if (shouldInstrument) { + try { + // Read the file content + const content = await fsPromises.readFile(fileURL, "utf8"); + + // Transform with Babel and istanbul plugin + const result = babel.transformSync(content, { + filename: fileURL.pathname, + plugins: ["babel-plugin-istanbul"], + sourceMaps: false, + }); + + const instrumentedCode = result.code; + const instrumentedSize = Buffer.byteLength(instrumentedCode, "utf8"); + + // Serve the instrumented code + response.setHeader("Content-Type", "application/javascript"); + response.setHeader("Content-Length", instrumentedSize); + if (this.cacheExpirationTime > 0) { + const expireTime = new Date(); + expireTime.setSeconds( + expireTime.getSeconds() + this.cacheExpirationTime + ); + response.setHeader("Expires", expireTime.toUTCString()); + } + response.writeHead(200); + response.end(instrumentedCode, "utf8"); + return; + } catch (error) { + console.error(`Failed to instrument ${fileURL.pathname}:`, error); + // Fall through to serve the original file + } + } + + // Serve the file normally const stream = fs.createReadStream(fileURL, { flags: "rs" }); stream.on("error", error => { response.writeHead(500);