/** * Node half of the HMR plugin: bundle watches follow the graph, stat changes * report through clientModuleHost.rebuilt, and everything dies with the fiber. */ import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules' import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts' const POLL_MS = 20 let dir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) }) afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) /** * Controllable clientModuleHost fake over a mutable id → bundle-path table. * Structural (Pick+cast): the plugin only touches the read/notify surface; * the service class carries private scan state a literal need not reproduce. */ type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void } interface FakeHostOptions { beforeGraphRead?: () => void rebuilt?: (id: string) => string | undefined } function fakeClientModuleHost(rows: Map, options: FakeHostOptions = {}): FakeHost { const graphListeners = new Set<() => void>() const rebuiltCalls: string[] = [] const fake: Pick = { rebuiltCalls, fireGraphChanged: () => { for (const l of graphListeners) l() }, graph: (): WebBootGraph => { options.beforeGraphRead?.() return { rev: 'r', entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })), } }, clientPath: id => rows.get(id), rebuilt: (id) => { rebuiltCalls.push(id) return options.rebuilt?.(id) ?? 'r2' }, onRebuilt: () => () => {}, onGraphChanged: (listener) => { graphListeners.add(listener) return () => { graphListeners.delete(listener) } }, } return fake as FakeHost } // Structural fake: the plugin only touches register(); the service class // carries private state a literal cannot (and need not) reproduce. function fakeHttpServer(routes: WebRoute[]): HttpServerService { const fake: Pick = { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, tapIndex: () => () => {}, port: 0, } return fake as HttpServerService } async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) { const ctx = new Context() ctx.provide('clientModuleHost', clientModuleHost) ctx.provide('httpServer', httpServer) const fiber = ctx.plugin( { inject: [...inject], Config, apply }, { pollIntervalMs: POLL_MS }, ) await fiber.await() return fiber } describe('hmr node half', () => { it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => { const bundle = join(dir, 'a.js') writeFileSync(bundle, 'v1') const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]])) const routes: WebRoute[] = [] const fiber = await mount(clientModuleHost, fakeHttpServer(routes)) expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT }) expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) clientModuleHost.rebuiltCalls.length = 0 // Nudge mtime past stat granularity so the poller sees a content signal. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) writeFileSync(bundle, 'v2-longer') await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 }) await fiber.dispose() expect(routes).toHaveLength(0) // Watcher gone: further file changes report nothing. clientModuleHost.rebuiltCalls.length = 0 writeFileSync(bundle, 'v3-even-longer') await new Promise(resolve => setTimeout(resolve, POLL_MS * 4)) expect(clientModuleHost.rebuiltCalls).toHaveLength(0) }) it('follows graph changes: rows added after activation get watched', async () => { const early = join(dir, 'early.js') const late = join(dir, 'late.js') writeFileSync(early, 'v1') const rows = new Map([['pkg-early', early]]) const clientModuleHost = fakeClientModuleHost(rows) const fiber = await mount(clientModuleHost, fakeHttpServer([])) clientModuleHost.rebuiltCalls.length = 0 writeFileSync(late, 'v1') rows.set('pkg-late', late) clientModuleHost.fireGraphChanged() expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-late']) clientModuleHost.rebuiltCalls.length = 0 await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) writeFileSync(late, 'v2-longer') await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 }) rows.delete('pkg-late') clientModuleHost.fireGraphChanged() clientModuleHost.rebuiltCalls.length = 0 writeFileSync(late, 'v3-even-longer') await new Promise(resolve => setTimeout(resolve, POLL_MS * 3)) expect(clientModuleHost.rebuiltCalls).toHaveLength(0) await fiber.dispose() }) it('rehashes after baseline capture so a construction-window write cannot become the baseline', async () => { const bundle = join(dir, 'construction.js') writeFileSync(bundle, 'v1') let rewrite = true const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), { beforeGraphRead: () => { if (!rewrite) return rewrite = false // The graph carries the hash from before this write. The old // fs.watchFile registration asynchronously captured the new file as // its first baseline and never requested a re-hash. writeFileSync(bundle, 'v2-written-during-watch-construction') }, }) const fiber = await mount(clientModuleHost, fakeHttpServer([])) expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) clientModuleHost.rebuiltCalls.length = 0 await new Promise(resolve => setTimeout(resolve, POLL_MS * 3)) expect(clientModuleHost.rebuiltCalls).toHaveLength(0) await fiber.dispose() }) it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => { const bundle = join(dir, 'replace.js') writeFileSync(bundle, 'seed') const fixedTime = new Date(1_600_000_000_000) utimesSync(bundle, fixedTime, fixedTime) const baseline = statSync(bundle) const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]])) const fiber = await mount(clientModuleHost, fakeHttpServer([])) clientModuleHost.rebuiltCalls.length = 0 unlinkSync(bundle) await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) writeFileSync(bundle, 'x'.repeat(baseline.size)) utimesSync(bundle, fixedTime, fixedTime) const restored = statSync(bundle) expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({ mtimeMs: baseline.mtimeMs, size: baseline.size, }) await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 }) await fiber.dispose() }) it('retains a dirty baseline when the immediate re-hash races a rename', async () => { const bundle = join(dir, 'rename.js') writeFileSync(bundle, 'v1') let first = true const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), { rebuilt: () => { if (!first) return 'r2' first = false throw Object.assign(new Error('bundle renamed'), { code: 'ENOENT' }) }, }) const fiber = await mount(clientModuleHost, fakeHttpServer([])) await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a', 'pkg-a']) }, { timeout: 3_000 }) await fiber.dispose() }) })