@mifort-solutions/qmetrix 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +51 -0
- package/src/audit-structure.mjs +103 -0
- package/src/bundle-codebase.mjs +626 -0
- package/src/check-images.mjs +125 -0
- package/src/coverage/clean.mjs +32 -0
- package/src/coverage/merge-istanbul.mjs +161 -0
- package/src/coverage/next-start-cov.mjs +38 -0
- package/src/coverage/report-global.mjs +90 -0
- package/src/coverage/report-suite.mjs +148 -0
- package/src/coverage/src-filter.mjs +50 -0
- package/src/dashboard/collectors/code.mjs +104 -0
- package/src/dashboard/collectors/composition-meta.mjs +295 -0
- package/src/dashboard/collectors/composition-transitions.mjs +0 -0
- package/src/dashboard/collectors/composition.mjs +360 -0
- package/src/dashboard/collectors/coverage.mjs +98 -0
- package/src/dashboard/collectors/deps.mjs +187 -0
- package/src/dashboard/collectors/entities.mjs +147 -0
- package/src/dashboard/collectors/graph.mjs +105 -0
- package/src/dashboard/collectors/lint.mjs +117 -0
- package/src/dashboard/collectors/routing.mjs +82 -0
- package/src/dashboard/collectors/security.mjs +182 -0
- package/src/dashboard/collectors/storybook.mjs +33 -0
- package/src/dashboard/config.mjs +15 -0
- package/src/dashboard/render/client.mjs +178 -0
- package/src/dashboard/render/components.mjs +247 -0
- package/src/dashboard/render/composition.mjs +192 -0
- package/src/dashboard/render/styles.mjs +217 -0
- package/src/dashboard/render/template.mjs +283 -0
- package/src/dashboard/utils/exec.mjs +29 -0
- package/src/dashboard/utils/format.mjs +32 -0
- package/src/dashboard/utils/fs.mjs +48 -0
- package/src/e2e-server-guard.mjs +283 -0
- package/src/optimize-images.mjs +231 -0
- package/src/quality-dashboard.mjs +291 -0
- package/src/security-scan.mjs +267 -0
- package/src/test-outline.mjs +98 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Image budget check — fails the build when committed images are too heavy for the web.
|
|
4
|
+
*
|
|
5
|
+
* Scans public/, src/ and content/ for raster + SVG assets and reports every file that
|
|
6
|
+
* exceeds the byte budget or (rasters only) the pixel budget. Wired into `npm run verify`.
|
|
7
|
+
*
|
|
8
|
+
* Budgets (override per run with flags, per file via EXCEPTIONS below):
|
|
9
|
+
* • size: ≤ 300 KB per file
|
|
10
|
+
* • dimensions: ≤ 3840 px on the longest side (raster formats, needs sharp)
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node dev/scripts/check-images.mjs [--max-kb 300] [--max-px 3840]
|
|
14
|
+
* npm run images:check
|
|
15
|
+
*
|
|
16
|
+
* Fix offenders with: npm run images:optimize -- <file|dir> [--format webp]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
// Anchored on the consuming repo's cwd — every verb runs from the app root.
|
|
23
|
+
const ROOT = process.cwd();
|
|
24
|
+
|
|
25
|
+
const SCAN_ROOTS = ['public', 'src', 'content'];
|
|
26
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'coverage']);
|
|
27
|
+
const RASTER_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.avif', '.gif']);
|
|
28
|
+
const VECTOR_EXT = new Set(['.svg']);
|
|
29
|
+
|
|
30
|
+
// Per-file budget overrides, keyed by posix path relative to the repo root.
|
|
31
|
+
// Use sparingly — prefer optimizing the asset over raising its budget.
|
|
32
|
+
// ['public/images/huge-hero.png', { maxKB: 800 }],
|
|
33
|
+
const EXCEPTIONS = new Map([]);
|
|
34
|
+
|
|
35
|
+
/* ── CLI flags ── */
|
|
36
|
+
const argv = process.argv.slice(2);
|
|
37
|
+
const flag = (name, fallback) => {
|
|
38
|
+
const i = argv.indexOf(name);
|
|
39
|
+
return i !== -1 && argv[i + 1] ? Number(argv[i + 1]) : fallback;
|
|
40
|
+
};
|
|
41
|
+
const MAX_KB = flag('--max-kb', 300);
|
|
42
|
+
const MAX_PX = flag('--max-px', 3840);
|
|
43
|
+
|
|
44
|
+
/* ── helpers ── */
|
|
45
|
+
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/');
|
|
46
|
+
const fmtKB = (bytes) =>
|
|
47
|
+
bytes >= 1024 * 1024
|
|
48
|
+
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
49
|
+
: `${Math.round(bytes / 1024)} KB`;
|
|
50
|
+
|
|
51
|
+
function* walk(dir) {
|
|
52
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
if (!SKIP_DIRS.has(entry.name)) {
|
|
55
|
+
yield* walk(path.join(dir, entry.name));
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
59
|
+
if (RASTER_EXT.has(ext) || VECTOR_EXT.has(ext)) {
|
|
60
|
+
yield path.join(dir, entry.name);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// sharp ships with Next 16; degrade to size-only checks if it ever goes missing.
|
|
67
|
+
let sharp = null;
|
|
68
|
+
try {
|
|
69
|
+
sharp = (await import('sharp')).default;
|
|
70
|
+
} catch {
|
|
71
|
+
console.log('⚠ sharp not found — checking file sizes only (no dimension checks).');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── scan ── */
|
|
75
|
+
const violations = [];
|
|
76
|
+
let checked = 0;
|
|
77
|
+
|
|
78
|
+
for (const root of SCAN_ROOTS) {
|
|
79
|
+
const abs = path.join(ROOT, root);
|
|
80
|
+
if (!existsSync(abs)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
for (const file of walk(abs)) {
|
|
84
|
+
checked++;
|
|
85
|
+
const relPath = rel(file);
|
|
86
|
+
const maxKB = EXCEPTIONS.get(relPath)?.maxKB ?? MAX_KB;
|
|
87
|
+
const bytes = statSync(file).size;
|
|
88
|
+
const problems = [];
|
|
89
|
+
|
|
90
|
+
if (bytes > maxKB * 1024) {
|
|
91
|
+
problems.push(`${fmtKB(bytes)} (budget ${maxKB} KB)`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (sharp && RASTER_EXT.has(path.extname(file).toLowerCase())) {
|
|
95
|
+
try {
|
|
96
|
+
const { width = 0, height = 0 } = await sharp(file).metadata();
|
|
97
|
+
if (Math.max(width, height) > MAX_PX) {
|
|
98
|
+
problems.push(`${width}×${height} px (budget ${MAX_PX} px)`);
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
problems.push('unreadable by sharp (corrupt image?)');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (problems.length) {
|
|
106
|
+
violations.push({ relPath, problems });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ── report ── */
|
|
112
|
+
console.log(`\n== Image budget check == (${checked} files, ≤${MAX_KB} KB, ≤${MAX_PX} px)\n`);
|
|
113
|
+
|
|
114
|
+
if (violations.length === 0) {
|
|
115
|
+
console.log('✓ All images within budget.\n');
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const v of violations) {
|
|
120
|
+
console.log(` ✗ ${v.relPath}\n ${v.problems.join(' · ')}`);
|
|
121
|
+
}
|
|
122
|
+
console.log(`\n${violations.length} image(s) over budget. Fix with:`);
|
|
123
|
+
console.log(' npm run images:optimize -- <file|dir> [--format webp]');
|
|
124
|
+
console.log('or add a justified exception in dev/scripts/check-images.mjs (EXCEPTIONS).\n');
|
|
125
|
+
process.exit(1);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Remove a coverage suite's outputs before a fresh run.
|
|
4
|
+
*
|
|
5
|
+
* MCR's own coverage cache lives under <report-dir>/.cache and is cleared by MCR
|
|
6
|
+
* on generate, so removing the report dir covers it. NODE_V8_COVERAGE server
|
|
7
|
+
* dumps live separately under .raw/<suite>-server/ and must be cleared here so a
|
|
8
|
+
* rerun doesn't fold in last run's dumps.
|
|
9
|
+
*
|
|
10
|
+
* node dev/scripts/coverage/clean.mjs <e2e|storybook|global>
|
|
11
|
+
*/
|
|
12
|
+
import { rmSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const suite = process.argv[2];
|
|
16
|
+
if (!suite) {
|
|
17
|
+
console.error('usage: node dev/scripts/coverage/clean.mjs <e2e|storybook|global>');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ROOT = process.cwd();
|
|
22
|
+
const COV = path.join(ROOT, 'dist', 'reports', 'coverage');
|
|
23
|
+
|
|
24
|
+
const targets = [path.join(COV, suite)];
|
|
25
|
+
if (suite !== 'global') {
|
|
26
|
+
targets.push(path.join(COV, '.raw', `${suite}-server`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const dir of targets) {
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
console.log(`[coverage:clean] removed ${path.relative(ROOT, dir) || dir}`);
|
|
32
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapse N per-suite Istanbul coverage maps into ONE, unioning coverage at the
|
|
3
|
+
* LINE level onto each file's richest structure.
|
|
4
|
+
*
|
|
5
|
+
* Why this is custom — and can't be a native istanbul/monocart merge: the three
|
|
6
|
+
* suites are instrumented by DIFFERENT tools (unit by jest's own istanbul
|
|
7
|
+
* instrumentation; e2e + storybook by monocart V8→istanbul). For the same source
|
|
8
|
+
* file their statementMap / fnMap / branchMap geometry differs, so istanbul's structural
|
|
9
|
+
* merge unions hit counts only when the maps are byte-identical; when they aren't
|
|
10
|
+
* it stacks them side by side and the per-file totals DOUBLE — a file covered by
|
|
11
|
+
* e2e but not unit then reads ~50% (one covered copy + one empty copy) instead of
|
|
12
|
+
* 100%, most visibly on tiny files like src/app/loading.tsx (1 fn → 1/2).
|
|
13
|
+
*
|
|
14
|
+
* This cross-instrumenter mismatch is IRREDUCIBLE while the browser/server suites
|
|
15
|
+
* use V8 coverage — verified empirically (2026-06-13): unit vs V8(e2e) maps
|
|
16
|
+
* matched on 5/129 shared files; jest's own `coverageProvider: v8` matched 0/129
|
|
17
|
+
* (a different v8-to-istanbul converter, degenerate fn/branch geometry); and the
|
|
18
|
+
* only route to identical geometry — istanbul-instrumenting the build — is closed
|
|
19
|
+
* because Turbopack ignores `experimental.swcPlugins` and would need the
|
|
20
|
+
* deprecated `--webpack` builder. So we union by line ourselves rather than hand
|
|
21
|
+
* two structures to a merger that can't reconcile them. See
|
|
22
|
+
* dev/docs/coverage-by-suite-plan.md § "Why the global merge is a custom union".
|
|
23
|
+
*
|
|
24
|
+
* The union: per file, pick the suite map with the most statements as the
|
|
25
|
+
* canonical structure, collect the set of source lines covered by ANY suite, and
|
|
26
|
+
* mark covered anything the canonical map left at 0 whose line is in that set. All
|
|
27
|
+
* suites instrument the SAME source, so 1-based line numbers are a shared
|
|
28
|
+
* coordinate system. Statements/branches union on their START line (istanbul's own
|
|
29
|
+
* line-coverage model); functions union on their full body RANGE, so a callback
|
|
30
|
+
* whose declaration line rendered but whose body never ran is NOT credited. The
|
|
31
|
+
* result is exactly one FileCoverage per file — nothing doubles — and the headline
|
|
32
|
+
* is the intended "covered by ANY suite" at line granularity.
|
|
33
|
+
*
|
|
34
|
+
* Single-suite files pass through untouched (the union of one map is itself), so
|
|
35
|
+
* this only changes files that appeared in more than one suite — the doubled ones.
|
|
36
|
+
*
|
|
37
|
+
* Limitation: across coarse (V8) vs fine (babel/SWC) maps the function/branch
|
|
38
|
+
* union is approximate — line coverage is the reliable headline.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const lineOf = (loc) => loc?.start?.line ?? null;
|
|
42
|
+
const stmtCount = (fc) => Object.keys(fc.statementMap || {}).length;
|
|
43
|
+
|
|
44
|
+
/** The line range to test a function against: full body if known, else its decl line. */
|
|
45
|
+
const fnLoc = (fn) =>
|
|
46
|
+
fn?.loc ??
|
|
47
|
+
fn?.decl ??
|
|
48
|
+
(fn?.line != null ? { start: { line: fn.line }, end: { line: fn.line } } : null);
|
|
49
|
+
|
|
50
|
+
/** Does the union cover any source line inside [loc.start.line, loc.end.line]? */
|
|
51
|
+
function rangeCovered(loc, union) {
|
|
52
|
+
const from = lineOf(loc);
|
|
53
|
+
if (from == null) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const to = loc.end?.line ?? from;
|
|
57
|
+
for (let l = from; l <= to; l++) {
|
|
58
|
+
if (union.has(l)) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Per-map collectors of executed source lines (split out so coveredLines stays
|
|
66
|
+
// under the complexity gate). Each adds to the shared set in place.
|
|
67
|
+
function addStatementLines(fc, lines) {
|
|
68
|
+
for (const [i, loc] of Object.entries(fc.statementMap || {})) {
|
|
69
|
+
if ((fc.s?.[i] || 0) > 0) {
|
|
70
|
+
lines.add(lineOf(loc));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function addFnLines(fc, lines) {
|
|
75
|
+
for (const [i, fn] of Object.entries(fc.fnMap || {})) {
|
|
76
|
+
if ((fc.f?.[i] || 0) > 0) {
|
|
77
|
+
lines.add(lineOf(fn.decl ?? { start: { line: fn.line } }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function addBranchLines(fc, lines) {
|
|
82
|
+
for (const [i, br] of Object.entries(fc.branchMap || {})) {
|
|
83
|
+
const counts = fc.b?.[i] || [];
|
|
84
|
+
(br.locations || []).forEach((loc, j) => {
|
|
85
|
+
if ((counts[j] || 0) > 0) {
|
|
86
|
+
lines.add(lineOf(loc));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Lines a single FileCoverage marks executed: covered statements + fn decls + branch arms. */
|
|
93
|
+
function coveredLines(fc) {
|
|
94
|
+
const lines = new Set();
|
|
95
|
+
addStatementLines(fc, lines);
|
|
96
|
+
addFnLines(fc, lines);
|
|
97
|
+
addBranchLines(fc, lines);
|
|
98
|
+
lines.delete(null);
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bump anything the canonical map left at 0 whose source line ANY suite covered. */
|
|
103
|
+
function applyUnion(out, union) {
|
|
104
|
+
for (const [i, loc] of Object.entries(out.statementMap || {})) {
|
|
105
|
+
if (!out.s[i] && union.has(lineOf(loc))) {
|
|
106
|
+
out.s[i] = 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const [i, fn] of Object.entries(out.fnMap || {})) {
|
|
110
|
+
if (!out.f[i] && rangeCovered(fnLoc(fn), union)) {
|
|
111
|
+
out.f[i] = 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const [i, br] of Object.entries(out.branchMap || {})) {
|
|
115
|
+
const counts = out.b[i] || [];
|
|
116
|
+
(br.locations || []).forEach((loc, j) => {
|
|
117
|
+
if (!counts[j] && union.has(lineOf(loc))) {
|
|
118
|
+
counts[j] = 1;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
out.b[i] = counts;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Merge several FileCoverage maps of the SAME file into one (line-level union). */
|
|
126
|
+
function mergeFile(path, maps) {
|
|
127
|
+
const union = new Set();
|
|
128
|
+
for (const fc of maps) {
|
|
129
|
+
for (const l of coveredLines(fc)) {
|
|
130
|
+
union.add(l);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Canonical structure = richest map (most statements); first-wins → deterministic.
|
|
134
|
+
const base = maps.reduce((a, b) => (stmtCount(b) > stmtCount(a) ? b : a), maps[0]);
|
|
135
|
+
const out = JSON.parse(JSON.stringify(base));
|
|
136
|
+
out.path = path;
|
|
137
|
+
applyUnion(out, union);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {Array<Record<string, object>>} suites - canonicalized istanbul maps,
|
|
143
|
+
* one object per suite, keyed by the SAME canonical path (e.g. 'src/app/x.tsx').
|
|
144
|
+
* @returns {Record<string, object>} one merged FileCoverage per path.
|
|
145
|
+
*/
|
|
146
|
+
export function mergeIstanbulSuites(suites) {
|
|
147
|
+
const byPath = new Map();
|
|
148
|
+
for (const data of suites) {
|
|
149
|
+
for (const [p, fc] of Object.entries(data)) {
|
|
150
|
+
if (!byPath.has(p)) {
|
|
151
|
+
byPath.set(p, []);
|
|
152
|
+
}
|
|
153
|
+
byPath.get(p).push(fc);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const [p, maps] of byPath) {
|
|
158
|
+
out[p] = maps.length === 1 ? maps[0] : mergeFile(p, maps);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Launch `next start` in-process and flush V8 coverage on shutdown signals.
|
|
4
|
+
*
|
|
5
|
+
* NODE_V8_COVERAGE is only written on a CLEAN process exit. Playwright stops the
|
|
6
|
+
* web server with SIGTERM — and on Windows that terminates the process WITHOUT
|
|
7
|
+
* delivering a catchable signal, so a shutdown-only flush never runs and the
|
|
8
|
+
* server-side coverage is lost. So we flush on a short interval: `v8.takeCoverage()`
|
|
9
|
+
* writes the cumulative coverage to the NODE_V8_COVERAGE dir on demand, so the
|
|
10
|
+
* latest snapshot survives even an abrupt kill. Signal/exit handlers add a final
|
|
11
|
+
* flush where the platform allows it. Used only by the coverage:e2e web server
|
|
12
|
+
* (see playwright.config.ts).
|
|
13
|
+
*/
|
|
14
|
+
import v8 from 'node:v8';
|
|
15
|
+
|
|
16
|
+
const flush = () => {
|
|
17
|
+
try {
|
|
18
|
+
v8.takeCoverage();
|
|
19
|
+
} catch {
|
|
20
|
+
// takeCoverage throws if NODE_V8_COVERAGE isn't set — nothing to flush then.
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Periodic flush is the load-bearing one (survives Windows' non-graceful kill).
|
|
25
|
+
const timer = setInterval(flush, 1500);
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
|
|
28
|
+
const shutdown = () => {
|
|
29
|
+
flush();
|
|
30
|
+
process.exit(0);
|
|
31
|
+
};
|
|
32
|
+
process.on('SIGTERM', shutdown);
|
|
33
|
+
process.on('SIGINT', shutdown);
|
|
34
|
+
process.on('exit', flush);
|
|
35
|
+
|
|
36
|
+
// The Next CLI reads process.argv.slice(2); make it `start`.
|
|
37
|
+
process.argv.splice(2, process.argv.length, 'start');
|
|
38
|
+
await import('next/dist/bin/next');
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Merge all three suites into one global coverage report over the full src/ tree.
|
|
4
|
+
*
|
|
5
|
+
* unit → dist/reports/coverage/unit/coverage-final.json (jest istanbul)
|
|
6
|
+
* e2e → dist/reports/coverage/e2e/coverage-final.json (browser + server V8 → istanbul)
|
|
7
|
+
* storybook → dist/reports/coverage/storybook/coverage-final.json (browser V8 → istanbul)
|
|
8
|
+
*
|
|
9
|
+
* All three are Istanbul coverage maps already scoped to src/. They are
|
|
10
|
+
* instrumented by DIFFERENT tools (unit: jest/babel; e2e + storybook: monocart
|
|
11
|
+
* V8 → istanbul), so the same file has different statementMap/fnMap structures
|
|
12
|
+
* across suites. Handing those straight to monocart/istanbul does NOT union them —
|
|
13
|
+
* mismatched structures are stacked side by side and per-file totals double, so a
|
|
14
|
+
* file covered by e2e but not unit reads ~50% instead of 100%. We therefore
|
|
15
|
+
* pre-merge into ONE structure per file (mergeIstanbulSuites, line-level union)
|
|
16
|
+
* before add(). Run the per-suite steps first (coverage:unit / :e2e / :storybook).
|
|
17
|
+
*
|
|
18
|
+
* The merged global figure is "covered by ANY suite" at line granularity — the
|
|
19
|
+
* right headline number. (See dev/docs/coverage-by-suite-plan.md.)
|
|
20
|
+
*
|
|
21
|
+
* node dev/scripts/coverage/report-global.mjs
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { CoverageReport } from 'monocart-coverage-reports';
|
|
27
|
+
|
|
28
|
+
import { mergeIstanbulSuites } from './merge-istanbul.mjs';
|
|
29
|
+
import { inSrc } from './src-filter.mjs';
|
|
30
|
+
|
|
31
|
+
const ROOT = process.cwd();
|
|
32
|
+
const COV = path.join(ROOT, 'dist', 'reports', 'coverage');
|
|
33
|
+
|
|
34
|
+
const mcr = new CoverageReport({
|
|
35
|
+
name: 'Global coverage (unit + e2e + storybook)',
|
|
36
|
+
outputDir: path.join(COV, 'global'),
|
|
37
|
+
sourceFilter: inSrc,
|
|
38
|
+
// istanbul reporters only (text-summary, not v8 'console-summary') so no Bytes /
|
|
39
|
+
// V8-Lines column appears anywhere — the merged input is istanbul maps with no
|
|
40
|
+
// byte data, so every metric here is statement/fn/branch-derived.
|
|
41
|
+
reports: ['text-summary', 'html', 'json', 'json-summary', 'lcovonly'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Canonicalize istanbul file keys to `src/<path>` (posix) so the same file is
|
|
46
|
+
* unified across suites. jest emits absolute Windows paths
|
|
47
|
+
* (C:\…\src\foo.tsx); MCR emits project-relative ones (name/src/foo.tsx) — left
|
|
48
|
+
* as-is they'd be treated as two different files and inflate the denominator.
|
|
49
|
+
*/
|
|
50
|
+
function canonicalize(data) {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [k, v] of Object.entries(data)) {
|
|
53
|
+
const p = k.replace(/\\/g, '/');
|
|
54
|
+
const i = p.lastIndexOf('/src/');
|
|
55
|
+
const key = i >= 0 ? p.slice(i + 1) : p; // 'src/…'
|
|
56
|
+
// Safety net: only merge real project src/ files. A stale suite-final could
|
|
57
|
+
// still carry unmapped `.next/server/**` dist bundles (Turbopack route stubs /
|
|
58
|
+
// runtime chunks); inSrc drops anything that doesn't resolve under <root>/src.
|
|
59
|
+
if (!inSrc(key)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
out[key] = { ...v, path: key };
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const suites = [];
|
|
68
|
+
for (const suite of ['unit', 'e2e', 'storybook']) {
|
|
69
|
+
const f = path.join(COV, suite, 'coverage-final.json');
|
|
70
|
+
if (!existsSync(f)) {
|
|
71
|
+
console.warn(`[coverage:global] ${suite}/coverage-final.json missing — run coverage:${suite}.`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
suites.push(canonicalize(JSON.parse(readFileSync(f, 'utf8'))));
|
|
75
|
+
}
|
|
76
|
+
const parts = suites.length;
|
|
77
|
+
|
|
78
|
+
if (!parts) {
|
|
79
|
+
console.warn('[coverage:global] no suite reports found — run coverage:unit / :e2e / :storybook.');
|
|
80
|
+
} else {
|
|
81
|
+
// Union the suites into one structure per file BEFORE handing to monocart, so
|
|
82
|
+
// cross-instrumenter structure mismatches can't double the per-file totals.
|
|
83
|
+
await mcr.add(mergeIstanbulSuites(suites));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const res = await mcr.generate();
|
|
87
|
+
const pct = res?.summary?.lines?.pct;
|
|
88
|
+
console.log(
|
|
89
|
+
`[coverage:global] merged ${parts} suite(s) → dist/reports/coverage/global/coverage-final.json (lines ${pct ?? '?'}%)`,
|
|
90
|
+
);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate a Playwright suite's Istanbul coverage report.
|
|
4
|
+
*
|
|
5
|
+
* The browser-side V8 coverage was already resolved against source maps and
|
|
6
|
+
* cached by the in-test fixture (.config/coverage-fixture.ts) — see the
|
|
7
|
+
* comment there for why that has to happen while the server is live. This step
|
|
8
|
+
* just adds the server-side V8 dumps (e2e only; their maps are on disk so they
|
|
9
|
+
* resolve offline) and renders the report from the shared cache.
|
|
10
|
+
*
|
|
11
|
+
* Cache (browser, written by the fixture across workers):
|
|
12
|
+
* dist/reports/coverage/.cache/<suite>/
|
|
13
|
+
* Server V8 (NODE_V8_COVERAGE, inspector format; e2e only):
|
|
14
|
+
* dist/reports/coverage/.raw/<suite>-server/*.json
|
|
15
|
+
* Output:
|
|
16
|
+
* dist/reports/coverage/<suite>/coverage-final.json (Istanbul, src/-mapped)
|
|
17
|
+
* dist/reports/coverage/<suite>/coverage-summary.json
|
|
18
|
+
* dist/reports/coverage/<suite>/lcov.info
|
|
19
|
+
* dist/reports/coverage/<suite>/index.html (istanbul HTML report)
|
|
20
|
+
*
|
|
21
|
+
* node dev/scripts/coverage/report-suite.mjs <e2e|storybook>
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
import { CoverageReport } from 'monocart-coverage-reports';
|
|
28
|
+
|
|
29
|
+
import { inSrc } from './src-filter.mjs';
|
|
30
|
+
|
|
31
|
+
// Picking the value out of the constant list (rather than trusting argv) keeps
|
|
32
|
+
// the argument from ever being used as a path fragment.
|
|
33
|
+
const suite = ['e2e', 'storybook'].find((s) => s === process.argv[2]);
|
|
34
|
+
if (!suite) {
|
|
35
|
+
console.error('usage: node dev/scripts/coverage/report-suite.mjs <e2e|storybook>');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ROOT = process.cwd();
|
|
40
|
+
const COV = path.join(ROOT, 'dist', 'reports', 'coverage');
|
|
41
|
+
const baseDir = path.join(COV, suite);
|
|
42
|
+
// MCR forces cacheDir to <outputDir>/.cache; the fixture (same outputDir) wrote
|
|
43
|
+
// the resolved browser coverage there, and generate() reads it back.
|
|
44
|
+
const cacheDir = path.join(baseDir, '.cache');
|
|
45
|
+
|
|
46
|
+
const mcr = new CoverageReport({
|
|
47
|
+
name: `${suite} coverage`,
|
|
48
|
+
outputDir: baseDir,
|
|
49
|
+
// Keep server bundles (file: URLs, source attached below) and browser bundles
|
|
50
|
+
// that carry a source map; drop node_modules and map-less runtime bundles.
|
|
51
|
+
entryFilter: (entry) => {
|
|
52
|
+
const url = String(entry.url || '');
|
|
53
|
+
if (!url || url.includes('node_modules')) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (url.startsWith('file:') || /[\\/]\.next[\\/]/.test(url)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return /sourceMappingURL=/.test(String(entry.source || ''));
|
|
60
|
+
},
|
|
61
|
+
sourceFilter: inSrc,
|
|
62
|
+
// Turbopack labels server-bundle sources `[project]/src/…`; the browser half of
|
|
63
|
+
// the same suite labels the identical file `src/…`. Strip the `[project]/`
|
|
64
|
+
// prefix so both halves collapse onto one `src/…` key — otherwise a file
|
|
65
|
+
// exercised on both server and client is listed (and counted) twice.
|
|
66
|
+
sourcePath: (filePath) => {
|
|
67
|
+
const s = String(filePath).replace(/\\/g, '/');
|
|
68
|
+
return s.startsWith('[project]/') ? s.slice('[project]/'.length) : s;
|
|
69
|
+
},
|
|
70
|
+
// istanbul reporters only — deliberately NOT monocart's 'v8' reporter. The v8
|
|
71
|
+
// report's headline (Bytes / V8-Lines) is byte-RANGE coverage: a module that was
|
|
72
|
+
// imported but never invoked reads ~100% there (its top-level eval covers the
|
|
73
|
+
// file's byte span) while no function actually ran — false confidence. istanbul
|
|
74
|
+
// 'html' + 'text-summary' derive every metric from statement/fn/branch hits, so a
|
|
75
|
+
// loaded-but-not-run file honestly reads ~0%. coverage-final.json is unaffected
|
|
76
|
+
// either way — it never carried byte data — so the global merge is unchanged.
|
|
77
|
+
reports: ['html', 'text-summary', 'json', 'json-summary', 'lcovonly'],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Read a server bundle's sibling source map and return its `sources` list
|
|
81
|
+
// (Turbopack maps may be sectioned). Returns [] when the map is missing or empty.
|
|
82
|
+
const mapSourcesFor = (bundlePath) => {
|
|
83
|
+
try {
|
|
84
|
+
const map = JSON.parse(readFileSync(`${bundlePath}.map`, 'utf8'));
|
|
85
|
+
const srcs = map.sections
|
|
86
|
+
? map.sections.flatMap((s) => s.map?.sources || [])
|
|
87
|
+
: map.sources || [];
|
|
88
|
+
return srcs.map(String);
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Server-side V8 (e2e): NODE_V8_COVERAGE inspector dumps. These carry no source
|
|
95
|
+
// text, so attach it from disk (MCR drops entries without source); the on-disk
|
|
96
|
+
// `.next/server/**/*.js.map` then resolve offline. We only bother loading source
|
|
97
|
+
// for .next server bundles — Node internals (node:…) are skipped.
|
|
98
|
+
//
|
|
99
|
+
// We also DROP any bundle whose map carries no project `src/` file: Turbopack
|
|
100
|
+
// route stubs ship an empty map (`sources: []`) and runtime/action chunks map
|
|
101
|
+
// only to node_modules. MCR's `sourceFilter` runs solely on sources unpacked FROM
|
|
102
|
+
// a map, so these never reach it — without this gate MCR keeps the raw
|
|
103
|
+
// `.next/server/**` dist file in the report instead of real source files.
|
|
104
|
+
let server = 0;
|
|
105
|
+
const serverDir = path.join(COV, '.raw', `${suite}-server`);
|
|
106
|
+
const loadSource = (entry) => {
|
|
107
|
+
const url = String(entry.url || '');
|
|
108
|
+
if (!/[\\/]\.next[\\/]/.test(url)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const p = url.startsWith('file:') ? fileURLToPath(url) : url;
|
|
113
|
+
if (!mapSourcesFor(p).some(inSrc)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return { ...entry, source: readFileSync(p, 'utf8') };
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
if (existsSync(serverDir)) {
|
|
122
|
+
for (const f of readdirSync(serverDir)) {
|
|
123
|
+
if (!f.endsWith('.json')) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const { result } = JSON.parse(readFileSync(path.join(serverDir, f), 'utf8'));
|
|
127
|
+
if (!Array.isArray(result)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const withSource = result.map(loadSource).filter(Boolean);
|
|
131
|
+
if (withSource.length) {
|
|
132
|
+
await mcr.add(withSource);
|
|
133
|
+
server++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!existsSync(cacheDir) && server === 0) {
|
|
139
|
+
console.warn(
|
|
140
|
+
`[coverage:${suite}] no coverage cache at ${path.relative(ROOT, cacheDir)} and no server dumps — did the suite run with COVERAGE=1?`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const res = await mcr.generate();
|
|
145
|
+
const pct = res?.summary?.lines?.pct;
|
|
146
|
+
console.log(
|
|
147
|
+
`[coverage:${suite}] server-dumps=${server} → ${path.relative(ROOT, baseDir)}/coverage-final.json (lines ${pct ?? '?'}%)`,
|
|
148
|
+
);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared source filter for the coverage reports (report-suite.mjs, report-global.mjs).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors jest's collectCoverageFrom (.config/jest.config.mjs): the project's own
|
|
5
|
+
* src/**\/*.{ts,tsx} minus stories, tests, e2e specs and .d.ts, so all four suites
|
|
6
|
+
* share one file universe.
|
|
7
|
+
*
|
|
8
|
+
* The on-disk existence check is load-bearing: bundled third-party packages
|
|
9
|
+
* (lucide-react, @heroui/*) ship source maps whose `sources` are bare `src/…` /
|
|
10
|
+
* `shared/src/…` paths with NO node_modules prefix, so a plain `/src/` match would
|
|
11
|
+
* fold ~230 vendor files into the totals. Resolving the tail against <root>/src and
|
|
12
|
+
* requiring it to exist keeps only the app's real source files.
|
|
13
|
+
*
|
|
14
|
+
* Vendor maps that DO keep the `node_modules/` prefix (e.g.
|
|
15
|
+
* `node_modules/@opentelemetry/instrumentation/src/instrumentation.ts`) would still
|
|
16
|
+
* slip through when their tail collides with a real file name (we have
|
|
17
|
+
* `src/instrumentation.ts`), so reject anything under node_modules outright first.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
const SRC_ROOT = path.join(process.cwd(), 'src');
|
|
23
|
+
|
|
24
|
+
export function inSrc(sourcePath) {
|
|
25
|
+
const s = String(sourcePath).replace(/\\/g, '/');
|
|
26
|
+
if (s.includes('node_modules/')) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const i = s.lastIndexOf('/src/');
|
|
30
|
+
const rel = i >= 0 ? s.slice(i + 5) : s.startsWith('src/') ? s.slice(4) : null;
|
|
31
|
+
if (rel == null) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (!/\.(ts|tsx)$/.test(rel)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (/\.stories\.tsx$/.test(rel)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (/\.test\.(ts|tsx)$/.test(rel)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (/\.(e2e|storybook)\.spec\.ts$/.test(rel)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (/\.d\.ts$/.test(rel)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return existsSync(path.join(SRC_ROOT, rel));
|
|
50
|
+
}
|