From 427479c1e6d9bb30bbd6f0ee030a7028f172902f Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Fri, 17 Oct 2025 22:43:23 +0200 Subject: [PATCH] Fix frontend coverage export for Codecov (#376) * fix: export frontend coverage for codecov * Merge V8 file coverages across workers --- web/package-lock.json | 163 ++++++++++++++++++++++++++++++++- web/package.json | 6 ++ web/scripts/export-coverage.js | 161 +++++++++++++++++++++++++++++--- 3 files changed, 315 insertions(+), 15 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index ce7ebe5..2f4a1ce 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6,7 +6,168 @@ "packages": { "": { "name": "potato-mesh", - "version": "0.5.0" + "version": "0.5.0", + "devDependencies": { + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "v8-to-istanbul": "^9.3.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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" + } } } } diff --git a/web/package.json b/web/package.json index 04eff9e..efb8d25 100644 --- a/web/package.json +++ b/web/package.json @@ -5,5 +5,11 @@ "private": true, "scripts": { "test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js" + }, + "devDependencies": { + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "v8-to-istanbul": "^9.3.0" } } diff --git a/web/scripts/export-coverage.js b/web/scripts/export-coverage.js index 7baebd1..8ee18cc 100644 --- a/web/scripts/export-coverage.js +++ b/web/scripts/export-coverage.js @@ -15,10 +15,25 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -const coverageDir = 'coverage'; -const reportsDir = 'reports'; -const outputPath = path.join(reportsDir, 'javascript-coverage.json'); +import istanbulLibCoverage from 'istanbul-lib-coverage'; +import istanbulLibReport from 'istanbul-lib-report'; +import istanbulReports from 'istanbul-reports'; +import v8toIstanbul from 'v8-to-istanbul'; +const { createCoverageMap } = istanbulLibCoverage; +const { createContext } = istanbulLibReport; + +const coverageDir = path.resolve('coverage'); +const reportsDir = path.resolve('reports'); +const jsonOutputName = 'javascript-coverage.json'; +const lcovOutputName = 'javascript-coverage.lcov'; +const projectRoot = process.cwd(); + +/** + * Ensure the reports directory exists so that coverage artefacts can be written. + * + * @returns {Promise} A promise that resolves when the directory is available. + */ async function ensureReportsDir() { try { await fs.mkdir(reportsDir, { recursive: true }); @@ -28,32 +43,150 @@ async function ensureReportsDir() { } } -async function copyLatestCoverage() { +/** + * Read the coverage directory and return a deterministically ordered list of JSON files. + * + * @returns {Promise} The absolute paths of available coverage JSON artefacts. + */ +async function listCoverageFiles() { let entries; try { entries = await fs.readdir(coverageDir); } catch (error) { if (error.code === 'ENOENT') { console.warn('Coverage directory not found; skipping export.'); - return; + return []; } throw error; } - const coverageFiles = entries.filter(name => name.endsWith('.json')); + const coverageFiles = entries + .filter(name => name.endsWith('.json')) + .map(name => path.join(coverageDir, name)) + .sort(); + if (!coverageFiles.length) { console.warn('No coverage files generated; skipping export.'); - return; + return []; } - // Sort to pick the most recent entry deterministically. - coverageFiles.sort(); - const latest = coverageFiles[coverageFiles.length - 1]; - const source = path.join(coverageDir, latest); + return coverageFiles; +} - await fs.copyFile(source, outputPath); - console.log(`Copied coverage report to ${outputPath}`); +/** + * Convert a V8 coverage URL to a project-local filesystem path. + * + * @param {string | undefined} url The coverage URL emitted by V8. + * @returns {string | null} A normalised absolute path, or null when the URL should be ignored. + */ +function normaliseFileUrl(url) { + if (!url || url.startsWith('node:')) { + return null; + } + + if (!url.startsWith('file://')) { + return null; + } + + let filePath; + try { + filePath = decodeURIComponent(new URL(url).pathname); + } catch { + return null; + } + + if (!filePath.startsWith(projectRoot)) { + return null; + } + + if (filePath.includes('node_modules')) { + return null; + } + + return filePath; +} + +/** + * Transform the raw V8 coverage reports into an Istanbul coverage map. + * + * @param {string[]} coverageFiles A list of coverage artefacts to consume. + * @returns {Promise} The aggregated coverage map. + */ +async function buildCoverageMap(coverageFiles) { + const coverageMap = createCoverageMap({}); + + for (const file of coverageFiles) { + const raw = await fs.readFile(file, 'utf8'); + const parsed = JSON.parse(raw); + const entries = Array.isArray(parsed.result) ? parsed.result : []; + + for (const entry of entries) { + const { url, functions } = entry; + const filePath = normaliseFileUrl(url); + if (!filePath) { + continue; + } + + try { + const converter = v8toIstanbul(filePath, 0, { + source: await fs.readFile(filePath, 'utf8'), + }); + await converter.load(); + converter.applyCoverage(functions); + const fileCoverages = converter.toIstanbul(); + for (const coverage of Object.values(fileCoverages)) { + if (coverage.path) { + const relativePath = path.relative(projectRoot, coverage.path); + coverage.path = relativePath || coverage.path; + } + try { + const existingCoverage = coverageMap.fileCoverageFor(coverage.path); + existingCoverage.merge(coverage); + } catch (error) { + if (error && typeof error.message === 'string' && error.message.includes('No file coverage')) { + coverageMap.addFileCoverage(coverage); + } else { + throw error; + } + } + } + } catch (error) { + console.warn(`Failed to translate coverage for ${filePath}:`, error); + } + } + } + + return coverageMap; +} + +/** + * Persist the Istanbul coverage map as JSON and LCOV artefacts for downstream tooling. + * + * @param {import('istanbul-lib-coverage').CoverageMap} coverageMap The populated coverage map. + * @returns {Promise} A promise that resolves when the outputs are written. + */ +async function writeCoverageOutputs(coverageMap) { + const jsonOutputPath = path.join(reportsDir, jsonOutputName); + const lcovOutputPath = path.join(reportsDir, lcovOutputName); + + await fs.writeFile(jsonOutputPath, `${JSON.stringify(coverageMap.toJSON(), null, 2)}\n`); + + const context = createContext({ dir: reportsDir, coverageMap }); + istanbulReports.create('lcovonly', { file: lcovOutputName }).execute(context); + + console.log(`Wrote coverage reports to ${jsonOutputPath} and ${lcovOutputPath}`); } await ensureReportsDir(); -await copyLatestCoverage(); +const coverageFiles = await listCoverageFiles(); +if (!coverageFiles.length) { + process.exit(0); +} + +const coverageMap = await buildCoverageMap(coverageFiles); +if (!coverageMap.files().length) { + console.warn('No project coverage entries were recognised; skipping export.'); + process.exit(0); +} + +await writeCoverageOutputs(coverageMap);