commit 105251337471ac4ecc67300cead946d54de3a2c9
parent 871980eb3274cf53891a703958587ba59852b8ad
Author: Florian Dold <dold@taler.net>
Date: Mon, 24 Aug 2026 02:39:55 +0200
bank web UI: report coverage for application source files
Diffstat:
2 files changed, 147 insertions(+), 1 deletion(-)
diff --git a/packages/libeufin-bank-webui/coverage-report.mjs b/packages/libeufin-bank-webui/coverage-report.mjs
@@ -0,0 +1,146 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+*/
+
+import { resolve, sep } from "node:path";
+import { readFileSync } from "node:fs";
+import { transformSync } from "esbuild";
+
+const sourceRoot = `${resolve("src")}${sep}`;
+const excludedSources = [
+ `${sep}i18n${sep}strings.ts`,
+ `${sep}pages${sep}rnd.ts`,
+ `${sep}Routing.tsx`,
+ `${sep}index.tsx`,
+];
+
+function isApplicationSource(file) {
+ return (
+ file.path.startsWith(sourceRoot) &&
+ !excludedSources.some((suffix) => file.path.endsWith(suffix)) &&
+ !/\.(?:examples|stories|test)\.tsx?$/.test(file.path) &&
+ !file.path.endsWith(`${sep}stories.tsx`)
+ );
+}
+
+function percentage(covered, total) {
+ return total === 0 ? 100 : (covered / total) * 100;
+}
+
+function format(value) {
+ return value.toFixed(2).padStart(6);
+}
+
+const base64 = new Map(
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+ .split("")
+ .map((character, index) => [character, index]),
+);
+
+function decodeSegment(segment) {
+ const values = [];
+ let value = 0;
+ let shift = 0;
+ for (const character of segment) {
+ const digit = base64.get(character);
+ if (digit === undefined) throw new Error("Invalid source map segment");
+ value += (digit & 31) << shift;
+ if (digit & 32) {
+ shift += 5;
+ continue;
+ }
+ const negative = value & 1;
+ value >>= 1;
+ values.push(negative ? -value : value);
+ value = 0;
+ shift = 0;
+ }
+ return values;
+}
+
+function executableLines(file) {
+ const source = readFileSync(file, "utf8");
+ const transpiled = transformSync(source, {
+ sourcefile: file,
+ loader: file.endsWith("x") ? "tsx" : "ts",
+ target: "es2023",
+ jsxFactory: "h",
+ jsxFragment: "Fragment",
+ sourcemap: "external",
+ });
+ const sourceMap = JSON.parse(transpiled.map);
+ const result = new Set();
+ let previousSource = 0;
+ let previousOriginalLine = 0;
+
+ for (const generatedLine of sourceMap.mappings.split(";")) {
+ for (const segment of generatedLine.split(",")) {
+ if (!segment) continue;
+ const values = decodeSegment(segment);
+ if (values.length < 4) continue;
+ previousSource += values[1];
+ previousOriginalLine += values[2];
+ if (previousSource === 0) result.add(previousOriginalLine + 1);
+ }
+ }
+ return result;
+}
+
+export default async function* coverageReport(source) {
+ for await (const event of source) {
+ if (event.type === "test:coverage") {
+ const files = event.data.summary.files.filter(isApplicationSource);
+ const totals = files.reduce(
+ (result, file) => {
+ const executable = executableLines(file.path);
+ return {
+ lines: result.lines + executable.size,
+ coveredLines: result.coveredLines + file.coveredLineCount,
+ branches: result.branches + file.totalBranchCount,
+ coveredBranches: result.coveredBranches + file.coveredBranchCount,
+ functions: result.functions + file.totalFunctionCount,
+ coveredFunctions:
+ result.coveredFunctions + file.coveredFunctionCount,
+ };
+ },
+ {
+ lines: 0,
+ coveredLines: 0,
+ branches: 0,
+ coveredBranches: 0,
+ functions: 0,
+ coveredFunctions: 0,
+ },
+ );
+ const coverage = {
+ line: percentage(totals.coveredLines, totals.lines),
+ branch: percentage(totals.coveredBranches, totals.branches),
+ function: percentage(totals.coveredFunctions, totals.functions),
+ };
+ if (process.env.COVERAGE_DEBUG) {
+ for (const file of files.sort(
+ (left, right) => right.totalLineCount - left.totalLineCount,
+ )) {
+ yield `${file.path.slice(sourceRoot.length)} ${file.coveredLinePercent.toFixed(2)}% (${file.coveredLineCount}/${file.totalLineCount})\n`;
+ }
+ }
+ yield [
+ "Bank WebUI application coverage",
+ ` Lines: ${format(coverage.line)}% (${totals.coveredLines}/${totals.lines})`,
+ ` Branches: ${format(coverage.branch)}% (${totals.coveredBranches}/${totals.branches})`,
+ ` Functions: ${format(coverage.function)}% (${totals.coveredFunctions}/${totals.functions})`,
+ "",
+ ].join("\n");
+ }
+ }
+}
diff --git a/packages/libeufin-bank-webui/package.json b/packages/libeufin-bank-webui/package.json
@@ -10,7 +10,7 @@
"build": "tsc && ./build.mjs",
"build:with-deps": "pnpm --filter \"{.}...\" run build",
"test": "./test.mjs && node --test 'dist/test/**/*.test.js' 'dist/test/**/test.js'",
- "coverage": "./test.mjs && node --experimental-test-coverage --test 'dist/test/**/*.test.js' 'dist/test/**/test.js'",
+ "coverage": "./test.mjs && node --experimental-test-coverage --test-concurrency=1 --test-reporter=./coverage-report.mjs --test 'dist/test/**/*.test.js' 'dist/test/**/test.js'",
"i18n:check": "pogen check",
"lint": "../qa-tooling/bin/eslint.mjs .",
"typedoc": "pnpm dlx typedoc --out dist/typedoc ./src/",