@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,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
|
+
});
|