@mandujs/core 0.31.0 → 0.33.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 (41) hide show
  1. package/package.json +7 -1
  2. package/src/bundler/build.ts +29 -1
  3. package/src/bundler/generate-static-params.ts +302 -290
  4. package/src/bundler/prerender.ts +446 -368
  5. package/src/bundler/types.ts +20 -0
  6. package/src/config/mandu.ts +58 -1
  7. package/src/config/validate.ts +119 -1
  8. package/src/diagnose/__tests__/checks.test.ts +378 -0
  9. package/src/diagnose/checks.ts +599 -0
  10. package/src/diagnose/index.ts +15 -0
  11. package/src/diagnose/run.ts +87 -0
  12. package/src/diagnose/types.ts +53 -0
  13. package/src/filling/context.ts +60 -0
  14. package/src/guard/check.ts +225 -1
  15. package/src/guard/define-rule.ts +243 -0
  16. package/src/guard/graph.ts +898 -0
  17. package/src/guard/index.ts +40 -0
  18. package/src/guard/rule-presets.ts +379 -0
  19. package/src/i18n/define.ts +126 -0
  20. package/src/i18n/index.ts +52 -0
  21. package/src/i18n/locale-resolver.ts +214 -0
  22. package/src/i18n/message-registry.ts +173 -0
  23. package/src/i18n/types.ts +112 -0
  24. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
  25. package/src/plugins/__tests__/runner.test.ts +409 -0
  26. package/src/plugins/define.ts +124 -0
  27. package/src/plugins/examples/dep-check-plugin.ts +80 -0
  28. package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
  29. package/src/plugins/examples/sitemap-plugin.ts +65 -0
  30. package/src/plugins/hooks.ts +297 -64
  31. package/src/plugins/index.ts +80 -41
  32. package/src/plugins/runner.ts +361 -0
  33. package/src/router/fs-routes.ts +64 -1
  34. package/src/router/fs-scanner.ts +101 -0
  35. package/src/router/index.ts +7 -1
  36. package/src/runtime/server.ts +409 -10
  37. package/src/runtime/ssr.ts +9 -0
  38. package/src/spec/schema.ts +25 -0
  39. package/src/testing/__tests__/reporter.test.ts +454 -0
  40. package/src/testing/index.ts +29 -0
  41. package/src/testing/reporter.ts +676 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Phase 18.σ — Regression tests for the unified test reporter.
3
+ *
4
+ * Covers:
5
+ * - `summarizeReport` counter math
6
+ * - `formatHuman` — color-on/color-off parity, grouping, error body
7
+ * - `formatJson` — schema version + round-trip stability
8
+ * - `formatJunit` — root element + per-suite buckets + XML escape
9
+ * - `formatLcov` — body passthrough + synthetic fallback
10
+ * - `mergeReports` — empty / single / multi-report shapes
11
+ * - `checkCoverageThresholds` — pass / fail / no-config / missing metric
12
+ * - `formatThresholdFailure` — per-metric breakdown
13
+ * - `parseLcovSummary` — LF/LH/BRF/BRH/FNF/FNH aggregation
14
+ */
15
+
16
+ import { describe, it, expect } from "bun:test";
17
+ import {
18
+ formatReport,
19
+ formatHuman,
20
+ formatJson,
21
+ formatJunit,
22
+ formatLcov,
23
+ mergeReports,
24
+ summarizeReport,
25
+ checkCoverageThresholds,
26
+ formatThresholdFailure,
27
+ parseLcovSummary,
28
+ emptyReport,
29
+ type TestReport,
30
+ } from "../reporter";
31
+
32
+ function makeReport(overrides: Partial<TestReport> = {}): TestReport {
33
+ return {
34
+ suite: "unit",
35
+ kind: "unit",
36
+ tests: [
37
+ { name: "adds 1+1", status: "passed", durationMs: 2, suite: "math" },
38
+ {
39
+ name: "divides by zero",
40
+ status: "failed",
41
+ durationMs: 5,
42
+ suite: "math",
43
+ error: { message: "Infinity, not NaN" },
44
+ },
45
+ { name: "draft", status: "skipped", durationMs: 0, suite: "misc" },
46
+ ],
47
+ durationMs: 7,
48
+ timestamp: "2026-04-20T00:00:00.000Z",
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ // ═══════════════════════════════════════════════════════════════════════════
54
+ // summarizeReport
55
+ // ═══════════════════════════════════════════════════════════════════════════
56
+
57
+ describe("summarizeReport", () => {
58
+ it("counts each status bucket correctly", () => {
59
+ const s = summarizeReport(makeReport());
60
+ expect(s.total).toBe(3);
61
+ expect(s.passed).toBe(1);
62
+ expect(s.failed).toBe(1);
63
+ expect(s.skipped).toBe(1);
64
+ expect(s.todo).toBe(0);
65
+ expect(s.durationMs).toBe(7);
66
+ });
67
+
68
+ it("handles an empty report without NaNs", () => {
69
+ const s = summarizeReport(emptyReport("unit", "unit"));
70
+ expect(s.total).toBe(0);
71
+ expect(s.passed).toBe(0);
72
+ expect(s.durationMs).toBe(0);
73
+ });
74
+ });
75
+
76
+ // ═══════════════════════════════════════════════════════════════════════════
77
+ // formatHuman
78
+ // ═══════════════════════════════════════════════════════════════════════════
79
+
80
+ describe("formatHuman", () => {
81
+ it("emits the suite heading and every test line", () => {
82
+ const out = formatHuman(makeReport(), { noColor: true });
83
+ expect(out).toContain("mandu test · unit");
84
+ expect(out).toContain("adds 1+1");
85
+ expect(out).toContain("divides by zero");
86
+ expect(out).toContain("draft");
87
+ });
88
+
89
+ it("prints the failure message beneath failed tests", () => {
90
+ const out = formatHuman(makeReport(), { noColor: true });
91
+ expect(out).toContain("Infinity, not NaN");
92
+ });
93
+
94
+ it("emits summary counters on the final summary line", () => {
95
+ const out = formatHuman(makeReport(), { noColor: true });
96
+ expect(out).toContain("1 passed");
97
+ expect(out).toContain("1 failed");
98
+ expect(out).toContain("1 skipped");
99
+ expect(out).toContain("3 total");
100
+ });
101
+
102
+ it("prints the coverage block when present", () => {
103
+ const out = formatHuman(
104
+ makeReport({
105
+ coverage: {
106
+ lines: { hit: 80, found: 100, pct: 80 },
107
+ lcovPath: ".mandu/coverage/lcov.info",
108
+ },
109
+ }),
110
+ { noColor: true },
111
+ );
112
+ expect(out).toContain("Coverage");
113
+ expect(out).toContain("80.00%");
114
+ expect(out).toContain(".mandu/coverage/lcov.info");
115
+ });
116
+
117
+ it("produces identical text content with and without color", () => {
118
+ const colored = formatHuman(makeReport(), { noColor: false });
119
+ const plain = formatHuman(makeReport(), { noColor: true });
120
+ // Strip ANSI — the visible text should match.
121
+ // eslint-disable-next-line no-control-regex
122
+ const stripped = colored.replace(/\x1b\[[0-9;]*m/g, "");
123
+ expect(stripped).toBe(plain);
124
+ });
125
+ });
126
+
127
+ // ═══════════════════════════════════════════════════════════════════════════
128
+ // formatJson
129
+ // ═══════════════════════════════════════════════════════════════════════════
130
+
131
+ describe("formatJson", () => {
132
+ it("emits a parseable document with the schema version", () => {
133
+ const out = formatJson(makeReport());
134
+ const parsed = JSON.parse(out);
135
+ expect(parsed.schema).toBe("mandu-test-report/v1");
136
+ expect(parsed.suite).toBe("unit");
137
+ expect(parsed.kind).toBe("unit");
138
+ expect(parsed.summary.total).toBe(3);
139
+ expect(Array.isArray(parsed.tests)).toBe(true);
140
+ expect(parsed.tests).toHaveLength(3);
141
+ });
142
+
143
+ it("includes structured failure data", () => {
144
+ const parsed = JSON.parse(formatJson(makeReport()));
145
+ const failed = parsed.tests.find((t: { status: string }) => t.status === "failed");
146
+ expect(failed).toBeDefined();
147
+ expect(failed.error.message).toBe("Infinity, not NaN");
148
+ });
149
+
150
+ it("preserves coverage metrics when present", () => {
151
+ const parsed = JSON.parse(
152
+ formatJson(
153
+ makeReport({
154
+ coverage: {
155
+ lines: { hit: 80, found: 100, pct: 80 },
156
+ branches: { hit: 10, found: 20, pct: 50 },
157
+ },
158
+ }),
159
+ ),
160
+ );
161
+ expect(parsed.coverage.lines).toEqual({ hit: 80, found: 100, pct: 80 });
162
+ expect(parsed.coverage.branches.pct).toBe(50);
163
+ });
164
+ });
165
+
166
+ // ═══════════════════════════════════════════════════════════════════════════
167
+ // formatJunit
168
+ // ═══════════════════════════════════════════════════════════════════════════
169
+
170
+ describe("formatJunit", () => {
171
+ it("emits an XML document with the testsuites root", () => {
172
+ const out = formatJunit(makeReport());
173
+ expect(out.startsWith("<?xml")).toBe(true);
174
+ expect(out).toContain(`<testsuites name="unit"`);
175
+ expect(out).toContain(`tests="3"`);
176
+ expect(out).toContain(`failures="1"`);
177
+ expect(out).toContain(`skipped="1"`);
178
+ });
179
+
180
+ it("buckets cases into per-suite <testsuite> blocks", () => {
181
+ const out = formatJunit(makeReport());
182
+ expect(out).toContain(`<testsuite name="math"`);
183
+ expect(out).toContain(`<testsuite name="misc"`);
184
+ });
185
+
186
+ it("emits <failure> for failed tests with the error message", () => {
187
+ const out = formatJunit(makeReport());
188
+ expect(out).toContain(
189
+ `<failure message="Infinity, not NaN" type="AssertionError">`,
190
+ );
191
+ });
192
+
193
+ it("escapes XML-dangerous characters in names and messages", () => {
194
+ const report = makeReport({
195
+ tests: [
196
+ {
197
+ name: `weird <name> & "quoted"`,
198
+ status: "failed",
199
+ durationMs: 1,
200
+ error: { message: `fail <body> & "q"` },
201
+ },
202
+ ],
203
+ suite: "x",
204
+ kind: "unit",
205
+ });
206
+ const out = formatJunit(report);
207
+ expect(out).not.toContain(`weird <name>`);
208
+ expect(out).toContain(`&lt;name&gt;`);
209
+ expect(out).toContain(`&quot;quoted&quot;`);
210
+ expect(out).toContain(`fail &lt;body&gt;`);
211
+ });
212
+
213
+ it("emits <skipped/> for skipped and todo tests", () => {
214
+ const out = formatJunit(makeReport());
215
+ expect(out).toContain(`<skipped/>`);
216
+ });
217
+ });
218
+
219
+ // ═══════════════════════════════════════════════════════════════════════════
220
+ // formatLcov
221
+ // ═══════════════════════════════════════════════════════════════════════════
222
+
223
+ describe("formatLcov", () => {
224
+ it("returns empty string when no coverage is attached", () => {
225
+ expect(formatLcov(makeReport())).toBe("");
226
+ });
227
+
228
+ it("passes through lcovBody when present", () => {
229
+ const body = "SF:x.ts\nLF:10\nLH:7\nend_of_record\n";
230
+ const out = formatLcov(
231
+ makeReport({
232
+ coverage: { lines: { hit: 7, found: 10, pct: 70 }, lcovBody: body },
233
+ }),
234
+ );
235
+ expect(out).toBe(body);
236
+ });
237
+
238
+ it("synthesizes a minimal summary when lcovBody is missing", () => {
239
+ const out = formatLcov(
240
+ makeReport({
241
+ coverage: { lines: { hit: 7, found: 10, pct: 70 } },
242
+ }),
243
+ );
244
+ expect(out).toContain("LF:10");
245
+ expect(out).toContain("LH:7");
246
+ expect(out).toContain("end_of_record");
247
+ });
248
+ });
249
+
250
+ // ═══════════════════════════════════════════════════════════════════════════
251
+ // formatReport dispatch
252
+ // ═══════════════════════════════════════════════════════════════════════════
253
+
254
+ describe("formatReport", () => {
255
+ it("dispatches to the correct formatter", () => {
256
+ const r = makeReport();
257
+ expect(formatReport(r, "human", { noColor: true })).toBe(
258
+ formatHuman(r, { noColor: true }),
259
+ );
260
+ expect(formatReport(r, "json")).toBe(formatJson(r));
261
+ expect(formatReport(r, "junit")).toBe(formatJunit(r));
262
+ expect(formatReport(r, "lcov")).toBe(formatLcov(r));
263
+ });
264
+ });
265
+
266
+ // ═══════════════════════════════════════════════════════════════════════════
267
+ // mergeReports
268
+ // ═══════════════════════════════════════════════════════════════════════════
269
+
270
+ describe("mergeReports", () => {
271
+ it("returns a synthetic empty report for zero inputs", () => {
272
+ const r = mergeReports();
273
+ expect(r.suite).toBe("combined");
274
+ expect(r.kind).toBe("combined");
275
+ expect(r.tests).toHaveLength(0);
276
+ expect(r.durationMs).toBe(0);
277
+ });
278
+
279
+ it("returns the input verbatim when called with one report", () => {
280
+ const r = makeReport();
281
+ expect(mergeReports(r)).toBe(r);
282
+ });
283
+
284
+ it("concatenates tests and sums durations", () => {
285
+ const a = makeReport({ suite: "unit" });
286
+ const b = makeReport({
287
+ suite: "integration",
288
+ tests: [{ name: "it works", status: "passed", durationMs: 3 }],
289
+ durationMs: 3,
290
+ timestamp: "2026-04-20T12:00:00.000Z",
291
+ });
292
+ const merged = mergeReports(a, b);
293
+ expect(merged.suite).toBe("combined");
294
+ expect(merged.kind).toBe("combined");
295
+ expect(merged.tests).toHaveLength(4);
296
+ expect(merged.durationMs).toBe(10);
297
+ expect(merged.timestamp).toBe("2026-04-20T12:00:00.000Z");
298
+ });
299
+
300
+ it("picks the last non-empty coverage block", () => {
301
+ const a = makeReport({
302
+ coverage: { lines: { hit: 5, found: 10, pct: 50 } },
303
+ });
304
+ const b = makeReport({
305
+ coverage: { lines: { hit: 8, found: 10, pct: 80 } },
306
+ });
307
+ const merged = mergeReports(a, b);
308
+ expect(merged.coverage?.lines?.pct).toBe(80);
309
+ });
310
+ });
311
+
312
+ // ═══════════════════════════════════════════════════════════════════════════
313
+ // checkCoverageThresholds
314
+ // ═══════════════════════════════════════════════════════════════════════════
315
+
316
+ describe("checkCoverageThresholds", () => {
317
+ const coverage = {
318
+ lines: { hit: 80, found: 100, pct: 80 },
319
+ branches: { hit: 30, found: 60, pct: 50 },
320
+ functions: { hit: 9, found: 10, pct: 90 },
321
+ };
322
+
323
+ it("passes when all metrics are at or above target", () => {
324
+ const res = checkCoverageThresholds(coverage, {
325
+ lines: 80,
326
+ branches: 50,
327
+ functions: 90,
328
+ });
329
+ expect(res.ok).toBe(true);
330
+ expect(res.breakdown).toHaveLength(3);
331
+ expect(res.breakdown.every((b) => b.ok)).toBe(true);
332
+ });
333
+
334
+ it("fails when a metric is below target", () => {
335
+ const res = checkCoverageThresholds(coverage, { branches: 80 });
336
+ expect(res.ok).toBe(false);
337
+ expect(res.breakdown).toHaveLength(1);
338
+ expect(res.breakdown[0]!.metric).toBe("branches");
339
+ expect(res.breakdown[0]!.ok).toBe(false);
340
+ expect(res.breakdown[0]!.actual).toBe(50);
341
+ expect(res.breakdown[0]!.expected).toBe(80);
342
+ });
343
+
344
+ it("returns ok=true and empty breakdown when thresholds undefined", () => {
345
+ const res = checkCoverageThresholds(coverage, undefined);
346
+ expect(res.ok).toBe(true);
347
+ expect(res.breakdown).toHaveLength(0);
348
+ });
349
+
350
+ it("treats missing metric as actual=0 when threshold is set", () => {
351
+ const res = checkCoverageThresholds(
352
+ { lines: { hit: 80, found: 100, pct: 80 } },
353
+ { statements: 50 },
354
+ );
355
+ expect(res.ok).toBe(false);
356
+ expect(res.breakdown[0]!.actual).toBe(0);
357
+ });
358
+
359
+ it("skips metrics with zero or negative thresholds", () => {
360
+ const res = checkCoverageThresholds(coverage, {
361
+ lines: 0,
362
+ branches: -5,
363
+ functions: 80,
364
+ });
365
+ expect(res.breakdown).toHaveLength(1);
366
+ expect(res.breakdown[0]!.metric).toBe("functions");
367
+ });
368
+
369
+ it("tolerates float-precision near-match at the threshold", () => {
370
+ // 80.0 minus a sub-nanosecond rounding artefact still counts as meeting
371
+ // the threshold, thanks to the 1e-9 tolerance.
372
+ const res = checkCoverageThresholds(
373
+ { lines: { hit: 80, found: 100, pct: 80 - 1e-12 } },
374
+ { lines: 80 },
375
+ );
376
+ expect(res.ok).toBe(true);
377
+ });
378
+ });
379
+
380
+ // ═══════════════════════════════════════════════════════════════════════════
381
+ // formatThresholdFailure
382
+ // ═══════════════════════════════════════════════════════════════════════════
383
+
384
+ describe("formatThresholdFailure", () => {
385
+ it("lists every failing metric with actual/expected values", () => {
386
+ const res = checkCoverageThresholds(
387
+ {
388
+ lines: { hit: 5, found: 10, pct: 50 },
389
+ branches: { hit: 1, found: 10, pct: 10 },
390
+ },
391
+ { lines: 80, branches: 50 },
392
+ );
393
+ const out = formatThresholdFailure(res);
394
+ expect(out).toContain("Coverage below threshold:");
395
+ expect(out).toContain("lines");
396
+ expect(out).toContain("50.00%");
397
+ expect(out).toContain("< 80%");
398
+ expect(out).toContain("branches");
399
+ expect(out).toContain("10.00%");
400
+ });
401
+
402
+ it("returns empty string when no metric fails", () => {
403
+ const res = checkCoverageThresholds(
404
+ { lines: { hit: 80, found: 100, pct: 80 } },
405
+ { lines: 80 },
406
+ );
407
+ expect(formatThresholdFailure(res)).toBe("");
408
+ });
409
+ });
410
+
411
+ // ═══════════════════════════════════════════════════════════════════════════
412
+ // parseLcovSummary
413
+ // ═══════════════════════════════════════════════════════════════════════════
414
+
415
+ describe("parseLcovSummary", () => {
416
+ it("aggregates LF/LH/BRF/BRH/FNF/FNH across records", () => {
417
+ const body = [
418
+ "SF:a.ts",
419
+ "FNF:2",
420
+ "FNH:1",
421
+ "BRF:4",
422
+ "BRH:2",
423
+ "LF:10",
424
+ "LH:7",
425
+ "end_of_record",
426
+ "SF:b.ts",
427
+ "FNF:3",
428
+ "FNH:3",
429
+ "LF:5",
430
+ "LH:5",
431
+ "end_of_record",
432
+ ].join("\n");
433
+ const c = parseLcovSummary(body);
434
+ expect(c.lines).toEqual({ hit: 12, found: 15, pct: 80 });
435
+ expect(c.branches).toEqual({ hit: 2, found: 4, pct: 50 });
436
+ expect(c.functions).toEqual({ hit: 4, found: 5, pct: 80 });
437
+ expect(c.files).toBe(2);
438
+ expect(c.lcovBody).toBe(body);
439
+ });
440
+
441
+ it("omits metric blocks with zero records", () => {
442
+ const body = "SF:a.ts\nLF:0\nLH:0\nend_of_record\n";
443
+ const c = parseLcovSummary(body);
444
+ expect(c.lines).toBeUndefined();
445
+ expect(c.branches).toBeUndefined();
446
+ expect(c.functions).toBeUndefined();
447
+ });
448
+
449
+ it("is tolerant to CRLF line endings", () => {
450
+ const body = "SF:a.ts\r\nLF:10\r\nLH:5\r\nend_of_record\r\n";
451
+ const c = parseLcovSummary(body);
452
+ expect(c.lines?.pct).toBe(50);
453
+ });
454
+ });
@@ -303,3 +303,32 @@ export {
303
303
  type SnapshotOptions,
304
304
  type SnapshotResult,
305
305
  } from "./snapshot";
306
+
307
+ // ========== Phase 18.σ — Unified reporter ==========
308
+
309
+ export {
310
+ formatReport,
311
+ formatHuman,
312
+ formatJson,
313
+ formatJunit,
314
+ formatLcov,
315
+ mergeReports,
316
+ summarizeReport,
317
+ checkCoverageThresholds,
318
+ formatThresholdFailure,
319
+ parseLcovSummary,
320
+ emptyReport,
321
+ type TestReport,
322
+ type TestCase,
323
+ type TestStatus,
324
+ type TestSuiteKind,
325
+ type Coverage,
326
+ type CoverageMetric,
327
+ type CoverageMetricResult,
328
+ type CoverageThresholds,
329
+ type CoverageThresholdBreakdown,
330
+ type CoverageThresholdResult,
331
+ type ReporterFormat,
332
+ type ReportSummary,
333
+ type FormatOptions,
334
+ } from "./reporter";