Skip to content
Open
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
6 changes: 0 additions & 6 deletions knip.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,6 @@ const config = {
},

ignoreDependencies: [
/**
* Used in packages/middleware-code-coverage/test/integration/fixtures/ui5-app/package.json
* which is not part of the scope that knip analyzes
*/
"@ui5/cli",

/**
* Used via nyc ava --node-arguments="--experimental-loader=@istanbuljs/esm-loader-hook"
* which is not detected by knip as a usage of this package
Expand Down
5,054 changes: 1,909 additions & 3,145 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"devDependencies": {
"@commitlint/cli": "^21.2.4",
"@commitlint/config-conventional": "^21.2.4",
"@ui5/cli": "^4.0.71",
"check-engine-light": "0.4.0",
"husky": "^9.1.7",
"knip": "^6.43.0",
Expand Down
8 changes: 5 additions & 3 deletions packages/middleware-code-coverage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ You find this middleware in action in the [OpenUI5 Sample App](https://github.co

## Requirements

This middleware requires UI5 CLI v3 and is meant for UI5 1.113 and above.
This middleware requires UI5 CLI v5 (currently still in development) and is meant for UI5 1.113 and above. It relies on the `builtResources` parameter (available for custom middleware defining Specification Version 5.0 and later) to access the build output.

## Limitations
**Note:** To obtain faithful per-file coverage for code that would otherwise be delivered via a bundle (e.g. `*-preload.js`), load the coverage test page with `sap-ui-debug="<cover globs>"` (the string/glob form, which does not reboot the page). The UI5 runtime then requests the covered modules individually as their unminified `-dbg` sources, which this middleware instruments and reports under the runtime path.

Note that this middleware is currently not compatible with TypeScript based projects, see [TypeScript support #189](https://github.com/UI5/cli-extensions/issues/189).
## TypeScript support

TypeScript-based projects are supported. When a module is transpiled to JavaScript and ships a source map, the middleware instruments the transpiled JavaScript and remaps the collected coverage back onto the original `.ts` source in the report.

## Install

Expand Down
18 changes: 17 additions & 1 deletion packages/middleware-code-coverage/ava-integration.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
export default {
files: ["test/integration/boot.js", "test/integration/connect.js"],
files: [
"test/integration/connect.js",
"test/integration/bundle.js",
"test/integration/build-tasks.js",
"test/integration/exclude.js",
"test/integration/dependency.js",
"test/integration/app-ts.js",
"test/integration/report.js"
],
// Run one test file at a time. Each boots a real `ui5 serve`, and several files share a fixture
// (e.g. coverage-lib-ts). Running them in parallel races on per-fixture shared state: the lazy
// `npm install` (ENOTEMPTY when workers reify the same node_modules at once) and the coverage
// report dir (`tmp/coverage-reports`, where concurrent runs overwrite each other's report). Serial
// execution removes both races; these heavyweight tests are few, so the wall-clock cost is small.
concurrency: 1,
// Each test starts a real `ui5 serve` and builds lazily on first request; allow generous time.
timeout: "5m",
watchMode: {
ignoreChanges: [
"tmp/**"
Expand Down
16 changes: 16 additions & 0 deletions packages/middleware-code-coverage/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ export default [
},
},
},
{
// Plain-JS fixtures (cross-project dependency + .library exclude probes, TS app bootstrap):
// UI5 runtime sources.
files: [
"test/integration/fixtures/coverage-dep-app/webapp/**/*.js",
"test/integration/fixtures/coverage-dep-lib/src/**/*.js",
"test/integration/fixtures/coverage-app-ts/webapp/**/*.js",
],

languageOptions: {
globals: {
...globals.browser,
sap: "readonly",
},
},
},
{
files: ["test/integration/*.js"],

Expand Down
101 changes: 90 additions & 11 deletions packages/middleware-code-coverage/lib/coverage-reporter.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import libReport from "istanbul-lib-report";
import reports from "istanbul-reports";
import istanbulLibCoverage from "istanbul-lib-coverage";
import {createSourceMapStore} from "istanbul-lib-source-maps";
import path from "node:path";
import {toDebugPath, isDebugPath} from "./util.js";

/**
* @typedef {object} @ui5/middleware-code-coverage/Coverage
Expand All @@ -12,41 +14,67 @@ import path from "node:path";
/**
* Reports the coverage
*
* Coverage posted by the client is remapped through any input source maps embedded in the coverage
* data (e.g. a TypeScript project's transpiled sources mapping back to the original <code>.ts</code>)
* so it is reported against the original source. For files without such a map (plain JS, keyed by the
* runtime path) the unminified <code>-dbg</code> variant is read from the build output.
*
* @param {object} coverageData
* @param {*} config
* @param {object} resources Resource collections
* @param {module:@ui5/fs.AbstractReader} resources.all Reader or Collection to read resources of the
* @param {object} builtResources Readers for accessing the build output
* @param {module:@ui5/fs.AbstractReader} builtResources.all Reader to access the build output of the
* root project and its dependencies
* @param {module:@ui5/fs.AbstractReader} resources.rootProject Reader or Collection to read resources of
* the project the server is started in
* @param {module:@ui5/fs.AbstractReader} resources.dependencies Reader or Collection to read resources of
* the projects dependencies
* @param {module:@ui5/fs.AbstractReader} builtResources.rootProject Reader to access the build output of
* the root project
* @param {module:@ui5/fs.AbstractReader} builtResources.dependencies Reader to access the build output of
* the project's dependencies
* @param {@ui5/logger/Logger} log
* Logger instance of the custom middleware instance
* @returns {@ui5/middleware-code-coverage/Coverage}
*/
export default async function(coverageData, config, resources, log) {
export default async function(coverageData, config, builtResources, log) {
let {coverage: globalCoverageMap, watermarks} = coverageData;

// For compatibility reasons with the old structure, we need first to check
// whether the "coverage" property is present in coverageData or use the
// whole coverageData object (old structure).
globalCoverageMap = globalCoverageMap || coverageData;

const coverageMap =
let coverageMap =
istanbulLibCoverage.createCoverageMap(globalCoverageMap);
const reportConfig = {...config.report};

// Frontend config for watermarks should take precedence if present.
reportConfig.watermarks = {...reportConfig.watermarks, ...watermarks};

// Get & stash code from the resources
// Later this would be needed to create the reports
// Remap coverage through any input source maps embedded in the coverage data (e.g. a TypeScript
// project's transpiled -dbg source mapping back to the original .ts), so coverage is reported
// against the original source. This is a no-op for files without an input source map (plain JS),
// which pass through keyed by their runtime path.
rebaseInputSourceMapsToRuntimeDir(coverageMap);
const sourceMapStore = createSourceMapStore();
coverageMap = await sourceMapStore.transformCoverage(coverageMap);
coverageMap = normalizeToRuntimePaths(coverageMap);

// Get & stash the source for each (possibly remapped) key. For a remapped original (e.g. a .ts)
// the transpiled build output contains the original source next to its -dbg variant; for un-mapped
// keys (plain JS, keyed by the runtime path) the unminified -dbg variant is read. Sources are
// resolved only against the sandboxed `builtResources` reader — never the raw filesystem — because
// the keys (and the source maps they were remapped through) originate from client-posted coverage
// data; reading arbitrary paths off disk from that input would be an arbitrary-file-read.
const coverageSources = await Promise.all(
Object.keys(coverageMap.data).map(async (key) => {
let source = "";

const matchedResource = await resources.all.byPath(key);
// Prefer the -dbg variant (the original unminified source) over the minified resource at
// the key itself. This inverts the instrument-time lookup done by the middleware.
let matchedResource;
if (!isDebugPath(key)) {
matchedResource = await builtResources.all.byPath(toDebugPath(key));
}
if (!matchedResource) {
matchedResource = await builtResources.all.byPath(key);
}

if (matchedResource) {
source = await matchedResource.getString();
Expand Down Expand Up @@ -95,3 +123,54 @@ export default async function(coverageData, config, resources, log) {
availableReports: reportResults
};
}

/**
* Rebases every embedded input source map so its <code>sources</code> are bare basenames and any
* <code>sourceRoot</code> is removed, before the coverage is remapped.
*
* istanbul's source-map transform resolves a <em>relative</em> source against the generated file's
* directory (its runtime path) but returns an <em>absolute</em> source verbatim.
* <code>ui5-tooling-transpile</code> emits the original <code>sources</code> (or a
* <code>sourceRoot</code>) as an absolute, build-machine path in some environments (observed on CI,
* e.g. <code>/home/runner/.../Thing.ts</code>, while a local build emits a bare <code>Thing.ts</code>)
* — which would key coverage to that build-time path instead of the runtime path. The transpile task
* always emits the original source next to its <code>-dbg</code> output, so reducing each source to its
* basename forces the resolve to re-root onto the runtime directory, yielding the correct runtime key
* on every machine (CI, Windows, local).
*
* @param {object} coverageMap Coverage map whose file coverages may carry an <code>inputSourceMap</code>
*/
function rebaseInputSourceMapsToRuntimeDir(coverageMap) {
for (const key of coverageMap.files()) {
const {inputSourceMap} = coverageMap.fileCoverageFor(key).data;
if (!inputSourceMap || !Array.isArray(inputSourceMap.sources)) {
continue;
}
delete inputSourceMap.sourceRoot;
inputSourceMap.sources = inputSourceMap.sources.map(
(source) => path.posix.basename(String(source).replace(/\\/g, "/"))
);
}
}

/**
* Re-keys a source-map-transformed coverage map to POSIX UI5 runtime paths.
*
* transformCoverage resolves map sources via Node's `path`, so on Windows remapped keys arrive as
* native paths (e.g. `D:\...`). Sources are rebased to basenames beforehand
* ({@link rebaseInputSourceMapsToRuntimeDir}), so the only recovery needed here is normalizing the
* drive letter and separators a Windows `path.resolve` prepends.
*
* @param {object} coverageMap Coverage map after the source-map transform
* @returns {object} Coverage map re-keyed to POSIX runtime paths
*/
function normalizeToRuntimePaths(coverageMap) {
const normalizedData = {};
for (const key of coverageMap.files()) {
const fileCoverage = coverageMap.fileCoverageFor(key).data;
const runtimePath = key.replace(/\\/g, "/").replace(/^[a-zA-Z]:/, "");
fileCoverage.path = runtimePath;
normalizedData[runtimePath] = fileCoverage;
}
return istanbulLibCoverage.createCoverageMap(normalizedData);
}
Loading
Loading