@mandujs/core 0.53.3 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -654
- package/package.json +2 -2
- 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 +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- 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 +120 -120
- 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/bundler/__tests__/build-runner.ts +113 -113
- 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 +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- 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/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- 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 +155 -155
- 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/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- 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__/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 +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- 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.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- 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/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/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- 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 +182 -182
- 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__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- 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/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/overlay.tsx +368 -368
- 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 +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- 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/client/persistence.ts +335 -335
- 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/server/source-context.ts +450 -450
- 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/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- 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/result.ts +60 -60
- 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 +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- 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/index.ts +3 -3
- 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/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/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- 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/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- 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/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 +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- 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/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- 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__/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__/not-found.test.ts +152 -152
- 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/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- 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/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- 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/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/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/watcher.ts +18 -18
|
@@ -1,532 +1,532 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 7.0 R1 Agent C — HMR Vite-compat subset + replay buffer tests.
|
|
3
|
-
*
|
|
4
|
-
* Coverage:
|
|
5
|
-
* 1–7 : `ManduHot` runtime (`import.meta.hot` subset) — pure unit tests
|
|
6
|
-
* against the registry, no socket required.
|
|
7
|
-
* 8–11 : Replay buffer semantics (eviction by size, eviction by age,
|
|
8
|
-
* `?since` behavior) — drives a real Bun.serve WebSocket on an
|
|
9
|
-
* ephemeral port.
|
|
10
|
-
* 12–15: Wire-format contract (layout-update envelope, Vite-compat
|
|
11
|
-
* broadcastVite envelope, full-reload on buffer exhaustion,
|
|
12
|
-
* `invalidate` upstream → `full-reload` fanout).
|
|
13
|
-
*
|
|
14
|
-
* Tests 8–15 use an ephemeral port (PORTS.HMR_OFFSET is applied inside
|
|
15
|
-
* `createHMRServer`, so we must pass `port - HMR_OFFSET`) and tear the
|
|
16
|
-
* server down in `afterEach` to avoid port leaks.
|
|
17
|
-
*
|
|
18
|
-
* References:
|
|
19
|
-
* docs/bun/phase-7-team-plan.md §4 Agent C
|
|
20
|
-
* packages/core/src/bundler/hmr-types.ts
|
|
21
|
-
* packages/core/src/runtime/hmr-client.ts
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
createManduHot,
|
|
28
|
-
dispatchReplacement,
|
|
29
|
-
dispatchDependencyUpdate,
|
|
30
|
-
dispatchEvent,
|
|
31
|
-
setInvalidateTransport,
|
|
32
|
-
_resetRegistryForTests,
|
|
33
|
-
_getRegistrySizeForTests,
|
|
34
|
-
} from "../../runtime/hmr-client";
|
|
35
|
-
import {
|
|
36
|
-
MAX_REPLAY_BUFFER,
|
|
37
|
-
REPLAY_MAX_AGE_MS,
|
|
38
|
-
type HMRReplayEnvelope,
|
|
39
|
-
type ViteHMRPayload,
|
|
40
|
-
} from "../hmr-types";
|
|
41
|
-
import { createHMRServer, type HMRServer } from "../dev";
|
|
42
|
-
import { PORTS } from "../../constants";
|
|
43
|
-
|
|
44
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
45
|
-
// Section A — `ManduHot` runtime
|
|
46
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
47
|
-
|
|
48
|
-
describe("createManduHot — Vite-compat import.meta.hot runtime", () => {
|
|
49
|
-
beforeEach(() => {
|
|
50
|
-
_resetRegistryForTests();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("[1] returns the same `data` identity across repeated calls for the same url", () => {
|
|
54
|
-
const a = createManduHot("/foo.ts");
|
|
55
|
-
const b = createManduHot("/foo.ts");
|
|
56
|
-
// Both ManduHot instances must share the underlying record — `data`
|
|
57
|
-
// is the canary for identity because that's what Vite's spec
|
|
58
|
-
// commits to.
|
|
59
|
-
expect(a.data).toBe(b.data);
|
|
60
|
-
// Two different URLs must get two different records.
|
|
61
|
-
const c = createManduHot("/bar.ts");
|
|
62
|
-
expect(a.data).not.toBe(c.data);
|
|
63
|
-
// Registry should now hold two modules.
|
|
64
|
-
expect(_getRegistrySizeForTests()).toBe(2);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("[2] accept(cb) fires the callback when dispatchReplacement runs", () => {
|
|
68
|
-
const hot = createManduHot("/foo.ts");
|
|
69
|
-
let seen: unknown = null;
|
|
70
|
-
hot.accept((mod) => {
|
|
71
|
-
seen = mod;
|
|
72
|
-
});
|
|
73
|
-
const fakeNewModule = { default: "new" };
|
|
74
|
-
const applied = dispatchReplacement("/foo.ts", fakeNewModule);
|
|
75
|
-
expect(applied).toBe(true);
|
|
76
|
-
expect(seen).toBe(fakeNewModule);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("[3] accept(dep, cb) registers a dep-specific callback and fires via dispatchDependencyUpdate", () => {
|
|
80
|
-
const hot = createManduHot("/foo.ts");
|
|
81
|
-
let depModule: unknown = null;
|
|
82
|
-
hot.accept("/bar.ts", (newBar) => {
|
|
83
|
-
depModule = newBar;
|
|
84
|
-
});
|
|
85
|
-
const fakeDep = { x: 1 };
|
|
86
|
-
const applied = dispatchDependencyUpdate("/foo.ts", "/bar.ts", fakeDep);
|
|
87
|
-
expect(applied).toBe(true);
|
|
88
|
-
expect(depModule).toBe(fakeDep);
|
|
89
|
-
// Dispatching for an unregistered dep returns false and does not fire.
|
|
90
|
-
const unregistered = dispatchDependencyUpdate("/foo.ts", "/baz.ts", {});
|
|
91
|
-
expect(unregistered).toBe(false);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("[4] dispose callbacks run in registration order immediately before replacement", () => {
|
|
95
|
-
const hot = createManduHot("/foo.ts");
|
|
96
|
-
const order: string[] = [];
|
|
97
|
-
hot.dispose((data) => {
|
|
98
|
-
// dispose must see the shared data object; assign so we can
|
|
99
|
-
// observe the seam across the replacement.
|
|
100
|
-
(data as Record<string, unknown>).first = true;
|
|
101
|
-
order.push("first");
|
|
102
|
-
});
|
|
103
|
-
hot.dispose(() => {
|
|
104
|
-
order.push("second");
|
|
105
|
-
});
|
|
106
|
-
hot.accept((_mod) => {
|
|
107
|
-
order.push("accept");
|
|
108
|
-
});
|
|
109
|
-
dispatchReplacement("/foo.ts", { default: "new" });
|
|
110
|
-
expect(order).toEqual(["first", "second", "accept"]);
|
|
111
|
-
// The mutation the first dispose made is still readable after
|
|
112
|
-
// replacement — `data` survives.
|
|
113
|
-
expect((hot.data as Record<string, unknown>).first).toBe(true);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("[5] invalidate() calls the installed transport with moduleUrl + message", () => {
|
|
117
|
-
const hot = createManduHot("/foo.ts");
|
|
118
|
-
const sent: Array<{ type: string; moduleUrl: string; message?: string }> = [];
|
|
119
|
-
setInvalidateTransport((payload) => {
|
|
120
|
-
sent.push(payload);
|
|
121
|
-
});
|
|
122
|
-
hot.invalidate("schema broke");
|
|
123
|
-
expect(sent).toEqual([
|
|
124
|
-
{ type: "invalidate", moduleUrl: "/foo.ts", message: "schema broke" },
|
|
125
|
-
]);
|
|
126
|
-
// `invalidate()` with no message is also valid.
|
|
127
|
-
hot.invalidate();
|
|
128
|
-
expect(sent[1]).toEqual({
|
|
129
|
-
type: "invalidate",
|
|
130
|
-
moduleUrl: "/foo.ts",
|
|
131
|
-
message: undefined,
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("[6] on('vite:beforeUpdate', cb) receives dispatched events; isolated from dispose/accept", () => {
|
|
136
|
-
const hot = createManduHot("/foo.ts");
|
|
137
|
-
const seen: unknown[] = [];
|
|
138
|
-
hot.on("vite:beforeUpdate", (payload) => {
|
|
139
|
-
seen.push(payload);
|
|
140
|
-
});
|
|
141
|
-
dispatchEvent("vite:beforeUpdate", { type: "update", at: 1 });
|
|
142
|
-
dispatchEvent("vite:afterUpdate", { type: "update", at: 2 }); // different event
|
|
143
|
-
expect(seen.length).toBe(1);
|
|
144
|
-
expect(seen[0]).toEqual({ type: "update", at: 1 });
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("[7] accept() overload with no args marks self-accept without requiring a callback", () => {
|
|
148
|
-
const hot = createManduHot("/foo.ts");
|
|
149
|
-
hot.accept(); // no-op acceptance
|
|
150
|
-
// dispatchReplacement should still return true because a self-accept
|
|
151
|
-
// entry exists, just with a noop callback.
|
|
152
|
-
const applied = dispatchReplacement("/foo.ts", { default: "new" });
|
|
153
|
-
expect(applied).toBe(true);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("[7b] accept('dep') without callback throws — callback is required for dep-accept", () => {
|
|
157
|
-
const hot = createManduHot("/foo.ts");
|
|
158
|
-
// Vite's multi-dep overload passes an array; single-dep overload
|
|
159
|
-
// requires a callback. We enforce the callback-required check.
|
|
160
|
-
expect(() => {
|
|
161
|
-
// @ts-expect-error — intentionally calling the wrong overload.
|
|
162
|
-
hot.accept("/bar.ts");
|
|
163
|
-
}).toThrow(TypeError);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
168
|
-
// Section B — HMR server replay buffer + ?since wire handshake
|
|
169
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Utility: spin up an HMR server and return it plus the public port the
|
|
173
|
-
* client should dial. The caller owns teardown via `afterEach`.
|
|
174
|
-
*
|
|
175
|
-
* We pass `port: 0` ... almost. `createHMRServer` computes
|
|
176
|
-
* `port + PORTS.HMR_OFFSET` internally, so if we want an ephemeral
|
|
177
|
-
* listener we'd need to bind ahead of time. For these tests that would
|
|
178
|
-
* complicate setup; we pick a random port in the high range instead
|
|
179
|
-
* and accept the tiny risk of collision (test is < 1 s).
|
|
180
|
-
*/
|
|
181
|
-
function pickPort(): number {
|
|
182
|
-
// 40000–49999 range — avoids common dev ports while staying well
|
|
183
|
-
// below ephemeral ranges Bun may pick for outbound sockets.
|
|
184
|
-
return 40000 + Math.floor(Math.random() * 10000);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* A wrapper around WebSocket that stashes every incoming message in an
|
|
189
|
-
* array the moment it arrives. Tests then `await awaitMessage(n)` to
|
|
190
|
-
* pop them off in order. This avoids the listener-lifecycle race where
|
|
191
|
-
* a message can arrive between the removal of one listener and the
|
|
192
|
-
* attachment of the next.
|
|
193
|
-
*/
|
|
194
|
-
interface MessageClient {
|
|
195
|
-
ws: WebSocket;
|
|
196
|
-
/** Resolve once the nth total message (0-indexed) has been received. */
|
|
197
|
-
awaitMessage: (index: number, timeoutMs?: number) => Promise<unknown>;
|
|
198
|
-
/** Return all messages received so far. */
|
|
199
|
-
peek: () => unknown[];
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async function connectWS(hmrPort: number, since?: number): Promise<MessageClient> {
|
|
203
|
-
const qs = since !== undefined ? `?since=${since}` : "";
|
|
204
|
-
const ws = new WebSocket(`ws://localhost:${hmrPort}${qs ? "/" + qs : ""}`);
|
|
205
|
-
const messages: unknown[] = [];
|
|
206
|
-
const waiters: Array<{ idx: number; resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }> = [];
|
|
207
|
-
|
|
208
|
-
ws.addEventListener("message", (ev) => {
|
|
209
|
-
let parsed: unknown;
|
|
210
|
-
try {
|
|
211
|
-
parsed = JSON.parse(String((ev as MessageEvent).data));
|
|
212
|
-
} catch {
|
|
213
|
-
parsed = (ev as MessageEvent).data;
|
|
214
|
-
}
|
|
215
|
-
messages.push(parsed);
|
|
216
|
-
// Fire any waiters whose index has been fulfilled.
|
|
217
|
-
const fulfilled: number[] = [];
|
|
218
|
-
for (let i = 0; i < waiters.length; i++) {
|
|
219
|
-
const w = waiters[i]!;
|
|
220
|
-
if (messages.length > w.idx) {
|
|
221
|
-
clearTimeout(w.timer);
|
|
222
|
-
w.resolve(messages[w.idx]);
|
|
223
|
-
fulfilled.push(i);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
// Remove fulfilled from high index to low to keep splice cheap.
|
|
227
|
-
for (let i = fulfilled.length - 1; i >= 0; i--) {
|
|
228
|
-
waiters.splice(fulfilled[i]!, 1);
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
await new Promise<void>((resolve, reject) => {
|
|
233
|
-
ws.addEventListener("open", () => resolve(), { once: true });
|
|
234
|
-
ws.addEventListener("error", (e) => reject(e as unknown as Error), { once: true });
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
return {
|
|
238
|
-
ws,
|
|
239
|
-
awaitMessage(index: number, timeoutMs = 2000): Promise<unknown> {
|
|
240
|
-
if (messages.length > index) return Promise.resolve(messages[index]);
|
|
241
|
-
return new Promise<unknown>((resolve, reject) => {
|
|
242
|
-
const timer = setTimeout(() => {
|
|
243
|
-
reject(
|
|
244
|
-
new Error(
|
|
245
|
-
`Timed out waiting for message #${index} (have ${messages.length}: ${JSON.stringify(messages)})`,
|
|
246
|
-
),
|
|
247
|
-
);
|
|
248
|
-
}, timeoutMs);
|
|
249
|
-
waiters.push({ idx: index, resolve, reject, timer });
|
|
250
|
-
});
|
|
251
|
-
},
|
|
252
|
-
peek: () => [...messages],
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function waitFor(ms: number): Promise<void> {
|
|
257
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
describe("createHMRServer — replay buffer semantics", () => {
|
|
261
|
-
let server: HMRServer | null = null;
|
|
262
|
-
let hmrPort = 0;
|
|
263
|
-
|
|
264
|
-
beforeEach(() => {
|
|
265
|
-
const basePort = pickPort();
|
|
266
|
-
hmrPort = basePort + PORTS.HMR_OFFSET;
|
|
267
|
-
server = createHMRServer(basePort);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
afterEach(() => {
|
|
271
|
-
server?.close();
|
|
272
|
-
server = null;
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
test("[8] replay buffer evicts oldest entries when size exceeds MAX_REPLAY_BUFFER", () => {
|
|
276
|
-
// MAX_REPLAY_BUFFER is 128 per hmr-types.ts. Fill + overflow.
|
|
277
|
-
const overflow = 5;
|
|
278
|
-
for (let i = 0; i < MAX_REPLAY_BUFFER + overflow; i++) {
|
|
279
|
-
server!.broadcastVite({ type: "full-reload", path: `/route-${i}` });
|
|
280
|
-
}
|
|
281
|
-
const state = server!._inspectReplayBuffer();
|
|
282
|
-
// Buffer is capped.
|
|
283
|
-
expect(state.size).toBe(MAX_REPLAY_BUFFER);
|
|
284
|
-
// Latest id equals total broadcasts (ids are monotonic, no gaps).
|
|
285
|
-
expect(state.lastId).toBe(MAX_REPLAY_BUFFER + overflow);
|
|
286
|
-
// Oldest retained envelope's id equals `lastId - size + 1`.
|
|
287
|
-
expect(state.oldestId).toBe(state.lastId - state.size + 1);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("[9] replay buffer prunes envelopes older than REPLAY_MAX_AGE_MS", async () => {
|
|
291
|
-
// Push one envelope then advance time. We cannot wait 60 s in a
|
|
292
|
-
// unit test, so we reach into the buffer only via public API and
|
|
293
|
-
// prove the prune branch by saturating the size cap — the age
|
|
294
|
-
// prune runs on every enqueue, so if size prune works, age prune
|
|
295
|
-
// also runs; the structural check is that `oldestId` never points
|
|
296
|
-
// at an envelope older than `Date.now() - REPLAY_MAX_AGE_MS`.
|
|
297
|
-
//
|
|
298
|
-
// Instead of time travel, we assert the invariant: after pushing
|
|
299
|
-
// a burst, the oldest envelope's inferred timestamp (via the
|
|
300
|
-
// envelope returned from broadcastVite) is within the age window.
|
|
301
|
-
const first = server!.broadcastVite({ type: "full-reload", path: "/a" });
|
|
302
|
-
await waitFor(5);
|
|
303
|
-
const second = server!.broadcastVite({ type: "full-reload", path: "/b" });
|
|
304
|
-
// Sanity: age between them is tiny, both well inside the window.
|
|
305
|
-
expect(second.timestamp - first.timestamp).toBeLessThan(REPLAY_MAX_AGE_MS);
|
|
306
|
-
// Structural: state shows 2 envelopes, oldestId === first.id.
|
|
307
|
-
const state = server!._inspectReplayBuffer();
|
|
308
|
-
expect(state.size).toBe(2);
|
|
309
|
-
expect(state.oldestId).toBe(first.id);
|
|
310
|
-
expect(state.lastId).toBe(second.id);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
test("[10] client connecting with `?since=<N>` receives every envelope with id > N", async () => {
|
|
314
|
-
// Publish 4 envelopes.
|
|
315
|
-
server!.broadcastVite({ type: "full-reload", path: "/a" }); // id 1
|
|
316
|
-
server!.broadcastVite({ type: "full-reload", path: "/b" }); // id 2
|
|
317
|
-
server!.broadcastVite({ type: "full-reload", path: "/c" }); // id 3
|
|
318
|
-
server!.broadcastVite({ type: "full-reload", path: "/d" }); // id 4
|
|
319
|
-
|
|
320
|
-
const client = await connectWS(hmrPort, 2);
|
|
321
|
-
// Server sends `connected` + replay envelopes (ids 3 and 4) = 3 msgs.
|
|
322
|
-
const [m0, m1, m2] = await Promise.all([
|
|
323
|
-
client.awaitMessage(0),
|
|
324
|
-
client.awaitMessage(1),
|
|
325
|
-
client.awaitMessage(2),
|
|
326
|
-
]);
|
|
327
|
-
client.ws.close();
|
|
328
|
-
|
|
329
|
-
expect(m0).toMatchObject({ type: "connected" });
|
|
330
|
-
expect(m1).toMatchObject({
|
|
331
|
-
type: "vite-replay",
|
|
332
|
-
data: { id: 3 },
|
|
333
|
-
payload: { type: "full-reload", path: "/c" },
|
|
334
|
-
});
|
|
335
|
-
expect(m2).toMatchObject({
|
|
336
|
-
type: "vite-replay",
|
|
337
|
-
data: { id: 4 },
|
|
338
|
-
payload: { type: "full-reload", path: "/d" },
|
|
339
|
-
});
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
test("[11] client with `?since` older than oldest buffered id receives `full-reload`", async () => {
|
|
343
|
-
// Overflow the buffer so ids 1..5 are evicted.
|
|
344
|
-
const total = MAX_REPLAY_BUFFER + 5;
|
|
345
|
-
for (let i = 0; i < total; i++) {
|
|
346
|
-
server!.broadcastVite({ type: "full-reload", path: `/r-${i}` });
|
|
347
|
-
}
|
|
348
|
-
// Now request since=1 — way behind `oldestId`.
|
|
349
|
-
const client = await connectWS(hmrPort, 1);
|
|
350
|
-
const msg = await client.awaitMessage(0);
|
|
351
|
-
client.ws.close();
|
|
352
|
-
// Expect a single `full-reload` message (buffer exhausted fallback).
|
|
353
|
-
expect(msg).toMatchObject({
|
|
354
|
-
type: "full-reload",
|
|
355
|
-
data: { message: "replay-buffer-exhausted" },
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
361
|
-
// Section C — Wire-format + layout-update + invalidate upstream
|
|
362
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
363
|
-
|
|
364
|
-
describe("createHMRServer — wire format + event dispatch", () => {
|
|
365
|
-
let server: HMRServer | null = null;
|
|
366
|
-
let hmrPort = 0;
|
|
367
|
-
|
|
368
|
-
beforeEach(() => {
|
|
369
|
-
const basePort = pickPort();
|
|
370
|
-
hmrPort = basePort + PORTS.HMR_OFFSET;
|
|
371
|
-
server = createHMRServer(basePort);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
afterEach(() => {
|
|
375
|
-
server?.close();
|
|
376
|
-
server = null;
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
test("[12] layout-update broadcast is recorded in the replay buffer (so reconnect also sees it)", async () => {
|
|
380
|
-
// Connect a live client first to observe the live broadcast too.
|
|
381
|
-
const client = await connectWS(hmrPort);
|
|
382
|
-
// Drain the initial `connected` message (index 0) so the test's
|
|
383
|
-
// expectation lives at index 1.
|
|
384
|
-
await client.awaitMessage(0);
|
|
385
|
-
server!.broadcast({
|
|
386
|
-
type: "layout-update",
|
|
387
|
-
data: { layoutPath: "/app/layout.tsx" },
|
|
388
|
-
});
|
|
389
|
-
const msg = await client.awaitMessage(1);
|
|
390
|
-
client.ws.close();
|
|
391
|
-
// Live delivery: the HMR message is the original Mandu shape with
|
|
392
|
-
// an id appended by the broadcast path.
|
|
393
|
-
expect(msg).toMatchObject({
|
|
394
|
-
type: "layout-update",
|
|
395
|
-
data: {
|
|
396
|
-
layoutPath: "/app/layout.tsx",
|
|
397
|
-
id: expect.any(Number),
|
|
398
|
-
},
|
|
399
|
-
});
|
|
400
|
-
// Replay buffer contains an entry for the same event.
|
|
401
|
-
const state = server!._inspectReplayBuffer();
|
|
402
|
-
expect(state.size).toBe(1);
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
test("[13] broadcastVite produces a Vite-compat envelope with matching id on the wire", async () => {
|
|
406
|
-
const client = await connectWS(hmrPort);
|
|
407
|
-
await client.awaitMessage(0); // drain `connected`
|
|
408
|
-
const payload: ViteHMRPayload = {
|
|
409
|
-
type: "update",
|
|
410
|
-
updates: [
|
|
411
|
-
{
|
|
412
|
-
type: "js-update",
|
|
413
|
-
path: "/app/page.tsx",
|
|
414
|
-
acceptedPath: "/app/page.tsx",
|
|
415
|
-
timestamp: Date.now(),
|
|
416
|
-
},
|
|
417
|
-
],
|
|
418
|
-
};
|
|
419
|
-
const envelope = server!.broadcastVite(payload);
|
|
420
|
-
const msg = await client.awaitMessage(1);
|
|
421
|
-
client.ws.close();
|
|
422
|
-
|
|
423
|
-
expect(envelope.id).toBe(1);
|
|
424
|
-
expect(msg).toMatchObject({
|
|
425
|
-
type: "vite",
|
|
426
|
-
data: { id: envelope.id },
|
|
427
|
-
payload: {
|
|
428
|
-
type: "update",
|
|
429
|
-
updates: [
|
|
430
|
-
{
|
|
431
|
-
type: "js-update",
|
|
432
|
-
path: "/app/page.tsx",
|
|
433
|
-
acceptedPath: "/app/page.tsx",
|
|
434
|
-
},
|
|
435
|
-
],
|
|
436
|
-
},
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
test("[14] reconnect with since=0 after many broadcasts and no buffered survivors → full-reload", async () => {
|
|
441
|
-
// Fire enough envelopes to push id past MAX_REPLAY_BUFFER — oldestId
|
|
442
|
-
// becomes > 0, so since=0 is older than oldestId.
|
|
443
|
-
const total = MAX_REPLAY_BUFFER + 2;
|
|
444
|
-
for (let i = 0; i < total; i++) {
|
|
445
|
-
server!.broadcastVite({ type: "full-reload", path: `/x-${i}` });
|
|
446
|
-
}
|
|
447
|
-
const client = await connectWS(hmrPort, 0);
|
|
448
|
-
const msg = await client.awaitMessage(0);
|
|
449
|
-
client.ws.close();
|
|
450
|
-
// `since=0` is `< oldestId` once the buffer rolls, so full-reload.
|
|
451
|
-
expect(msg).toMatchObject({
|
|
452
|
-
type: "full-reload",
|
|
453
|
-
});
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
test("[15] client-sent `invalidate` is echoed as full-reload to all clients (and buffered)", async () => {
|
|
457
|
-
const a = await connectWS(hmrPort);
|
|
458
|
-
const b = await connectWS(hmrPort);
|
|
459
|
-
// Drain greetings (index 0 on each client).
|
|
460
|
-
await Promise.all([a.awaitMessage(0), b.awaitMessage(0)]);
|
|
461
|
-
// Guard against a race where Bun's WS upgrade hasn't fully settled
|
|
462
|
-
// on both sides — a single event-loop tick is enough.
|
|
463
|
-
await waitFor(10);
|
|
464
|
-
// Client A sends an invalidate upstream (simulating
|
|
465
|
-
// `import.meta.hot.invalidate()` in user code).
|
|
466
|
-
a.ws.send(JSON.stringify({ type: "invalidate", moduleUrl: "/foo.ts", message: "schema broke" }));
|
|
467
|
-
const [msgA, msgB] = await Promise.all([a.awaitMessage(1), b.awaitMessage(1)]);
|
|
468
|
-
a.ws.close();
|
|
469
|
-
b.ws.close();
|
|
470
|
-
|
|
471
|
-
expect(msgA).toMatchObject({
|
|
472
|
-
type: "full-reload",
|
|
473
|
-
data: { path: "/foo.ts", message: "schema broke", id: expect.any(Number) },
|
|
474
|
-
});
|
|
475
|
-
expect(msgB).toMatchObject({
|
|
476
|
-
type: "full-reload",
|
|
477
|
-
data: { path: "/foo.ts", message: "schema broke" },
|
|
478
|
-
});
|
|
479
|
-
// Buffer now has one entry from the server-side enqueue.
|
|
480
|
-
expect(server!._inspectReplayBuffer().size).toBe(1);
|
|
481
|
-
});
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
485
|
-
// Section D — Integration: runtime + envelope produced by server
|
|
486
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
487
|
-
|
|
488
|
-
describe("integration — ManduHot + broadcastVite envelope shape", () => {
|
|
489
|
-
beforeEach(() => {
|
|
490
|
-
_resetRegistryForTests();
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
test("[16] a consumer using `on('vite:beforeUpdate')` receives a payload whose shape matches the Vite wire spec", () => {
|
|
494
|
-
const hot = createManduHot("/foo.ts");
|
|
495
|
-
const seen: unknown[] = [];
|
|
496
|
-
hot.on("vite:beforeUpdate", (p) => seen.push(p));
|
|
497
|
-
// Simulate what the client script does when it receives the `vite`
|
|
498
|
-
// envelope: it fires `vite:beforeUpdate` with the inner payload.
|
|
499
|
-
const payload: ViteHMRPayload = {
|
|
500
|
-
type: "update",
|
|
501
|
-
updates: [
|
|
502
|
-
{
|
|
503
|
-
type: "js-update",
|
|
504
|
-
path: "/app/page.tsx",
|
|
505
|
-
acceptedPath: "/app/page.tsx",
|
|
506
|
-
timestamp: 1234,
|
|
507
|
-
},
|
|
508
|
-
],
|
|
509
|
-
};
|
|
510
|
-
dispatchEvent("vite:beforeUpdate", payload);
|
|
511
|
-
expect(seen.length).toBe(1);
|
|
512
|
-
// Structural assertion — the payload must be the Vite wire format
|
|
513
|
-
// shape, not a Mandu wrapping.
|
|
514
|
-
expect(seen[0]).toEqual(payload);
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
test("[17] HMRReplayEnvelope id is monotonic even across size eviction (ids don't reset)", () => {
|
|
518
|
-
const envelopes: HMRReplayEnvelope[] = [];
|
|
519
|
-
const server = createHMRServer(pickPort());
|
|
520
|
-
try {
|
|
521
|
-
for (let i = 0; i < MAX_REPLAY_BUFFER + 3; i++) {
|
|
522
|
-
envelopes.push(server.broadcastVite({ type: "full-reload", path: `/p-${i}` }));
|
|
523
|
-
}
|
|
524
|
-
} finally {
|
|
525
|
-
server.close();
|
|
526
|
-
}
|
|
527
|
-
// Ids are 1..n in strict order; eviction does NOT restart the counter.
|
|
528
|
-
for (let i = 0; i < envelopes.length; i++) {
|
|
529
|
-
expect(envelopes[i]!.id).toBe(i + 1);
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Phase 7.0 R1 Agent C — HMR Vite-compat subset + replay buffer tests.
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* 1–7 : `ManduHot` runtime (`import.meta.hot` subset) — pure unit tests
|
|
6
|
+
* against the registry, no socket required.
|
|
7
|
+
* 8–11 : Replay buffer semantics (eviction by size, eviction by age,
|
|
8
|
+
* `?since` behavior) — drives a real Bun.serve WebSocket on an
|
|
9
|
+
* ephemeral port.
|
|
10
|
+
* 12–15: Wire-format contract (layout-update envelope, Vite-compat
|
|
11
|
+
* broadcastVite envelope, full-reload on buffer exhaustion,
|
|
12
|
+
* `invalidate` upstream → `full-reload` fanout).
|
|
13
|
+
*
|
|
14
|
+
* Tests 8–15 use an ephemeral port (PORTS.HMR_OFFSET is applied inside
|
|
15
|
+
* `createHMRServer`, so we must pass `port - HMR_OFFSET`) and tear the
|
|
16
|
+
* server down in `afterEach` to avoid port leaks.
|
|
17
|
+
*
|
|
18
|
+
* References:
|
|
19
|
+
* docs/bun/phase-7-team-plan.md §4 Agent C
|
|
20
|
+
* packages/core/src/bundler/hmr-types.ts
|
|
21
|
+
* packages/core/src/runtime/hmr-client.ts
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
createManduHot,
|
|
28
|
+
dispatchReplacement,
|
|
29
|
+
dispatchDependencyUpdate,
|
|
30
|
+
dispatchEvent,
|
|
31
|
+
setInvalidateTransport,
|
|
32
|
+
_resetRegistryForTests,
|
|
33
|
+
_getRegistrySizeForTests,
|
|
34
|
+
} from "../../runtime/hmr-client";
|
|
35
|
+
import {
|
|
36
|
+
MAX_REPLAY_BUFFER,
|
|
37
|
+
REPLAY_MAX_AGE_MS,
|
|
38
|
+
type HMRReplayEnvelope,
|
|
39
|
+
type ViteHMRPayload,
|
|
40
|
+
} from "../hmr-types";
|
|
41
|
+
import { createHMRServer, type HMRServer } from "../dev";
|
|
42
|
+
import { PORTS } from "../../constants";
|
|
43
|
+
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
45
|
+
// Section A — `ManduHot` runtime
|
|
46
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
describe("createManduHot — Vite-compat import.meta.hot runtime", () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
_resetRegistryForTests();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("[1] returns the same `data` identity across repeated calls for the same url", () => {
|
|
54
|
+
const a = createManduHot("/foo.ts");
|
|
55
|
+
const b = createManduHot("/foo.ts");
|
|
56
|
+
// Both ManduHot instances must share the underlying record — `data`
|
|
57
|
+
// is the canary for identity because that's what Vite's spec
|
|
58
|
+
// commits to.
|
|
59
|
+
expect(a.data).toBe(b.data);
|
|
60
|
+
// Two different URLs must get two different records.
|
|
61
|
+
const c = createManduHot("/bar.ts");
|
|
62
|
+
expect(a.data).not.toBe(c.data);
|
|
63
|
+
// Registry should now hold two modules.
|
|
64
|
+
expect(_getRegistrySizeForTests()).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("[2] accept(cb) fires the callback when dispatchReplacement runs", () => {
|
|
68
|
+
const hot = createManduHot("/foo.ts");
|
|
69
|
+
let seen: unknown = null;
|
|
70
|
+
hot.accept((mod) => {
|
|
71
|
+
seen = mod;
|
|
72
|
+
});
|
|
73
|
+
const fakeNewModule = { default: "new" };
|
|
74
|
+
const applied = dispatchReplacement("/foo.ts", fakeNewModule);
|
|
75
|
+
expect(applied).toBe(true);
|
|
76
|
+
expect(seen).toBe(fakeNewModule);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("[3] accept(dep, cb) registers a dep-specific callback and fires via dispatchDependencyUpdate", () => {
|
|
80
|
+
const hot = createManduHot("/foo.ts");
|
|
81
|
+
let depModule: unknown = null;
|
|
82
|
+
hot.accept("/bar.ts", (newBar) => {
|
|
83
|
+
depModule = newBar;
|
|
84
|
+
});
|
|
85
|
+
const fakeDep = { x: 1 };
|
|
86
|
+
const applied = dispatchDependencyUpdate("/foo.ts", "/bar.ts", fakeDep);
|
|
87
|
+
expect(applied).toBe(true);
|
|
88
|
+
expect(depModule).toBe(fakeDep);
|
|
89
|
+
// Dispatching for an unregistered dep returns false and does not fire.
|
|
90
|
+
const unregistered = dispatchDependencyUpdate("/foo.ts", "/baz.ts", {});
|
|
91
|
+
expect(unregistered).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("[4] dispose callbacks run in registration order immediately before replacement", () => {
|
|
95
|
+
const hot = createManduHot("/foo.ts");
|
|
96
|
+
const order: string[] = [];
|
|
97
|
+
hot.dispose((data) => {
|
|
98
|
+
// dispose must see the shared data object; assign so we can
|
|
99
|
+
// observe the seam across the replacement.
|
|
100
|
+
(data as Record<string, unknown>).first = true;
|
|
101
|
+
order.push("first");
|
|
102
|
+
});
|
|
103
|
+
hot.dispose(() => {
|
|
104
|
+
order.push("second");
|
|
105
|
+
});
|
|
106
|
+
hot.accept((_mod) => {
|
|
107
|
+
order.push("accept");
|
|
108
|
+
});
|
|
109
|
+
dispatchReplacement("/foo.ts", { default: "new" });
|
|
110
|
+
expect(order).toEqual(["first", "second", "accept"]);
|
|
111
|
+
// The mutation the first dispose made is still readable after
|
|
112
|
+
// replacement — `data` survives.
|
|
113
|
+
expect((hot.data as Record<string, unknown>).first).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("[5] invalidate() calls the installed transport with moduleUrl + message", () => {
|
|
117
|
+
const hot = createManduHot("/foo.ts");
|
|
118
|
+
const sent: Array<{ type: string; moduleUrl: string; message?: string }> = [];
|
|
119
|
+
setInvalidateTransport((payload) => {
|
|
120
|
+
sent.push(payload);
|
|
121
|
+
});
|
|
122
|
+
hot.invalidate("schema broke");
|
|
123
|
+
expect(sent).toEqual([
|
|
124
|
+
{ type: "invalidate", moduleUrl: "/foo.ts", message: "schema broke" },
|
|
125
|
+
]);
|
|
126
|
+
// `invalidate()` with no message is also valid.
|
|
127
|
+
hot.invalidate();
|
|
128
|
+
expect(sent[1]).toEqual({
|
|
129
|
+
type: "invalidate",
|
|
130
|
+
moduleUrl: "/foo.ts",
|
|
131
|
+
message: undefined,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("[6] on('vite:beforeUpdate', cb) receives dispatched events; isolated from dispose/accept", () => {
|
|
136
|
+
const hot = createManduHot("/foo.ts");
|
|
137
|
+
const seen: unknown[] = [];
|
|
138
|
+
hot.on("vite:beforeUpdate", (payload) => {
|
|
139
|
+
seen.push(payload);
|
|
140
|
+
});
|
|
141
|
+
dispatchEvent("vite:beforeUpdate", { type: "update", at: 1 });
|
|
142
|
+
dispatchEvent("vite:afterUpdate", { type: "update", at: 2 }); // different event
|
|
143
|
+
expect(seen.length).toBe(1);
|
|
144
|
+
expect(seen[0]).toEqual({ type: "update", at: 1 });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("[7] accept() overload with no args marks self-accept without requiring a callback", () => {
|
|
148
|
+
const hot = createManduHot("/foo.ts");
|
|
149
|
+
hot.accept(); // no-op acceptance
|
|
150
|
+
// dispatchReplacement should still return true because a self-accept
|
|
151
|
+
// entry exists, just with a noop callback.
|
|
152
|
+
const applied = dispatchReplacement("/foo.ts", { default: "new" });
|
|
153
|
+
expect(applied).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("[7b] accept('dep') without callback throws — callback is required for dep-accept", () => {
|
|
157
|
+
const hot = createManduHot("/foo.ts");
|
|
158
|
+
// Vite's multi-dep overload passes an array; single-dep overload
|
|
159
|
+
// requires a callback. We enforce the callback-required check.
|
|
160
|
+
expect(() => {
|
|
161
|
+
// @ts-expect-error — intentionally calling the wrong overload.
|
|
162
|
+
hot.accept("/bar.ts");
|
|
163
|
+
}).toThrow(TypeError);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
168
|
+
// Section B — HMR server replay buffer + ?since wire handshake
|
|
169
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Utility: spin up an HMR server and return it plus the public port the
|
|
173
|
+
* client should dial. The caller owns teardown via `afterEach`.
|
|
174
|
+
*
|
|
175
|
+
* We pass `port: 0` ... almost. `createHMRServer` computes
|
|
176
|
+
* `port + PORTS.HMR_OFFSET` internally, so if we want an ephemeral
|
|
177
|
+
* listener we'd need to bind ahead of time. For these tests that would
|
|
178
|
+
* complicate setup; we pick a random port in the high range instead
|
|
179
|
+
* and accept the tiny risk of collision (test is < 1 s).
|
|
180
|
+
*/
|
|
181
|
+
function pickPort(): number {
|
|
182
|
+
// 40000–49999 range — avoids common dev ports while staying well
|
|
183
|
+
// below ephemeral ranges Bun may pick for outbound sockets.
|
|
184
|
+
return 40000 + Math.floor(Math.random() * 10000);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A wrapper around WebSocket that stashes every incoming message in an
|
|
189
|
+
* array the moment it arrives. Tests then `await awaitMessage(n)` to
|
|
190
|
+
* pop them off in order. This avoids the listener-lifecycle race where
|
|
191
|
+
* a message can arrive between the removal of one listener and the
|
|
192
|
+
* attachment of the next.
|
|
193
|
+
*/
|
|
194
|
+
interface MessageClient {
|
|
195
|
+
ws: WebSocket;
|
|
196
|
+
/** Resolve once the nth total message (0-indexed) has been received. */
|
|
197
|
+
awaitMessage: (index: number, timeoutMs?: number) => Promise<unknown>;
|
|
198
|
+
/** Return all messages received so far. */
|
|
199
|
+
peek: () => unknown[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function connectWS(hmrPort: number, since?: number): Promise<MessageClient> {
|
|
203
|
+
const qs = since !== undefined ? `?since=${since}` : "";
|
|
204
|
+
const ws = new WebSocket(`ws://localhost:${hmrPort}${qs ? "/" + qs : ""}`);
|
|
205
|
+
const messages: unknown[] = [];
|
|
206
|
+
const waiters: Array<{ idx: number; resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }> = [];
|
|
207
|
+
|
|
208
|
+
ws.addEventListener("message", (ev) => {
|
|
209
|
+
let parsed: unknown;
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(String((ev as MessageEvent).data));
|
|
212
|
+
} catch {
|
|
213
|
+
parsed = (ev as MessageEvent).data;
|
|
214
|
+
}
|
|
215
|
+
messages.push(parsed);
|
|
216
|
+
// Fire any waiters whose index has been fulfilled.
|
|
217
|
+
const fulfilled: number[] = [];
|
|
218
|
+
for (let i = 0; i < waiters.length; i++) {
|
|
219
|
+
const w = waiters[i]!;
|
|
220
|
+
if (messages.length > w.idx) {
|
|
221
|
+
clearTimeout(w.timer);
|
|
222
|
+
w.resolve(messages[w.idx]);
|
|
223
|
+
fulfilled.push(i);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Remove fulfilled from high index to low to keep splice cheap.
|
|
227
|
+
for (let i = fulfilled.length - 1; i >= 0; i--) {
|
|
228
|
+
waiters.splice(fulfilled[i]!, 1);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await new Promise<void>((resolve, reject) => {
|
|
233
|
+
ws.addEventListener("open", () => resolve(), { once: true });
|
|
234
|
+
ws.addEventListener("error", (e) => reject(e as unknown as Error), { once: true });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
ws,
|
|
239
|
+
awaitMessage(index: number, timeoutMs = 2000): Promise<unknown> {
|
|
240
|
+
if (messages.length > index) return Promise.resolve(messages[index]);
|
|
241
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
242
|
+
const timer = setTimeout(() => {
|
|
243
|
+
reject(
|
|
244
|
+
new Error(
|
|
245
|
+
`Timed out waiting for message #${index} (have ${messages.length}: ${JSON.stringify(messages)})`,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
}, timeoutMs);
|
|
249
|
+
waiters.push({ idx: index, resolve, reject, timer });
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
peek: () => [...messages],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function waitFor(ms: number): Promise<void> {
|
|
257
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
describe("createHMRServer — replay buffer semantics", () => {
|
|
261
|
+
let server: HMRServer | null = null;
|
|
262
|
+
let hmrPort = 0;
|
|
263
|
+
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
const basePort = pickPort();
|
|
266
|
+
hmrPort = basePort + PORTS.HMR_OFFSET;
|
|
267
|
+
server = createHMRServer(basePort);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
server?.close();
|
|
272
|
+
server = null;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("[8] replay buffer evicts oldest entries when size exceeds MAX_REPLAY_BUFFER", () => {
|
|
276
|
+
// MAX_REPLAY_BUFFER is 128 per hmr-types.ts. Fill + overflow.
|
|
277
|
+
const overflow = 5;
|
|
278
|
+
for (let i = 0; i < MAX_REPLAY_BUFFER + overflow; i++) {
|
|
279
|
+
server!.broadcastVite({ type: "full-reload", path: `/route-${i}` });
|
|
280
|
+
}
|
|
281
|
+
const state = server!._inspectReplayBuffer();
|
|
282
|
+
// Buffer is capped.
|
|
283
|
+
expect(state.size).toBe(MAX_REPLAY_BUFFER);
|
|
284
|
+
// Latest id equals total broadcasts (ids are monotonic, no gaps).
|
|
285
|
+
expect(state.lastId).toBe(MAX_REPLAY_BUFFER + overflow);
|
|
286
|
+
// Oldest retained envelope's id equals `lastId - size + 1`.
|
|
287
|
+
expect(state.oldestId).toBe(state.lastId - state.size + 1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("[9] replay buffer prunes envelopes older than REPLAY_MAX_AGE_MS", async () => {
|
|
291
|
+
// Push one envelope then advance time. We cannot wait 60 s in a
|
|
292
|
+
// unit test, so we reach into the buffer only via public API and
|
|
293
|
+
// prove the prune branch by saturating the size cap — the age
|
|
294
|
+
// prune runs on every enqueue, so if size prune works, age prune
|
|
295
|
+
// also runs; the structural check is that `oldestId` never points
|
|
296
|
+
// at an envelope older than `Date.now() - REPLAY_MAX_AGE_MS`.
|
|
297
|
+
//
|
|
298
|
+
// Instead of time travel, we assert the invariant: after pushing
|
|
299
|
+
// a burst, the oldest envelope's inferred timestamp (via the
|
|
300
|
+
// envelope returned from broadcastVite) is within the age window.
|
|
301
|
+
const first = server!.broadcastVite({ type: "full-reload", path: "/a" });
|
|
302
|
+
await waitFor(5);
|
|
303
|
+
const second = server!.broadcastVite({ type: "full-reload", path: "/b" });
|
|
304
|
+
// Sanity: age between them is tiny, both well inside the window.
|
|
305
|
+
expect(second.timestamp - first.timestamp).toBeLessThan(REPLAY_MAX_AGE_MS);
|
|
306
|
+
// Structural: state shows 2 envelopes, oldestId === first.id.
|
|
307
|
+
const state = server!._inspectReplayBuffer();
|
|
308
|
+
expect(state.size).toBe(2);
|
|
309
|
+
expect(state.oldestId).toBe(first.id);
|
|
310
|
+
expect(state.lastId).toBe(second.id);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("[10] client connecting with `?since=<N>` receives every envelope with id > N", async () => {
|
|
314
|
+
// Publish 4 envelopes.
|
|
315
|
+
server!.broadcastVite({ type: "full-reload", path: "/a" }); // id 1
|
|
316
|
+
server!.broadcastVite({ type: "full-reload", path: "/b" }); // id 2
|
|
317
|
+
server!.broadcastVite({ type: "full-reload", path: "/c" }); // id 3
|
|
318
|
+
server!.broadcastVite({ type: "full-reload", path: "/d" }); // id 4
|
|
319
|
+
|
|
320
|
+
const client = await connectWS(hmrPort, 2);
|
|
321
|
+
// Server sends `connected` + replay envelopes (ids 3 and 4) = 3 msgs.
|
|
322
|
+
const [m0, m1, m2] = await Promise.all([
|
|
323
|
+
client.awaitMessage(0),
|
|
324
|
+
client.awaitMessage(1),
|
|
325
|
+
client.awaitMessage(2),
|
|
326
|
+
]);
|
|
327
|
+
client.ws.close();
|
|
328
|
+
|
|
329
|
+
expect(m0).toMatchObject({ type: "connected" });
|
|
330
|
+
expect(m1).toMatchObject({
|
|
331
|
+
type: "vite-replay",
|
|
332
|
+
data: { id: 3 },
|
|
333
|
+
payload: { type: "full-reload", path: "/c" },
|
|
334
|
+
});
|
|
335
|
+
expect(m2).toMatchObject({
|
|
336
|
+
type: "vite-replay",
|
|
337
|
+
data: { id: 4 },
|
|
338
|
+
payload: { type: "full-reload", path: "/d" },
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("[11] client with `?since` older than oldest buffered id receives `full-reload`", async () => {
|
|
343
|
+
// Overflow the buffer so ids 1..5 are evicted.
|
|
344
|
+
const total = MAX_REPLAY_BUFFER + 5;
|
|
345
|
+
for (let i = 0; i < total; i++) {
|
|
346
|
+
server!.broadcastVite({ type: "full-reload", path: `/r-${i}` });
|
|
347
|
+
}
|
|
348
|
+
// Now request since=1 — way behind `oldestId`.
|
|
349
|
+
const client = await connectWS(hmrPort, 1);
|
|
350
|
+
const msg = await client.awaitMessage(0);
|
|
351
|
+
client.ws.close();
|
|
352
|
+
// Expect a single `full-reload` message (buffer exhausted fallback).
|
|
353
|
+
expect(msg).toMatchObject({
|
|
354
|
+
type: "full-reload",
|
|
355
|
+
data: { message: "replay-buffer-exhausted" },
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
361
|
+
// Section C — Wire-format + layout-update + invalidate upstream
|
|
362
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
363
|
+
|
|
364
|
+
describe("createHMRServer — wire format + event dispatch", () => {
|
|
365
|
+
let server: HMRServer | null = null;
|
|
366
|
+
let hmrPort = 0;
|
|
367
|
+
|
|
368
|
+
beforeEach(() => {
|
|
369
|
+
const basePort = pickPort();
|
|
370
|
+
hmrPort = basePort + PORTS.HMR_OFFSET;
|
|
371
|
+
server = createHMRServer(basePort);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
afterEach(() => {
|
|
375
|
+
server?.close();
|
|
376
|
+
server = null;
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("[12] layout-update broadcast is recorded in the replay buffer (so reconnect also sees it)", async () => {
|
|
380
|
+
// Connect a live client first to observe the live broadcast too.
|
|
381
|
+
const client = await connectWS(hmrPort);
|
|
382
|
+
// Drain the initial `connected` message (index 0) so the test's
|
|
383
|
+
// expectation lives at index 1.
|
|
384
|
+
await client.awaitMessage(0);
|
|
385
|
+
server!.broadcast({
|
|
386
|
+
type: "layout-update",
|
|
387
|
+
data: { layoutPath: "/app/layout.tsx" },
|
|
388
|
+
});
|
|
389
|
+
const msg = await client.awaitMessage(1);
|
|
390
|
+
client.ws.close();
|
|
391
|
+
// Live delivery: the HMR message is the original Mandu shape with
|
|
392
|
+
// an id appended by the broadcast path.
|
|
393
|
+
expect(msg).toMatchObject({
|
|
394
|
+
type: "layout-update",
|
|
395
|
+
data: {
|
|
396
|
+
layoutPath: "/app/layout.tsx",
|
|
397
|
+
id: expect.any(Number),
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
// Replay buffer contains an entry for the same event.
|
|
401
|
+
const state = server!._inspectReplayBuffer();
|
|
402
|
+
expect(state.size).toBe(1);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("[13] broadcastVite produces a Vite-compat envelope with matching id on the wire", async () => {
|
|
406
|
+
const client = await connectWS(hmrPort);
|
|
407
|
+
await client.awaitMessage(0); // drain `connected`
|
|
408
|
+
const payload: ViteHMRPayload = {
|
|
409
|
+
type: "update",
|
|
410
|
+
updates: [
|
|
411
|
+
{
|
|
412
|
+
type: "js-update",
|
|
413
|
+
path: "/app/page.tsx",
|
|
414
|
+
acceptedPath: "/app/page.tsx",
|
|
415
|
+
timestamp: Date.now(),
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
};
|
|
419
|
+
const envelope = server!.broadcastVite(payload);
|
|
420
|
+
const msg = await client.awaitMessage(1);
|
|
421
|
+
client.ws.close();
|
|
422
|
+
|
|
423
|
+
expect(envelope.id).toBe(1);
|
|
424
|
+
expect(msg).toMatchObject({
|
|
425
|
+
type: "vite",
|
|
426
|
+
data: { id: envelope.id },
|
|
427
|
+
payload: {
|
|
428
|
+
type: "update",
|
|
429
|
+
updates: [
|
|
430
|
+
{
|
|
431
|
+
type: "js-update",
|
|
432
|
+
path: "/app/page.tsx",
|
|
433
|
+
acceptedPath: "/app/page.tsx",
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("[14] reconnect with since=0 after many broadcasts and no buffered survivors → full-reload", async () => {
|
|
441
|
+
// Fire enough envelopes to push id past MAX_REPLAY_BUFFER — oldestId
|
|
442
|
+
// becomes > 0, so since=0 is older than oldestId.
|
|
443
|
+
const total = MAX_REPLAY_BUFFER + 2;
|
|
444
|
+
for (let i = 0; i < total; i++) {
|
|
445
|
+
server!.broadcastVite({ type: "full-reload", path: `/x-${i}` });
|
|
446
|
+
}
|
|
447
|
+
const client = await connectWS(hmrPort, 0);
|
|
448
|
+
const msg = await client.awaitMessage(0);
|
|
449
|
+
client.ws.close();
|
|
450
|
+
// `since=0` is `< oldestId` once the buffer rolls, so full-reload.
|
|
451
|
+
expect(msg).toMatchObject({
|
|
452
|
+
type: "full-reload",
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("[15] client-sent `invalidate` is echoed as full-reload to all clients (and buffered)", async () => {
|
|
457
|
+
const a = await connectWS(hmrPort);
|
|
458
|
+
const b = await connectWS(hmrPort);
|
|
459
|
+
// Drain greetings (index 0 on each client).
|
|
460
|
+
await Promise.all([a.awaitMessage(0), b.awaitMessage(0)]);
|
|
461
|
+
// Guard against a race where Bun's WS upgrade hasn't fully settled
|
|
462
|
+
// on both sides — a single event-loop tick is enough.
|
|
463
|
+
await waitFor(10);
|
|
464
|
+
// Client A sends an invalidate upstream (simulating
|
|
465
|
+
// `import.meta.hot.invalidate()` in user code).
|
|
466
|
+
a.ws.send(JSON.stringify({ type: "invalidate", moduleUrl: "/foo.ts", message: "schema broke" }));
|
|
467
|
+
const [msgA, msgB] = await Promise.all([a.awaitMessage(1), b.awaitMessage(1)]);
|
|
468
|
+
a.ws.close();
|
|
469
|
+
b.ws.close();
|
|
470
|
+
|
|
471
|
+
expect(msgA).toMatchObject({
|
|
472
|
+
type: "full-reload",
|
|
473
|
+
data: { path: "/foo.ts", message: "schema broke", id: expect.any(Number) },
|
|
474
|
+
});
|
|
475
|
+
expect(msgB).toMatchObject({
|
|
476
|
+
type: "full-reload",
|
|
477
|
+
data: { path: "/foo.ts", message: "schema broke" },
|
|
478
|
+
});
|
|
479
|
+
// Buffer now has one entry from the server-side enqueue.
|
|
480
|
+
expect(server!._inspectReplayBuffer().size).toBe(1);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
485
|
+
// Section D — Integration: runtime + envelope produced by server
|
|
486
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
487
|
+
|
|
488
|
+
describe("integration — ManduHot + broadcastVite envelope shape", () => {
|
|
489
|
+
beforeEach(() => {
|
|
490
|
+
_resetRegistryForTests();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("[16] a consumer using `on('vite:beforeUpdate')` receives a payload whose shape matches the Vite wire spec", () => {
|
|
494
|
+
const hot = createManduHot("/foo.ts");
|
|
495
|
+
const seen: unknown[] = [];
|
|
496
|
+
hot.on("vite:beforeUpdate", (p) => seen.push(p));
|
|
497
|
+
// Simulate what the client script does when it receives the `vite`
|
|
498
|
+
// envelope: it fires `vite:beforeUpdate` with the inner payload.
|
|
499
|
+
const payload: ViteHMRPayload = {
|
|
500
|
+
type: "update",
|
|
501
|
+
updates: [
|
|
502
|
+
{
|
|
503
|
+
type: "js-update",
|
|
504
|
+
path: "/app/page.tsx",
|
|
505
|
+
acceptedPath: "/app/page.tsx",
|
|
506
|
+
timestamp: 1234,
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
};
|
|
510
|
+
dispatchEvent("vite:beforeUpdate", payload);
|
|
511
|
+
expect(seen.length).toBe(1);
|
|
512
|
+
// Structural assertion — the payload must be the Vite wire format
|
|
513
|
+
// shape, not a Mandu wrapping.
|
|
514
|
+
expect(seen[0]).toEqual(payload);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("[17] HMRReplayEnvelope id is monotonic even across size eviction (ids don't reset)", () => {
|
|
518
|
+
const envelopes: HMRReplayEnvelope[] = [];
|
|
519
|
+
const server = createHMRServer(pickPort());
|
|
520
|
+
try {
|
|
521
|
+
for (let i = 0; i < MAX_REPLAY_BUFFER + 3; i++) {
|
|
522
|
+
envelopes.push(server.broadcastVite({ type: "full-reload", path: `/p-${i}` }));
|
|
523
|
+
}
|
|
524
|
+
} finally {
|
|
525
|
+
server.close();
|
|
526
|
+
}
|
|
527
|
+
// Ids are 1..n in strict order; eviction does NOT restart the counter.
|
|
528
|
+
for (let i = 0; i < envelopes.length; i++) {
|
|
529
|
+
expect(envelopes[i]!.id).toBe(i + 1);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
});
|