@mandujs/core 0.41.2 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
|
@@ -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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
"
|
|
582
|
-
"
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
"
|
|
588
|
-
"
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
expect(
|
|
594
|
-
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
+
);
|