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