modified: .gitignore

modified:   .vscode/launch.json
	new file:   e2e/playwright-collect-coverage.spec.ts
	modified:   package-lock.json
	modified:   package.json
	modified:   playwright.config.ts
	new file:   scripts/merge-lcov.cjs
	new file:   scripts/normalize-lcov.cjs
	new file:   scripts/playwright-collect-coverage.cjs
	new file:   scripts/remap-and-report.cjs
	modified:   vite.config.ts
	new file:   vitest.config.ts
This commit is contained in:
beatzaplenty committed 2025-12-02 16:14:12 +10:00
1 parent d0eb4e953c
commit 6192bd20eb
12 files changed
+1134 -10

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const files = process.argv.slice(2);
if (files.length < 2) {
console.error('Usage: node merge-lcov.cjs <file1.lcov> <file2.lcov> [<file3.lcov> ...]');
process.exit(2);
}
function ensureEntry(map, sf) {
if (!map.has(sf)) map.set(sf, { other: new Set(), da: new Map() });
return map.get(sf);
}
const combined = new Map();
for (const f of files) {
let content;
try {
content = fs.readFileSync(f, 'utf8');
} catch (err) {
console.error('Could not read', f, err.message);
process.exit(3);
}
const lines = content.split(/\r?\n/);
let currentSF = null;
for (let raw of lines) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith('SF:')) {
currentSF = line.slice(3);
ensureEntry(combined, currentSF);
} else if (!currentSF) {
// ignore lines before first SF
} else if (line.startsWith('DA:')) {
const parts = line.slice(3).split(',');
const lnNum = parseInt(parts[0], 10);
const hitsNum = parseInt(parts[1] || '0', 10);
const entry = ensureEntry(combined, currentSF);
entry.da.set(lnNum, (entry.da.get(lnNum) || 0) + hitsNum);
} else if (line.startsWith('end_of_record')) {
// ignore
} else {
const entry = ensureEntry(combined, currentSF);
entry.other.add(line);
}
}
}
let out = '';
for (const [sf, data] of combined.entries()) {
out += `SF:${sf}\n`;
const other = Array.from(data.other).sort();
for (const l of other) out += l + '\n';
const daEntries = Array.from(data.da.entries()).sort((a, b) => a[0] - b[0]);
for (const [ln, hits] of daEntries) out += `DA:${ln},${hits}\n`;
const lf = daEntries.length;
const lh = daEntries.reduce((acc, [, hits]) => acc + (hits > 0 ? 1 : 0), 0);
out += `LF:${lf}\nLH:${lh}\nend_of_record\n`;
}
process.stdout.write(out);
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env node
// scripts/normalize-lcov.cjs
// Usage: node scripts/normalize-lcov.cjs input.lcov [output.lcov]
const fs = require('fs');
const path = require('path');
const repoRoot = process.cwd();
const inFile = process.argv[2] || 'coverage-reports/merged/merged.lcov';
const outFile = process.argv[3] || inFile.replace(/\.lcov$/, '.normalized.lcov');
if (!fs.existsSync(inFile)) {
console.error('Input file not found:', inFile);
process.exit(2);
}
const content = fs.readFileSync(inFile, 'utf8');
const lines = content.split(/\r?\n/);
// split into records separated by "end_of_record"
const records = [];
let cur = [];
for (const L of lines) {
cur.push(L);
if (L.trim() === 'end_of_record') {
records.push(cur.join('\n'));
cur = [];
}
}
// if file didn't end with end_of_record, push remainder
if (cur.length) records.push(cur.join('\n'));
function exists(p) {
try { return fs.existsSync(p); } catch { return false; }
}
function findInSrc(basename) {
const srcRoot = path.join(repoRoot, 'src');
if (!fs.existsSync(srcRoot)) return null;
// recursive search (stop when found)
function walk(dir) {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const it of items) {
const full = path.join(dir, it.name);
if (it.isFile() && it.name === basename) return full;
if (it.isDirectory() && it.name !== 'node_modules' && it.name !== '.git') {
const found = walk(full);
if (found) return found;
}
}
return null;
}
return walk(srcRoot);
}
function normalizeSF(sfLine) {
// sfLine is the part after 'SF:'
let sf = sfLine.trim();
// Skip clearly virtual / query-string entries
if (sf.startsWith('@') || sf.includes('?v=') || sf === '@react-refresh') {
return null;
}
// If it's an absolute path that exists, make it repo-relative.
if (path.isAbsolute(sf)) {
if (exists(sf)) return path.relative(repoRoot, sf);
// Try chopping off any leading workspace prefix and look again
const repoName = path.basename(repoRoot);
const idx = sf.indexOf(`/${repoName}/`);
if (idx !== -1) {
const candidate = sf.slice(idx + repoName.length + 2); // after /repoName/
if (exists(path.join(repoRoot, candidate))) return candidate;
if (exists(path.join(repoRoot, 'src', candidate))) return path.posix.join('src', candidate);
}
}
// If it's already relative, check relative to repo root
if (!path.isAbsolute(sf)) {
if (exists(path.join(repoRoot, sf))) return sf;
// If it looks like "stockfill/..." or "<repoName>/..." try to strip the repo prefix
const repoName = path.basename(repoRoot);
if (sf.startsWith(`${repoName}/`) || sf.startsWith('stockfill/')) {
const candidate = sf.replace(new RegExp(`^(${repoName}|stockfill)/`), '');
if (exists(path.join(repoRoot, candidate))) return candidate;
if (exists(path.join(repoRoot, 'src', candidate))) return path.posix.join('src', candidate);
}
// If it is a bare filename "DBProvider.tsx" try src/
if (!sf.includes('/') && exists(path.join(repoRoot, 'src', sf))) {
return path.posix.join('src', sf);
}
}
// If it contains '/src/' somewhere, try to extract the src/... suffix
const srcIdx = sf.indexOf('/src/');
if (srcIdx !== -1) {
const candidate = sf.slice(srcIdx + 1); // remove leading slash
if (exists(path.join(repoRoot, candidate))) return candidate;
}
// Finally, try finding by basename under src
const base = path.basename(sf);
const found = findInSrc(base);
if (found) return path.relative(repoRoot, found);
// give up
return null;
}
let kept = 0, dropped = 0;
const outRecords = records.map(rec => {
// find SF line
const m = rec.match(/^SF:(.*)$/m);
if (!m) {
// no SF line? keep it (unlikely)
kept++;
return rec;
}
const sfRaw = m[1].trim();
const normalized = normalizeSF(sfRaw);
if (!normalized) {
dropped++;
return null; // drop entire record
}
// replace SF:... with normalized relative path using posix separators
const normalizedPosix = normalized.split(path.sep).join(path.posix.sep);
const newRec = rec.replace(/^SF:.*$/m, `SF:${normalizedPosix}`);
kept++;
return newRec;
}).filter(Boolean);
fs.writeFileSync(outFile, outRecords.join('\n') + '\n');
console.log(`Wrote ${outFile} (kept ${kept}, dropped ${dropped} records)`);
+23
View File
@@ -0,0 +1,23 @@
// tests/playwright-collect-coverage.js
const fs = require('fs');
const path = require('path');
const { test } = require('@playwright/test');
test.afterEach(async ({ page }, testInfo) => {
try {
// evaluate coverage from the page
const coverage = await page.evaluate(() => (globalThis.__coverage__ || {}));
if (coverage && Object.keys(coverage).length) {
const dir = path.join(process.cwd(), '.nyc_output');
fs.mkdirSync(dir, { recursive: true });
// unique file per worker/test
const filename = path.join(dir, `coverage-e2e-${process.pid}-${Date.now()}.json`);
fs.writeFileSync(filename, JSON.stringify(coverage));
console.log('Wrote coverage:', filename);
}
} catch (err) {
// don't fail tests if coverage collection fails
console.warn('Failed to collect coverage from page:', err.message || err);
}
});
+35
View File
@@ -0,0 +1,35 @@
// scripts/remap-and-report.cjs
const fs = require('fs');
const path = require('path');
const libCoverage = require('istanbul-lib-coverage');
const libSourceMaps = require('istanbul-lib-source-maps');
const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');
const out = libCoverage.createCoverageMap({});
const dir = path.join(process.cwd(), '.nyc_output');
if (!fs.existsSync(dir)) {
console.error('.nyc_output not found');
process.exit(2);
}
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
out.merge(data);
}
// Remap using source maps
const sourceMapStore = libSourceMaps.createSourceMapStore();
const transformed = sourceMapStore.transformCoverage(out);
const remapped = transformed.map;
// Write reports
const outDir = path.join(process.cwd(), 'coverage-reports', 'e2e');
fs.mkdirSync(outDir, { recursive: true });
const context = libReport.createContext({ dir: outDir, coverageMap: remapped });
// lcov + html
reports.create('lcovonly', {}).execute(context);
reports.create('html', {}).execute(context);
console.log('Wrote remapped e2e coverage to', outDir);