From cb8055f0a993558233a7e3ee41d01fc5d078bc87 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 27 Apr 2026 21:41:57 +0200 Subject: [PATCH] Collect worker-side coverage for browser unit tests --- test/coverage_utils.js | 58 ++++++++++++++++++++++++++++++++ test/driver.js | 36 ++------------------ test/unit/jasmine-boot.js | 69 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 test/coverage_utils.js diff --git a/test/coverage_utils.js b/test/coverage_utils.js new file mode 100644 index 000000000..118e63593 --- /dev/null +++ b/test/coverage_utils.js @@ -0,0 +1,58 @@ +/* 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. + */ + +// Istanbul coverage objects use s (statements), b (branches), and f (functions) +// as shorthand keys for the hit-count maps. +function mergeWorkerCoverageIntoWindow(coverage) { + if (!coverage || Object.keys(coverage).length === 0) { + return; + } + window.__coverage__ ??= {}; + for (const [key, fileCoverage] of Object.entries(coverage)) { + const existing = window.__coverage__[key]; + if (!existing) { + window.__coverage__[key] = fileCoverage; + continue; + } + for (const id of Object.keys(fileCoverage.s)) { + existing.s[id] = (existing.s[id] ?? 0) + fileCoverage.s[id]; + } + for (const id of Object.keys(fileCoverage.b)) { + existing.b[id] = fileCoverage.b[id].map( + (c, i) => (existing.b[id]?.[i] ?? 0) + c + ); + } + for (const id of Object.keys(fileCoverage.f)) { + existing.f[id] = (existing.f[id] ?? 0) + fileCoverage.f[id]; + } + } +} + +async function fetchAndMergeWorkerCoverage(pdfWorker) { + if (!pdfWorker) { + return; + } + try { + const coverage = await pdfWorker.messageHandler.sendWithPromise( + "GetWorkerCoverage", + null + ); + mergeWorkerCoverageIntoWindow(coverage); + } catch (e) { + console.warn(`Failed to collect worker coverage: ${e}`); + } +} + +export { fetchAndMergeWorkerCoverage, mergeWorkerCoverageIntoWindow }; diff --git a/test/driver.js b/test/driver.js index 128c9a639..b8b6b8ab0 100644 --- a/test/driver.js +++ b/test/driver.js @@ -14,6 +14,8 @@ */ /* globals pdfjsLib, _pdfjsTestingUtils, pdfjsViewer */ +import { fetchAndMergeWorkerCoverage } from "./coverage_utils.js"; + const { AnnotationLayer, AnnotationMode, @@ -1531,39 +1533,7 @@ class Driver { } async _collectWorkerCoverage() { - try { - const workerCoverage = - await this.#pdfWorker.messageHandler.sendWithPromise( - "GetWorkerCoverage", - null - ); - if (workerCoverage && Object.keys(workerCoverage).length > 0) { - window.__coverage__ ??= {}; - for (const [key, fileCoverage] of Object.entries(workerCoverage)) { - if (window.__coverage__[key]) { - // Istanbul coverage objects use s (statements), b (branches), and - // f (functions) as shorthand keys for the hit-count maps. - for (const id of Object.keys(fileCoverage.s)) { - window.__coverage__[key].s[id] = - (window.__coverage__[key].s[id] ?? 0) + fileCoverage.s[id]; - } - for (const id of Object.keys(fileCoverage.b)) { - window.__coverage__[key].b[id] = fileCoverage.b[id].map( - (c, i) => (window.__coverage__[key].b[id]?.[i] ?? 0) + c - ); - } - for (const id of Object.keys(fileCoverage.f)) { - window.__coverage__[key].f[id] = - (window.__coverage__[key].f[id] ?? 0) + fileCoverage.f[id]; - } - } else { - window.__coverage__[key] = fileCoverage; - } - } - } - } catch (e) { - console.warn(`Failed to collect worker coverage: ${e}`); - } + await fetchAndMergeWorkerCoverage(this.#pdfWorker); this.#pdfWorker.destroy(); this.#pdfWorker = null; } diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index ec8592ae3..44c461ff5 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -42,6 +42,9 @@ import { GlobalWorkerOptions } from "pdfjs/display/worker_options.js"; import { isNodeJS } from "../../src/shared/util.js"; +import { mergeWorkerCoverageIntoWindow } from "../coverage_utils.js"; +import { MessageHandler } from "pdfjs/shared/message_handler.js"; +import { PDFWorker } from "pdfjs/display/api.js"; import { TestReporter } from "../reporter.js"; async function initializePDFJS(callback) { @@ -114,12 +117,66 @@ async function initializePDFJS(callback) { "The `gulp unittest` command cannot be used in Node.js environments." ); } - // Configure the worker. - GlobalWorkerOptions.workerSrc = "../../build/generic/build/pdf.worker.mjs"; + // Configure the worker. Point at the raw source so the webserver can + // instrument it on request and the worker accumulates `__coverage__`. + GlobalWorkerOptions.workerSrc = "../../src/pdf.worker.js"; callback(); } +// Each unit-test typically spins up its own `PDFWorker`, which is destroyed +// when the loading task is. Hook `destroy` so that we extract the worker-side +// `__coverage__` before terminating, and merge it into the main thread's +// `window.__coverage__`. Without this, anything tested through `getDocument` +// → worker (most of `core/`) has its execution counts dropped on the floor. +const pendingWorkerCoverage = new Set(); + +function installWorkerCoverageHook() { + if (!window.__coverage__) { + return; + } + const originalDestroy = PDFWorker.prototype.destroy; + PDFWorker.prototype.destroy = function () { + if (this.destroyed || !this._webWorker) { + // Already torn down, or wrapping a foreign port — defer to the original + // implementation, which leaves the underlying `Worker` alone. + return originalDestroy.call(this); + } + // Capture the underlying Worker, then run the original destroy with + // `terminate` neutralized so the public `destroyed`/`port` contract is + // preserved synchronously while the Worker stays alive long enough to + // hand back its `__coverage__`. + const webWorker = this._webWorker; + const realTerminate = webWorker.terminate.bind(webWorker); + webWorker.terminate = () => {}; + try { + originalDestroy.call(this); + } finally { + webWorker.terminate = realTerminate; + } + const handler = new MessageHandler("main", "worker", webWorker); + const promise = handler + .sendWithPromise("GetWorkerCoverage", null) + .then(mergeWorkerCoverageIntoWindow) + .catch(e => { + console.warn(`Failed to collect worker coverage: ${e}`); + }) + .finally(() => { + handler.destroy(); + realTerminate(); + pendingWorkerCoverage.delete(promise); + }); + pendingWorkerCoverage.add(promise); + return undefined; + }; +} + +async function flushPendingWorkerCoverage() { + while (pendingWorkerCoverage.size > 0) { + await Promise.allSettled(pendingWorkerCoverage); + } +} + (function () { window.jasmine = jasmineRequire.core(jasmineRequire); @@ -140,6 +197,13 @@ async function initializePDFJS(callback) { env.addReporter(htmlReporter); + if (window.__coverage__) { + // Must run before `TestReporter`, whose `jasmineDone` triggers the + // browser teardown; the worker-side counters need to be merged into + // `window.__coverage__` before the page is closed. + env.addReporter({ jasmineDone: flushPendingWorkerCoverage }); + } + if (urls.queryString.getParam("browser")) { const testReporter = new TestReporter(urls.queryString.getParam("browser")); env.addReporter(testReporter); @@ -157,6 +221,7 @@ async function initializePDFJS(callback) { function unitTestInit() { initializePDFJS(function () { + installWorkerCoverageHook(); env.execute(); }); }