/** * Phase 7.0 R1 Agent C — HMR Vite-compat subset + replay buffer tests. * * Coverage: * 1–7 : `ManduHot` runtime (`import.meta.hot` subset) — pure unit tests * against the registry, no socket required. * 8–11 : Replay buffer semantics (eviction by size, eviction by age, * `?since` behavior) — drives a real Bun.serve WebSocket on an * ephemeral port. * 12–15: Wire-format contract (layout-update envelope, Vite-compat * broadcastVite envelope, full-reload on buffer exhaustion, * `invalidate` upstream → `full-reload` fanout). * * Tests 8–15 use an ephemeral port (PORTS.HMR_OFFSET is applied inside * `createHMRServer`, so we must pass `port - HMR_OFFSET`) and tear the * server down in `afterEach` to avoid port leaks. * * References: * docs/bun/phase-7-team-plan.md §4 Agent C * packages/core/src/bundler/hmr-types.ts * packages/core/src/runtime/hmr-client.ts */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createManduHot, dispatchReplacement, dispatchDependencyUpdate, dispatchEvent, setInvalidateTransport, _resetRegistryForTests, _getRegistrySizeForTests, } from "../../runtime/hmr-client"; import { MAX_REPLAY_BUFFER, REPLAY_MAX_AGE_MS, type HMRReplayEnvelope, type ViteHMRPayload, } from "../hmr-types"; import { createHMRServer, type HMRServer } from "../dev"; import { PORTS } from "../../constants"; // ═══════════════════════════════════════════════════════════════════ // Section A — `ManduHot` runtime // ═══════════════════════════════════════════════════════════════════ describe("createManduHot — Vite-compat import.meta.hot runtime", () => { beforeEach(() => { _resetRegistryForTests(); }); test("[1] returns the same `data` identity across repeated calls for the same url", () => { const a = createManduHot("/foo.ts"); const b = createManduHot("/foo.ts"); // Both ManduHot instances must share the underlying record — `data` // is the canary for identity because that's what Vite's spec // commits to. expect(a.data).toBe(b.data); // Two different URLs must get two different records. const c = createManduHot("/bar.ts"); expect(a.data).not.toBe(c.data); // Registry should now hold two modules. expect(_getRegistrySizeForTests()).toBe(2); }); test("[2] accept(cb) fires the callback when dispatchReplacement runs", () => { const hot = createManduHot("/foo.ts"); let seen: unknown = null; hot.accept((mod) => { seen = mod; }); const fakeNewModule = { default: "new" }; const applied = dispatchReplacement("/foo.ts", fakeNewModule); expect(applied).toBe(true); expect(seen).toBe(fakeNewModule); }); test("[3] accept(dep, cb) registers a dep-specific callback and fires via dispatchDependencyUpdate", () => { const hot = createManduHot("/foo.ts"); let depModule: unknown = null; hot.accept("/bar.ts", (newBar) => { depModule = newBar; }); const fakeDep = { x: 1 }; const applied = dispatchDependencyUpdate("/foo.ts", "/bar.ts", fakeDep); expect(applied).toBe(true); expect(depModule).toBe(fakeDep); // Dispatching for an unregistered dep returns false and does not fire. const unregistered = dispatchDependencyUpdate("/foo.ts", "/baz.ts", {}); expect(unregistered).toBe(false); }); test("[4] dispose callbacks run in registration order immediately before replacement", () => { const hot = createManduHot("/foo.ts"); const order: string[] = []; hot.dispose((data) => { // dispose must see the shared data object; assign so we can // observe the seam across the replacement. (data as Record).first = true; order.push("first"); }); hot.dispose(() => { order.push("second"); }); hot.accept((_mod) => { order.push("accept"); }); dispatchReplacement("/foo.ts", { default: "new" }); expect(order).toEqual(["first", "second", "accept"]); // The mutation the first dispose made is still readable after // replacement — `data` survives. expect((hot.data as Record).first).toBe(true); }); test("[5] invalidate() calls the installed transport with moduleUrl + message", () => { const hot = createManduHot("/foo.ts"); const sent: Array<{ type: string; moduleUrl: string; message?: string }> = []; setInvalidateTransport((payload) => { sent.push(payload); }); hot.invalidate("schema broke"); expect(sent).toEqual([ { type: "invalidate", moduleUrl: "/foo.ts", message: "schema broke" }, ]); // `invalidate()` with no message is also valid. hot.invalidate(); expect(sent[1]).toEqual({ type: "invalidate", moduleUrl: "/foo.ts", message: undefined, }); }); test("[6] on('vite:beforeUpdate', cb) receives dispatched events; isolated from dispose/accept", () => { const hot = createManduHot("/foo.ts"); const seen: unknown[] = []; hot.on("vite:beforeUpdate", (payload) => { seen.push(payload); }); dispatchEvent("vite:beforeUpdate", { type: "update", at: 1 }); dispatchEvent("vite:afterUpdate", { type: "update", at: 2 }); // different event expect(seen.length).toBe(1); expect(seen[0]).toEqual({ type: "update", at: 1 }); }); test("[7] accept() overload with no args marks self-accept without requiring a callback", () => { const hot = createManduHot("/foo.ts"); hot.accept(); // no-op acceptance // dispatchReplacement should still return true because a self-accept // entry exists, just with a noop callback. const applied = dispatchReplacement("/foo.ts", { default: "new" }); expect(applied).toBe(true); }); test("[7b] accept('dep') without callback throws — callback is required for dep-accept", () => { const hot = createManduHot("/foo.ts"); // Vite's multi-dep overload passes an array; single-dep overload // requires a callback. We enforce the callback-required check. expect(() => { // @ts-expect-error — intentionally calling the wrong overload. hot.accept("/bar.ts"); }).toThrow(TypeError); }); }); // ═══════════════════════════════════════════════════════════════════ // Section B — HMR server replay buffer + ?since wire handshake // ═══════════════════════════════════════════════════════════════════ /** * Utility: spin up an HMR server and return it plus the public port the * client should dial. The caller owns teardown via `afterEach`. * * We pass `port: 0` ... almost. `createHMRServer` computes * `port + PORTS.HMR_OFFSET` internally, so if we want an ephemeral * listener we'd need to bind ahead of time. These tests instead use a * process-local monotonic port allocator to avoid random collisions * during the full parallel core suite. */ const HMR_TEST_PORT_STATE = "__MANDU_HMR_TEST_PORT_STATE__"; function pickPort(): number { const stateGlobal = globalThis as typeof globalThis & { __MANDU_HMR_TEST_PORT_STATE__?: { next: number }; }; stateGlobal.__MANDU_HMR_TEST_PORT_STATE__ ??= { next: 0 }; const index = stateGlobal.__MANDU_HMR_TEST_PORT_STATE__.next++; return 41000 + (((process.pid % 3500) * 2 + index * 2) % 7000); } /** * A wrapper around WebSocket that stashes every incoming message in an * array the moment it arrives. Tests then `await awaitMessage(n)` to * pop them off in order. This avoids the listener-lifecycle race where * a message can arrive between the removal of one listener and the * attachment of the next. */ interface MessageClient { ws: WebSocket; /** Resolve once the nth total message (0-indexed) has been received. */ awaitMessage: (index: number, timeoutMs?: number) => Promise; /** Return all messages received so far. */ peek: () => unknown[]; } async function connectWS(hmrPort: number, since?: number): Promise { const qs = since !== undefined ? `?since=${since}` : ""; const ws = new WebSocket(`ws://localhost:${hmrPort}${qs ? "/" + qs : ""}`); const messages: unknown[] = []; const waiters: Array<{ idx: number; resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType }> = []; ws.addEventListener("message", (ev) => { let parsed: unknown; try { parsed = JSON.parse(String((ev as MessageEvent).data)); } catch { parsed = (ev as MessageEvent).data; } messages.push(parsed); // Fire any waiters whose index has been fulfilled. const fulfilled: number[] = []; for (let i = 0; i < waiters.length; i++) { const w = waiters[i]!; if (messages.length > w.idx) { clearTimeout(w.timer); w.resolve(messages[w.idx]); fulfilled.push(i); } } // Remove fulfilled from high index to low to keep splice cheap. for (let i = fulfilled.length - 1; i >= 0; i--) { waiters.splice(fulfilled[i]!, 1); } }); await new Promise((resolve, reject) => { ws.addEventListener("open", () => resolve(), { once: true }); ws.addEventListener("error", (e) => reject(e as unknown as Error), { once: true }); }); return { ws, awaitMessage(index: number, timeoutMs = 2000): Promise { if (messages.length > index) return Promise.resolve(messages[index]); return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject( new Error( `Timed out waiting for message #${index} (have ${messages.length}: ${JSON.stringify(messages)})`, ), ); }, timeoutMs); waiters.push({ idx: index, resolve, reject, timer }); }); }, peek: () => [...messages], }; } function waitFor(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } describe("createHMRServer — replay buffer semantics", () => { let server: HMRServer | null = null; let hmrPort = 0; beforeEach(() => { const basePort = pickPort(); hmrPort = basePort + PORTS.HMR_OFFSET; server = createHMRServer(basePort); }); afterEach(() => { server?.close(); server = null; }); test("[8] replay buffer evicts oldest entries when size exceeds MAX_REPLAY_BUFFER", () => { // MAX_REPLAY_BUFFER is 128 per hmr-types.ts. Fill + overflow. const overflow = 5; for (let i = 0; i < MAX_REPLAY_BUFFER + overflow; i++) { server!.broadcastVite({ type: "full-reload", path: `/route-${i}` }); } const state = server!._inspectReplayBuffer(); // Buffer is capped. expect(state.size).toBe(MAX_REPLAY_BUFFER); // Latest id equals total broadcasts (ids are monotonic, no gaps). expect(state.lastId).toBe(MAX_REPLAY_BUFFER + overflow); // Oldest retained envelope's id equals `lastId - size + 1`. expect(state.oldestId).toBe(state.lastId - state.size + 1); }); test("[9] replay buffer prunes envelopes older than REPLAY_MAX_AGE_MS", async () => { // Push one envelope then advance time. We cannot wait 60 s in a // unit test, so we reach into the buffer only via public API and // prove the prune branch by saturating the size cap — the age // prune runs on every enqueue, so if size prune works, age prune // also runs; the structural check is that `oldestId` never points // at an envelope older than `Date.now() - REPLAY_MAX_AGE_MS`. // // Instead of time travel, we assert the invariant: after pushing // a burst, the oldest envelope's inferred timestamp (via the // envelope returned from broadcastVite) is within the age window. const first = server!.broadcastVite({ type: "full-reload", path: "/a" }); await waitFor(5); const second = server!.broadcastVite({ type: "full-reload", path: "/b" }); // Sanity: age between them is tiny, both well inside the window. expect(second.timestamp - first.timestamp).toBeLessThan(REPLAY_MAX_AGE_MS); // Structural: state shows 2 envelopes, oldestId === first.id. const state = server!._inspectReplayBuffer(); expect(state.size).toBe(2); expect(state.oldestId).toBe(first.id); expect(state.lastId).toBe(second.id); }); test("[10] client connecting with `?since=` receives every envelope with id > N", async () => { // Publish 4 envelopes. server!.broadcastVite({ type: "full-reload", path: "/a" }); // id 1 server!.broadcastVite({ type: "full-reload", path: "/b" }); // id 2 server!.broadcastVite({ type: "full-reload", path: "/c" }); // id 3 server!.broadcastVite({ type: "full-reload", path: "/d" }); // id 4 const client = await connectWS(hmrPort, 2); // Server sends `connected` + replay envelopes (ids 3 and 4) = 3 msgs. const [m0, m1, m2] = await Promise.all([ client.awaitMessage(0), client.awaitMessage(1), client.awaitMessage(2), ]); client.ws.close(); expect(m0).toMatchObject({ type: "connected" }); expect(m1).toMatchObject({ type: "vite-replay", data: { id: 3 }, payload: { type: "full-reload", path: "/c" }, }); expect(m2).toMatchObject({ type: "vite-replay", data: { id: 4 }, payload: { type: "full-reload", path: "/d" }, }); }); test("[11] client with `?since` older than oldest buffered id receives `full-reload`", async () => { // Overflow the buffer so ids 1..5 are evicted. const total = MAX_REPLAY_BUFFER + 5; for (let i = 0; i < total; i++) { server!.broadcastVite({ type: "full-reload", path: `/r-${i}` }); } // Now request since=1 — way behind `oldestId`. const client = await connectWS(hmrPort, 1); const msg = await client.awaitMessage(0); client.ws.close(); // Expect a single `full-reload` message (buffer exhausted fallback). expect(msg).toMatchObject({ type: "full-reload", data: { message: "replay-buffer-exhausted" }, }); }); }); // ═══════════════════════════════════════════════════════════════════ // Section C — Wire-format + layout-update + invalidate upstream // ═══════════════════════════════════════════════════════════════════ describe("createHMRServer — wire format + event dispatch", () => { let server: HMRServer | null = null; let hmrPort = 0; beforeEach(() => { const basePort = pickPort(); hmrPort = basePort + PORTS.HMR_OFFSET; server = createHMRServer(basePort); }); afterEach(() => { server?.close(); server = null; }); test("[12] layout-update broadcast is recorded in the replay buffer (so reconnect also sees it)", async () => { // Connect a live client first to observe the live broadcast too. const client = await connectWS(hmrPort); // Drain the initial `connected` message (index 0) so the test's // expectation lives at index 1. await client.awaitMessage(0); server!.broadcast({ type: "layout-update", data: { layoutPath: "/app/layout.tsx" }, }); const msg = await client.awaitMessage(1); client.ws.close(); // Live delivery: the HMR message is the original Mandu shape with // an id appended by the broadcast path. expect(msg).toMatchObject({ type: "layout-update", data: { layoutPath: "/app/layout.tsx", id: expect.any(Number), }, }); // Replay buffer contains an entry for the same event. const state = server!._inspectReplayBuffer(); expect(state.size).toBe(1); }); test("[13] broadcastVite produces a Vite-compat envelope with matching id on the wire", async () => { const client = await connectWS(hmrPort); await client.awaitMessage(0); // drain `connected` const payload: ViteHMRPayload = { type: "update", updates: [ { type: "js-update", path: "/app/page.tsx", acceptedPath: "/app/page.tsx", timestamp: Date.now(), }, ], }; const envelope = server!.broadcastVite(payload); const msg = await client.awaitMessage(1); client.ws.close(); expect(envelope.id).toBe(1); expect(msg).toMatchObject({ type: "vite", data: { id: envelope.id }, payload: { type: "update", updates: [ { type: "js-update", path: "/app/page.tsx", acceptedPath: "/app/page.tsx", }, ], }, }); }); test("[14] reconnect with since=0 after many broadcasts and no buffered survivors → full-reload", async () => { // Fire enough envelopes to push id past MAX_REPLAY_BUFFER — oldestId // becomes > 0, so since=0 is older than oldestId. const total = MAX_REPLAY_BUFFER + 2; for (let i = 0; i < total; i++) { server!.broadcastVite({ type: "full-reload", path: `/x-${i}` }); } const client = await connectWS(hmrPort, 0); const msg = await client.awaitMessage(0); client.ws.close(); // `since=0` is `< oldestId` once the buffer rolls, so full-reload. expect(msg).toMatchObject({ type: "full-reload", }); }); test("[15] client-sent `invalidate` is echoed as full-reload to all clients (and buffered)", async () => { const a = await connectWS(hmrPort); const b = await connectWS(hmrPort); // Drain greetings (index 0 on each client). await Promise.all([a.awaitMessage(0), b.awaitMessage(0)]); // Guard against a race where Bun's WS upgrade hasn't fully settled // on both sides — a single event-loop tick is enough. await waitFor(10); // Client A sends an invalidate upstream (simulating // `import.meta.hot.invalidate()` in user code). a.ws.send(JSON.stringify({ type: "invalidate", moduleUrl: "/foo.ts", message: "schema broke" })); const [msgA, msgB] = await Promise.all([a.awaitMessage(1), b.awaitMessage(1)]); a.ws.close(); b.ws.close(); expect(msgA).toMatchObject({ type: "full-reload", data: { path: "/foo.ts", message: "schema broke", id: expect.any(Number) }, }); expect(msgB).toMatchObject({ type: "full-reload", data: { path: "/foo.ts", message: "schema broke" }, }); // Buffer now has one entry from the server-side enqueue. expect(server!._inspectReplayBuffer().size).toBe(1); }); }); // ═══════════════════════════════════════════════════════════════════ // Section D — Integration: runtime + envelope produced by server // ═══════════════════════════════════════════════════════════════════ describe("integration — ManduHot + broadcastVite envelope shape", () => { beforeEach(() => { _resetRegistryForTests(); }); test("[16] a consumer using `on('vite:beforeUpdate')` receives a payload whose shape matches the Vite wire spec", () => { const hot = createManduHot("/foo.ts"); const seen: unknown[] = []; hot.on("vite:beforeUpdate", (p) => seen.push(p)); // Simulate what the client script does when it receives the `vite` // envelope: it fires `vite:beforeUpdate` with the inner payload. const payload: ViteHMRPayload = { type: "update", updates: [ { type: "js-update", path: "/app/page.tsx", acceptedPath: "/app/page.tsx", timestamp: 1234, }, ], }; dispatchEvent("vite:beforeUpdate", payload); expect(seen.length).toBe(1); // Structural assertion — the payload must be the Vite wire format // shape, not a Mandu wrapping. expect(seen[0]).toEqual(payload); }); test("[17] HMRReplayEnvelope id is monotonic even across size eviction (ids don't reset)", () => { const envelopes: HMRReplayEnvelope[] = []; const server = createHMRServer(pickPort()); try { for (let i = 0; i < MAX_REPLAY_BUFFER + 3; i++) { envelopes.push(server.broadcastVite({ type: "full-reload", path: `/p-${i}` })); } } finally { server.close(); } // Ids are 1..n in strict order; eviction does NOT restart the counter. for (let i = 0; i < envelopes.length; i++) { expect(envelopes[i]!.id).toBe(i + 1); } }); });