@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,606 +1,607 @@
1
- /**
2
- * Phase 7.1 R1 Agent B — React Fast Refresh tests.
3
- *
4
- * Split into four sections:
5
- *
6
- * A — `appendBoundary` + `classifyBoundary` pure unit tests (fast).
7
- * B — `manduHMR` runtime unit tests (no DOM required).
8
- * C — `Bun.build({ reactFastRefresh: true })` + plugin integration.
9
- * D — `dispatchReplacement` + `__MANDU_HMR__` end-to-end wiring.
10
- *
11
- * Bundler tests (Section C) run a real `Bun.build`, so they honor the
12
- * same `MANDU_SKIP_BUNDLER_TESTS` escape hatch used by `build.test.ts`
13
- * — on CI shards where cross-worker races have been observed.
14
- *
15
- * References:
16
- * docs/bun/phase-7-1-diagnostics/fast-refresh-strategy.md §4
17
- * docs/bun/phase-7-1-team-plan.md §4 Agent B
18
- * packages/core/src/bundler/fast-refresh-plugin.ts
19
- * packages/core/src/runtime/fast-refresh-runtime.ts
20
- */
21
-
22
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
23
- import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises";
24
- import path from "path";
25
-
26
- import {
27
- appendBoundary,
28
- classifyBoundary,
29
- fastRefreshPlugin,
30
- DEFAULT_INCLUDE,
31
- _testOnly_ALREADY_INJECTED,
32
- } from "../fast-refresh-plugin";
33
- import {
34
- manduHMR,
35
- installPreamble,
36
- bindRuntime,
37
- installGlobal,
38
- _resetForTests,
39
- _getBoundaryCountForTests,
40
- _isRefreshScheduledForTests,
41
- type ReactRefreshRuntime,
42
- } from "../../runtime/fast-refresh-runtime";
43
- import {
44
- createManduHot,
45
- dispatchReplacement,
46
- _resetRegistryForTests,
47
- } from "../../runtime/hmr-client";
48
- import { generateFastRefreshPreamble } from "../dev";
49
-
50
- // ═══════════════════════════════════════════════════════════════════
51
- // Section A — plugin pure unit tests
52
- // ═══════════════════════════════════════════════════════════════════
53
-
54
- describe("fast-refresh-plugin — classifyBoundary / appendBoundary", () => {
55
- test("[A1] DEFAULT_INCLUDE matches .client.tsx / .island.tsx / their .ts variants", () => {
56
- expect(DEFAULT_INCLUDE.test("/root/app/foo.client.tsx")).toBe(true);
57
- expect(DEFAULT_INCLUDE.test("/root/app/bar.island.tsx")).toBe(true);
58
- expect(DEFAULT_INCLUDE.test("/root/app/baz.client.ts")).toBe(true);
59
- expect(DEFAULT_INCLUDE.test("/root/app/qux.island.ts")).toBe(true);
60
- // These must NOT match — plain .tsx files are excluded by design.
61
- expect(DEFAULT_INCLUDE.test("/root/app/page.tsx")).toBe(false);
62
- expect(DEFAULT_INCLUDE.test("/root/app/layout.tsx")).toBe(false);
63
- expect(DEFAULT_INCLUDE.test("/root/app/regular.ts")).toBe(false);
64
- });
65
-
66
- test("[A2] classifyBoundary accepts client/island files", () => {
67
- const d = classifyBoundary("/app/counter.client.tsx");
68
- expect(d.accepted).toBe(true);
69
- if (d.accepted) {
70
- expect(d.reason).toBe("matched-include");
71
- expect(d.source).toBe("/app/counter.client.tsx");
72
- }
73
- });
74
-
75
- test("[A3] classifyBoundary rejects plain .tsx + node_modules + disabled", () => {
76
- // Excluded by include filter
77
- expect(classifyBoundary("/app/page.tsx").accepted).toBe(false);
78
- const excluded = classifyBoundary("/app/page.tsx");
79
- if (!excluded.accepted) expect(excluded.reason).toBe("excluded-by-include");
80
-
81
- // Under node_modules, even with matching name
82
- const nm = classifyBoundary("/root/node_modules/foo/bar.client.tsx");
83
- expect(nm.accepted).toBe(false);
84
- if (!nm.accepted) expect(nm.reason).toBe("non-react");
85
-
86
- // Disabled plugin (prod build)
87
- const d = classifyBoundary("/app/counter.client.tsx", { disabled: true });
88
- expect(d.accepted).toBe(false);
89
- if (!d.accepted) expect(d.reason).toBe("disabled");
90
- });
91
-
92
- test("[A4] appendBoundary emits window.__MANDU_HMR__.acceptFile with a JSON-safe URL literal", () => {
93
- const src = "export const x = 1;\n";
94
- const out = appendBoundary(src, "/app/counter.client.tsx");
95
- // Guard must be present
96
- expect(out).toContain('typeof window !== "undefined"');
97
- expect(out).toContain("window.__MANDU_HMR__");
98
- expect(out).toContain(".acceptFile(");
99
- // URL must be JSON-quoted
100
- expect(out).toContain('"/app/counter.client.tsx"');
101
- });
102
-
103
- test("[A5] appendBoundary is idempotent — second call does not stack", () => {
104
- const src = "export const x = 1;\n";
105
- const once = appendBoundary(src, "/app/counter.client.tsx");
106
- const twice = appendBoundary(once, "/app/counter.client.tsx");
107
- expect(twice).toBe(once);
108
- // And the guard regex ALREADY_INJECTED only matches once
109
- expect(_testOnly_ALREADY_INJECTED.test(once)).toBe(true);
110
- });
111
-
112
- test("[A6] appendBoundary escapes URLs with backslashes for Windows paths", () => {
113
- const src = "";
114
- // Note: appendBoundary does not normalize — it trusts the caller.
115
- // The plugin's onLoad hook normalizes before calling. Verify the
116
- // JSON.stringify escaping is correct for backslashes regardless.
117
- const out = appendBoundary(src, "C:\\app\\counter.client.tsx");
118
- // JSON.stringify double-escapes backslashes
119
- expect(out).toContain('"C:\\\\app\\\\counter.client.tsx"');
120
- });
121
-
122
- test("[A7] fastRefreshPlugin factory returns a plugin with the expected name", () => {
123
- const plugin = fastRefreshPlugin();
124
- expect(plugin.name).toBe("mandu:fast-refresh-boundary");
125
- expect(typeof plugin.setup).toBe("function");
126
- });
127
- });
128
-
129
- // ═══════════════════════════════════════════════════════════════════
130
- // Section B — manduHMR runtime pure unit tests
131
- // ═══════════════════════════════════════════════════════════════════
132
-
133
- describe("manduHMR — __MANDU_HMR__ global behavior", () => {
134
- beforeEach(() => {
135
- _resetForTests();
136
- });
137
-
138
- test("[B1] acceptFile registers a URL as a boundary", () => {
139
- expect(manduHMR.isBoundary("/a.client.tsx")).toBe(false);
140
- manduHMR.acceptFile("/a.client.tsx");
141
- expect(manduHMR.isBoundary("/a.client.tsx")).toBe(true);
142
- expect(_getBoundaryCountForTests()).toBe(1);
143
- });
144
-
145
- test("[B2] acceptFile is idempotent — double-register does not increment", () => {
146
- manduHMR.acceptFile("/a.client.tsx");
147
- manduHMR.acceptFile("/a.client.tsx");
148
- manduHMR.acceptFile("/a.client.tsx");
149
- expect(_getBoundaryCountForTests()).toBe(1);
150
- });
151
-
152
- test("[B3] acceptFile rejects empty / non-string input", () => {
153
- manduHMR.acceptFile("");
154
- // @ts-expect-error — runtime guard
155
- manduHMR.acceptFile(null);
156
- // @ts-expect-error — runtime guard
157
- manduHMR.acceptFile(undefined);
158
- expect(_getBoundaryCountForTests()).toBe(0);
159
- });
160
-
161
- test("[B4] performReactRefresh coalesces multiple calls into one microtask", async () => {
162
- let refreshCalls = 0;
163
- const fakeRuntime: ReactRefreshRuntime = {
164
- injectIntoGlobalHook: () => undefined,
165
- register: () => undefined,
166
- createSignatureFunctionForTransform: () => (t) => t,
167
- performReactRefresh: () => {
168
- refreshCalls += 1;
169
- },
170
- };
171
- bindRuntime(fakeRuntime);
172
-
173
- // Call three times in the same tick
174
- manduHMR.performReactRefresh();
175
- manduHMR.performReactRefresh();
176
- manduHMR.performReactRefresh();
177
- expect(_isRefreshScheduledForTests()).toBe(true);
178
- expect(refreshCalls).toBe(0); // not yet — queued in microtask
179
-
180
- // Yield for microtasks to drain
181
- await Promise.resolve();
182
- await Promise.resolve();
183
-
184
- expect(refreshCalls).toBe(1);
185
- expect(_isRefreshScheduledForTests()).toBe(false);
186
- });
187
-
188
- test("[B5] performReactRefresh is a no-op when no runtime is bound", async () => {
189
- // _resetForTests() cleared the runtime, so state is un-bound
190
- manduHMR.performReactRefresh();
191
- await Promise.resolve();
192
- await Promise.resolve();
193
- // No throw, no effect — scheduled flag stays off
194
- expect(_isRefreshScheduledForTests()).toBe(false);
195
- });
196
-
197
- test("[B6] installPreamble installs inert $RefreshReg$ / $RefreshSig$ stubs", () => {
198
- const target = {} as typeof globalThis;
199
- installPreamble(target);
200
- const t = target as unknown as {
201
- $RefreshReg$?: unknown;
202
- $RefreshSig$?: unknown;
203
- };
204
- expect(typeof t.$RefreshReg$).toBe("function");
205
- expect(typeof t.$RefreshSig$).toBe("function");
206
- // $RefreshSig$ must return a function that behaves like identity
207
- const sig = (t.$RefreshSig$ as () => (x: unknown) => unknown)();
208
- expect(sig(42)).toBe(42);
209
- });
210
-
211
- test("[B7] installGlobal wires __MANDU_HMR__ onto globalThis with an injected fake runtime", async () => {
212
- let injectedHook: unknown = null;
213
- let registered: Array<[unknown, string]> = [];
214
- const fakeRuntime: ReactRefreshRuntime = {
215
- injectIntoGlobalHook: (t) => {
216
- injectedHook = t;
217
- },
218
- register: (type, id) => {
219
- registered.push([type, id]);
220
- },
221
- createSignatureFunctionForTransform: () => (t) => t,
222
- performReactRefresh: () => undefined,
223
- };
224
- await installGlobal({ runtime: fakeRuntime });
225
- const g = globalThis as unknown as {
226
- __MANDU_HMR__?: typeof manduHMR;
227
- $RefreshReg$?: (t: unknown, id: string) => void;
228
- };
229
- expect(g.__MANDU_HMR__).toBe(manduHMR);
230
- expect(injectedHook).toBe(globalThis);
231
- // $RefreshReg$ should now route through runtime.register
232
- g.$RefreshReg$?.({ name: "X" }, "X.tsx:default");
233
- expect(registered.length).toBe(1);
234
- expect(registered[0]?.[1]).toBe("X.tsx:default");
235
- });
236
-
237
- test("[B8] _testOnly_reset clears boundaries, runtime, and pending refresh", async () => {
238
- const fakeRuntime: ReactRefreshRuntime = {
239
- injectIntoGlobalHook: () => undefined,
240
- register: () => undefined,
241
- createSignatureFunctionForTransform: () => (t) => t,
242
- performReactRefresh: () => undefined,
243
- };
244
- bindRuntime(fakeRuntime);
245
- manduHMR.acceptFile("/a.client.tsx");
246
- manduHMR.performReactRefresh();
247
- expect(_isRefreshScheduledForTests()).toBe(true);
248
-
249
- manduHMR._testOnly_reset();
250
- expect(_getBoundaryCountForTests()).toBe(0);
251
- expect(_isRefreshScheduledForTests()).toBe(false);
252
- // After reset, performReactRefresh is a no-op until rebind
253
- manduHMR.performReactRefresh();
254
- await Promise.resolve();
255
- expect(_isRefreshScheduledForTests()).toBe(false);
256
- });
257
- });
258
-
259
- // ═══════════════════════════════════════════════════════════════════
260
- // Section C — Bun.build integration
261
- // ═══════════════════════════════════════════════════════════════════
262
-
263
- describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
264
- "Bun.build({ reactFastRefresh: true }) + fastRefreshPlugin()",
265
- () => {
266
- let rootDir: string;
267
-
268
- beforeAll(async () => {
269
- rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-fr-build-"));
270
- // Three source files — one boundary, one plain, one .island.tsx —
271
- // give us the minimal matrix for the plugin's include filter.
272
- await writeFile(
273
- path.join(rootDir, "counter.client.tsx"),
274
- `import { useState } from 'react';
275
- export default function Counter() {
276
- const [count, setCount] = useState(0);
277
- return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
278
- }
279
- `,
280
- "utf-8",
281
- );
282
- await writeFile(
283
- path.join(rootDir, "hero.island.tsx"),
284
- `export default function Hero() { return <div>hero</div>; }\n`,
285
- "utf-8",
286
- );
287
- await writeFile(
288
- path.join(rootDir, "util.ts"),
289
- `export function add(a: number, b: number) { return a + b; }\n`,
290
- "utf-8",
291
- );
292
- });
293
-
294
- afterAll(async () => {
295
- if (rootDir) await rm(rootDir, { recursive: true, force: true });
296
- });
297
-
298
- test("[C1] `reactFastRefresh: true` injects $RefreshReg$ / $RefreshSig$ into the output", async () => {
299
- const result = await Bun.build({
300
- entrypoints: [path.join(rootDir, "counter.client.tsx")],
301
- target: "browser",
302
- reactFastRefresh: true,
303
- external: ["react", "react-dom", "react/jsx-dev-runtime"],
304
- });
305
- expect(result.success).toBe(true);
306
- const src = await result.outputs[0]!.text();
307
- expect(src).toContain("$RefreshReg$");
308
- expect(src).toContain("$RefreshSig$");
309
- });
310
-
311
- test("[C2] fastRefreshPlugin injects acceptFile call into .client.tsx output", async () => {
312
- const result = await Bun.build({
313
- entrypoints: [path.join(rootDir, "counter.client.tsx")],
314
- target: "browser",
315
- reactFastRefresh: true,
316
- plugins: [fastRefreshPlugin()],
317
- external: ["react", "react-dom", "react/jsx-dev-runtime"],
318
- });
319
- expect(result.success).toBe(true);
320
- const src = await result.outputs[0]!.text();
321
- expect(src).toContain("window.__MANDU_HMR__");
322
- expect(src).toContain(".acceptFile(");
323
- });
324
-
325
- test("[C3] fastRefreshPlugin injects acceptFile call into .island.tsx output", async () => {
326
- const result = await Bun.build({
327
- entrypoints: [path.join(rootDir, "hero.island.tsx")],
328
- target: "browser",
329
- reactFastRefresh: true,
330
- plugins: [fastRefreshPlugin()],
331
- external: ["react", "react-dom", "react/jsx-dev-runtime"],
332
- });
333
- expect(result.success).toBe(true);
334
- const src = await result.outputs[0]!.text();
335
- expect(src).toContain("__MANDU_HMR__");
336
- expect(src).toContain(".acceptFile(");
337
- // URL should contain hero.island.tsx — bundler normalizes to fwd slash
338
- expect(src).toMatch(/hero\.island\.tsx/);
339
- });
340
-
341
- test("[C4] plain .ts files are not transformed by the plugin (no acceptFile injection)", async () => {
342
- // util.ts is imported by counter.client.tsx would still be a
343
- // boundary. Here we build util.ts directly to prove the plugin's
344
- // include filter excludes it.
345
- const result = await Bun.build({
346
- entrypoints: [path.join(rootDir, "util.ts")],
347
- target: "browser",
348
- reactFastRefresh: true,
349
- plugins: [fastRefreshPlugin()],
350
- });
351
- expect(result.success).toBe(true);
352
- const src = await result.outputs[0]!.text();
353
- expect(src).not.toContain("__MANDU_HMR__");
354
- });
355
-
356
- test("[C5] disabled plugin emits nothing — production path", async () => {
357
- const result = await Bun.build({
358
- entrypoints: [path.join(rootDir, "counter.client.tsx")],
359
- target: "browser",
360
- // NOTE: not enabling reactFastRefresh here — prod uses both flags off
361
- plugins: [fastRefreshPlugin({ disabled: true })],
362
- external: ["react", "react-dom", "react/jsx-dev-runtime"],
363
- });
364
- expect(result.success).toBe(true);
365
- const src = await result.outputs[0]!.text();
366
- expect(src).not.toContain("__MANDU_HMR__");
367
- expect(src).not.toContain("$RefreshReg$");
368
- });
369
- },
370
- );
371
-
372
- // ═══════════════════════════════════════════════════════════════════
373
- // Section D — End-to-end wiring (dispatchReplacement ↔ __MANDU_HMR__)
374
- // ═══════════════════════════════════════════════════════════════════
375
-
376
- describe("dispatchReplacement + __MANDU_HMR__ integration", () => {
377
- beforeEach(() => {
378
- _resetRegistryForTests();
379
- _resetForTests();
380
- });
381
-
382
- test("[D1] dispatchReplacement triggers performReactRefresh for a registered boundary", async () => {
383
- let refreshCalls = 0;
384
- const fakeRuntime: ReactRefreshRuntime = {
385
- injectIntoGlobalHook: () => undefined,
386
- register: () => undefined,
387
- createSignatureFunctionForTransform: () => (t) => t,
388
- performReactRefresh: () => {
389
- refreshCalls += 1;
390
- },
391
- };
392
- await installGlobal({ runtime: fakeRuntime });
393
-
394
- const url = "/app/counter.client.tsx";
395
- manduHMR.acceptFile(url);
396
- const hot = createManduHot(url);
397
- hot.accept();
398
-
399
- const applied = dispatchReplacement(url, { default: "new" });
400
- expect(applied).toBe(true);
401
- // Refresh is queued on a microtask
402
- await Promise.resolve();
403
- await Promise.resolve();
404
- expect(refreshCalls).toBe(1);
405
- });
406
-
407
- test("[D2] dispatchReplacement does NOT trigger refresh for a non-boundary module", async () => {
408
- let refreshCalls = 0;
409
- const fakeRuntime: ReactRefreshRuntime = {
410
- injectIntoGlobalHook: () => undefined,
411
- register: () => undefined,
412
- createSignatureFunctionForTransform: () => (t) => t,
413
- performReactRefresh: () => {
414
- refreshCalls += 1;
415
- },
416
- };
417
- await installGlobal({ runtime: fakeRuntime });
418
-
419
- const url = "/app/not-boundary.ts";
420
- // NOTE: no acceptFile() call — url is never registered
421
- const hot = createManduHot(url);
422
- hot.accept();
423
-
424
- dispatchReplacement(url, { default: "new" });
425
- await Promise.resolve();
426
- await Promise.resolve();
427
- expect(refreshCalls).toBe(0);
428
- });
429
-
430
- test("[D3] dispatchReplacement swallows Fast Refresh runtime errors without breaking HMR", async () => {
431
- const fakeRuntime: ReactRefreshRuntime = {
432
- injectIntoGlobalHook: () => undefined,
433
- register: () => undefined,
434
- createSignatureFunctionForTransform: () => (t) => t,
435
- performReactRefresh: () => {
436
- throw new Error("simulated runtime failure");
437
- },
438
- };
439
- await installGlobal({ runtime: fakeRuntime });
440
-
441
- const url = "/app/counter.client.tsx";
442
- manduHMR.acceptFile(url);
443
- const hot = createManduHot(url);
444
- let cbFired = false;
445
- hot.accept(() => {
446
- cbFired = true;
447
- });
448
-
449
- // Should not throw — the user callback still ran and return is true
450
- const applied = dispatchReplacement(url, { default: "new" });
451
- expect(applied).toBe(true);
452
- expect(cbFired).toBe(true);
453
-
454
- // The throwing refresh happens on a microtask; catch it via unhandled
455
- // rejection suppression. Yield to let it fire.
456
- await Promise.resolve();
457
- await Promise.resolve();
458
- // No assertion on thrown error — only that dispatchReplacement
459
- // returned cleanly. A thrown performReactRefresh is logged but not
460
- // propagated.
461
- });
462
-
463
- test("[D4] HTML preamble string — snapshot shape", () => {
464
- const glue = "/.mandu/client/_fast-refresh-runtime.js";
465
- const runtime = "/.mandu/client/_vendor-react-refresh.js";
466
- const out = generateFastRefreshPreamble(glue, runtime);
467
- // Must be a <script>...</script> block
468
- expect(out.startsWith("<script>")).toBe(true);
469
- expect(out.trim().endsWith("</script>")).toBe(true);
470
- // Must install stubs
471
- expect(out).toContain("$RefreshReg$");
472
- expect(out).toContain("$RefreshSig$");
473
- // Must dynamic-import both URLs (as JSON string literals)
474
- expect(out).toContain('"/.mandu/client/_fast-refresh-runtime.js"');
475
- expect(out).toContain('"/.mandu/client/_vendor-react-refresh.js"');
476
- // Must call installGlobal
477
- expect(out).toContain("installGlobal");
478
- });
479
-
480
- test("[D5] HTML preamble guards against missing URLs (graceful degrade)", () => {
481
- expect(generateFastRefreshPreamble("", "")).toContain(
482
- "missing runtime assets",
483
- );
484
- expect(generateFastRefreshPreamble("", "/ok.js")).toContain(
485
- "missing runtime assets",
486
- );
487
- });
488
-
489
- test("[D6] react-refresh package is at a version compatible with React 19 (≥0.18.0)", async () => {
490
- // Load the upstream package.json dynamically so this test validates
491
- // the environment the build will actually consume. `require` via
492
- // import() keeps us off static resolver quirks.
493
- const pkgUrl = "react-refresh/package.json";
494
- const pkg = await import(/* @vite-ignore */ pkgUrl, { with: { type: "json" } });
495
- const version: string = (pkg as { default: { version: string } }).default.version;
496
- expect(typeof version).toBe("string");
497
- // Parse major.minor — must be ≥ 0.18. react-refresh uses 0.x
498
- // forever, so "major" is always 0.
499
- const m = version.match(/^(\d+)\.(\d+)\./);
500
- expect(m).not.toBeNull();
501
- const major = Number(m![1]);
502
- const minor = Number(m![2]);
503
- expect(major).toBe(0);
504
- expect(minor >= 18).toBe(true);
505
- });
506
- });
507
-
508
- // ═══════════════════════════════════════════════════════════════════
509
- // Section E — Vendor shim smoke (emits _vendor-react-refresh.js path)
510
- // ═══════════════════════════════════════════════════════════════════
511
-
512
- describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
513
- "buildVendorShims emits fast-refresh shims in dev mode",
514
- () => {
515
- let rootDir: string;
516
-
517
- beforeAll(async () => {
518
- rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-fr-vendor-"));
519
- await mkdir(path.join(rootDir, "app"), { recursive: true });
520
- await writeFile(
521
- path.join(rootDir, "package.json"),
522
- JSON.stringify({ name: "mandu-fr-vendor-test", type: "module" }),
523
- "utf-8",
524
- );
525
- await writeFile(
526
- path.join(rootDir, "app", "demo.client.tsx"),
527
- "export default function DemoIsland() { return null; }\n",
528
- "utf-8",
529
- );
530
- });
531
-
532
- afterAll(async () => {
533
- if (rootDir) await rm(rootDir, { recursive: true, force: true });
534
- });
535
-
536
- test("[E1] dev build produces _vendor-react-refresh.js + _fast-refresh-runtime.js + manifest.shared.fastRefresh entry", async () => {
537
- // Force dev-mode build by spawning `buildClientBundles` in a fresh
538
- // bun subprocess — see build-runner.ts header for the full story.
539
- // In short: Bun 1.3.x's bundler resolver state gets poisoned when
540
- // any sibling test file imports `react` / `react-dom`, making the
541
- // 7-parallel shim fan-out fail with `AggregateError: Bundle failed`
542
- // ~100 % of the time for the affected shim(s). A subprocess has a
543
- // clean module graph.
544
- const { spawn } = await import("node:child_process");
545
- const runner = path.join(import.meta.dir, "build-runner.ts");
546
- const cwd = path.resolve(import.meta.dir, "..", "..", "..");
547
- const out = await new Promise<string>((resolve) => {
548
- const proc = spawn(process.execPath, ["run", runner, rootDir], {
549
- cwd,
550
- stdio: ["ignore", "pipe", "inherit"],
551
- });
552
- let buf = "";
553
- proc.stdout.on("data", (d: Buffer) => (buf += d.toString("utf-8")));
554
- proc.on("close", () => resolve(buf));
555
- });
556
- const jsonLine =
557
- out
558
- .split(/\r?\n/)
559
- .filter((l) => l.trim().length > 0)
560
- .pop() ?? "";
561
- let parsed: {
562
- success: boolean;
563
- errors: string[];
564
- manifest: { shared: { fastRefresh: { runtime: string; glue: string } | null } } | null;
565
- };
566
- try {
567
- parsed = JSON.parse(jsonLine);
568
- } catch (e) {
569
- throw new Error(
570
- `build-runner stdout could not be parsed as JSON: ${String(e)}\nLast line: ${jsonLine}`,
571
- );
572
- }
573
- if (!parsed.success) {
574
- console.error("[fr-vendor] errors:", parsed.errors);
575
- }
576
- expect(parsed.success).toBe(true);
577
- // Both shim files must exist on disk
578
- const glueFile = path.join(
579
- rootDir,
580
- ".mandu",
581
- "client",
582
- "_fast-refresh-runtime.js",
583
- );
584
- const rtFile = path.join(
585
- rootDir,
586
- ".mandu",
587
- "client",
588
- "_vendor-react-refresh.js",
589
- );
590
- const glueContents = await readFile(glueFile, "utf-8");
591
- const rtContents = await readFile(rtFile, "utf-8");
592
- expect(glueContents.length).toBeGreaterThan(0);
593
- expect(rtContents.length).toBeGreaterThan(0);
594
- // Glue exports installGlobal (string-level smoke check on the
595
- // bundle output avoids requiring a full evaluation)
596
- expect(glueContents).toContain("installGlobal");
597
- // Manifest exposes the paths
598
- expect(parsed.manifest?.shared.fastRefresh?.runtime).toBe(
599
- "/.mandu/client/_vendor-react-refresh.js",
600
- );
601
- expect(parsed.manifest?.shared.fastRefresh?.glue).toBe(
602
- "/.mandu/client/_fast-refresh-runtime.js",
603
- );
604
- });
605
- },
606
- );
1
+ /**
2
+ * Phase 7.1 R1 Agent B — React Fast Refresh tests.
3
+ *
4
+ * Split into four sections:
5
+ *
6
+ * A — `appendBoundary` + `classifyBoundary` pure unit tests (fast).
7
+ * B — `manduHMR` runtime unit tests (no DOM required).
8
+ * C — `Bun.build({ reactFastRefresh: true })` + plugin integration.
9
+ * D — `dispatchReplacement` + `__MANDU_HMR__` end-to-end wiring.
10
+ *
11
+ * Bundler tests (Section C) run a real `Bun.build`, so they honor the
12
+ * same `MANDU_SKIP_BUNDLER_TESTS` escape hatch used by `build.test.ts`
13
+ * — on CI shards where cross-worker races have been observed.
14
+ *
15
+ * References:
16
+ * docs/bun/phase-7-1-diagnostics/fast-refresh-strategy.md §4
17
+ * docs/bun/phase-7-1-team-plan.md §4 Agent B
18
+ * packages/core/src/bundler/fast-refresh-plugin.ts
19
+ * packages/core/src/runtime/fast-refresh-runtime.ts
20
+ */
21
+
22
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
23
+ import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises";
24
+ import path from "path";
25
+
26
+ import {
27
+ appendBoundary,
28
+ classifyBoundary,
29
+ fastRefreshPlugin,
30
+ DEFAULT_INCLUDE,
31
+ _testOnly_ALREADY_INJECTED,
32
+ } from "../fast-refresh-plugin";
33
+ import {
34
+ manduHMR,
35
+ installPreamble,
36
+ bindRuntime,
37
+ installGlobal,
38
+ _resetForTests,
39
+ _getBoundaryCountForTests,
40
+ _isRefreshScheduledForTests,
41
+ type ReactRefreshRuntime,
42
+ } from "../../runtime/fast-refresh-runtime";
43
+ import {
44
+ createManduHot,
45
+ dispatchReplacement,
46
+ _resetRegistryForTests,
47
+ } from "../../runtime/hmr-client";
48
+ import { generateFastRefreshPreamble } from "../dev";
49
+
50
+ // ═══════════════════════════════════════════════════════════════════
51
+ // Section A — plugin pure unit tests
52
+ // ═══════════════════════════════════════════════════════════════════
53
+
54
+ describe("fast-refresh-plugin — classifyBoundary / appendBoundary", () => {
55
+ test("[A1] DEFAULT_INCLUDE matches .client.tsx / .island.tsx / their .ts variants", () => {
56
+ expect(DEFAULT_INCLUDE.test("/root/app/foo.client.tsx")).toBe(true);
57
+ expect(DEFAULT_INCLUDE.test("/root/app/bar.island.tsx")).toBe(true);
58
+ expect(DEFAULT_INCLUDE.test("/root/app/baz.client.ts")).toBe(true);
59
+ expect(DEFAULT_INCLUDE.test("/root/app/qux.island.ts")).toBe(true);
60
+ // These must NOT match — plain .tsx files are excluded by design.
61
+ expect(DEFAULT_INCLUDE.test("/root/app/page.tsx")).toBe(false);
62
+ expect(DEFAULT_INCLUDE.test("/root/app/layout.tsx")).toBe(false);
63
+ expect(DEFAULT_INCLUDE.test("/root/app/regular.ts")).toBe(false);
64
+ });
65
+
66
+ test("[A2] classifyBoundary accepts client/island files", () => {
67
+ const d = classifyBoundary("/app/counter.client.tsx");
68
+ expect(d.accepted).toBe(true);
69
+ if (d.accepted) {
70
+ expect(d.reason).toBe("matched-include");
71
+ expect(d.source).toBe("/app/counter.client.tsx");
72
+ }
73
+ });
74
+
75
+ test("[A3] classifyBoundary rejects plain .tsx + node_modules + disabled", () => {
76
+ // Excluded by include filter
77
+ expect(classifyBoundary("/app/page.tsx").accepted).toBe(false);
78
+ const excluded = classifyBoundary("/app/page.tsx");
79
+ if (!excluded.accepted) expect(excluded.reason).toBe("excluded-by-include");
80
+
81
+ // Under node_modules, even with matching name
82
+ const nm = classifyBoundary("/root/node_modules/foo/bar.client.tsx");
83
+ expect(nm.accepted).toBe(false);
84
+ if (!nm.accepted) expect(nm.reason).toBe("non-react");
85
+
86
+ // Disabled plugin (prod build)
87
+ const d = classifyBoundary("/app/counter.client.tsx", { disabled: true });
88
+ expect(d.accepted).toBe(false);
89
+ if (!d.accepted) expect(d.reason).toBe("disabled");
90
+ });
91
+
92
+ test("[A4] appendBoundary emits window.__MANDU_HMR__.acceptFile with a JSON-safe URL literal", () => {
93
+ const src = "export const x = 1;\n";
94
+ const out = appendBoundary(src, "/app/counter.client.tsx");
95
+ // Guard must be present
96
+ expect(out).toContain('typeof window !== "undefined"');
97
+ expect(out).toContain("window.__MANDU_HMR__");
98
+ expect(out).toContain(".acceptFile(");
99
+ // URL must be JSON-quoted
100
+ expect(out).toContain('"/app/counter.client.tsx"');
101
+ });
102
+
103
+ test("[A5] appendBoundary is idempotent — second call does not stack", () => {
104
+ const src = "export const x = 1;\n";
105
+ const once = appendBoundary(src, "/app/counter.client.tsx");
106
+ const twice = appendBoundary(once, "/app/counter.client.tsx");
107
+ expect(twice).toBe(once);
108
+ // And the guard regex ALREADY_INJECTED only matches once
109
+ expect(_testOnly_ALREADY_INJECTED.test(once)).toBe(true);
110
+ });
111
+
112
+ test("[A6] appendBoundary escapes URLs with backslashes for Windows paths", () => {
113
+ const src = "";
114
+ // Note: appendBoundary does not normalize — it trusts the caller.
115
+ // The plugin's onLoad hook normalizes before calling. Verify the
116
+ // JSON.stringify escaping is correct for backslashes regardless.
117
+ const out = appendBoundary(src, "C:\\app\\counter.client.tsx");
118
+ // JSON.stringify double-escapes backslashes
119
+ expect(out).toContain('"C:\\\\app\\\\counter.client.tsx"');
120
+ });
121
+
122
+ test("[A7] fastRefreshPlugin factory returns a plugin with the expected name", () => {
123
+ const plugin = fastRefreshPlugin();
124
+ expect(plugin.name).toBe("mandu:fast-refresh-boundary");
125
+ expect(typeof plugin.setup).toBe("function");
126
+ });
127
+ });
128
+
129
+ // ═══════════════════════════════════════════════════════════════════
130
+ // Section B — manduHMR runtime pure unit tests
131
+ // ═══════════════════════════════════════════════════════════════════
132
+
133
+ describe("manduHMR — __MANDU_HMR__ global behavior", () => {
134
+ beforeEach(() => {
135
+ _resetForTests();
136
+ });
137
+
138
+ test("[B1] acceptFile registers a URL as a boundary", () => {
139
+ expect(manduHMR.isBoundary("/a.client.tsx")).toBe(false);
140
+ manduHMR.acceptFile("/a.client.tsx");
141
+ expect(manduHMR.isBoundary("/a.client.tsx")).toBe(true);
142
+ expect(_getBoundaryCountForTests()).toBe(1);
143
+ });
144
+
145
+ test("[B2] acceptFile is idempotent — double-register does not increment", () => {
146
+ manduHMR.acceptFile("/a.client.tsx");
147
+ manduHMR.acceptFile("/a.client.tsx");
148
+ manduHMR.acceptFile("/a.client.tsx");
149
+ expect(_getBoundaryCountForTests()).toBe(1);
150
+ });
151
+
152
+ test("[B3] acceptFile rejects empty / non-string input", () => {
153
+ manduHMR.acceptFile("");
154
+ // @ts-expect-error — runtime guard
155
+ manduHMR.acceptFile(null);
156
+ // @ts-expect-error — runtime guard
157
+ manduHMR.acceptFile(undefined);
158
+ expect(_getBoundaryCountForTests()).toBe(0);
159
+ });
160
+
161
+ test("[B4] performReactRefresh coalesces multiple calls into one microtask", async () => {
162
+ let refreshCalls = 0;
163
+ const fakeRuntime: ReactRefreshRuntime = {
164
+ injectIntoGlobalHook: () => undefined,
165
+ register: () => undefined,
166
+ createSignatureFunctionForTransform: () => (t) => t,
167
+ performReactRefresh: () => {
168
+ refreshCalls += 1;
169
+ },
170
+ };
171
+ bindRuntime(fakeRuntime);
172
+
173
+ // Call three times in the same tick
174
+ manduHMR.performReactRefresh();
175
+ manduHMR.performReactRefresh();
176
+ manduHMR.performReactRefresh();
177
+ expect(_isRefreshScheduledForTests()).toBe(true);
178
+ expect(refreshCalls).toBe(0); // not yet — queued in microtask
179
+
180
+ // Yield for microtasks to drain
181
+ await Promise.resolve();
182
+ await Promise.resolve();
183
+
184
+ expect(refreshCalls).toBe(1);
185
+ expect(_isRefreshScheduledForTests()).toBe(false);
186
+ });
187
+
188
+ test("[B5] performReactRefresh is a no-op when no runtime is bound", async () => {
189
+ // _resetForTests() cleared the runtime, so state is un-bound
190
+ manduHMR.performReactRefresh();
191
+ await Promise.resolve();
192
+ await Promise.resolve();
193
+ // No throw, no effect — scheduled flag stays off
194
+ expect(_isRefreshScheduledForTests()).toBe(false);
195
+ });
196
+
197
+ test("[B6] installPreamble installs inert $RefreshReg$ / $RefreshSig$ stubs", () => {
198
+ const target = {} as typeof globalThis;
199
+ installPreamble(target);
200
+ const t = target as unknown as {
201
+ $RefreshReg$?: unknown;
202
+ $RefreshSig$?: unknown;
203
+ };
204
+ expect(typeof t.$RefreshReg$).toBe("function");
205
+ expect(typeof t.$RefreshSig$).toBe("function");
206
+ // $RefreshSig$ must return a function that behaves like identity
207
+ const sig = (t.$RefreshSig$ as () => (x: unknown) => unknown)();
208
+ expect(sig(42)).toBe(42);
209
+ });
210
+
211
+ test("[B7] installGlobal wires __MANDU_HMR__ onto globalThis with an injected fake runtime", async () => {
212
+ let injectedHook: unknown = null;
213
+ let registered: Array<[unknown, string]> = [];
214
+ const fakeRuntime: ReactRefreshRuntime = {
215
+ injectIntoGlobalHook: (t) => {
216
+ injectedHook = t;
217
+ },
218
+ register: (type, id) => {
219
+ registered.push([type, id]);
220
+ },
221
+ createSignatureFunctionForTransform: () => (t) => t,
222
+ performReactRefresh: () => undefined,
223
+ };
224
+ await installGlobal({ runtime: fakeRuntime });
225
+ const g = globalThis as unknown as {
226
+ __MANDU_HMR__?: typeof manduHMR;
227
+ $RefreshReg$?: (t: unknown, id: string) => void;
228
+ };
229
+ expect(g.__MANDU_HMR__).toBe(manduHMR);
230
+ expect(injectedHook).toBe(globalThis);
231
+ // $RefreshReg$ should now route through runtime.register
232
+ g.$RefreshReg$?.({ name: "X" }, "X.tsx:default");
233
+ expect(registered.length).toBe(1);
234
+ expect(registered[0]?.[1]).toBe("X.tsx:default");
235
+ });
236
+
237
+ test("[B8] _testOnly_reset clears boundaries, runtime, and pending refresh", async () => {
238
+ const fakeRuntime: ReactRefreshRuntime = {
239
+ injectIntoGlobalHook: () => undefined,
240
+ register: () => undefined,
241
+ createSignatureFunctionForTransform: () => (t) => t,
242
+ performReactRefresh: () => undefined,
243
+ };
244
+ bindRuntime(fakeRuntime);
245
+ manduHMR.acceptFile("/a.client.tsx");
246
+ manduHMR.performReactRefresh();
247
+ expect(_isRefreshScheduledForTests()).toBe(true);
248
+
249
+ manduHMR._testOnly_reset();
250
+ expect(_getBoundaryCountForTests()).toBe(0);
251
+ expect(_isRefreshScheduledForTests()).toBe(false);
252
+ // After reset, performReactRefresh is a no-op until rebind
253
+ manduHMR.performReactRefresh();
254
+ await Promise.resolve();
255
+ expect(_isRefreshScheduledForTests()).toBe(false);
256
+ });
257
+ });
258
+
259
+ // ═══════════════════════════════════════════════════════════════════
260
+ // Section C — Bun.build integration
261
+ // ═══════════════════════════════════════════════════════════════════
262
+
263
+ describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
264
+ "Bun.build({ reactFastRefresh: true }) + fastRefreshPlugin()",
265
+ () => {
266
+ let rootDir: string;
267
+
268
+ beforeAll(async () => {
269
+ rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-fr-build-"));
270
+ // Three source files — one boundary, one plain, one .island.tsx —
271
+ // give us the minimal matrix for the plugin's include filter.
272
+ await writeFile(
273
+ path.join(rootDir, "counter.client.tsx"),
274
+ `import { useState } from 'react';
275
+ export default function Counter() {
276
+ const [count, setCount] = useState(0);
277
+ return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
278
+ }
279
+ `,
280
+ "utf-8",
281
+ );
282
+ await writeFile(
283
+ path.join(rootDir, "hero.island.tsx"),
284
+ `export default function Hero() { return <div>hero</div>; }\n`,
285
+ "utf-8",
286
+ );
287
+ await writeFile(
288
+ path.join(rootDir, "util.ts"),
289
+ `export function add(a: number, b: number) { return a + b; }\n`,
290
+ "utf-8",
291
+ );
292
+ });
293
+
294
+ afterAll(async () => {
295
+ if (rootDir) await rm(rootDir, { recursive: true, force: true });
296
+ });
297
+
298
+ test("[C1] `reactFastRefresh: true` injects $RefreshReg$ / $RefreshSig$ into the output", async () => {
299
+ const result = await Bun.build({
300
+ entrypoints: [path.join(rootDir, "counter.client.tsx")],
301
+ target: "browser",
302
+ reactFastRefresh: true,
303
+ external: ["react", "react-dom", "react/jsx-dev-runtime"],
304
+ });
305
+ expect(result.success).toBe(true);
306
+ const src = await result.outputs[0]!.text();
307
+ expect(src).toContain("$RefreshReg$");
308
+ expect(src).toContain("$RefreshSig$");
309
+ });
310
+
311
+ test("[C2] fastRefreshPlugin injects acceptFile call into .client.tsx output", async () => {
312
+ const result = await Bun.build({
313
+ entrypoints: [path.join(rootDir, "counter.client.tsx")],
314
+ target: "browser",
315
+ reactFastRefresh: true,
316
+ plugins: [fastRefreshPlugin()],
317
+ external: ["react", "react-dom", "react/jsx-dev-runtime"],
318
+ });
319
+ expect(result.success).toBe(true);
320
+ const src = await result.outputs[0]!.text();
321
+ expect(src).toContain("window.__MANDU_HMR__");
322
+ expect(src).toContain(".acceptFile(");
323
+ });
324
+
325
+ test("[C3] fastRefreshPlugin injects acceptFile call into .island.tsx output", async () => {
326
+ const result = await Bun.build({
327
+ entrypoints: [path.join(rootDir, "hero.island.tsx")],
328
+ target: "browser",
329
+ reactFastRefresh: true,
330
+ plugins: [fastRefreshPlugin()],
331
+ external: ["react", "react-dom", "react/jsx-dev-runtime"],
332
+ });
333
+ expect(result.success).toBe(true);
334
+ const src = await result.outputs[0]!.text();
335
+ expect(src).toContain("__MANDU_HMR__");
336
+ expect(src).toContain(".acceptFile(");
337
+ // URL should contain hero.island.tsx — bundler normalizes to fwd slash
338
+ expect(src).toMatch(/hero\.island\.tsx/);
339
+ });
340
+
341
+ test("[C4] plain .ts files are not transformed by the plugin (no acceptFile injection)", async () => {
342
+ // util.ts is imported by counter.client.tsx would still be a
343
+ // boundary. Here we build util.ts directly to prove the plugin's
344
+ // include filter excludes it.
345
+ const result = await Bun.build({
346
+ entrypoints: [path.join(rootDir, "util.ts")],
347
+ target: "browser",
348
+ reactFastRefresh: true,
349
+ plugins: [fastRefreshPlugin()],
350
+ });
351
+ expect(result.success).toBe(true);
352
+ const src = await result.outputs[0]!.text();
353
+ expect(src).not.toContain("__MANDU_HMR__");
354
+ });
355
+
356
+ test("[C5] disabled plugin emits nothing — production path", async () => {
357
+ const result = await Bun.build({
358
+ entrypoints: [path.join(rootDir, "counter.client.tsx")],
359
+ target: "browser",
360
+ // NOTE: not enabling reactFastRefresh here — prod uses both flags off
361
+ plugins: [fastRefreshPlugin({ disabled: true })],
362
+ external: ["react", "react-dom", "react/jsx-dev-runtime"],
363
+ });
364
+ expect(result.success).toBe(true);
365
+ const src = await result.outputs[0]!.text();
366
+ expect(src).not.toContain("__MANDU_HMR__");
367
+ expect(src).not.toContain("$RefreshReg$");
368
+ });
369
+ },
370
+ );
371
+
372
+ // ═══════════════════════════════════════════════════════════════════
373
+ // Section D — End-to-end wiring (dispatchReplacement ↔ __MANDU_HMR__)
374
+ // ═══════════════════════════════════════════════════════════════════
375
+
376
+ describe("dispatchReplacement + __MANDU_HMR__ integration", () => {
377
+ beforeEach(() => {
378
+ _resetRegistryForTests();
379
+ _resetForTests();
380
+ });
381
+
382
+ test("[D1] dispatchReplacement triggers performReactRefresh for a registered boundary", async () => {
383
+ let refreshCalls = 0;
384
+ const fakeRuntime: ReactRefreshRuntime = {
385
+ injectIntoGlobalHook: () => undefined,
386
+ register: () => undefined,
387
+ createSignatureFunctionForTransform: () => (t) => t,
388
+ performReactRefresh: () => {
389
+ refreshCalls += 1;
390
+ },
391
+ };
392
+ await installGlobal({ runtime: fakeRuntime });
393
+
394
+ const url = "/app/counter.client.tsx";
395
+ manduHMR.acceptFile(url);
396
+ const hot = createManduHot(url);
397
+ hot.accept();
398
+
399
+ const applied = dispatchReplacement(url, { default: "new" });
400
+ expect(applied).toBe(true);
401
+ // Refresh is queued on a microtask
402
+ await Promise.resolve();
403
+ await Promise.resolve();
404
+ expect(refreshCalls).toBe(1);
405
+ });
406
+
407
+ test("[D2] dispatchReplacement does NOT trigger refresh for a non-boundary module", async () => {
408
+ let refreshCalls = 0;
409
+ const fakeRuntime: ReactRefreshRuntime = {
410
+ injectIntoGlobalHook: () => undefined,
411
+ register: () => undefined,
412
+ createSignatureFunctionForTransform: () => (t) => t,
413
+ performReactRefresh: () => {
414
+ refreshCalls += 1;
415
+ },
416
+ };
417
+ await installGlobal({ runtime: fakeRuntime });
418
+
419
+ const url = "/app/not-boundary.ts";
420
+ // NOTE: no acceptFile() call — url is never registered
421
+ const hot = createManduHot(url);
422
+ hot.accept();
423
+
424
+ dispatchReplacement(url, { default: "new" });
425
+ await Promise.resolve();
426
+ await Promise.resolve();
427
+ expect(refreshCalls).toBe(0);
428
+ });
429
+
430
+ test("[D3] dispatchReplacement swallows Fast Refresh runtime errors without breaking HMR", async () => {
431
+ const fakeRuntime: ReactRefreshRuntime = {
432
+ injectIntoGlobalHook: () => undefined,
433
+ register: () => undefined,
434
+ createSignatureFunctionForTransform: () => (t) => t,
435
+ performReactRefresh: () => {
436
+ throw new Error("simulated runtime failure");
437
+ },
438
+ };
439
+ await installGlobal({ runtime: fakeRuntime });
440
+
441
+ const url = "/app/counter.client.tsx";
442
+ manduHMR.acceptFile(url);
443
+ const hot = createManduHot(url);
444
+ let cbFired = false;
445
+ hot.accept(() => {
446
+ cbFired = true;
447
+ });
448
+
449
+ // Should not throw — the user callback still ran and return is true
450
+ const applied = dispatchReplacement(url, { default: "new" });
451
+ expect(applied).toBe(true);
452
+ expect(cbFired).toBe(true);
453
+
454
+ // The throwing refresh happens on a microtask; catch it via unhandled
455
+ // rejection suppression. Yield to let it fire.
456
+ await Promise.resolve();
457
+ await Promise.resolve();
458
+ // No assertion on thrown error — only that dispatchReplacement
459
+ // returned cleanly. A thrown performReactRefresh is logged but not
460
+ // propagated.
461
+ });
462
+
463
+ test("[D4] HTML preamble string — snapshot shape", () => {
464
+ const glue = "/.mandu/client/_fast-refresh-runtime.js";
465
+ const runtime = "/.mandu/client/_vendor-react-refresh.js";
466
+ const out = generateFastRefreshPreamble(glue, runtime);
467
+ // Must be a <script>...</script> block
468
+ expect(out.startsWith("<script>")).toBe(true);
469
+ expect(out.trim().endsWith("</script>")).toBe(true);
470
+ // Must install stubs
471
+ expect(out).toContain("$RefreshReg$");
472
+ expect(out).toContain("$RefreshSig$");
473
+ // Must dynamic-import both URLs (as JSON string literals)
474
+ expect(out).toContain('"/.mandu/client/_fast-refresh-runtime.js"');
475
+ expect(out).toContain('"/.mandu/client/_vendor-react-refresh.js"');
476
+ // Must call installGlobal
477
+ expect(out).toContain("installGlobal");
478
+ });
479
+
480
+ test("[D5] HTML preamble guards against missing URLs (graceful degrade)", () => {
481
+ expect(generateFastRefreshPreamble("", "")).toContain(
482
+ "missing runtime assets",
483
+ );
484
+ expect(generateFastRefreshPreamble("", "/ok.js")).toContain(
485
+ "missing runtime assets",
486
+ );
487
+ });
488
+
489
+ test("[D6] react-refresh package is at a version compatible with React 19 (≥0.18.0)", async () => {
490
+ // Load the upstream package.json dynamically so this test validates
491
+ // the environment the build will actually consume. `require` via
492
+ // import() keeps us off static resolver quirks.
493
+ const pkgUrl = "react-refresh/package.json";
494
+ const pkg = await import(/* @vite-ignore */ pkgUrl, { with: { type: "json" } });
495
+ const version: string = (pkg as { default: { version: string } }).default.version;
496
+ expect(typeof version).toBe("string");
497
+ // Parse major.minor — must be ≥ 0.18. react-refresh uses 0.x
498
+ // forever, so "major" is always 0.
499
+ const m = version.match(/^(\d+)\.(\d+)\./);
500
+ expect(m).not.toBeNull();
501
+ const major = Number(m![1]);
502
+ const minor = Number(m![2]);
503
+ expect(major).toBe(0);
504
+ expect(minor >= 18).toBe(true);
505
+ });
506
+ });
507
+
508
+ // ═══════════════════════════════════════════════════════════════════
509
+ // Section E — Vendor shim smoke (emits _vendor-react-refresh.js path)
510
+ // ═══════════════════════════════════════════════════════════════════
511
+
512
+ describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
513
+ "buildVendorShims emits fast-refresh shims in dev mode",
514
+ () => {
515
+ let rootDir: string;
516
+
517
+ beforeAll(async () => {
518
+ rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-fr-vendor-"));
519
+ await mkdir(path.join(rootDir, "app"), { recursive: true });
520
+ await writeFile(
521
+ path.join(rootDir, "package.json"),
522
+ JSON.stringify({ name: "mandu-fr-vendor-test", type: "module" }),
523
+ "utf-8",
524
+ );
525
+ await writeFile(
526
+ path.join(rootDir, "app", "demo.client.tsx"),
527
+ "export default function DemoIsland() { return null; }\n",
528
+ "utf-8",
529
+ );
530
+ });
531
+
532
+ afterAll(async () => {
533
+ if (rootDir) await rm(rootDir, { recursive: true, force: true });
534
+ });
535
+
536
+ test("[E1] dev build produces _vendor-react-refresh.js + _fast-refresh-runtime.js + manifest.shared.fastRefresh entry", async () => {
537
+ // Force dev-mode build by spawning `buildClientBundles` in a fresh
538
+ // bun subprocess — see build-runner.ts header for the full story.
539
+ // In short: Bun 1.3.x's bundler resolver state gets poisoned when
540
+ // any sibling test file imports `react` / `react-dom`, making the
541
+ // 7-parallel shim fan-out fail with `AggregateError: Bundle failed`
542
+ // ~100 % of the time for the affected shim(s). A subprocess has a
543
+ // clean module graph.
544
+ const { spawn } = await import("node:child_process");
545
+ const runner = path.join(import.meta.dir, "build-runner.ts");
546
+ const cwd = path.resolve(import.meta.dir, "..", "..", "..");
547
+ const out = await new Promise<string>((resolve) => {
548
+ const proc = spawn(process.execPath, ["run", runner, rootDir], {
549
+ cwd,
550
+ stdio: ["ignore", "pipe", "inherit"],
551
+ });
552
+ let buf = "";
553
+ proc.stdout.on("data", (d: Buffer) => (buf += d.toString("utf-8")));
554
+ proc.on("close", () => resolve(buf));
555
+ });
556
+ const jsonLine =
557
+ out
558
+ .split(/\r?\n/)
559
+ .filter((l) => l.trim().length > 0)
560
+ .pop() ?? "";
561
+ let parsed: {
562
+ success: boolean;
563
+ errors: string[];
564
+ manifest: { shared: { fastRefresh: { runtime: string; glue: string } | null } } | null;
565
+ };
566
+ try {
567
+ parsed = JSON.parse(jsonLine);
568
+ } catch (e) {
569
+ throw new Error(
570
+ `build-runner stdout could not be parsed as JSON: ${String(e)}\nLast line: ${jsonLine}`,
571
+ { cause: e },
572
+ );
573
+ }
574
+ if (!parsed.success) {
575
+ console.error("[fr-vendor] errors:", parsed.errors);
576
+ }
577
+ expect(parsed.success).toBe(true);
578
+ // Both shim files must exist on disk
579
+ const glueFile = path.join(
580
+ rootDir,
581
+ ".mandu",
582
+ "client",
583
+ "_fast-refresh-runtime.js",
584
+ );
585
+ const rtFile = path.join(
586
+ rootDir,
587
+ ".mandu",
588
+ "client",
589
+ "_vendor-react-refresh.js",
590
+ );
591
+ const glueContents = await readFile(glueFile, "utf-8");
592
+ const rtContents = await readFile(rtFile, "utf-8");
593
+ expect(glueContents.length).toBeGreaterThan(0);
594
+ expect(rtContents.length).toBeGreaterThan(0);
595
+ // Glue exports installGlobal (string-level smoke check on the
596
+ // bundle output — avoids requiring a full evaluation)
597
+ expect(glueContents).toContain("installGlobal");
598
+ // Manifest exposes the paths
599
+ expect(parsed.manifest?.shared.fastRefresh?.runtime).toBe(
600
+ "/.mandu/client/_vendor-react-refresh.js",
601
+ );
602
+ expect(parsed.manifest?.shared.fastRefresh?.glue).toBe(
603
+ "/.mandu/client/_fast-refresh-runtime.js",
604
+ );
605
+ });
606
+ },
607
+ );