@mandujs/core 0.41.2 → 0.43.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/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/testing/reporter.ts
CHANGED
|
@@ -1,676 +1,676 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/testing/reporter — Phase 18.σ
|
|
3
|
-
*
|
|
4
|
-
* Unified test reporter for `mandu test`. Collapses the four legacy
|
|
5
|
-
* stdout formats (unit / integration / e2e / coverage) into a single
|
|
6
|
-
* structured `TestReport` shape with four output formats:
|
|
7
|
-
*
|
|
8
|
-
* - `human` → colorized summary for interactive terminals
|
|
9
|
-
* - `json` → machine-readable schema, ready for pipelines
|
|
10
|
-
* - `junit` → JUnit XML, compatible with GitHub Actions `publish-
|
|
11
|
-
* test-results`, Jenkins, CircleCI, GitLab CI
|
|
12
|
-
* - `lcov` → pass-through / re-emit of the merged coverage LCOV
|
|
13
|
-
*
|
|
14
|
-
* The reporter is **pure** — it accepts a `TestReport` and returns a
|
|
15
|
-
* string. No I/O, no spawning, no process.exit. The CLI composes the
|
|
16
|
-
* report from runner outputs and picks the format based on
|
|
17
|
-
* `--reporter=<format>`.
|
|
18
|
-
*
|
|
19
|
-
* ## Design constraints
|
|
20
|
-
*
|
|
21
|
-
* 1. **No runtime dependencies.** We hand-roll the ANSI codes and XML
|
|
22
|
-
* serializer so this module can be imported in any context
|
|
23
|
-
* (including dev mode in browsers via the MCP test doctor).
|
|
24
|
-
* 2. **Deterministic output.** Tests are sorted by (suite, name) before
|
|
25
|
-
* rendering so CI diffs stay minimal. Durations are rounded to
|
|
26
|
-
* integer milliseconds. Timestamps are emitted in UTC ISO-8601 via
|
|
27
|
-
* `toISOString()` for cross-timezone stability.
|
|
28
|
-
* 3. **Round-trip safe.** `mergeReports()` followed by
|
|
29
|
-
* `formatReport(r, 'json')` produces a body that can be parsed back
|
|
30
|
-
* via `JSON.parse` — every field is JSON-serializable.
|
|
31
|
-
*
|
|
32
|
-
* ## Coverage thresholds
|
|
33
|
-
*
|
|
34
|
-
* `checkCoverageThresholds(coverage, thresholds)` is a pure comparator
|
|
35
|
-
* returning a structured breakdown (actual vs expected per metric).
|
|
36
|
-
* The CLI layer calls this after merging LCOV and prints the human
|
|
37
|
-
* error when `ok === false`.
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
-
// Public types
|
|
42
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
-
|
|
44
|
-
/** Outcome of a single test case inside a suite. */
|
|
45
|
-
export type TestStatus = "passed" | "failed" | "skipped" | "todo";
|
|
46
|
-
|
|
47
|
-
/** Test category — maps to the legacy subcommand. */
|
|
48
|
-
export type TestSuiteKind = "unit" | "integration" | "e2e";
|
|
49
|
-
|
|
50
|
-
/** Coverage metric names — match Bun/LCOV conventions. */
|
|
51
|
-
export type CoverageMetric =
|
|
52
|
-
| "lines"
|
|
53
|
-
| "branches"
|
|
54
|
-
| "functions"
|
|
55
|
-
| "statements";
|
|
56
|
-
|
|
57
|
-
export interface TestCase {
|
|
58
|
-
/** Human-readable name — typically the `it(...)` description. */
|
|
59
|
-
readonly name: string;
|
|
60
|
-
/** Dotted suite path (e.g. `"auth > login > rate-limit"`). Optional. */
|
|
61
|
-
readonly suite?: string;
|
|
62
|
-
/** Outcome classification. */
|
|
63
|
-
readonly status: TestStatus;
|
|
64
|
-
/** Elapsed wall time in ms (integer, rounded). */
|
|
65
|
-
readonly durationMs: number;
|
|
66
|
-
/** Populated for `status === "failed"`. */
|
|
67
|
-
readonly error?: {
|
|
68
|
-
readonly message: string;
|
|
69
|
-
readonly stack?: string;
|
|
70
|
-
};
|
|
71
|
-
/** Source file path, absolute or project-relative. */
|
|
72
|
-
readonly file?: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface CoverageMetricResult {
|
|
76
|
-
readonly hit: number;
|
|
77
|
-
readonly found: number;
|
|
78
|
-
/** Ratio in 0-100 range, rounded to 2 decimals. 0 when `found === 0`. */
|
|
79
|
-
readonly pct: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface Coverage {
|
|
83
|
-
readonly lines?: CoverageMetricResult;
|
|
84
|
-
readonly branches?: CoverageMetricResult;
|
|
85
|
-
readonly functions?: CoverageMetricResult;
|
|
86
|
-
readonly statements?: CoverageMetricResult;
|
|
87
|
-
/** Number of files represented in the underlying LCOV. */
|
|
88
|
-
readonly files?: number;
|
|
89
|
-
/** Path to the merged LCOV body. Reporter embeds this for `lcov` format. */
|
|
90
|
-
readonly lcovPath?: string;
|
|
91
|
-
/** Optional raw LCOV body (used by the `lcov` reporter format). */
|
|
92
|
-
readonly lcovBody?: string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface TestReport {
|
|
96
|
-
/** Logical label — `"mandu test"`, `"unit"`, `"integration"`, `"e2e"`, etc. */
|
|
97
|
-
readonly suite: string;
|
|
98
|
-
/** Classification — used for grouping in human output. */
|
|
99
|
-
readonly kind: TestSuiteKind | "combined";
|
|
100
|
-
/** Every case observed for this report. */
|
|
101
|
-
readonly tests: readonly TestCase[];
|
|
102
|
-
/** Merged coverage metrics, if `--coverage` was enabled. */
|
|
103
|
-
readonly coverage?: Coverage;
|
|
104
|
-
/** Wall-clock total across all cases. Integer ms. */
|
|
105
|
-
readonly durationMs: number;
|
|
106
|
-
/** Report creation time — ISO 8601 UTC. */
|
|
107
|
-
readonly timestamp: string;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** User-configurable thresholds from `mandu.config.ts`. */
|
|
111
|
-
export interface CoverageThresholds {
|
|
112
|
-
readonly lines?: number;
|
|
113
|
-
readonly branches?: number;
|
|
114
|
-
readonly functions?: number;
|
|
115
|
-
readonly statements?: number;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export interface CoverageThresholdBreakdown {
|
|
119
|
-
readonly metric: CoverageMetric;
|
|
120
|
-
readonly expected: number;
|
|
121
|
-
readonly actual: number;
|
|
122
|
-
readonly ok: boolean;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export interface CoverageThresholdResult {
|
|
126
|
-
/** True when no thresholds were configured, or every configured
|
|
127
|
-
* metric is at-or-above its target. */
|
|
128
|
-
readonly ok: boolean;
|
|
129
|
-
readonly breakdown: readonly CoverageThresholdBreakdown[];
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export type ReporterFormat = "human" | "json" | "junit" | "lcov";
|
|
133
|
-
|
|
134
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
135
|
-
// Aggregation
|
|
136
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Combine two or more reports into one "combined" report. Useful when
|
|
140
|
-
* `mandu test --e2e --coverage` has produced a unit+integration report
|
|
141
|
-
* plus a separate E2E report that both need to land in a single
|
|
142
|
-
* JUnit/JSON artifact.
|
|
143
|
-
*
|
|
144
|
-
* Rules:
|
|
145
|
-
* - Test cases are concatenated (stable order preserved).
|
|
146
|
-
* - `coverage` is **not** re-merged here — LCOV merging lives in
|
|
147
|
-
* `@mandujs/ate/coverage-merger`. We pick the last non-empty
|
|
148
|
-
* coverage block, since in the CLI pipeline coverage is computed
|
|
149
|
-
* after all runs complete.
|
|
150
|
-
* - `durationMs` is summed.
|
|
151
|
-
* - `timestamp` uses the latest reporting timestamp.
|
|
152
|
-
* - `suite` becomes `"combined"`, `kind` becomes `"combined"`.
|
|
153
|
-
*/
|
|
154
|
-
export function mergeReports(
|
|
155
|
-
...reports: readonly TestReport[]
|
|
156
|
-
): TestReport {
|
|
157
|
-
if (reports.length === 0) {
|
|
158
|
-
return {
|
|
159
|
-
suite: "combined",
|
|
160
|
-
kind: "combined",
|
|
161
|
-
tests: [],
|
|
162
|
-
durationMs: 0,
|
|
163
|
-
timestamp: new Date(0).toISOString(),
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
if (reports.length === 1) return reports[0]!;
|
|
167
|
-
|
|
168
|
-
const tests: TestCase[] = [];
|
|
169
|
-
let durationMs = 0;
|
|
170
|
-
let latestTs = "";
|
|
171
|
-
let coverage: Coverage | undefined;
|
|
172
|
-
for (const r of reports) {
|
|
173
|
-
tests.push(...r.tests);
|
|
174
|
-
durationMs += r.durationMs;
|
|
175
|
-
if (r.timestamp > latestTs) latestTs = r.timestamp;
|
|
176
|
-
if (r.coverage) coverage = r.coverage;
|
|
177
|
-
}
|
|
178
|
-
return {
|
|
179
|
-
suite: "combined",
|
|
180
|
-
kind: "combined",
|
|
181
|
-
tests,
|
|
182
|
-
coverage,
|
|
183
|
-
durationMs,
|
|
184
|
-
timestamp: latestTs || new Date().toISOString(),
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Summary counters used across reporter formats. */
|
|
189
|
-
export interface ReportSummary {
|
|
190
|
-
readonly total: number;
|
|
191
|
-
readonly passed: number;
|
|
192
|
-
readonly failed: number;
|
|
193
|
-
readonly skipped: number;
|
|
194
|
-
readonly todo: number;
|
|
195
|
-
readonly durationMs: number;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function summarizeReport(report: TestReport): ReportSummary {
|
|
199
|
-
let passed = 0;
|
|
200
|
-
let failed = 0;
|
|
201
|
-
let skipped = 0;
|
|
202
|
-
let todo = 0;
|
|
203
|
-
for (const t of report.tests) {
|
|
204
|
-
switch (t.status) {
|
|
205
|
-
case "passed":
|
|
206
|
-
passed++;
|
|
207
|
-
break;
|
|
208
|
-
case "failed":
|
|
209
|
-
failed++;
|
|
210
|
-
break;
|
|
211
|
-
case "skipped":
|
|
212
|
-
skipped++;
|
|
213
|
-
break;
|
|
214
|
-
case "todo":
|
|
215
|
-
todo++;
|
|
216
|
-
break;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return {
|
|
220
|
-
total: report.tests.length,
|
|
221
|
-
passed,
|
|
222
|
-
failed,
|
|
223
|
-
skipped,
|
|
224
|
-
todo,
|
|
225
|
-
durationMs: report.durationMs,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
230
|
-
// Coverage threshold check
|
|
231
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Compare a {@link Coverage} block against configured thresholds.
|
|
235
|
-
* Missing thresholds are skipped (no constraint). Missing coverage
|
|
236
|
-
* metrics whose threshold is configured fail with `actual = 0`.
|
|
237
|
-
*
|
|
238
|
-
* Tolerance: floating-point equality uses `actual + 1e-9 >= expected`
|
|
239
|
-
* to avoid spurious failures at exactly the target percentage.
|
|
240
|
-
*/
|
|
241
|
-
export function checkCoverageThresholds(
|
|
242
|
-
coverage: Coverage | undefined,
|
|
243
|
-
thresholds: CoverageThresholds | undefined
|
|
244
|
-
): CoverageThresholdResult {
|
|
245
|
-
if (!thresholds) return { ok: true, breakdown: [] };
|
|
246
|
-
|
|
247
|
-
const metrics: CoverageMetric[] = [
|
|
248
|
-
"lines",
|
|
249
|
-
"branches",
|
|
250
|
-
"functions",
|
|
251
|
-
"statements",
|
|
252
|
-
];
|
|
253
|
-
const breakdown: CoverageThresholdBreakdown[] = [];
|
|
254
|
-
let ok = true;
|
|
255
|
-
for (const metric of metrics) {
|
|
256
|
-
const expected = thresholds[metric];
|
|
257
|
-
if (expected === undefined || expected <= 0) continue;
|
|
258
|
-
const actual = coverage?.[metric]?.pct ?? 0;
|
|
259
|
-
const metOk = actual + 1e-9 >= expected;
|
|
260
|
-
if (!metOk) ok = false;
|
|
261
|
-
breakdown.push({ metric, expected, actual, ok: metOk });
|
|
262
|
-
}
|
|
263
|
-
return { ok, breakdown };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Human-readable multi-line error block listing every failing metric.
|
|
268
|
-
* Safe to print to stderr — does not colorize (the caller decides).
|
|
269
|
-
*/
|
|
270
|
-
export function formatThresholdFailure(
|
|
271
|
-
result: CoverageThresholdResult
|
|
272
|
-
): string {
|
|
273
|
-
const failing = result.breakdown.filter((b) => !b.ok);
|
|
274
|
-
if (failing.length === 0) return "";
|
|
275
|
-
const lines = ["Coverage below threshold:"];
|
|
276
|
-
for (const b of failing) {
|
|
277
|
-
lines.push(
|
|
278
|
-
` - ${b.metric.padEnd(11)} ${b.actual.toFixed(2)}% < ${b.expected}%`
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
return lines.join("\n");
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
285
|
-
// Formatters
|
|
286
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
287
|
-
|
|
288
|
-
export interface FormatOptions {
|
|
289
|
-
/**
|
|
290
|
-
* Disable ANSI color output. Autodetected via `NO_COLOR` / `FORCE_
|
|
291
|
-
* COLOR` / `process.stdout.isTTY` when undefined.
|
|
292
|
-
*/
|
|
293
|
-
readonly noColor?: boolean;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** Public dispatch — single entry point for all four formats. */
|
|
297
|
-
export function formatReport(
|
|
298
|
-
report: TestReport,
|
|
299
|
-
format: ReporterFormat,
|
|
300
|
-
opts: FormatOptions = {}
|
|
301
|
-
): string {
|
|
302
|
-
switch (format) {
|
|
303
|
-
case "human":
|
|
304
|
-
return formatHuman(report, opts);
|
|
305
|
-
case "json":
|
|
306
|
-
return formatJson(report);
|
|
307
|
-
case "junit":
|
|
308
|
-
return formatJunit(report);
|
|
309
|
-
case "lcov":
|
|
310
|
-
return formatLcov(report);
|
|
311
|
-
}
|
|
312
|
-
// TypeScript exhaustiveness — surfaces bad callers at runtime too.
|
|
313
|
-
throw new Error(`Unknown reporter format: ${String(format)}`);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ──────── Human ──────────────────────────────────────────────────
|
|
317
|
-
|
|
318
|
-
const ANSI = {
|
|
319
|
-
reset: "\x1b[0m",
|
|
320
|
-
bold: "\x1b[1m",
|
|
321
|
-
dim: "\x1b[2m",
|
|
322
|
-
green: "\x1b[32m",
|
|
323
|
-
red: "\x1b[31m",
|
|
324
|
-
yellow: "\x1b[33m",
|
|
325
|
-
cyan: "\x1b[36m",
|
|
326
|
-
gray: "\x1b[90m",
|
|
327
|
-
} as const;
|
|
328
|
-
|
|
329
|
-
function shouldColor(opts: FormatOptions): boolean {
|
|
330
|
-
if (opts.noColor === true) return false;
|
|
331
|
-
if (opts.noColor === false) return true;
|
|
332
|
-
// Auto-detect: match the behavior of terminal/theme.ts.
|
|
333
|
-
if (process.env.NO_COLOR) return false;
|
|
334
|
-
if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") {
|
|
335
|
-
return true;
|
|
336
|
-
}
|
|
337
|
-
if (process.env.CI) return false;
|
|
338
|
-
return Boolean(process.stdout?.isTTY);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function color(code: string, text: string, enabled: boolean): string {
|
|
342
|
-
return enabled ? `${code}${text}${ANSI.reset}` : text;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function statusGlyph(status: TestStatus, colorOn: boolean): string {
|
|
346
|
-
switch (status) {
|
|
347
|
-
case "passed":
|
|
348
|
-
return color(ANSI.green, "\u2713", colorOn); // ✓
|
|
349
|
-
case "failed":
|
|
350
|
-
return color(ANSI.red, "\u2717", colorOn); // ✗
|
|
351
|
-
case "skipped":
|
|
352
|
-
return color(ANSI.yellow, "\u25CB", colorOn); // ○
|
|
353
|
-
case "todo":
|
|
354
|
-
return color(ANSI.cyan, "\u2022", colorOn); // •
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
export function formatHuman(
|
|
359
|
-
report: TestReport,
|
|
360
|
-
opts: FormatOptions = {}
|
|
361
|
-
): string {
|
|
362
|
-
const colorOn = shouldColor(opts);
|
|
363
|
-
const summary = summarizeReport(report);
|
|
364
|
-
const out: string[] = [];
|
|
365
|
-
|
|
366
|
-
const heading = `mandu test · ${report.suite}`;
|
|
367
|
-
out.push(color(ANSI.bold, heading, colorOn));
|
|
368
|
-
|
|
369
|
-
// Group tests by suite for readable grouping.
|
|
370
|
-
const bySuite = new Map<string, TestCase[]>();
|
|
371
|
-
for (const t of report.tests) {
|
|
372
|
-
const key = t.suite ?? "(top-level)";
|
|
373
|
-
const arr = bySuite.get(key) ?? [];
|
|
374
|
-
arr.push(t);
|
|
375
|
-
bySuite.set(key, arr);
|
|
376
|
-
}
|
|
377
|
-
const suiteNames = [...bySuite.keys()].sort();
|
|
378
|
-
for (const suite of suiteNames) {
|
|
379
|
-
out.push(color(ANSI.gray, ` ${suite}`, colorOn));
|
|
380
|
-
const cases = bySuite.get(suite)!.slice().sort((a, b) =>
|
|
381
|
-
a.name.localeCompare(b.name)
|
|
382
|
-
);
|
|
383
|
-
for (const t of cases) {
|
|
384
|
-
const glyph = statusGlyph(t.status, colorOn);
|
|
385
|
-
const ms = color(ANSI.dim, `(${Math.round(t.durationMs)}ms)`, colorOn);
|
|
386
|
-
out.push(` ${glyph} ${t.name} ${ms}`);
|
|
387
|
-
if (t.status === "failed" && t.error) {
|
|
388
|
-
out.push(color(ANSI.red, ` ${t.error.message}`, colorOn));
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
out.push("");
|
|
394
|
-
const passedStr = color(
|
|
395
|
-
ANSI.green,
|
|
396
|
-
`${summary.passed} passed`,
|
|
397
|
-
colorOn
|
|
398
|
-
);
|
|
399
|
-
const failedStr =
|
|
400
|
-
summary.failed > 0
|
|
401
|
-
? color(ANSI.red, `${summary.failed} failed`, colorOn)
|
|
402
|
-
: `${summary.failed} failed`;
|
|
403
|
-
const skippedStr =
|
|
404
|
-
summary.skipped > 0
|
|
405
|
-
? color(ANSI.yellow, `${summary.skipped} skipped`, colorOn)
|
|
406
|
-
: `${summary.skipped} skipped`;
|
|
407
|
-
out.push(
|
|
408
|
-
` ${passedStr} ${failedStr} ${skippedStr} ${color(
|
|
409
|
-
ANSI.dim,
|
|
410
|
-
`(${summary.total} total, ${summary.durationMs}ms)`,
|
|
411
|
-
colorOn
|
|
412
|
-
)}`
|
|
413
|
-
);
|
|
414
|
-
|
|
415
|
-
if (report.coverage) {
|
|
416
|
-
out.push("");
|
|
417
|
-
out.push(color(ANSI.bold, " Coverage", colorOn));
|
|
418
|
-
for (const metric of [
|
|
419
|
-
"lines",
|
|
420
|
-
"branches",
|
|
421
|
-
"functions",
|
|
422
|
-
"statements",
|
|
423
|
-
] as const) {
|
|
424
|
-
const c = report.coverage[metric];
|
|
425
|
-
if (!c) continue;
|
|
426
|
-
out.push(
|
|
427
|
-
` ${metric.padEnd(11)} ${c.pct.toFixed(2)}% (${c.hit}/${c.found})`
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
if (report.coverage.lcovPath) {
|
|
431
|
-
out.push(
|
|
432
|
-
color(
|
|
433
|
-
ANSI.dim,
|
|
434
|
-
` → ${report.coverage.lcovPath}`,
|
|
435
|
-
colorOn
|
|
436
|
-
)
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return out.join("\n");
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// ──────── JSON ───────────────────────────────────────────────────
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Stable JSON serialization. We deliberately do NOT key-sort so
|
|
448
|
-
* human readers see the natural reporter order; stability comes from
|
|
449
|
-
* the deterministic ordering of `tests` at composition time.
|
|
450
|
-
*/
|
|
451
|
-
export function formatJson(report: TestReport): string {
|
|
452
|
-
const summary = summarizeReport(report);
|
|
453
|
-
const payload = {
|
|
454
|
-
schema: "mandu-test-report/v1",
|
|
455
|
-
suite: report.suite,
|
|
456
|
-
kind: report.kind,
|
|
457
|
-
timestamp: report.timestamp,
|
|
458
|
-
durationMs: report.durationMs,
|
|
459
|
-
summary,
|
|
460
|
-
tests: report.tests.map((t) => ({
|
|
461
|
-
name: t.name,
|
|
462
|
-
suite: t.suite ?? null,
|
|
463
|
-
status: t.status,
|
|
464
|
-
durationMs: Math.round(t.durationMs),
|
|
465
|
-
file: t.file ?? null,
|
|
466
|
-
error: t.error
|
|
467
|
-
? {
|
|
468
|
-
message: t.error.message,
|
|
469
|
-
stack: t.error.stack ?? null,
|
|
470
|
-
}
|
|
471
|
-
: null,
|
|
472
|
-
})),
|
|
473
|
-
coverage: report.coverage
|
|
474
|
-
? {
|
|
475
|
-
lines: report.coverage.lines ?? null,
|
|
476
|
-
branches: report.coverage.branches ?? null,
|
|
477
|
-
functions: report.coverage.functions ?? null,
|
|
478
|
-
statements: report.coverage.statements ?? null,
|
|
479
|
-
files: report.coverage.files ?? null,
|
|
480
|
-
lcovPath: report.coverage.lcovPath ?? null,
|
|
481
|
-
}
|
|
482
|
-
: null,
|
|
483
|
-
};
|
|
484
|
-
return JSON.stringify(payload, null, 2);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// ──────── JUnit XML ──────────────────────────────────────────────
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Escape text for inclusion inside an XML element or double-quoted
|
|
491
|
-
* attribute. Strips the 5 XML predefined entities plus raw C0 control
|
|
492
|
-
* characters (which are invalid in XML 1.0).
|
|
493
|
-
*/
|
|
494
|
-
function escapeXml(value: string): string {
|
|
495
|
-
return value
|
|
496
|
-
.replace(/&/g, "&")
|
|
497
|
-
.replace(/</g, "<")
|
|
498
|
-
.replace(/>/g, ">")
|
|
499
|
-
.replace(/"/g, """)
|
|
500
|
-
.replace(/'/g, "'")
|
|
501
|
-
// eslint-disable-next-line no-control-regex
|
|
502
|
-
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Emit GitHub-Actions-compatible JUnit XML. Schema follows the de-
|
|
507
|
-
* facto Jenkins variant (`<testsuites>` root, `<testsuite>` per suite
|
|
508
|
-
* bucket, `<testcase>` per test). `time` attributes are seconds with
|
|
509
|
-
* millisecond precision to match Jenkins/GitLab tooling.
|
|
510
|
-
*/
|
|
511
|
-
export function formatJunit(report: TestReport): string {
|
|
512
|
-
const summary = summarizeReport(report);
|
|
513
|
-
const totalSeconds = (summary.durationMs / 1000).toFixed(3);
|
|
514
|
-
|
|
515
|
-
// Bucket by suite — each bucket becomes a <testsuite>.
|
|
516
|
-
const bySuite = new Map<string, TestCase[]>();
|
|
517
|
-
for (const t of report.tests) {
|
|
518
|
-
const key = t.suite ?? report.suite;
|
|
519
|
-
const arr = bySuite.get(key) ?? [];
|
|
520
|
-
arr.push(t);
|
|
521
|
-
bySuite.set(key, arr);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
const suiteXml: string[] = [];
|
|
525
|
-
const suiteNames = [...bySuite.keys()].sort();
|
|
526
|
-
for (const suiteName of suiteNames) {
|
|
527
|
-
const cases = bySuite.get(suiteName)!;
|
|
528
|
-
const suiteDurationMs = cases.reduce((acc, t) => acc + t.durationMs, 0);
|
|
529
|
-
const suiteFailed = cases.filter((c) => c.status === "failed").length;
|
|
530
|
-
const suiteSkipped = cases.filter((c) => c.status === "skipped").length;
|
|
531
|
-
const suiteAttrs = [
|
|
532
|
-
`name="${escapeXml(suiteName)}"`,
|
|
533
|
-
`tests="${cases.length}"`,
|
|
534
|
-
`failures="${suiteFailed}"`,
|
|
535
|
-
`skipped="${suiteSkipped}"`,
|
|
536
|
-
`time="${(suiteDurationMs / 1000).toFixed(3)}"`,
|
|
537
|
-
`timestamp="${escapeXml(report.timestamp)}"`,
|
|
538
|
-
].join(" ");
|
|
539
|
-
|
|
540
|
-
const caseXml: string[] = [];
|
|
541
|
-
for (const t of cases) {
|
|
542
|
-
const caseAttrs = [
|
|
543
|
-
`name="${escapeXml(t.name)}"`,
|
|
544
|
-
`classname="${escapeXml(t.suite ?? report.suite)}"`,
|
|
545
|
-
`time="${(t.durationMs / 1000).toFixed(3)}"`,
|
|
546
|
-
];
|
|
547
|
-
if (t.file) caseAttrs.push(`file="${escapeXml(t.file)}"`);
|
|
548
|
-
|
|
549
|
-
if (t.status === "passed") {
|
|
550
|
-
caseXml.push(` <testcase ${caseAttrs.join(" ")}/>`);
|
|
551
|
-
} else if (t.status === "failed") {
|
|
552
|
-
const msg = escapeXml(t.error?.message ?? "Test failed");
|
|
553
|
-
const stack = escapeXml(t.error?.stack ?? "");
|
|
554
|
-
caseXml.push(
|
|
555
|
-
` <testcase ${caseAttrs.join(" ")}>`,
|
|
556
|
-
` <failure message="${msg}" type="AssertionError">${stack}</failure>`,
|
|
557
|
-
` </testcase>`
|
|
558
|
-
);
|
|
559
|
-
} else if (t.status === "skipped" || t.status === "todo") {
|
|
560
|
-
caseXml.push(
|
|
561
|
-
` <testcase ${caseAttrs.join(" ")}>`,
|
|
562
|
-
` <skipped/>`,
|
|
563
|
-
` </testcase>`
|
|
564
|
-
);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
suiteXml.push(` <testsuite ${suiteAttrs}>`);
|
|
569
|
-
suiteXml.push(...caseXml);
|
|
570
|
-
suiteXml.push(` </testsuite>`);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
return [
|
|
574
|
-
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
575
|
-
`<testsuites name="${escapeXml(report.suite)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${totalSeconds}">`,
|
|
576
|
-
...suiteXml,
|
|
577
|
-
`</testsuites>`,
|
|
578
|
-
``,
|
|
579
|
-
].join("\n");
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// ──────── LCOV ───────────────────────────────────────────────────
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* Emit the merged LCOV body. If `report.coverage.lcovBody` is set, we
|
|
586
|
-
* return it verbatim; otherwise we synthesize a minimal `SF:/LF/LH`
|
|
587
|
-
* summary from the aggregate line metric (sufficient for tooling like
|
|
588
|
-
* Codecov which only needs the summary line when per-file data is
|
|
589
|
-
* unavailable).
|
|
590
|
-
*
|
|
591
|
-
* Returns an empty string when no coverage block is present — callers
|
|
592
|
-
* writing to stdout should treat that as "no coverage to emit" and
|
|
593
|
-
* exit early.
|
|
594
|
-
*/
|
|
595
|
-
export function formatLcov(report: TestReport): string {
|
|
596
|
-
if (!report.coverage) return "";
|
|
597
|
-
if (report.coverage.lcovBody) return report.coverage.lcovBody;
|
|
598
|
-
const lines = report.coverage.lines;
|
|
599
|
-
if (!lines) return "";
|
|
600
|
-
// Minimal LCOV synthetic record — every real consumer will have the
|
|
601
|
-
// full body via `lcovBody`. This path exists so the format stays
|
|
602
|
-
// non-empty for round-trip tests.
|
|
603
|
-
return [
|
|
604
|
-
"SF:.mandu/coverage/synthetic",
|
|
605
|
-
`LF:${lines.found}`,
|
|
606
|
-
`LH:${lines.hit}`,
|
|
607
|
-
"end_of_record",
|
|
608
|
-
"",
|
|
609
|
-
].join("\n");
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
613
|
-
// Helpers for the CLI layer
|
|
614
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Compute a {@link Coverage} struct from a raw LCOV body. Supports
|
|
618
|
-
* `DA:` / `LF:` / `LH:` (lines), `BRF:` / `BRH:` (branches), `FNF:` /
|
|
619
|
-
* `FNH:` (functions). `statements` is not present in LCOV — callers
|
|
620
|
-
* wire it in separately when the underlying runner provides it.
|
|
621
|
-
*
|
|
622
|
-
* Exported so the CLI can build a `Coverage` block after
|
|
623
|
-
* `mergeCoverageOutputs()` writes the LCOV.
|
|
624
|
-
*/
|
|
625
|
-
export function parseLcovSummary(lcovBody: string): Coverage {
|
|
626
|
-
let LF = 0;
|
|
627
|
-
let LH = 0;
|
|
628
|
-
let BRF = 0;
|
|
629
|
-
let BRH = 0;
|
|
630
|
-
let FNF = 0;
|
|
631
|
-
let FNH = 0;
|
|
632
|
-
const files = new Set<string>();
|
|
633
|
-
for (const line of lcovBody.split(/\r?\n/)) {
|
|
634
|
-
if (line.startsWith("SF:")) files.add(line.slice(3));
|
|
635
|
-
else if (line.startsWith("LF:")) LF += Number(line.slice(3)) || 0;
|
|
636
|
-
else if (line.startsWith("LH:")) LH += Number(line.slice(3)) || 0;
|
|
637
|
-
else if (line.startsWith("BRF:")) BRF += Number(line.slice(4)) || 0;
|
|
638
|
-
else if (line.startsWith("BRH:")) BRH += Number(line.slice(4)) || 0;
|
|
639
|
-
else if (line.startsWith("FNF:")) FNF += Number(line.slice(4)) || 0;
|
|
640
|
-
else if (line.startsWith("FNH:")) FNH += Number(line.slice(4)) || 0;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const pct = (hit: number, found: number): number =>
|
|
644
|
-
found === 0 ? 0 : Math.round((hit / found) * 10000) / 100;
|
|
645
|
-
|
|
646
|
-
const result: Coverage = {
|
|
647
|
-
lines: LF > 0 ? { hit: LH, found: LF, pct: pct(LH, LF) } : undefined,
|
|
648
|
-
branches:
|
|
649
|
-
BRF > 0 ? { hit: BRH, found: BRF, pct: pct(BRH, BRF) } : undefined,
|
|
650
|
-
functions:
|
|
651
|
-
FNF > 0 ? { hit: FNH, found: FNF, pct: pct(FNH, FNF) } : undefined,
|
|
652
|
-
files: files.size || undefined,
|
|
653
|
-
lcovBody,
|
|
654
|
-
};
|
|
655
|
-
return result;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* Convenience: build a minimal, empty-suite report. Used by callers
|
|
660
|
-
* that have no per-test granularity (e.g. when we only forwarded bun
|
|
661
|
-
* test's exit code but haven't parsed its stdout). Still useful for
|
|
662
|
-
* `--coverage`-only runs where the reporter just needs to emit the
|
|
663
|
-
* coverage block.
|
|
664
|
-
*/
|
|
665
|
-
export function emptyReport(
|
|
666
|
-
suite: string,
|
|
667
|
-
kind: TestSuiteKind | "combined"
|
|
668
|
-
): TestReport {
|
|
669
|
-
return {
|
|
670
|
-
suite,
|
|
671
|
-
kind,
|
|
672
|
-
tests: [],
|
|
673
|
-
durationMs: 0,
|
|
674
|
-
timestamp: new Date().toISOString(),
|
|
675
|
-
};
|
|
676
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/testing/reporter — Phase 18.σ
|
|
3
|
+
*
|
|
4
|
+
* Unified test reporter for `mandu test`. Collapses the four legacy
|
|
5
|
+
* stdout formats (unit / integration / e2e / coverage) into a single
|
|
6
|
+
* structured `TestReport` shape with four output formats:
|
|
7
|
+
*
|
|
8
|
+
* - `human` → colorized summary for interactive terminals
|
|
9
|
+
* - `json` → machine-readable schema, ready for pipelines
|
|
10
|
+
* - `junit` → JUnit XML, compatible with GitHub Actions `publish-
|
|
11
|
+
* test-results`, Jenkins, CircleCI, GitLab CI
|
|
12
|
+
* - `lcov` → pass-through / re-emit of the merged coverage LCOV
|
|
13
|
+
*
|
|
14
|
+
* The reporter is **pure** — it accepts a `TestReport` and returns a
|
|
15
|
+
* string. No I/O, no spawning, no process.exit. The CLI composes the
|
|
16
|
+
* report from runner outputs and picks the format based on
|
|
17
|
+
* `--reporter=<format>`.
|
|
18
|
+
*
|
|
19
|
+
* ## Design constraints
|
|
20
|
+
*
|
|
21
|
+
* 1. **No runtime dependencies.** We hand-roll the ANSI codes and XML
|
|
22
|
+
* serializer so this module can be imported in any context
|
|
23
|
+
* (including dev mode in browsers via the MCP test doctor).
|
|
24
|
+
* 2. **Deterministic output.** Tests are sorted by (suite, name) before
|
|
25
|
+
* rendering so CI diffs stay minimal. Durations are rounded to
|
|
26
|
+
* integer milliseconds. Timestamps are emitted in UTC ISO-8601 via
|
|
27
|
+
* `toISOString()` for cross-timezone stability.
|
|
28
|
+
* 3. **Round-trip safe.** `mergeReports()` followed by
|
|
29
|
+
* `formatReport(r, 'json')` produces a body that can be parsed back
|
|
30
|
+
* via `JSON.parse` — every field is JSON-serializable.
|
|
31
|
+
*
|
|
32
|
+
* ## Coverage thresholds
|
|
33
|
+
*
|
|
34
|
+
* `checkCoverageThresholds(coverage, thresholds)` is a pure comparator
|
|
35
|
+
* returning a structured breakdown (actual vs expected per metric).
|
|
36
|
+
* The CLI layer calls this after merging LCOV and prints the human
|
|
37
|
+
* error when `ok === false`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
+
// Public types
|
|
42
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
/** Outcome of a single test case inside a suite. */
|
|
45
|
+
export type TestStatus = "passed" | "failed" | "skipped" | "todo";
|
|
46
|
+
|
|
47
|
+
/** Test category — maps to the legacy subcommand. */
|
|
48
|
+
export type TestSuiteKind = "unit" | "integration" | "e2e";
|
|
49
|
+
|
|
50
|
+
/** Coverage metric names — match Bun/LCOV conventions. */
|
|
51
|
+
export type CoverageMetric =
|
|
52
|
+
| "lines"
|
|
53
|
+
| "branches"
|
|
54
|
+
| "functions"
|
|
55
|
+
| "statements";
|
|
56
|
+
|
|
57
|
+
export interface TestCase {
|
|
58
|
+
/** Human-readable name — typically the `it(...)` description. */
|
|
59
|
+
readonly name: string;
|
|
60
|
+
/** Dotted suite path (e.g. `"auth > login > rate-limit"`). Optional. */
|
|
61
|
+
readonly suite?: string;
|
|
62
|
+
/** Outcome classification. */
|
|
63
|
+
readonly status: TestStatus;
|
|
64
|
+
/** Elapsed wall time in ms (integer, rounded). */
|
|
65
|
+
readonly durationMs: number;
|
|
66
|
+
/** Populated for `status === "failed"`. */
|
|
67
|
+
readonly error?: {
|
|
68
|
+
readonly message: string;
|
|
69
|
+
readonly stack?: string;
|
|
70
|
+
};
|
|
71
|
+
/** Source file path, absolute or project-relative. */
|
|
72
|
+
readonly file?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CoverageMetricResult {
|
|
76
|
+
readonly hit: number;
|
|
77
|
+
readonly found: number;
|
|
78
|
+
/** Ratio in 0-100 range, rounded to 2 decimals. 0 when `found === 0`. */
|
|
79
|
+
readonly pct: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Coverage {
|
|
83
|
+
readonly lines?: CoverageMetricResult;
|
|
84
|
+
readonly branches?: CoverageMetricResult;
|
|
85
|
+
readonly functions?: CoverageMetricResult;
|
|
86
|
+
readonly statements?: CoverageMetricResult;
|
|
87
|
+
/** Number of files represented in the underlying LCOV. */
|
|
88
|
+
readonly files?: number;
|
|
89
|
+
/** Path to the merged LCOV body. Reporter embeds this for `lcov` format. */
|
|
90
|
+
readonly lcovPath?: string;
|
|
91
|
+
/** Optional raw LCOV body (used by the `lcov` reporter format). */
|
|
92
|
+
readonly lcovBody?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TestReport {
|
|
96
|
+
/** Logical label — `"mandu test"`, `"unit"`, `"integration"`, `"e2e"`, etc. */
|
|
97
|
+
readonly suite: string;
|
|
98
|
+
/** Classification — used for grouping in human output. */
|
|
99
|
+
readonly kind: TestSuiteKind | "combined";
|
|
100
|
+
/** Every case observed for this report. */
|
|
101
|
+
readonly tests: readonly TestCase[];
|
|
102
|
+
/** Merged coverage metrics, if `--coverage` was enabled. */
|
|
103
|
+
readonly coverage?: Coverage;
|
|
104
|
+
/** Wall-clock total across all cases. Integer ms. */
|
|
105
|
+
readonly durationMs: number;
|
|
106
|
+
/** Report creation time — ISO 8601 UTC. */
|
|
107
|
+
readonly timestamp: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** User-configurable thresholds from `mandu.config.ts`. */
|
|
111
|
+
export interface CoverageThresholds {
|
|
112
|
+
readonly lines?: number;
|
|
113
|
+
readonly branches?: number;
|
|
114
|
+
readonly functions?: number;
|
|
115
|
+
readonly statements?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CoverageThresholdBreakdown {
|
|
119
|
+
readonly metric: CoverageMetric;
|
|
120
|
+
readonly expected: number;
|
|
121
|
+
readonly actual: number;
|
|
122
|
+
readonly ok: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface CoverageThresholdResult {
|
|
126
|
+
/** True when no thresholds were configured, or every configured
|
|
127
|
+
* metric is at-or-above its target. */
|
|
128
|
+
readonly ok: boolean;
|
|
129
|
+
readonly breakdown: readonly CoverageThresholdBreakdown[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type ReporterFormat = "human" | "json" | "junit" | "lcov";
|
|
133
|
+
|
|
134
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
135
|
+
// Aggregation
|
|
136
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Combine two or more reports into one "combined" report. Useful when
|
|
140
|
+
* `mandu test --e2e --coverage` has produced a unit+integration report
|
|
141
|
+
* plus a separate E2E report that both need to land in a single
|
|
142
|
+
* JUnit/JSON artifact.
|
|
143
|
+
*
|
|
144
|
+
* Rules:
|
|
145
|
+
* - Test cases are concatenated (stable order preserved).
|
|
146
|
+
* - `coverage` is **not** re-merged here — LCOV merging lives in
|
|
147
|
+
* `@mandujs/ate/coverage-merger`. We pick the last non-empty
|
|
148
|
+
* coverage block, since in the CLI pipeline coverage is computed
|
|
149
|
+
* after all runs complete.
|
|
150
|
+
* - `durationMs` is summed.
|
|
151
|
+
* - `timestamp` uses the latest reporting timestamp.
|
|
152
|
+
* - `suite` becomes `"combined"`, `kind` becomes `"combined"`.
|
|
153
|
+
*/
|
|
154
|
+
export function mergeReports(
|
|
155
|
+
...reports: readonly TestReport[]
|
|
156
|
+
): TestReport {
|
|
157
|
+
if (reports.length === 0) {
|
|
158
|
+
return {
|
|
159
|
+
suite: "combined",
|
|
160
|
+
kind: "combined",
|
|
161
|
+
tests: [],
|
|
162
|
+
durationMs: 0,
|
|
163
|
+
timestamp: new Date(0).toISOString(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (reports.length === 1) return reports[0]!;
|
|
167
|
+
|
|
168
|
+
const tests: TestCase[] = [];
|
|
169
|
+
let durationMs = 0;
|
|
170
|
+
let latestTs = "";
|
|
171
|
+
let coverage: Coverage | undefined;
|
|
172
|
+
for (const r of reports) {
|
|
173
|
+
tests.push(...r.tests);
|
|
174
|
+
durationMs += r.durationMs;
|
|
175
|
+
if (r.timestamp > latestTs) latestTs = r.timestamp;
|
|
176
|
+
if (r.coverage) coverage = r.coverage;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
suite: "combined",
|
|
180
|
+
kind: "combined",
|
|
181
|
+
tests,
|
|
182
|
+
coverage,
|
|
183
|
+
durationMs,
|
|
184
|
+
timestamp: latestTs || new Date().toISOString(),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Summary counters used across reporter formats. */
|
|
189
|
+
export interface ReportSummary {
|
|
190
|
+
readonly total: number;
|
|
191
|
+
readonly passed: number;
|
|
192
|
+
readonly failed: number;
|
|
193
|
+
readonly skipped: number;
|
|
194
|
+
readonly todo: number;
|
|
195
|
+
readonly durationMs: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function summarizeReport(report: TestReport): ReportSummary {
|
|
199
|
+
let passed = 0;
|
|
200
|
+
let failed = 0;
|
|
201
|
+
let skipped = 0;
|
|
202
|
+
let todo = 0;
|
|
203
|
+
for (const t of report.tests) {
|
|
204
|
+
switch (t.status) {
|
|
205
|
+
case "passed":
|
|
206
|
+
passed++;
|
|
207
|
+
break;
|
|
208
|
+
case "failed":
|
|
209
|
+
failed++;
|
|
210
|
+
break;
|
|
211
|
+
case "skipped":
|
|
212
|
+
skipped++;
|
|
213
|
+
break;
|
|
214
|
+
case "todo":
|
|
215
|
+
todo++;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
total: report.tests.length,
|
|
221
|
+
passed,
|
|
222
|
+
failed,
|
|
223
|
+
skipped,
|
|
224
|
+
todo,
|
|
225
|
+
durationMs: report.durationMs,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
230
|
+
// Coverage threshold check
|
|
231
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Compare a {@link Coverage} block against configured thresholds.
|
|
235
|
+
* Missing thresholds are skipped (no constraint). Missing coverage
|
|
236
|
+
* metrics whose threshold is configured fail with `actual = 0`.
|
|
237
|
+
*
|
|
238
|
+
* Tolerance: floating-point equality uses `actual + 1e-9 >= expected`
|
|
239
|
+
* to avoid spurious failures at exactly the target percentage.
|
|
240
|
+
*/
|
|
241
|
+
export function checkCoverageThresholds(
|
|
242
|
+
coverage: Coverage | undefined,
|
|
243
|
+
thresholds: CoverageThresholds | undefined
|
|
244
|
+
): CoverageThresholdResult {
|
|
245
|
+
if (!thresholds) return { ok: true, breakdown: [] };
|
|
246
|
+
|
|
247
|
+
const metrics: CoverageMetric[] = [
|
|
248
|
+
"lines",
|
|
249
|
+
"branches",
|
|
250
|
+
"functions",
|
|
251
|
+
"statements",
|
|
252
|
+
];
|
|
253
|
+
const breakdown: CoverageThresholdBreakdown[] = [];
|
|
254
|
+
let ok = true;
|
|
255
|
+
for (const metric of metrics) {
|
|
256
|
+
const expected = thresholds[metric];
|
|
257
|
+
if (expected === undefined || expected <= 0) continue;
|
|
258
|
+
const actual = coverage?.[metric]?.pct ?? 0;
|
|
259
|
+
const metOk = actual + 1e-9 >= expected;
|
|
260
|
+
if (!metOk) ok = false;
|
|
261
|
+
breakdown.push({ metric, expected, actual, ok: metOk });
|
|
262
|
+
}
|
|
263
|
+
return { ok, breakdown };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Human-readable multi-line error block listing every failing metric.
|
|
268
|
+
* Safe to print to stderr — does not colorize (the caller decides).
|
|
269
|
+
*/
|
|
270
|
+
export function formatThresholdFailure(
|
|
271
|
+
result: CoverageThresholdResult
|
|
272
|
+
): string {
|
|
273
|
+
const failing = result.breakdown.filter((b) => !b.ok);
|
|
274
|
+
if (failing.length === 0) return "";
|
|
275
|
+
const lines = ["Coverage below threshold:"];
|
|
276
|
+
for (const b of failing) {
|
|
277
|
+
lines.push(
|
|
278
|
+
` - ${b.metric.padEnd(11)} ${b.actual.toFixed(2)}% < ${b.expected}%`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return lines.join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
285
|
+
// Formatters
|
|
286
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
287
|
+
|
|
288
|
+
export interface FormatOptions {
|
|
289
|
+
/**
|
|
290
|
+
* Disable ANSI color output. Autodetected via `NO_COLOR` / `FORCE_
|
|
291
|
+
* COLOR` / `process.stdout.isTTY` when undefined.
|
|
292
|
+
*/
|
|
293
|
+
readonly noColor?: boolean;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Public dispatch — single entry point for all four formats. */
|
|
297
|
+
export function formatReport(
|
|
298
|
+
report: TestReport,
|
|
299
|
+
format: ReporterFormat,
|
|
300
|
+
opts: FormatOptions = {}
|
|
301
|
+
): string {
|
|
302
|
+
switch (format) {
|
|
303
|
+
case "human":
|
|
304
|
+
return formatHuman(report, opts);
|
|
305
|
+
case "json":
|
|
306
|
+
return formatJson(report);
|
|
307
|
+
case "junit":
|
|
308
|
+
return formatJunit(report);
|
|
309
|
+
case "lcov":
|
|
310
|
+
return formatLcov(report);
|
|
311
|
+
}
|
|
312
|
+
// TypeScript exhaustiveness — surfaces bad callers at runtime too.
|
|
313
|
+
throw new Error(`Unknown reporter format: ${String(format)}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ──────── Human ──────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
const ANSI = {
|
|
319
|
+
reset: "\x1b[0m",
|
|
320
|
+
bold: "\x1b[1m",
|
|
321
|
+
dim: "\x1b[2m",
|
|
322
|
+
green: "\x1b[32m",
|
|
323
|
+
red: "\x1b[31m",
|
|
324
|
+
yellow: "\x1b[33m",
|
|
325
|
+
cyan: "\x1b[36m",
|
|
326
|
+
gray: "\x1b[90m",
|
|
327
|
+
} as const;
|
|
328
|
+
|
|
329
|
+
function shouldColor(opts: FormatOptions): boolean {
|
|
330
|
+
if (opts.noColor === true) return false;
|
|
331
|
+
if (opts.noColor === false) return true;
|
|
332
|
+
// Auto-detect: match the behavior of terminal/theme.ts.
|
|
333
|
+
if (process.env.NO_COLOR) return false;
|
|
334
|
+
if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if (process.env.CI) return false;
|
|
338
|
+
return Boolean(process.stdout?.isTTY);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function color(code: string, text: string, enabled: boolean): string {
|
|
342
|
+
return enabled ? `${code}${text}${ANSI.reset}` : text;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function statusGlyph(status: TestStatus, colorOn: boolean): string {
|
|
346
|
+
switch (status) {
|
|
347
|
+
case "passed":
|
|
348
|
+
return color(ANSI.green, "\u2713", colorOn); // ✓
|
|
349
|
+
case "failed":
|
|
350
|
+
return color(ANSI.red, "\u2717", colorOn); // ✗
|
|
351
|
+
case "skipped":
|
|
352
|
+
return color(ANSI.yellow, "\u25CB", colorOn); // ○
|
|
353
|
+
case "todo":
|
|
354
|
+
return color(ANSI.cyan, "\u2022", colorOn); // •
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function formatHuman(
|
|
359
|
+
report: TestReport,
|
|
360
|
+
opts: FormatOptions = {}
|
|
361
|
+
): string {
|
|
362
|
+
const colorOn = shouldColor(opts);
|
|
363
|
+
const summary = summarizeReport(report);
|
|
364
|
+
const out: string[] = [];
|
|
365
|
+
|
|
366
|
+
const heading = `mandu test · ${report.suite}`;
|
|
367
|
+
out.push(color(ANSI.bold, heading, colorOn));
|
|
368
|
+
|
|
369
|
+
// Group tests by suite for readable grouping.
|
|
370
|
+
const bySuite = new Map<string, TestCase[]>();
|
|
371
|
+
for (const t of report.tests) {
|
|
372
|
+
const key = t.suite ?? "(top-level)";
|
|
373
|
+
const arr = bySuite.get(key) ?? [];
|
|
374
|
+
arr.push(t);
|
|
375
|
+
bySuite.set(key, arr);
|
|
376
|
+
}
|
|
377
|
+
const suiteNames = [...bySuite.keys()].sort();
|
|
378
|
+
for (const suite of suiteNames) {
|
|
379
|
+
out.push(color(ANSI.gray, ` ${suite}`, colorOn));
|
|
380
|
+
const cases = bySuite.get(suite)!.slice().sort((a, b) =>
|
|
381
|
+
a.name.localeCompare(b.name)
|
|
382
|
+
);
|
|
383
|
+
for (const t of cases) {
|
|
384
|
+
const glyph = statusGlyph(t.status, colorOn);
|
|
385
|
+
const ms = color(ANSI.dim, `(${Math.round(t.durationMs)}ms)`, colorOn);
|
|
386
|
+
out.push(` ${glyph} ${t.name} ${ms}`);
|
|
387
|
+
if (t.status === "failed" && t.error) {
|
|
388
|
+
out.push(color(ANSI.red, ` ${t.error.message}`, colorOn));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
out.push("");
|
|
394
|
+
const passedStr = color(
|
|
395
|
+
ANSI.green,
|
|
396
|
+
`${summary.passed} passed`,
|
|
397
|
+
colorOn
|
|
398
|
+
);
|
|
399
|
+
const failedStr =
|
|
400
|
+
summary.failed > 0
|
|
401
|
+
? color(ANSI.red, `${summary.failed} failed`, colorOn)
|
|
402
|
+
: `${summary.failed} failed`;
|
|
403
|
+
const skippedStr =
|
|
404
|
+
summary.skipped > 0
|
|
405
|
+
? color(ANSI.yellow, `${summary.skipped} skipped`, colorOn)
|
|
406
|
+
: `${summary.skipped} skipped`;
|
|
407
|
+
out.push(
|
|
408
|
+
` ${passedStr} ${failedStr} ${skippedStr} ${color(
|
|
409
|
+
ANSI.dim,
|
|
410
|
+
`(${summary.total} total, ${summary.durationMs}ms)`,
|
|
411
|
+
colorOn
|
|
412
|
+
)}`
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
if (report.coverage) {
|
|
416
|
+
out.push("");
|
|
417
|
+
out.push(color(ANSI.bold, " Coverage", colorOn));
|
|
418
|
+
for (const metric of [
|
|
419
|
+
"lines",
|
|
420
|
+
"branches",
|
|
421
|
+
"functions",
|
|
422
|
+
"statements",
|
|
423
|
+
] as const) {
|
|
424
|
+
const c = report.coverage[metric];
|
|
425
|
+
if (!c) continue;
|
|
426
|
+
out.push(
|
|
427
|
+
` ${metric.padEnd(11)} ${c.pct.toFixed(2)}% (${c.hit}/${c.found})`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (report.coverage.lcovPath) {
|
|
431
|
+
out.push(
|
|
432
|
+
color(
|
|
433
|
+
ANSI.dim,
|
|
434
|
+
` → ${report.coverage.lcovPath}`,
|
|
435
|
+
colorOn
|
|
436
|
+
)
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return out.join("\n");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ──────── JSON ───────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Stable JSON serialization. We deliberately do NOT key-sort so
|
|
448
|
+
* human readers see the natural reporter order; stability comes from
|
|
449
|
+
* the deterministic ordering of `tests` at composition time.
|
|
450
|
+
*/
|
|
451
|
+
export function formatJson(report: TestReport): string {
|
|
452
|
+
const summary = summarizeReport(report);
|
|
453
|
+
const payload = {
|
|
454
|
+
schema: "mandu-test-report/v1",
|
|
455
|
+
suite: report.suite,
|
|
456
|
+
kind: report.kind,
|
|
457
|
+
timestamp: report.timestamp,
|
|
458
|
+
durationMs: report.durationMs,
|
|
459
|
+
summary,
|
|
460
|
+
tests: report.tests.map((t) => ({
|
|
461
|
+
name: t.name,
|
|
462
|
+
suite: t.suite ?? null,
|
|
463
|
+
status: t.status,
|
|
464
|
+
durationMs: Math.round(t.durationMs),
|
|
465
|
+
file: t.file ?? null,
|
|
466
|
+
error: t.error
|
|
467
|
+
? {
|
|
468
|
+
message: t.error.message,
|
|
469
|
+
stack: t.error.stack ?? null,
|
|
470
|
+
}
|
|
471
|
+
: null,
|
|
472
|
+
})),
|
|
473
|
+
coverage: report.coverage
|
|
474
|
+
? {
|
|
475
|
+
lines: report.coverage.lines ?? null,
|
|
476
|
+
branches: report.coverage.branches ?? null,
|
|
477
|
+
functions: report.coverage.functions ?? null,
|
|
478
|
+
statements: report.coverage.statements ?? null,
|
|
479
|
+
files: report.coverage.files ?? null,
|
|
480
|
+
lcovPath: report.coverage.lcovPath ?? null,
|
|
481
|
+
}
|
|
482
|
+
: null,
|
|
483
|
+
};
|
|
484
|
+
return JSON.stringify(payload, null, 2);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ──────── JUnit XML ──────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Escape text for inclusion inside an XML element or double-quoted
|
|
491
|
+
* attribute. Strips the 5 XML predefined entities plus raw C0 control
|
|
492
|
+
* characters (which are invalid in XML 1.0).
|
|
493
|
+
*/
|
|
494
|
+
function escapeXml(value: string): string {
|
|
495
|
+
return value
|
|
496
|
+
.replace(/&/g, "&")
|
|
497
|
+
.replace(/</g, "<")
|
|
498
|
+
.replace(/>/g, ">")
|
|
499
|
+
.replace(/"/g, """)
|
|
500
|
+
.replace(/'/g, "'")
|
|
501
|
+
// eslint-disable-next-line no-control-regex
|
|
502
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Emit GitHub-Actions-compatible JUnit XML. Schema follows the de-
|
|
507
|
+
* facto Jenkins variant (`<testsuites>` root, `<testsuite>` per suite
|
|
508
|
+
* bucket, `<testcase>` per test). `time` attributes are seconds with
|
|
509
|
+
* millisecond precision to match Jenkins/GitLab tooling.
|
|
510
|
+
*/
|
|
511
|
+
export function formatJunit(report: TestReport): string {
|
|
512
|
+
const summary = summarizeReport(report);
|
|
513
|
+
const totalSeconds = (summary.durationMs / 1000).toFixed(3);
|
|
514
|
+
|
|
515
|
+
// Bucket by suite — each bucket becomes a <testsuite>.
|
|
516
|
+
const bySuite = new Map<string, TestCase[]>();
|
|
517
|
+
for (const t of report.tests) {
|
|
518
|
+
const key = t.suite ?? report.suite;
|
|
519
|
+
const arr = bySuite.get(key) ?? [];
|
|
520
|
+
arr.push(t);
|
|
521
|
+
bySuite.set(key, arr);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const suiteXml: string[] = [];
|
|
525
|
+
const suiteNames = [...bySuite.keys()].sort();
|
|
526
|
+
for (const suiteName of suiteNames) {
|
|
527
|
+
const cases = bySuite.get(suiteName)!;
|
|
528
|
+
const suiteDurationMs = cases.reduce((acc, t) => acc + t.durationMs, 0);
|
|
529
|
+
const suiteFailed = cases.filter((c) => c.status === "failed").length;
|
|
530
|
+
const suiteSkipped = cases.filter((c) => c.status === "skipped").length;
|
|
531
|
+
const suiteAttrs = [
|
|
532
|
+
`name="${escapeXml(suiteName)}"`,
|
|
533
|
+
`tests="${cases.length}"`,
|
|
534
|
+
`failures="${suiteFailed}"`,
|
|
535
|
+
`skipped="${suiteSkipped}"`,
|
|
536
|
+
`time="${(suiteDurationMs / 1000).toFixed(3)}"`,
|
|
537
|
+
`timestamp="${escapeXml(report.timestamp)}"`,
|
|
538
|
+
].join(" ");
|
|
539
|
+
|
|
540
|
+
const caseXml: string[] = [];
|
|
541
|
+
for (const t of cases) {
|
|
542
|
+
const caseAttrs = [
|
|
543
|
+
`name="${escapeXml(t.name)}"`,
|
|
544
|
+
`classname="${escapeXml(t.suite ?? report.suite)}"`,
|
|
545
|
+
`time="${(t.durationMs / 1000).toFixed(3)}"`,
|
|
546
|
+
];
|
|
547
|
+
if (t.file) caseAttrs.push(`file="${escapeXml(t.file)}"`);
|
|
548
|
+
|
|
549
|
+
if (t.status === "passed") {
|
|
550
|
+
caseXml.push(` <testcase ${caseAttrs.join(" ")}/>`);
|
|
551
|
+
} else if (t.status === "failed") {
|
|
552
|
+
const msg = escapeXml(t.error?.message ?? "Test failed");
|
|
553
|
+
const stack = escapeXml(t.error?.stack ?? "");
|
|
554
|
+
caseXml.push(
|
|
555
|
+
` <testcase ${caseAttrs.join(" ")}>`,
|
|
556
|
+
` <failure message="${msg}" type="AssertionError">${stack}</failure>`,
|
|
557
|
+
` </testcase>`
|
|
558
|
+
);
|
|
559
|
+
} else if (t.status === "skipped" || t.status === "todo") {
|
|
560
|
+
caseXml.push(
|
|
561
|
+
` <testcase ${caseAttrs.join(" ")}>`,
|
|
562
|
+
` <skipped/>`,
|
|
563
|
+
` </testcase>`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
suiteXml.push(` <testsuite ${suiteAttrs}>`);
|
|
569
|
+
suiteXml.push(...caseXml);
|
|
570
|
+
suiteXml.push(` </testsuite>`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return [
|
|
574
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
575
|
+
`<testsuites name="${escapeXml(report.suite)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${totalSeconds}">`,
|
|
576
|
+
...suiteXml,
|
|
577
|
+
`</testsuites>`,
|
|
578
|
+
``,
|
|
579
|
+
].join("\n");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ──────── LCOV ───────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Emit the merged LCOV body. If `report.coverage.lcovBody` is set, we
|
|
586
|
+
* return it verbatim; otherwise we synthesize a minimal `SF:/LF/LH`
|
|
587
|
+
* summary from the aggregate line metric (sufficient for tooling like
|
|
588
|
+
* Codecov which only needs the summary line when per-file data is
|
|
589
|
+
* unavailable).
|
|
590
|
+
*
|
|
591
|
+
* Returns an empty string when no coverage block is present — callers
|
|
592
|
+
* writing to stdout should treat that as "no coverage to emit" and
|
|
593
|
+
* exit early.
|
|
594
|
+
*/
|
|
595
|
+
export function formatLcov(report: TestReport): string {
|
|
596
|
+
if (!report.coverage) return "";
|
|
597
|
+
if (report.coverage.lcovBody) return report.coverage.lcovBody;
|
|
598
|
+
const lines = report.coverage.lines;
|
|
599
|
+
if (!lines) return "";
|
|
600
|
+
// Minimal LCOV synthetic record — every real consumer will have the
|
|
601
|
+
// full body via `lcovBody`. This path exists so the format stays
|
|
602
|
+
// non-empty for round-trip tests.
|
|
603
|
+
return [
|
|
604
|
+
"SF:.mandu/coverage/synthetic",
|
|
605
|
+
`LF:${lines.found}`,
|
|
606
|
+
`LH:${lines.hit}`,
|
|
607
|
+
"end_of_record",
|
|
608
|
+
"",
|
|
609
|
+
].join("\n");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
613
|
+
// Helpers for the CLI layer
|
|
614
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Compute a {@link Coverage} struct from a raw LCOV body. Supports
|
|
618
|
+
* `DA:` / `LF:` / `LH:` (lines), `BRF:` / `BRH:` (branches), `FNF:` /
|
|
619
|
+
* `FNH:` (functions). `statements` is not present in LCOV — callers
|
|
620
|
+
* wire it in separately when the underlying runner provides it.
|
|
621
|
+
*
|
|
622
|
+
* Exported so the CLI can build a `Coverage` block after
|
|
623
|
+
* `mergeCoverageOutputs()` writes the LCOV.
|
|
624
|
+
*/
|
|
625
|
+
export function parseLcovSummary(lcovBody: string): Coverage {
|
|
626
|
+
let LF = 0;
|
|
627
|
+
let LH = 0;
|
|
628
|
+
let BRF = 0;
|
|
629
|
+
let BRH = 0;
|
|
630
|
+
let FNF = 0;
|
|
631
|
+
let FNH = 0;
|
|
632
|
+
const files = new Set<string>();
|
|
633
|
+
for (const line of lcovBody.split(/\r?\n/)) {
|
|
634
|
+
if (line.startsWith("SF:")) files.add(line.slice(3));
|
|
635
|
+
else if (line.startsWith("LF:")) LF += Number(line.slice(3)) || 0;
|
|
636
|
+
else if (line.startsWith("LH:")) LH += Number(line.slice(3)) || 0;
|
|
637
|
+
else if (line.startsWith("BRF:")) BRF += Number(line.slice(4)) || 0;
|
|
638
|
+
else if (line.startsWith("BRH:")) BRH += Number(line.slice(4)) || 0;
|
|
639
|
+
else if (line.startsWith("FNF:")) FNF += Number(line.slice(4)) || 0;
|
|
640
|
+
else if (line.startsWith("FNH:")) FNH += Number(line.slice(4)) || 0;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const pct = (hit: number, found: number): number =>
|
|
644
|
+
found === 0 ? 0 : Math.round((hit / found) * 10000) / 100;
|
|
645
|
+
|
|
646
|
+
const result: Coverage = {
|
|
647
|
+
lines: LF > 0 ? { hit: LH, found: LF, pct: pct(LH, LF) } : undefined,
|
|
648
|
+
branches:
|
|
649
|
+
BRF > 0 ? { hit: BRH, found: BRF, pct: pct(BRH, BRF) } : undefined,
|
|
650
|
+
functions:
|
|
651
|
+
FNF > 0 ? { hit: FNH, found: FNF, pct: pct(FNH, FNF) } : undefined,
|
|
652
|
+
files: files.size || undefined,
|
|
653
|
+
lcovBody,
|
|
654
|
+
};
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Convenience: build a minimal, empty-suite report. Used by callers
|
|
660
|
+
* that have no per-test granularity (e.g. when we only forwarded bun
|
|
661
|
+
* test's exit code but haven't parsed its stdout). Still useful for
|
|
662
|
+
* `--coverage`-only runs where the reporter just needs to emit the
|
|
663
|
+
* coverage block.
|
|
664
|
+
*/
|
|
665
|
+
export function emptyReport(
|
|
666
|
+
suite: string,
|
|
667
|
+
kind: TestSuiteKind | "combined"
|
|
668
|
+
): TestReport {
|
|
669
|
+
return {
|
|
670
|
+
suite,
|
|
671
|
+
kind,
|
|
672
|
+
tests: [],
|
|
673
|
+
durationMs: 0,
|
|
674
|
+
timestamp: new Date().toISOString(),
|
|
675
|
+
};
|
|
676
|
+
}
|