@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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -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, "&amp;")
497
- .replace(/</g, "&lt;")
498
- .replace(/>/g, "&gt;")
499
- .replace(/"/g, "&quot;")
500
- .replace(/'/g, "&apos;")
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, "&amp;")
497
+ .replace(/</g, "&lt;")
498
+ .replace(/>/g, "&gt;")
499
+ .replace(/"/g, "&quot;")
500
+ .replace(/'/g, "&apos;")
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
+ }