@mandujs/core 0.21.0 → 0.22.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 (122) hide show
  1. package/package.json +94 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Phase 7.0 R2 Agent D — Extended file watch tests.
3
+ *
4
+ * Covers the six file kinds that Mandu's dev watcher silently ignored
5
+ * prior to Phase 7:
6
+ *
7
+ * 1. `spec/contracts/**\/*.contract.ts` — code-gen / handler re-register
8
+ * 2. `spec/resources/**\/*.resource.ts` — full artifact regeneration
9
+ * 3. `app/**\/middleware.ts` — route handler re-register
10
+ * 4. `mandu.config.ts` — auto-restart
11
+ * 5. `.env*` (root) — auto-restart
12
+ * 6. `package.json` — advisory notification only
13
+ *
14
+ * The pure static tests (classify / predicate) run in all modes — they
15
+ * do not spin up a watcher. The integration tests that drive real
16
+ * `fs.watch` events are gated behind `MANDU_SKIP_BUNDLER_TESTS=1`
17
+ * (the same env var Agent A uses) to stay compatible with the CI
18
+ * randomize protocol.
19
+ *
20
+ * References:
21
+ * docs/bun/phase-7-team-plan.md §4 Agent D
22
+ * docs/bun/phase-7-diagnostics/performance-reliability.md §2 B10
23
+ * docs/bun/phase-7-diagnostics/hmr-internals.md §2
24
+ */
25
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
26
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
27
+ import { tmpdir } from "os";
28
+ import path from "path";
29
+ import {
30
+ startDevBundler,
31
+ SSR_CHANGE_WILDCARD,
32
+ isConfigOrEnvFile,
33
+ isResourceOrContractFile,
34
+ isRouteMiddlewareFile,
35
+ isPackageJsonFile,
36
+ _testOnly_classifyFileKind,
37
+ } from "../dev";
38
+ import type { RoutesManifest } from "../../spec/schema";
39
+
40
+ // -----------------------------------------------------------------------------
41
+ // Helpers — mirror the patterns in dev-reliability.test.ts so the two suites
42
+ // can share a mental model.
43
+ // -----------------------------------------------------------------------------
44
+
45
+ /** Same settle window Agent A's suite uses — derived from WATCHER_DEBOUNCE
46
+ * (100 ms) plus Windows ReadDirectoryChangesW slack. */
47
+ const WATCH_SETTLE_MS = 350;
48
+
49
+ function sleep(ms: number): Promise<void> {
50
+ return new Promise((resolve) => setTimeout(resolve, ms));
51
+ }
52
+
53
+ /**
54
+ * Write to `filePath` repeatedly until the observer callback reports a
55
+ * change. Windows `fs.watch` occasionally drops the first event on a
56
+ * freshly-armed watcher; a retry loop with varying content is the
57
+ * robust cross-platform pattern.
58
+ */
59
+ async function touchUntilSeen(
60
+ filePath: string,
61
+ observedCount: () => number,
62
+ maxAttempts = 4,
63
+ ): Promise<void> {
64
+ const before = observedCount();
65
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
66
+ writeFileSync(filePath, `export const V = ${Date.now() + attempt};\n`);
67
+ await sleep(WATCH_SETTLE_MS);
68
+ if (observedCount() > before) return;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Variant of `touchUntilSeen` for files that are not TS/TSX — `.env`,
74
+ * `package.json`. Writes raw text, not an `export const` line.
75
+ */
76
+ async function touchNonTsUntilSeen(
77
+ filePath: string,
78
+ contentFactory: (attempt: number) => string,
79
+ observedCount: () => number,
80
+ maxAttempts = 4,
81
+ ): Promise<void> {
82
+ const before = observedCount();
83
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
84
+ writeFileSync(filePath, contentFactory(attempt));
85
+ await sleep(WATCH_SETTLE_MS);
86
+ if (observedCount() > before) return;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Build a minimal on-disk project that exercises every directory the
92
+ * extended watcher cares about:
93
+ * - `spec/contracts/foo.contract.ts`
94
+ * - `spec/resources/user.resource.ts`
95
+ * - `app/api/hello/middleware.ts`
96
+ * - `src/` (so main watch dispatch is live)
97
+ * - `mandu.config.ts` / `.env` / `package.json` at the root
98
+ *
99
+ * `.mandu/manifest.json` is pre-populated to match the pattern Agent A's
100
+ * fixtures use.
101
+ */
102
+ function createTempProject(): string {
103
+ const root = mkdtempSync(path.join(tmpdir(), "mandu-extended-watch-"));
104
+
105
+ // `.mandu` tree for the bundler's initial build to succeed silently.
106
+ mkdirSync(path.join(root, ".mandu/client"), { recursive: true });
107
+ writeFileSync(
108
+ path.join(root, ".mandu/manifest.json"),
109
+ JSON.stringify(
110
+ {
111
+ version: 1,
112
+ buildTime: new Date().toISOString(),
113
+ env: "development",
114
+ bundles: {},
115
+ shared: {
116
+ runtime: "/.mandu/client/runtime.js",
117
+ vendor: "/.mandu/client/vendor.js",
118
+ },
119
+ },
120
+ null,
121
+ 2,
122
+ ),
123
+ );
124
+
125
+ // Spec trees — these are the ones this agent adds watchers for.
126
+ mkdirSync(path.join(root, "spec/contracts"), { recursive: true });
127
+ writeFileSync(
128
+ path.join(root, "spec/contracts/foo.contract.ts"),
129
+ "export const FooContract = { foo: 'bar' };\n",
130
+ );
131
+ mkdirSync(path.join(root, "spec/resources"), { recursive: true });
132
+ writeFileSync(
133
+ path.join(root, "spec/resources/user.resource.ts"),
134
+ "export default { name: 'user', fields: {} };\n",
135
+ );
136
+
137
+ // Per-route middleware
138
+ mkdirSync(path.join(root, "app/api/hello"), { recursive: true });
139
+ writeFileSync(
140
+ path.join(root, "app/api/hello/middleware.ts"),
141
+ "export default function hello() {}\n",
142
+ );
143
+ writeFileSync(
144
+ path.join(root, "app/api/hello/route.ts"),
145
+ "export function GET() { return Response.json({}); }\n",
146
+ );
147
+
148
+ // `src/` for the main common-dir watcher — proves regression tests
149
+ // that the old behavior is intact.
150
+ mkdirSync(path.join(root, "src/shared"), { recursive: true });
151
+ writeFileSync(path.join(root, "src/shared/foo.ts"), "export const S = 1;\n");
152
+ writeFileSync(path.join(root, "src/top-level.ts"), "export const T = 1;\n");
153
+
154
+ // Root-level config / env / package.json — the new dedicated watcher.
155
+ writeFileSync(path.join(root, "mandu.config.ts"), "export default { };\n");
156
+ writeFileSync(path.join(root, ".env"), "NODE_ENV=development\n");
157
+ writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "tmp" }, null, 2));
158
+
159
+ return root;
160
+ }
161
+
162
+ /**
163
+ * Manifest with one API route so `apiModuleSet` in the bundler is not
164
+ * empty — exercises the middleware path that shares the api-only kind.
165
+ */
166
+ const hydrationlessManifest = (): RoutesManifest =>
167
+ ({
168
+ version: 1,
169
+ routes: [
170
+ {
171
+ id: "api.hello",
172
+ kind: "api",
173
+ pattern: "/api/hello",
174
+ module: "app/api/hello/route.ts",
175
+ },
176
+ ],
177
+ } as unknown as RoutesManifest);
178
+
179
+ // -----------------------------------------------------------------------------
180
+ // Section A — Pure predicate tests (no watcher startup)
181
+ // -----------------------------------------------------------------------------
182
+
183
+ describe("Phase 7.0 R2 Agent D — predicates", () => {
184
+ it("isConfigOrEnvFile matches mandu.config.ts + variants", () => {
185
+ // Basic mandu.config.ts
186
+ expect(isConfigOrEnvFile("/repo/mandu.config.ts")).toBe(true);
187
+ expect(isConfigOrEnvFile("/repo/mandu.config.js")).toBe(true);
188
+ expect(isConfigOrEnvFile("/repo/mandu.config.mjs")).toBe(true);
189
+ expect(isConfigOrEnvFile("/repo/mandu.config.cjs")).toBe(true);
190
+ // Partial names must NOT match — `mandu.config.local.ts` would be
191
+ // user namespace, not a framework hook.
192
+ expect(isConfigOrEnvFile("/repo/mandu.config.local.ts")).toBe(false);
193
+ expect(isConfigOrEnvFile("/repo/mandu-config.ts")).toBe(false);
194
+ });
195
+
196
+ it("isConfigOrEnvFile matches .env family", () => {
197
+ expect(isConfigOrEnvFile("/repo/.env")).toBe(true);
198
+ expect(isConfigOrEnvFile("/repo/.env.local")).toBe(true);
199
+ expect(isConfigOrEnvFile("/repo/.env.development")).toBe(true);
200
+ expect(isConfigOrEnvFile("/repo/.env.production")).toBe(true);
201
+ expect(isConfigOrEnvFile("/repo/.env.test")).toBe(true);
202
+ expect(isConfigOrEnvFile("/repo/.env.staging")).toBe(true);
203
+ // `.envoy` / `.envelope` must NOT match — prefix-only, not generic.
204
+ expect(isConfigOrEnvFile("/repo/.envoy")).toBe(false);
205
+ expect(isConfigOrEnvFile("/repo/env")).toBe(false);
206
+ });
207
+
208
+ it("isResourceOrContractFile matches *.resource.ts and *.contract.ts", () => {
209
+ expect(isResourceOrContractFile("/repo/spec/resources/user.resource.ts")).toBe(true);
210
+ expect(isResourceOrContractFile("/repo/spec/resources/deep/nested/post.resource.ts")).toBe(true);
211
+ expect(isResourceOrContractFile("/repo/spec/contracts/api.contract.ts")).toBe(true);
212
+ expect(isResourceOrContractFile("/repo/app/users/users.contract.ts")).toBe(true);
213
+ // `.tsx` variants (rare, but supported).
214
+ expect(isResourceOrContractFile("/repo/spec/resources/foo.resource.tsx")).toBe(true);
215
+ // Unrelated names must NOT match.
216
+ expect(isResourceOrContractFile("/repo/spec/resources/user.ts")).toBe(false);
217
+ expect(isResourceOrContractFile("/repo/spec/foo.contract.md")).toBe(false);
218
+ });
219
+
220
+ it("isRouteMiddlewareFile matches app/**/middleware.ts", () => {
221
+ expect(isRouteMiddlewareFile("/repo/app/api/hello/middleware.ts")).toBe(true);
222
+ expect(isRouteMiddlewareFile("/repo/app/middleware.ts")).toBe(true);
223
+ expect(isRouteMiddlewareFile("/repo/app/middleware.tsx")).toBe(true);
224
+ // Partial matches must NOT fire — a user module named `auth-middleware.ts`
225
+ // is regular code, not the framework hook.
226
+ expect(isRouteMiddlewareFile("/repo/app/auth-middleware.ts")).toBe(false);
227
+ expect(isRouteMiddlewareFile("/repo/app/middlewares/guard.ts")).toBe(false);
228
+ });
229
+
230
+ it("isPackageJsonFile matches only the basename", () => {
231
+ expect(isPackageJsonFile("/repo/package.json")).toBe(true);
232
+ expect(isPackageJsonFile("/repo/packages/core/package.json")).toBe(true);
233
+ // Case-insensitive (Windows can surface mixed case).
234
+ expect(isPackageJsonFile("/repo/Package.JSON")).toBe(true);
235
+ // Substring must NOT match.
236
+ expect(isPackageJsonFile("/repo/package-lock.json")).toBe(false);
237
+ expect(isPackageJsonFile("/repo/package.json.bak")).toBe(false);
238
+ });
239
+ });
240
+
241
+ // -----------------------------------------------------------------------------
242
+ // Section B — classifyBatch static shape (no watcher startup)
243
+ // -----------------------------------------------------------------------------
244
+
245
+ describe("Phase 7.0 R2 Agent D — static classification (_testOnly_classifyFileKind)", () => {
246
+ it("(1) foo.contract.ts → resource-regen", () => {
247
+ expect(_testOnly_classifyFileKind("/repo/spec/contracts/foo.contract.ts")).toBe(
248
+ "resource-regen",
249
+ );
250
+ });
251
+
252
+ it("(2) bar.resource.ts → resource-regen", () => {
253
+ expect(_testOnly_classifyFileKind("/repo/spec/resources/bar.resource.ts")).toBe(
254
+ "resource-regen",
255
+ );
256
+ });
257
+
258
+ it("(3) app/api/hello/middleware.ts → api-only", () => {
259
+ expect(_testOnly_classifyFileKind("/repo/app/api/hello/middleware.ts")).toBe(
260
+ "api-only",
261
+ );
262
+ });
263
+
264
+ it("(4) mandu.config.ts → config-reload", () => {
265
+ expect(_testOnly_classifyFileKind("/repo/mandu.config.ts")).toBe(
266
+ "config-reload",
267
+ );
268
+ });
269
+
270
+ it("(5) .env → config-reload", () => {
271
+ expect(_testOnly_classifyFileKind("/repo/.env")).toBe("config-reload");
272
+ });
273
+
274
+ it("(6) .env.local → config-reload", () => {
275
+ expect(_testOnly_classifyFileKind("/repo/.env.local")).toBe("config-reload");
276
+ expect(_testOnly_classifyFileKind("/repo/.env.development")).toBe("config-reload");
277
+ expect(_testOnly_classifyFileKind("/repo/.env.production")).toBe("config-reload");
278
+ });
279
+
280
+ it("regression: src/shared/foo.ts stays common-dir when commonDirs provided", () => {
281
+ // `commonDirs` is optional — the pure-static classifier needs it to
282
+ // distinguish a common-dir path from a mixed one. This proves the
283
+ // Agent D extension doesn't steal priority from Agent A's common-dir
284
+ // path.
285
+ expect(
286
+ _testOnly_classifyFileKind("/repo/src/shared/foo.ts", {
287
+ commonDirs: ["/repo/src"],
288
+ }),
289
+ ).toBe("common-dir");
290
+ });
291
+
292
+ it("regression: app/page.tsx falls through to mixed (no handler for it in the static classifier)", () => {
293
+ // The live classifier (inside startDevBundler) uses manifest-derived
294
+ // maps to route a page path to ssr-only / islands-only. The static
295
+ // export intentionally does NOT do that — it's a pure rule table.
296
+ // This test pins the boundary: do NOT expect the static helper to
297
+ // know about routes.
298
+ expect(_testOnly_classifyFileKind("/repo/app/page.tsx")).toBe("mixed");
299
+ });
300
+ });
301
+
302
+ // -----------------------------------------------------------------------------
303
+ // Section C — Live watcher integration (gated)
304
+ // -----------------------------------------------------------------------------
305
+
306
+ describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
307
+ "Phase 7.0 R2 Agent D — watcher integration",
308
+ () => {
309
+ let rootDir: string;
310
+ let close: (() => void) | null = null;
311
+
312
+ beforeEach(() => {
313
+ rootDir = createTempProject();
314
+ });
315
+
316
+ afterEach(() => {
317
+ close?.();
318
+ close = null;
319
+ try {
320
+ rmSync(rootDir, { recursive: true, force: true });
321
+ } catch {
322
+ /* Windows may hold locks during teardown — best-effort cleanup. */
323
+ }
324
+ });
325
+
326
+ it("(1) .contract.ts change fires onResourceChange", async () => {
327
+ const resourceCalls: string[] = [];
328
+ const bundler = await startDevBundler({
329
+ rootDir,
330
+ manifest: hydrationlessManifest(),
331
+ onResourceChange: (filePath) => {
332
+ resourceCalls.push(filePath);
333
+ },
334
+ });
335
+ close = bundler.close;
336
+
337
+ await sleep(300);
338
+
339
+ await touchUntilSeen(
340
+ path.join(rootDir, "spec/contracts/foo.contract.ts"),
341
+ () => resourceCalls.length,
342
+ );
343
+
344
+ expect(resourceCalls.length).toBeGreaterThan(0);
345
+ expect(resourceCalls[0]!.endsWith("foo.contract.ts")).toBe(true);
346
+ }, 15_000);
347
+
348
+ it("(2) .resource.ts change fires onResourceChange", async () => {
349
+ const resourceCalls: string[] = [];
350
+ const bundler = await startDevBundler({
351
+ rootDir,
352
+ manifest: hydrationlessManifest(),
353
+ onResourceChange: (filePath) => {
354
+ resourceCalls.push(filePath);
355
+ },
356
+ });
357
+ close = bundler.close;
358
+
359
+ await sleep(300);
360
+
361
+ await touchUntilSeen(
362
+ path.join(rootDir, "spec/resources/user.resource.ts"),
363
+ () => resourceCalls.length,
364
+ );
365
+
366
+ expect(resourceCalls.length).toBeGreaterThan(0);
367
+ expect(resourceCalls[0]!.endsWith("user.resource.ts")).toBe(true);
368
+ }, 15_000);
369
+
370
+ it("(3) app/api/hello/middleware.ts change fires onAPIChange", async () => {
371
+ // Middleware shares the API-change rail — the CLI's handleAPIChange
372
+ // re-registers route handlers, which is exactly what a middleware
373
+ // edit needs (routes pull in middleware via the import graph).
374
+ const apiCalls: string[] = [];
375
+ const bundler = await startDevBundler({
376
+ rootDir,
377
+ manifest: hydrationlessManifest(),
378
+ onAPIChange: (filePath) => {
379
+ apiCalls.push(filePath);
380
+ },
381
+ });
382
+ close = bundler.close;
383
+
384
+ await sleep(300);
385
+
386
+ await touchUntilSeen(
387
+ path.join(rootDir, "app/api/hello/middleware.ts"),
388
+ () => apiCalls.length,
389
+ );
390
+
391
+ expect(apiCalls.length).toBeGreaterThan(0);
392
+ expect(apiCalls.some((p) => p.endsWith("middleware.ts"))).toBe(true);
393
+ }, 15_000);
394
+
395
+ it("(4) mandu.config.ts change fires onConfigReload (mocked restart)", async () => {
396
+ // The CLI-side restart is mocked out — we only assert the bundler
397
+ // delivered the signal. The full wiring (restartDevServer) is
398
+ // tested at the CLI layer.
399
+ const configCalls: string[] = [];
400
+ const bundler = await startDevBundler({
401
+ rootDir,
402
+ manifest: hydrationlessManifest(),
403
+ onConfigReload: (filePath) => {
404
+ configCalls.push(filePath);
405
+ },
406
+ });
407
+ close = bundler.close;
408
+
409
+ await sleep(300);
410
+
411
+ await touchNonTsUntilSeen(
412
+ path.join(rootDir, "mandu.config.ts"),
413
+ (i) => `export default { marker: ${i} };\n`,
414
+ () => configCalls.length,
415
+ );
416
+
417
+ expect(configCalls.length).toBeGreaterThan(0);
418
+ expect(configCalls[0]!.endsWith("mandu.config.ts")).toBe(true);
419
+ }, 15_000);
420
+
421
+ it("(5) .env change fires onConfigReload", async () => {
422
+ const configCalls: string[] = [];
423
+ const bundler = await startDevBundler({
424
+ rootDir,
425
+ manifest: hydrationlessManifest(),
426
+ onConfigReload: (filePath) => {
427
+ configCalls.push(filePath);
428
+ },
429
+ });
430
+ close = bundler.close;
431
+
432
+ await sleep(300);
433
+
434
+ await touchNonTsUntilSeen(
435
+ path.join(rootDir, ".env"),
436
+ (i) => `NODE_ENV=development\nMARKER=${i}\n`,
437
+ () => configCalls.length,
438
+ );
439
+
440
+ expect(configCalls.length).toBeGreaterThan(0);
441
+ expect(configCalls[0]!.endsWith(".env")).toBe(true);
442
+ }, 15_000);
443
+
444
+ it("(6) .env.local change fires onConfigReload", async () => {
445
+ // `.env.local` must be detected alongside the bare `.env` — this
446
+ // is the common "override for personal dev" file Next/Vite users
447
+ // expect to work.
448
+ writeFileSync(path.join(rootDir, ".env.local"), "A=1\n");
449
+ const configCalls: string[] = [];
450
+ const bundler = await startDevBundler({
451
+ rootDir,
452
+ manifest: hydrationlessManifest(),
453
+ onConfigReload: (filePath) => {
454
+ configCalls.push(filePath);
455
+ },
456
+ });
457
+ close = bundler.close;
458
+
459
+ await sleep(300);
460
+
461
+ await touchNonTsUntilSeen(
462
+ path.join(rootDir, ".env.local"),
463
+ (i) => `A=${i}\n`,
464
+ () => configCalls.length,
465
+ );
466
+
467
+ expect(configCalls.length).toBeGreaterThan(0);
468
+ expect(configCalls.some((p) => p.endsWith(".env.local"))).toBe(true);
469
+ }, 15_000);
470
+
471
+ it("(7) package.json change fires onPackageJsonChange (no auto-restart)", async () => {
472
+ // Package manifest watching is advisory only — if we wired this to
473
+ // restartDevServer it would loop during `bun install`. The
474
+ // callback asserts we saw the signal; the CLI's handler prints a
475
+ // restart hint.
476
+ const pkgCalls: string[] = [];
477
+ const configCalls: string[] = [];
478
+ const bundler = await startDevBundler({
479
+ rootDir,
480
+ manifest: hydrationlessManifest(),
481
+ onPackageJsonChange: (filePath) => {
482
+ pkgCalls.push(filePath);
483
+ },
484
+ onConfigReload: (filePath) => {
485
+ // Must NOT fire for package.json — guard against accidental
486
+ // category promotion.
487
+ configCalls.push(filePath);
488
+ },
489
+ });
490
+ close = bundler.close;
491
+
492
+ await sleep(300);
493
+
494
+ await touchNonTsUntilSeen(
495
+ path.join(rootDir, "package.json"),
496
+ (i) => JSON.stringify({ name: "tmp", marker: i }, null, 2),
497
+ () => pkgCalls.length,
498
+ );
499
+
500
+ expect(pkgCalls.length).toBeGreaterThan(0);
501
+ // Regression guard — package.json must never classify as config.
502
+ expect(configCalls.length).toBe(0);
503
+ }, 15_000);
504
+
505
+ it("(8) batched .env edits coalesce to one onConfigReload", async () => {
506
+ // Multiple `.env` saves inside the per-file debounce window must
507
+ // fire the restart handler ONCE — not once per keystroke. This
508
+ // is the coalescing contract the diagnostic doc calls out.
509
+ const configCalls: string[] = [];
510
+ const bundler = await startDevBundler({
511
+ rootDir,
512
+ manifest: hydrationlessManifest(),
513
+ onConfigReload: async (filePath) => {
514
+ configCalls.push(filePath);
515
+ // Slow handler so subsequent edits land in pendingBuildSet,
516
+ // exactly as the restart coalescing contract requires.
517
+ await sleep(200);
518
+ },
519
+ });
520
+ close = bundler.close;
521
+
522
+ await sleep(300);
523
+
524
+ // Four rapid saves of the same file — per-file debounce must
525
+ // coalesce them. We don't mix different env files here because
526
+ // we want the purest "one file, many saves" assertion.
527
+ const target = path.join(rootDir, ".env");
528
+ writeFileSync(target, "A=1\n");
529
+ await sleep(20);
530
+ writeFileSync(target, "A=2\n");
531
+ await sleep(20);
532
+ writeFileSync(target, "A=3\n");
533
+ await sleep(20);
534
+ writeFileSync(target, "A=4\n");
535
+
536
+ await sleep(WATCH_SETTLE_MS * 3);
537
+
538
+ // At least one fire from the coalesced debounce. Typical observed
539
+ // count is exactly 1 (per-file timer reset x3, fired once) — we
540
+ // allow up to 2 because Windows ReadDirectoryChangesW occasionally
541
+ // emits a second event after the 4th write.
542
+ expect(configCalls.length).toBeGreaterThanOrEqual(1);
543
+ expect(configCalls.length).toBeLessThanOrEqual(2);
544
+ }, 15_000);
545
+
546
+ it("(9) contract + resource mix in a single batch fires resource-regen coalesced", async () => {
547
+ // Two different code-gen inputs touched in the same debounce
548
+ // window. Each should fire onResourceChange — not coalesced down
549
+ // to a single callback because the consumer needs the specific
550
+ // path to drive `parseResourceSchema(filePath)` /
551
+ // `generateResourceArtifacts(parsed)`.
552
+ const resourceCalls: string[] = [];
553
+ const bundler = await startDevBundler({
554
+ rootDir,
555
+ manifest: hydrationlessManifest(),
556
+ onResourceChange: (filePath) => {
557
+ resourceCalls.push(filePath);
558
+ },
559
+ });
560
+ close = bundler.close;
561
+
562
+ await sleep(300);
563
+
564
+ writeFileSync(
565
+ path.join(rootDir, "spec/contracts/foo.contract.ts"),
566
+ "export const X = 1;\n",
567
+ );
568
+ await sleep(30);
569
+ writeFileSync(
570
+ path.join(rootDir, "spec/resources/user.resource.ts"),
571
+ "export default { name: 'user', fields: { id: { type: 'string' } } };\n",
572
+ );
573
+
574
+ await sleep(WATCH_SETTLE_MS * 2);
575
+
576
+ // Both files must surface — coalescing is per-file, not per-batch
577
+ // (see `handleResourceRegenBatch` comment block).
578
+ expect(resourceCalls.length).toBeGreaterThanOrEqual(2);
579
+ expect(resourceCalls.some((p) => p.endsWith("foo.contract.ts"))).toBe(true);
580
+ expect(resourceCalls.some((p) => p.endsWith("user.resource.ts"))).toBe(true);
581
+ }, 15_000);
582
+
583
+ it("(10) regression: src/shared change still fires onSSRChange wildcard (common-dir path intact)", async () => {
584
+ // The extended watcher additions must not steal priority from
585
+ // Agent A's common-dir path. This test mirrors the corresponding
586
+ // assertion in dev-reliability.test.ts so a future refactor that
587
+ // breaks either suite is caught in both places.
588
+ const wildcardFires: string[] = [];
589
+ const bundler = await startDevBundler({
590
+ rootDir,
591
+ manifest: hydrationlessManifest(),
592
+ onSSRChange: (filePath) => {
593
+ wildcardFires.push(filePath);
594
+ },
595
+ });
596
+ close = bundler.close;
597
+
598
+ await sleep(300);
599
+
600
+ await touchUntilSeen(
601
+ path.join(rootDir, "src/shared/foo.ts"),
602
+ () => wildcardFires.length,
603
+ );
604
+
605
+ expect(wildcardFires.length).toBeGreaterThan(0);
606
+ expect(wildcardFires[0]).toBe(SSR_CHANGE_WILDCARD);
607
+ }, 15_000);
608
+
609
+ it("(11) regression: src/top-level.ts still fires onSSRChange wildcard (B1 kept)", async () => {
610
+ // Second half of the regression coverage — Agent A's B1 fix
611
+ // (src top-level file detection) must coexist with the Agent D
612
+ // root-level watcher. If the new package.json/config watcher
613
+ // somehow swallowed src/ events we'd see zero fires here.
614
+ const wildcardFires: string[] = [];
615
+ const bundler = await startDevBundler({
616
+ rootDir,
617
+ manifest: hydrationlessManifest(),
618
+ onSSRChange: (filePath) => {
619
+ wildcardFires.push(filePath);
620
+ },
621
+ });
622
+ close = bundler.close;
623
+
624
+ await sleep(300);
625
+
626
+ await touchUntilSeen(
627
+ path.join(rootDir, "src/top-level.ts"),
628
+ () => wildcardFires.length,
629
+ );
630
+
631
+ expect(wildcardFires.length).toBeGreaterThan(0);
632
+ expect(wildcardFires[0]).toBe(SSR_CHANGE_WILDCARD);
633
+ }, 15_000);
634
+
635
+ it("(12) resource-regen followed by onSSRChange wildcard fan-out", async () => {
636
+ // Document the exact order of side effects after a resource
637
+ // change — the resource callback runs FIRST (so the generator
638
+ // can emit files), then the SSR invalidation fires so the
639
+ // handler registry picks up the regenerated artifacts.
640
+ const order: string[] = [];
641
+ const bundler = await startDevBundler({
642
+ rootDir,
643
+ manifest: hydrationlessManifest(),
644
+ onResourceChange: (filePath) => {
645
+ order.push(`resource:${path.basename(filePath)}`);
646
+ },
647
+ onSSRChange: (filePath) => {
648
+ order.push(`ssr:${filePath}`);
649
+ },
650
+ });
651
+ close = bundler.close;
652
+
653
+ await sleep(300);
654
+
655
+ await touchUntilSeen(
656
+ path.join(rootDir, "spec/resources/user.resource.ts"),
657
+ () => order.length,
658
+ );
659
+
660
+ // The resource callback must appear before ANY SSR wildcard fire.
661
+ const resourceIdx = order.findIndex((e) => e.startsWith("resource:"));
662
+ const ssrIdx = order.findIndex((e) => e === `ssr:${SSR_CHANGE_WILDCARD}`);
663
+ expect(resourceIdx).toBeGreaterThanOrEqual(0);
664
+ if (ssrIdx >= 0) {
665
+ // If both fired, resource must have fired first.
666
+ expect(resourceIdx).toBeLessThan(ssrIdx);
667
+ }
668
+ }, 15_000);
669
+
670
+ it("(13) close() tears down the root config watcher (no leak)", async () => {
671
+ // Extra belt-and-suspenders — the new `rootWatcher` must be
672
+ // added to the `watchers` array so `close()` reclaims it. If we
673
+ // leaked it, the test runner would flag a pending file watcher.
674
+ const bundler = await startDevBundler({
675
+ rootDir,
676
+ manifest: hydrationlessManifest(),
677
+ onConfigReload: () => {},
678
+ });
679
+
680
+ await sleep(200);
681
+ writeFileSync(path.join(rootDir, "mandu.config.ts"), "export default { marker: 42 };\n");
682
+
683
+ // Close before debounce fires — all timers + watchers must go.
684
+ bundler.close();
685
+ await sleep(WATCH_SETTLE_MS);
686
+
687
+ expect(true).toBe(true);
688
+ }, 10_000);
689
+ },
690
+ );
691
+
692
+ // -----------------------------------------------------------------------------
693
+ // Section D — CoalescedChange["kind"] surface contract
694
+ // -----------------------------------------------------------------------------
695
+
696
+ describe("Phase 7.0 R2 Agent D — CoalescedChange kind surface", () => {
697
+ it("config-reload and resource-regen values exist on the type-level contract", async () => {
698
+ // Proves the hmr-types contract already exposes the two new kinds
699
+ // Agent D classifies toward. If someone drops either from the union,
700
+ // this test fails at compile time (we assign a literal into a
701
+ // typed variable).
702
+
703
+ // Literal assignments — if the union shrinks, TS errors in this
704
+ // file before the suite even runs.
705
+ const a: import("../hmr-types").CoalescedChange["kind"] = "config-reload";
706
+ const b: import("../hmr-types").CoalescedChange["kind"] = "resource-regen";
707
+ expect(a).toBe("config-reload");
708
+ expect(b).toBe("resource-regen");
709
+ });
710
+ });