@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,504 +1,504 @@
1
- /**
2
- * Phase 7.1 R1 Agent C — Cold start Tier 1 regression tests.
3
- *
4
- * Covers the three optimisations introduced to close the R0 → R3 F cold
5
- * start regression (395 → 626 ms, +231 ms):
6
- *
7
- * 1. Boot parallelization in `cli/commands/dev.ts` — `validateAndReport`
8
- * stays serial (seed), but `validateRuntimeLockfile` + `loadEnv` run
9
- * through `Promise.allSettled`. Savings: ~40-70 ms (diagnostic §3.A).
10
- * 2. `startSqliteStore` fire-and-forget — boot no longer blocks on the
11
- * observability store's dynamic `bun:sqlite` import + schema. Savings:
12
- * ~20-40 ms (diagnostic §3.B).
13
- * 3. Per-island conditional skip — `scanIslandFiles` defensively re-
14
- * filters its input via `needsHydration`, so a caller that forgets
15
- * to pre-filter does not readdir every page route. Savings: ~40-70 ms
16
- * on mixed-hydration projects (diagnostic §5 Tier 1 item 3).
17
- *
18
- * Plus: pins the 9 new B_gap `HMR_PERF` markers (BOOT_VALIDATE_CONFIG
19
- * through BOOT_WATCH_FS_ROUTES) so Agent D's perf-regression fixture
20
- * can match on them without ad-hoc string literals.
21
- *
22
- * References:
23
- * docs/bun/phase-7-1-diagnostics/cold-start-breakdown.md §§3, 5, 7
24
- * docs/bun/phase-7-1-team-plan.md §4 Agent C
25
- * packages/core/src/perf/hmr-markers.ts BOOT_* markers
26
- */
27
- import { describe, it, expect, beforeEach, afterEach } from "bun:test";
28
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
29
- import { tmpdir } from "os";
30
- import path from "path";
31
- import type { RouteSpec } from "../../spec/schema";
32
- import {
33
- _testOnly_scanIslandFiles,
34
- _testOnly_getHydratedRoutes,
35
- } from "../build";
36
- import { HMR_PERF } from "../../perf/hmr-markers";
37
- import {
38
- _resetCacheForTesting as _resetPerfCache,
39
- isPerfEnabled,
40
- mark,
41
- measure,
42
- withPerf,
43
- } from "../../perf";
44
-
45
- // -----------------------------------------------------------------------------
46
- // Helpers — minimal RouteSpec factories.
47
- // -----------------------------------------------------------------------------
48
-
49
- /** Produce a hydrated page route spec rooted at `dir` (relative). */
50
- function pageRoute(
51
- id: string,
52
- pattern: string,
53
- dir: string,
54
- opts: { hydration?: "visible" | "idle" | "immediate" | "never" | "none" } = {},
55
- ): RouteSpec {
56
- const hydration =
57
- opts.hydration === undefined
58
- ? undefined
59
- : opts.hydration === "none" || opts.hydration === "never"
60
- ? { strategy: "none" as const }
61
- : { strategy: opts.hydration as "visible" | "idle" | "immediate" };
62
- return {
63
- id,
64
- pattern,
65
- kind: "page",
66
- module: `${dir}/page.tsx`,
67
- componentModule: `${dir}/page.tsx`,
68
- clientModule: `${dir}/page.tsx`,
69
- ...(hydration ? { hydration } : {}),
70
- } as RouteSpec;
71
- }
72
-
73
- /** Produce a pure-SSR page route (hydration.strategy === "none"). */
74
- function pureSsrRoute(id: string, pattern: string, dir: string): RouteSpec {
75
- return {
76
- id,
77
- pattern,
78
- kind: "page",
79
- module: `${dir}/page.tsx`,
80
- componentModule: `${dir}/page.tsx`,
81
- hydration: { strategy: "none" },
82
- } as RouteSpec;
83
- }
84
-
85
- /** Produce an API route spec (never hydrated). */
86
- function apiRoute(id: string, pattern: string, dir: string): RouteSpec {
87
- return {
88
- id,
89
- pattern,
90
- kind: "api",
91
- module: `${dir}/route.ts`,
92
- } as RouteSpec;
93
- }
94
-
95
- /** Materialise an app/ tree on disk so `scanIslandFiles` can readdir it. */
96
- function createProject(): {
97
- rootDir: string;
98
- writeIslandFile: (dir: string, fileName: string) => void;
99
- } {
100
- const rootDir = mkdtempSync(path.join(tmpdir(), "mandu-coldstart-"));
101
- const writeIslandFile = (dir: string, fileName: string): void => {
102
- const abs = path.join(rootDir, dir);
103
- mkdirSync(abs, { recursive: true });
104
- writeFileSync(
105
- path.join(abs, fileName),
106
- `export default function X(){return null}\n`,
107
- );
108
- };
109
- return { rootDir, writeIslandFile };
110
- }
111
-
112
- // -----------------------------------------------------------------------------
113
- // Tests
114
- // -----------------------------------------------------------------------------
115
-
116
- describe("Phase 7.1 R1 Agent C — B_gap markers (9 boot stages)", () => {
117
- it("defines BOOT_VALIDATE_CONFIG", () => {
118
- expect(HMR_PERF.BOOT_VALIDATE_CONFIG).toBe("boot:validate-config");
119
- });
120
-
121
- it("defines BOOT_LOCKFILE_CHECK", () => {
122
- expect(HMR_PERF.BOOT_LOCKFILE_CHECK).toBe("boot:lockfile-check");
123
- });
124
-
125
- it("defines BOOT_LOAD_ENV", () => {
126
- expect(HMR_PERF.BOOT_LOAD_ENV).toBe("boot:load-env");
127
- });
128
-
129
- it("defines BOOT_SQLITE_START", () => {
130
- expect(HMR_PERF.BOOT_SQLITE_START).toBe("boot:sqlite-start");
131
- });
132
-
133
- it("defines BOOT_GUARD_PREFLIGHT", () => {
134
- expect(HMR_PERF.BOOT_GUARD_PREFLIGHT).toBe("boot:guard-preflight");
135
- });
136
-
137
- it("defines BOOT_RESOLVE_PORT", () => {
138
- expect(HMR_PERF.BOOT_RESOLVE_PORT).toBe("boot:resolve-port");
139
- });
140
-
141
- it("defines BOOT_HMR_SERVER", () => {
142
- expect(HMR_PERF.BOOT_HMR_SERVER).toBe("boot:hmr-server");
143
- });
144
-
145
- it("defines BOOT_START_SERVER", () => {
146
- expect(HMR_PERF.BOOT_START_SERVER).toBe("boot:start-server");
147
- });
148
-
149
- it("defines BOOT_WATCH_FS_ROUTES", () => {
150
- expect(HMR_PERF.BOOT_WATCH_FS_ROUTES).toBe("boot:watch-fs-routes");
151
- });
152
-
153
- it("all 9 markers share the `boot:` namespace", () => {
154
- // Consistency check so log aggregation tooling can grep for `boot:`.
155
- const bootKeys = [
156
- HMR_PERF.BOOT_VALIDATE_CONFIG,
157
- HMR_PERF.BOOT_LOCKFILE_CHECK,
158
- HMR_PERF.BOOT_LOAD_ENV,
159
- HMR_PERF.BOOT_SQLITE_START,
160
- HMR_PERF.BOOT_GUARD_PREFLIGHT,
161
- HMR_PERF.BOOT_RESOLVE_PORT,
162
- HMR_PERF.BOOT_HMR_SERVER,
163
- HMR_PERF.BOOT_START_SERVER,
164
- HMR_PERF.BOOT_WATCH_FS_ROUTES,
165
- ];
166
- for (const key of bootKeys) {
167
- expect(key.startsWith("boot:")).toBe(true);
168
- }
169
- // No duplicate values — a typo renaming one to match another would
170
- // break per-stage accounting.
171
- const unique = new Set(bootKeys);
172
- expect(unique.size).toBe(bootKeys.length);
173
- });
174
- });
175
-
176
- describe("Phase 7.1 R1 Agent C — perf markers fire when MANDU_PERF=1", () => {
177
- const originalPerf = process.env.MANDU_PERF;
178
-
179
- beforeEach(() => {
180
- process.env.MANDU_PERF = "1";
181
- _resetPerfCache();
182
- });
183
-
184
- afterEach(() => {
185
- if (originalPerf === undefined) delete process.env.MANDU_PERF;
186
- else process.env.MANDU_PERF = originalPerf;
187
- _resetPerfCache();
188
- });
189
-
190
- it("isPerfEnabled reflects MANDU_PERF=1", () => {
191
- expect(isPerfEnabled()).toBe(true);
192
- });
193
-
194
- it("mark/measure round-trips through all 9 BOOT markers without throwing", () => {
195
- // Exercises every marker — ensures none collide with a previously
196
- // reserved key (e.g. accidental reuse of `ssr:bundled-import`).
197
- const markers = [
198
- HMR_PERF.BOOT_VALIDATE_CONFIG,
199
- HMR_PERF.BOOT_LOCKFILE_CHECK,
200
- HMR_PERF.BOOT_LOAD_ENV,
201
- HMR_PERF.BOOT_SQLITE_START,
202
- HMR_PERF.BOOT_GUARD_PREFLIGHT,
203
- HMR_PERF.BOOT_RESOLVE_PORT,
204
- HMR_PERF.BOOT_HMR_SERVER,
205
- HMR_PERF.BOOT_START_SERVER,
206
- HMR_PERF.BOOT_WATCH_FS_ROUTES,
207
- ];
208
- for (const m of markers) {
209
- mark(m);
210
- const ms = measure(m, m);
211
- // Non-negative; may be 0 if the mark/measure pair runs within the
212
- // same nanosecond tick on very fast hosts.
213
- expect(ms).toBeGreaterThanOrEqual(0);
214
- }
215
- });
216
-
217
- it("withPerf wraps async work with a BOOT marker", async () => {
218
- // Mirrors the production usage at
219
- // `cli/src/commands/dev.ts:73` (BOOT_VALIDATE_CONFIG wrapper).
220
- const result = await withPerf(HMR_PERF.BOOT_VALIDATE_CONFIG, async () => {
221
- // simulate a tiny async boot task
222
- await new Promise((resolve) => setTimeout(resolve, 1));
223
- return 42;
224
- });
225
- expect(result).toBe(42);
226
- });
227
- });
228
-
229
- describe("Phase 7.1 R1 Agent C — boot parallelization semantics", () => {
230
- // Unit tests that pin the Promise.allSettled / Promise.all choice and
231
- // ordering contract described in dev.ts:89-112. Integration coverage
232
- // (spawning real `mandu dev`) lives in `scripts/hmr-bench.ts`.
233
-
234
- it("Promise.allSettled surfaces BOTH rejections without short-circuit", async () => {
235
- // Guards against an accidental refactor to Promise.all, which would
236
- // swallow the second task's result on the first rejection. The boot
237
- // path needs env failures to be warned-about, not hidden behind a
238
- // lockfile error.
239
- const lockReject = Promise.reject(new Error("lockfile corrupt"));
240
- const envReject = Promise.reject(new Error("env malformed"));
241
- const [a, b] = await Promise.allSettled([lockReject, envReject]);
242
- expect(a.status).toBe("rejected");
243
- expect(b.status).toBe("rejected");
244
- });
245
-
246
- it("Promise.allSettled resolves one side while the other rejects", async () => {
247
- // The envResult advisory branch in dev.ts:168-176 must still fire
248
- // when the lockfile rejects. This test pins the allSettled contract
249
- // so a `Promise.all` regression cannot quietly mask env warnings.
250
- const lockOk = Promise.resolve({ lockfile: null });
251
- const envReject = Promise.reject(new Error("env cooked"));
252
- const [a, b] = await Promise.allSettled([lockOk, envReject]);
253
- expect(a.status).toBe("fulfilled");
254
- expect(b.status).toBe("rejected");
255
- });
256
-
257
- it("fire-and-forget Promise does not block the boot path", async () => {
258
- // Models the `sqliteStorePromise` pattern in dev.ts:120-138. The
259
- // "boot" side must resolve immediately even if the background task
260
- // is still pending.
261
- let backgroundDone = false;
262
- const bgPromise = new Promise<void>((resolve) => {
263
- setTimeout(() => {
264
- backgroundDone = true;
265
- resolve();
266
- }, 100);
267
- }).catch(() => {});
268
-
269
- const bootStart = Date.now();
270
- // Simulate boot continuing past the SQLite start mark without
271
- // awaiting bgPromise.
272
- const bootElapsed = Date.now() - bootStart;
273
- expect(bootElapsed).toBeLessThan(50); // < 50ms — not blocked
274
-
275
- // Cleanup — wait for the background task so the test suite doesn't
276
- // leak an unresolved promise into the next case.
277
- await bgPromise;
278
- expect(backgroundDone).toBe(true);
279
- });
280
-
281
- it("fire-and-forget caught rejection does not bubble up", async () => {
282
- // Pins the `.catch(() => {})` behaviour on `sqliteStorePromise` so
283
- // boot never aborts on a missing bun:sqlite.
284
- const rejected = Promise.reject(new Error("bun:sqlite unavailable")).catch(
285
- () => {
286
- /* swallow */
287
- },
288
- );
289
- // Should resolve to undefined without rethrowing.
290
- await expect(rejected).resolves.toBeUndefined();
291
- });
292
- });
293
-
294
- describe("Phase 7.1 R1 Agent C — getHydratedRoutes filter", () => {
295
- // These pin the existing filter so Agent B's Fast Refresh changes in
296
- // `buildPerIslandBundle` cannot accidentally re-enable hydration on
297
- // pure-SSR routes.
298
-
299
- it("keeps pages with default hydration (no explicit config)", () => {
300
- const route = pageRoute("home", "/", "app");
301
- const manifest = { version: 1, routes: [route] };
302
- const hydrated = _testOnly_getHydratedRoutes(manifest);
303
- expect(hydrated).toHaveLength(1);
304
- expect(hydrated[0].id).toBe("home");
305
- });
306
-
307
- it("keeps pages with explicit visible/idle/immediate strategies", () => {
308
- const manifest = {
309
- version: 1,
310
- routes: [
311
- pageRoute("a", "/a", "app/a", { hydration: "visible" }),
312
- pageRoute("b", "/b", "app/b", { hydration: "idle" }),
313
- pageRoute("c", "/c", "app/c", { hydration: "immediate" }),
314
- ],
315
- };
316
- const hydrated = _testOnly_getHydratedRoutes(manifest);
317
- expect(hydrated).toHaveLength(3);
318
- });
319
-
320
- it("drops pages with hydration.strategy === 'none'", () => {
321
- // pureSsrRoute has no clientModule — we construct with clientModule
322
- // to cover the filter's clientModule-guard separately below.
323
- const manifest = {
324
- version: 1,
325
- routes: [pureSsrRoute("static", "/static", "app/static")],
326
- };
327
- const hydrated = _testOnly_getHydratedRoutes(manifest);
328
- expect(hydrated).toHaveLength(0);
329
- });
330
-
331
- it("drops pages without a clientModule", () => {
332
- const route: RouteSpec = {
333
- id: "api-like-page",
334
- pattern: "/foo",
335
- kind: "page",
336
- module: "app/foo/page.tsx",
337
- componentModule: "app/foo/page.tsx",
338
- // No clientModule — should NOT get an island bundle.
339
- } as RouteSpec;
340
- const manifest = { version: 1, routes: [route] };
341
- const hydrated = _testOnly_getHydratedRoutes(manifest);
342
- expect(hydrated).toHaveLength(0);
343
- });
344
-
345
- it("drops API routes entirely", () => {
346
- const manifest = {
347
- version: 1,
348
- routes: [
349
- pageRoute("home", "/", "app"),
350
- apiRoute("users", "/api/users", "app/api/users"),
351
- ],
352
- };
353
- const hydrated = _testOnly_getHydratedRoutes(manifest);
354
- expect(hydrated).toHaveLength(1);
355
- expect(hydrated[0].id).toBe("home");
356
- });
357
-
358
- it("mixed manifest: drops pure-SSR, keeps hydrated", () => {
359
- const manifest = {
360
- version: 1,
361
- routes: [
362
- pageRoute("home", "/", "app", { hydration: "visible" }),
363
- pureSsrRoute("about", "/about", "app/about"),
364
- pageRoute("dashboard", "/dashboard", "app/dashboard", {
365
- hydration: "idle",
366
- }),
367
- apiRoute("health", "/api/health", "app/api/health"),
368
- ],
369
- };
370
- const hydrated = _testOnly_getHydratedRoutes(manifest);
371
- expect(hydrated.map((r) => r.id).sort()).toEqual(["dashboard", "home"]);
372
- });
373
- });
374
-
375
- describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", () => {
376
- let project: ReturnType<typeof createProject>;
377
-
378
- beforeEach(() => {
379
- project = createProject();
380
- });
381
-
382
- afterEach(() => {
383
- try {
384
- rmSync(project.rootDir, { recursive: true, force: true });
385
- } catch {
386
- /* Windows lock tolerance */
387
- }
388
- });
389
-
390
- it("skips a route with hydration.strategy === 'none' even if passed explicitly", async () => {
391
- // Regression guard — the task spec says "이미 되고 있을 수 있음 (확인 필요)".
392
- // We pin that scanIslandFiles itself (not just its callers) rejects
393
- // pure-SSR routes via the defensive `needsHydration` check.
394
- project.writeIslandFile("app/static", "page.tsx");
395
- project.writeIslandFile("app/static", "widget.island.tsx");
396
-
397
- const route = pureSsrRoute("static", "/static", "app/static");
398
- const result = await _testOnly_scanIslandFiles([route], project.rootDir);
399
-
400
- // Because the route is pure-SSR, the widget.island.tsx is ignored —
401
- // nothing to hydrate. This also saves a readdir on the app/static
402
- // directory (the -40-70 ms Tier 1 optimisation).
403
- expect(result).toHaveLength(0);
404
- });
405
-
406
- it("scans directories of hydrated routes and picks up *.island.tsx files", async () => {
407
- project.writeIslandFile("app", "page.tsx");
408
- project.writeIslandFile("app", "counter.island.tsx");
409
- project.writeIslandFile("app", "banner.island.ts");
410
-
411
- const route = pageRoute("home", "/", "app", { hydration: "visible" });
412
- const result = await _testOnly_scanIslandFiles([route], project.rootDir);
413
-
414
- expect(result).toHaveLength(2);
415
- expect(result.map((e) => e.name).sort()).toEqual(["banner", "counter"]);
416
- for (const entry of result) {
417
- expect(entry.routeId).toBe("home");
418
- expect(entry.priority).toBeDefined();
419
- }
420
- });
421
-
422
- it("dedupes directory scans when multiple hydrated routes share a dir", async () => {
423
- // Same dir = seenDirs filter should kick in after the first scan.
424
- // We verify via the number of entries — a duplicate scan would
425
- // return 2 entries per island file.
426
- project.writeIslandFile("app", "page.tsx");
427
- project.writeIslandFile("app", "counter.island.tsx");
428
-
429
- const r1 = pageRoute("home", "/", "app", { hydration: "visible" });
430
- const r2 = pageRoute("alt", "/alt", "app", { hydration: "idle" });
431
- const result = await _testOnly_scanIslandFiles([r1, r2], project.rootDir);
432
-
433
- expect(result).toHaveLength(1);
434
- // First-encountered route wins (seenDirs short-circuits subsequent).
435
- expect(result[0].routeId).toBe("home");
436
- });
437
-
438
- it("skips pure-SSR routes interleaved between hydrated routes", async () => {
439
- // Mixed-hydration project — the defensive skip saves an fs.readdir
440
- // on the pure-SSR branch. This is the canonical §5 Tier 1 win.
441
- project.writeIslandFile("app", "page.tsx");
442
- project.writeIslandFile("app", "home.island.tsx");
443
- project.writeIslandFile("app/about", "page.tsx");
444
- // about has NO island files AND is pure-SSR — should not be readdir'd.
445
- project.writeIslandFile("app/dashboard", "page.tsx");
446
- project.writeIslandFile("app/dashboard", "widget.island.tsx");
447
-
448
- const routes = [
449
- pageRoute("home", "/", "app", { hydration: "visible" }),
450
- pureSsrRoute("about", "/about", "app/about"),
451
- pageRoute("dashboard", "/dashboard", "app/dashboard", {
452
- hydration: "idle",
453
- }),
454
- ];
455
- const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
456
-
457
- expect(result.map((e) => e.name).sort()).toEqual(["home", "widget"]);
458
- // about is not even attempted — no error, no scan entry.
459
- expect(result.some((e) => e.routeId === "about")).toBe(false);
460
- });
461
-
462
- it("handles missing route directories gracefully (fs.readdir catch)", async () => {
463
- // An orphan route — the readdir will throw ENOENT. The scanner
464
- // must swallow it and continue with later routes.
465
- project.writeIslandFile("app", "page.tsx");
466
- project.writeIslandFile("app", "counter.island.tsx");
467
-
468
- const routes = [
469
- pageRoute("missing", "/missing", "app/nonexistent", {
470
- hydration: "visible",
471
- }),
472
- pageRoute("home", "/", "app", { hydration: "visible" }),
473
- ];
474
- const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
475
-
476
- expect(result).toHaveLength(1);
477
- expect(result[0].name).toBe("counter");
478
- });
479
-
480
- it("returns empty array for all-empty input (no hydrated routes)", async () => {
481
- // Guards the "hydratedRoutes.length === 0" fast path — even if a
482
- // caller bypasses the early-return at build.ts:1478 and calls the
483
- // scanner directly with no hydrated routes, we still return [].
484
- const result = await _testOnly_scanIslandFiles([], project.rootDir);
485
- expect(result).toEqual([]);
486
- });
487
-
488
- it("returns empty array when ALL input routes are pure-SSR", async () => {
489
- // §5 Tier 1 item 3 — the "per-island conditional skip" big win.
490
- // When no routes need hydration, scanIslandFiles must be a pure
491
- // no-op (no fs.readdir calls), not just return [].
492
- project.writeIslandFile("app/a", "page.tsx");
493
- project.writeIslandFile("app/a", "x.island.tsx");
494
- project.writeIslandFile("app/b", "page.tsx");
495
- project.writeIslandFile("app/b", "y.island.tsx");
496
-
497
- const routes = [
498
- pureSsrRoute("a", "/a", "app/a"),
499
- pureSsrRoute("b", "/b", "app/b"),
500
- ];
501
- const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
502
- expect(result).toEqual([]);
503
- });
504
- });
1
+ /**
2
+ * Phase 7.1 R1 Agent C — Cold start Tier 1 regression tests.
3
+ *
4
+ * Covers the three optimisations introduced to close the R0 → R3 F cold
5
+ * start regression (395 → 626 ms, +231 ms):
6
+ *
7
+ * 1. Boot parallelization in `cli/commands/dev.ts` — `validateAndReport`
8
+ * stays serial (seed), but `validateRuntimeLockfile` + `loadEnv` run
9
+ * through `Promise.allSettled`. Savings: ~40-70 ms (diagnostic §3.A).
10
+ * 2. `startSqliteStore` fire-and-forget — boot no longer blocks on the
11
+ * observability store's dynamic `bun:sqlite` import + schema. Savings:
12
+ * ~20-40 ms (diagnostic §3.B).
13
+ * 3. Per-island conditional skip — `scanIslandFiles` defensively re-
14
+ * filters its input via `needsHydration`, so a caller that forgets
15
+ * to pre-filter does not readdir every page route. Savings: ~40-70 ms
16
+ * on mixed-hydration projects (diagnostic §5 Tier 1 item 3).
17
+ *
18
+ * Plus: pins the 9 new B_gap `HMR_PERF` markers (BOOT_VALIDATE_CONFIG
19
+ * through BOOT_WATCH_FS_ROUTES) so Agent D's perf-regression fixture
20
+ * can match on them without ad-hoc string literals.
21
+ *
22
+ * References:
23
+ * docs/bun/phase-7-1-diagnostics/cold-start-breakdown.md §§3, 5, 7
24
+ * docs/bun/phase-7-1-team-plan.md §4 Agent C
25
+ * packages/core/src/perf/hmr-markers.ts BOOT_* markers
26
+ */
27
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
28
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
29
+ import { tmpdir } from "os";
30
+ import path from "path";
31
+ import type { RouteSpec } from "../../spec/schema";
32
+ import {
33
+ _testOnly_scanIslandFiles,
34
+ _testOnly_getHydratedRoutes,
35
+ } from "../build";
36
+ import { HMR_PERF } from "../../perf/hmr-markers";
37
+ import {
38
+ _resetCacheForTesting as _resetPerfCache,
39
+ isPerfEnabled,
40
+ mark,
41
+ measure,
42
+ withPerf,
43
+ } from "../../perf";
44
+
45
+ // -----------------------------------------------------------------------------
46
+ // Helpers — minimal RouteSpec factories.
47
+ // -----------------------------------------------------------------------------
48
+
49
+ /** Produce a hydrated page route spec rooted at `dir` (relative). */
50
+ function pageRoute(
51
+ id: string,
52
+ pattern: string,
53
+ dir: string,
54
+ opts: { hydration?: "visible" | "idle" | "immediate" | "never" | "none" } = {},
55
+ ): RouteSpec {
56
+ const hydration =
57
+ opts.hydration === undefined
58
+ ? undefined
59
+ : opts.hydration === "none" || opts.hydration === "never"
60
+ ? { strategy: "none" as const }
61
+ : { strategy: opts.hydration as "visible" | "idle" | "immediate" };
62
+ return {
63
+ id,
64
+ pattern,
65
+ kind: "page",
66
+ module: `${dir}/page.tsx`,
67
+ componentModule: `${dir}/page.tsx`,
68
+ clientModule: `${dir}/page.tsx`,
69
+ ...(hydration ? { hydration } : {}),
70
+ } as RouteSpec;
71
+ }
72
+
73
+ /** Produce a pure-SSR page route (hydration.strategy === "none"). */
74
+ function pureSsrRoute(id: string, pattern: string, dir: string): RouteSpec {
75
+ return {
76
+ id,
77
+ pattern,
78
+ kind: "page",
79
+ module: `${dir}/page.tsx`,
80
+ componentModule: `${dir}/page.tsx`,
81
+ hydration: { strategy: "none" },
82
+ } as RouteSpec;
83
+ }
84
+
85
+ /** Produce an API route spec (never hydrated). */
86
+ function apiRoute(id: string, pattern: string, dir: string): RouteSpec {
87
+ return {
88
+ id,
89
+ pattern,
90
+ kind: "api",
91
+ module: `${dir}/route.ts`,
92
+ } as RouteSpec;
93
+ }
94
+
95
+ /** Materialise an app/ tree on disk so `scanIslandFiles` can readdir it. */
96
+ function createProject(): {
97
+ rootDir: string;
98
+ writeIslandFile: (dir: string, fileName: string) => void;
99
+ } {
100
+ const rootDir = mkdtempSync(path.join(tmpdir(), "mandu-coldstart-"));
101
+ const writeIslandFile = (dir: string, fileName: string): void => {
102
+ const abs = path.join(rootDir, dir);
103
+ mkdirSync(abs, { recursive: true });
104
+ writeFileSync(
105
+ path.join(abs, fileName),
106
+ `export default function X(){return null}\n`,
107
+ );
108
+ };
109
+ return { rootDir, writeIslandFile };
110
+ }
111
+
112
+ // -----------------------------------------------------------------------------
113
+ // Tests
114
+ // -----------------------------------------------------------------------------
115
+
116
+ describe("Phase 7.1 R1 Agent C — B_gap markers (9 boot stages)", () => {
117
+ it("defines BOOT_VALIDATE_CONFIG", () => {
118
+ expect(HMR_PERF.BOOT_VALIDATE_CONFIG).toBe("boot:validate-config");
119
+ });
120
+
121
+ it("defines BOOT_LOCKFILE_CHECK", () => {
122
+ expect(HMR_PERF.BOOT_LOCKFILE_CHECK).toBe("boot:lockfile-check");
123
+ });
124
+
125
+ it("defines BOOT_LOAD_ENV", () => {
126
+ expect(HMR_PERF.BOOT_LOAD_ENV).toBe("boot:load-env");
127
+ });
128
+
129
+ it("defines BOOT_SQLITE_START", () => {
130
+ expect(HMR_PERF.BOOT_SQLITE_START).toBe("boot:sqlite-start");
131
+ });
132
+
133
+ it("defines BOOT_GUARD_PREFLIGHT", () => {
134
+ expect(HMR_PERF.BOOT_GUARD_PREFLIGHT).toBe("boot:guard-preflight");
135
+ });
136
+
137
+ it("defines BOOT_RESOLVE_PORT", () => {
138
+ expect(HMR_PERF.BOOT_RESOLVE_PORT).toBe("boot:resolve-port");
139
+ });
140
+
141
+ it("defines BOOT_HMR_SERVER", () => {
142
+ expect(HMR_PERF.BOOT_HMR_SERVER).toBe("boot:hmr-server");
143
+ });
144
+
145
+ it("defines BOOT_START_SERVER", () => {
146
+ expect(HMR_PERF.BOOT_START_SERVER).toBe("boot:start-server");
147
+ });
148
+
149
+ it("defines BOOT_WATCH_FS_ROUTES", () => {
150
+ expect(HMR_PERF.BOOT_WATCH_FS_ROUTES).toBe("boot:watch-fs-routes");
151
+ });
152
+
153
+ it("all 9 markers share the `boot:` namespace", () => {
154
+ // Consistency check so log aggregation tooling can grep for `boot:`.
155
+ const bootKeys = [
156
+ HMR_PERF.BOOT_VALIDATE_CONFIG,
157
+ HMR_PERF.BOOT_LOCKFILE_CHECK,
158
+ HMR_PERF.BOOT_LOAD_ENV,
159
+ HMR_PERF.BOOT_SQLITE_START,
160
+ HMR_PERF.BOOT_GUARD_PREFLIGHT,
161
+ HMR_PERF.BOOT_RESOLVE_PORT,
162
+ HMR_PERF.BOOT_HMR_SERVER,
163
+ HMR_PERF.BOOT_START_SERVER,
164
+ HMR_PERF.BOOT_WATCH_FS_ROUTES,
165
+ ];
166
+ for (const key of bootKeys) {
167
+ expect(key.startsWith("boot:")).toBe(true);
168
+ }
169
+ // No duplicate values — a typo renaming one to match another would
170
+ // break per-stage accounting.
171
+ const unique = new Set(bootKeys);
172
+ expect(unique.size).toBe(bootKeys.length);
173
+ });
174
+ });
175
+
176
+ describe("Phase 7.1 R1 Agent C — perf markers fire when MANDU_PERF=1", () => {
177
+ const originalPerf = process.env.MANDU_PERF;
178
+
179
+ beforeEach(() => {
180
+ process.env.MANDU_PERF = "1";
181
+ _resetPerfCache();
182
+ });
183
+
184
+ afterEach(() => {
185
+ if (originalPerf === undefined) delete process.env.MANDU_PERF;
186
+ else process.env.MANDU_PERF = originalPerf;
187
+ _resetPerfCache();
188
+ });
189
+
190
+ it("isPerfEnabled reflects MANDU_PERF=1", () => {
191
+ expect(isPerfEnabled()).toBe(true);
192
+ });
193
+
194
+ it("mark/measure round-trips through all 9 BOOT markers without throwing", () => {
195
+ // Exercises every marker — ensures none collide with a previously
196
+ // reserved key (e.g. accidental reuse of `ssr:bundled-import`).
197
+ const markers = [
198
+ HMR_PERF.BOOT_VALIDATE_CONFIG,
199
+ HMR_PERF.BOOT_LOCKFILE_CHECK,
200
+ HMR_PERF.BOOT_LOAD_ENV,
201
+ HMR_PERF.BOOT_SQLITE_START,
202
+ HMR_PERF.BOOT_GUARD_PREFLIGHT,
203
+ HMR_PERF.BOOT_RESOLVE_PORT,
204
+ HMR_PERF.BOOT_HMR_SERVER,
205
+ HMR_PERF.BOOT_START_SERVER,
206
+ HMR_PERF.BOOT_WATCH_FS_ROUTES,
207
+ ];
208
+ for (const m of markers) {
209
+ mark(m);
210
+ const ms = measure(m, m);
211
+ // Non-negative; may be 0 if the mark/measure pair runs within the
212
+ // same nanosecond tick on very fast hosts.
213
+ expect(ms).toBeGreaterThanOrEqual(0);
214
+ }
215
+ });
216
+
217
+ it("withPerf wraps async work with a BOOT marker", async () => {
218
+ // Mirrors the production usage at
219
+ // `cli/src/commands/dev.ts:73` (BOOT_VALIDATE_CONFIG wrapper).
220
+ const result = await withPerf(HMR_PERF.BOOT_VALIDATE_CONFIG, async () => {
221
+ // simulate a tiny async boot task
222
+ await new Promise((resolve) => setTimeout(resolve, 1));
223
+ return 42;
224
+ });
225
+ expect(result).toBe(42);
226
+ });
227
+ });
228
+
229
+ describe("Phase 7.1 R1 Agent C — boot parallelization semantics", () => {
230
+ // Unit tests that pin the Promise.allSettled / Promise.all choice and
231
+ // ordering contract described in dev.ts:89-112. Integration coverage
232
+ // (spawning real `mandu dev`) lives in `scripts/hmr-bench.ts`.
233
+
234
+ it("Promise.allSettled surfaces BOTH rejections without short-circuit", async () => {
235
+ // Guards against an accidental refactor to Promise.all, which would
236
+ // swallow the second task's result on the first rejection. The boot
237
+ // path needs env failures to be warned-about, not hidden behind a
238
+ // lockfile error.
239
+ const lockReject = Promise.reject(new Error("lockfile corrupt"));
240
+ const envReject = Promise.reject(new Error("env malformed"));
241
+ const [a, b] = await Promise.allSettled([lockReject, envReject]);
242
+ expect(a.status).toBe("rejected");
243
+ expect(b.status).toBe("rejected");
244
+ });
245
+
246
+ it("Promise.allSettled resolves one side while the other rejects", async () => {
247
+ // The envResult advisory branch in dev.ts:168-176 must still fire
248
+ // when the lockfile rejects. This test pins the allSettled contract
249
+ // so a `Promise.all` regression cannot quietly mask env warnings.
250
+ const lockOk = Promise.resolve({ lockfile: null });
251
+ const envReject = Promise.reject(new Error("env cooked"));
252
+ const [a, b] = await Promise.allSettled([lockOk, envReject]);
253
+ expect(a.status).toBe("fulfilled");
254
+ expect(b.status).toBe("rejected");
255
+ });
256
+
257
+ it("fire-and-forget Promise does not block the boot path", async () => {
258
+ // Models the `sqliteStorePromise` pattern in dev.ts:120-138. The
259
+ // "boot" side must resolve immediately even if the background task
260
+ // is still pending.
261
+ let backgroundDone = false;
262
+ const bgPromise = new Promise<void>((resolve) => {
263
+ setTimeout(() => {
264
+ backgroundDone = true;
265
+ resolve();
266
+ }, 100);
267
+ }).catch(() => {});
268
+
269
+ const bootStart = Date.now();
270
+ // Simulate boot continuing past the SQLite start mark without
271
+ // awaiting bgPromise.
272
+ const bootElapsed = Date.now() - bootStart;
273
+ expect(bootElapsed).toBeLessThan(50); // < 50ms — not blocked
274
+
275
+ // Cleanup — wait for the background task so the test suite doesn't
276
+ // leak an unresolved promise into the next case.
277
+ await bgPromise;
278
+ expect(backgroundDone).toBe(true);
279
+ });
280
+
281
+ it("fire-and-forget caught rejection does not bubble up", async () => {
282
+ // Pins the `.catch(() => {})` behaviour on `sqliteStorePromise` so
283
+ // boot never aborts on a missing bun:sqlite.
284
+ const rejected = Promise.reject(new Error("bun:sqlite unavailable")).catch(
285
+ () => {
286
+ /* swallow */
287
+ },
288
+ );
289
+ // Should resolve to undefined without rethrowing.
290
+ await expect(rejected).resolves.toBeUndefined();
291
+ });
292
+ });
293
+
294
+ describe("Phase 7.1 R1 Agent C — getHydratedRoutes filter", () => {
295
+ // These pin the existing filter so Agent B's Fast Refresh changes in
296
+ // `buildPerIslandBundle` cannot accidentally re-enable hydration on
297
+ // pure-SSR routes.
298
+
299
+ it("keeps pages with default hydration (no explicit config)", () => {
300
+ const route = pageRoute("home", "/", "app");
301
+ const manifest = { version: 1, routes: [route] };
302
+ const hydrated = _testOnly_getHydratedRoutes(manifest);
303
+ expect(hydrated).toHaveLength(1);
304
+ expect(hydrated[0].id).toBe("home");
305
+ });
306
+
307
+ it("keeps pages with explicit visible/idle/immediate strategies", () => {
308
+ const manifest = {
309
+ version: 1,
310
+ routes: [
311
+ pageRoute("a", "/a", "app/a", { hydration: "visible" }),
312
+ pageRoute("b", "/b", "app/b", { hydration: "idle" }),
313
+ pageRoute("c", "/c", "app/c", { hydration: "immediate" }),
314
+ ],
315
+ };
316
+ const hydrated = _testOnly_getHydratedRoutes(manifest);
317
+ expect(hydrated).toHaveLength(3);
318
+ });
319
+
320
+ it("drops pages with hydration.strategy === 'none'", () => {
321
+ // pureSsrRoute has no clientModule — we construct with clientModule
322
+ // to cover the filter's clientModule-guard separately below.
323
+ const manifest = {
324
+ version: 1,
325
+ routes: [pureSsrRoute("static", "/static", "app/static")],
326
+ };
327
+ const hydrated = _testOnly_getHydratedRoutes(manifest);
328
+ expect(hydrated).toHaveLength(0);
329
+ });
330
+
331
+ it("drops pages without a clientModule", () => {
332
+ const route: RouteSpec = {
333
+ id: "api-like-page",
334
+ pattern: "/foo",
335
+ kind: "page",
336
+ module: "app/foo/page.tsx",
337
+ componentModule: "app/foo/page.tsx",
338
+ // No clientModule — should NOT get an island bundle.
339
+ } as RouteSpec;
340
+ const manifest = { version: 1, routes: [route] };
341
+ const hydrated = _testOnly_getHydratedRoutes(manifest);
342
+ expect(hydrated).toHaveLength(0);
343
+ });
344
+
345
+ it("drops API routes entirely", () => {
346
+ const manifest = {
347
+ version: 1,
348
+ routes: [
349
+ pageRoute("home", "/", "app"),
350
+ apiRoute("users", "/api/users", "app/api/users"),
351
+ ],
352
+ };
353
+ const hydrated = _testOnly_getHydratedRoutes(manifest);
354
+ expect(hydrated).toHaveLength(1);
355
+ expect(hydrated[0].id).toBe("home");
356
+ });
357
+
358
+ it("mixed manifest: drops pure-SSR, keeps hydrated", () => {
359
+ const manifest = {
360
+ version: 1,
361
+ routes: [
362
+ pageRoute("home", "/", "app", { hydration: "visible" }),
363
+ pureSsrRoute("about", "/about", "app/about"),
364
+ pageRoute("dashboard", "/dashboard", "app/dashboard", {
365
+ hydration: "idle",
366
+ }),
367
+ apiRoute("health", "/api/health", "app/api/health"),
368
+ ],
369
+ };
370
+ const hydrated = _testOnly_getHydratedRoutes(manifest);
371
+ expect(hydrated.map((r) => r.id).sort()).toEqual(["dashboard", "home"]);
372
+ });
373
+ });
374
+
375
+ describe("Phase 7.1 R1 Agent C — per-island scan skips non-hydrated routes", () => {
376
+ let project: ReturnType<typeof createProject>;
377
+
378
+ beforeEach(() => {
379
+ project = createProject();
380
+ });
381
+
382
+ afterEach(() => {
383
+ try {
384
+ rmSync(project.rootDir, { recursive: true, force: true });
385
+ } catch {
386
+ /* Windows lock tolerance */
387
+ }
388
+ });
389
+
390
+ it("skips a route with hydration.strategy === 'none' even if passed explicitly", async () => {
391
+ // Regression guard — the task spec says "이미 되고 있을 수 있음 (확인 필요)".
392
+ // We pin that scanIslandFiles itself (not just its callers) rejects
393
+ // pure-SSR routes via the defensive `needsHydration` check.
394
+ project.writeIslandFile("app/static", "page.tsx");
395
+ project.writeIslandFile("app/static", "widget.island.tsx");
396
+
397
+ const route = pureSsrRoute("static", "/static", "app/static");
398
+ const result = await _testOnly_scanIslandFiles([route], project.rootDir);
399
+
400
+ // Because the route is pure-SSR, the widget.island.tsx is ignored —
401
+ // nothing to hydrate. This also saves a readdir on the app/static
402
+ // directory (the -40-70 ms Tier 1 optimisation).
403
+ expect(result).toHaveLength(0);
404
+ });
405
+
406
+ it("scans directories of hydrated routes and picks up *.island.tsx files", async () => {
407
+ project.writeIslandFile("app", "page.tsx");
408
+ project.writeIslandFile("app", "counter.island.tsx");
409
+ project.writeIslandFile("app", "banner.island.ts");
410
+
411
+ const route = pageRoute("home", "/", "app", { hydration: "visible" });
412
+ const result = await _testOnly_scanIslandFiles([route], project.rootDir);
413
+
414
+ expect(result).toHaveLength(2);
415
+ expect(result.map((e) => e.name).sort()).toEqual(["banner", "counter"]);
416
+ for (const entry of result) {
417
+ expect(entry.routeId).toBe("home");
418
+ expect(entry.priority).toBeDefined();
419
+ }
420
+ });
421
+
422
+ it("dedupes directory scans when multiple hydrated routes share a dir", async () => {
423
+ // Same dir = seenDirs filter should kick in after the first scan.
424
+ // We verify via the number of entries — a duplicate scan would
425
+ // return 2 entries per island file.
426
+ project.writeIslandFile("app", "page.tsx");
427
+ project.writeIslandFile("app", "counter.island.tsx");
428
+
429
+ const r1 = pageRoute("home", "/", "app", { hydration: "visible" });
430
+ const r2 = pageRoute("alt", "/alt", "app", { hydration: "idle" });
431
+ const result = await _testOnly_scanIslandFiles([r1, r2], project.rootDir);
432
+
433
+ expect(result).toHaveLength(1);
434
+ // First-encountered route wins (seenDirs short-circuits subsequent).
435
+ expect(result[0].routeId).toBe("home");
436
+ });
437
+
438
+ it("skips pure-SSR routes interleaved between hydrated routes", async () => {
439
+ // Mixed-hydration project — the defensive skip saves an fs.readdir
440
+ // on the pure-SSR branch. This is the canonical §5 Tier 1 win.
441
+ project.writeIslandFile("app", "page.tsx");
442
+ project.writeIslandFile("app", "home.island.tsx");
443
+ project.writeIslandFile("app/about", "page.tsx");
444
+ // about has NO island files AND is pure-SSR — should not be readdir'd.
445
+ project.writeIslandFile("app/dashboard", "page.tsx");
446
+ project.writeIslandFile("app/dashboard", "widget.island.tsx");
447
+
448
+ const routes = [
449
+ pageRoute("home", "/", "app", { hydration: "visible" }),
450
+ pureSsrRoute("about", "/about", "app/about"),
451
+ pageRoute("dashboard", "/dashboard", "app/dashboard", {
452
+ hydration: "idle",
453
+ }),
454
+ ];
455
+ const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
456
+
457
+ expect(result.map((e) => e.name).sort()).toEqual(["home", "widget"]);
458
+ // about is not even attempted — no error, no scan entry.
459
+ expect(result.some((e) => e.routeId === "about")).toBe(false);
460
+ });
461
+
462
+ it("handles missing route directories gracefully (fs.readdir catch)", async () => {
463
+ // An orphan route — the readdir will throw ENOENT. The scanner
464
+ // must swallow it and continue with later routes.
465
+ project.writeIslandFile("app", "page.tsx");
466
+ project.writeIslandFile("app", "counter.island.tsx");
467
+
468
+ const routes = [
469
+ pageRoute("missing", "/missing", "app/nonexistent", {
470
+ hydration: "visible",
471
+ }),
472
+ pageRoute("home", "/", "app", { hydration: "visible" }),
473
+ ];
474
+ const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
475
+
476
+ expect(result).toHaveLength(1);
477
+ expect(result[0].name).toBe("counter");
478
+ });
479
+
480
+ it("returns empty array for all-empty input (no hydrated routes)", async () => {
481
+ // Guards the "hydratedRoutes.length === 0" fast path — even if a
482
+ // caller bypasses the early-return at build.ts:1478 and calls the
483
+ // scanner directly with no hydrated routes, we still return [].
484
+ const result = await _testOnly_scanIslandFiles([], project.rootDir);
485
+ expect(result).toEqual([]);
486
+ });
487
+
488
+ it("returns empty array when ALL input routes are pure-SSR", async () => {
489
+ // §5 Tier 1 item 3 — the "per-island conditional skip" big win.
490
+ // When no routes need hydration, scanIslandFiles must be a pure
491
+ // no-op (no fs.readdir calls), not just return [].
492
+ project.writeIslandFile("app/a", "page.tsx");
493
+ project.writeIslandFile("app/a", "x.island.tsx");
494
+ project.writeIslandFile("app/b", "page.tsx");
495
+ project.writeIslandFile("app/b", "y.island.tsx");
496
+
497
+ const routes = [
498
+ pureSsrRoute("a", "/a", "app/a"),
499
+ pureSsrRoute("b", "/b", "app/b"),
500
+ ];
501
+ const result = await _testOnly_scanIslandFiles(routes, project.rootDir);
502
+ expect(result).toEqual([]);
503
+ });
504
+ });