node-half.spec.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. /**
  2. * Node half of the HMR plugin: bundle watches follow the graph, stat changes
  3. * report through clientModuleHost.rebuilt, and everything dies with the fiber.
  4. */
  5. import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import { Context } from 'cordis'
  9. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  10. import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
  11. import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
  12. import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
  13. const POLL_MS = 20
  14. let dir: string
  15. beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
  16. afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
  17. /**
  18. * Controllable clientModuleHost fake over a mutable id → bundle-path table.
  19. * Structural (Pick+cast): the plugin only touches the read/notify surface;
  20. * the service class carries private scan state a literal need not reproduce.
  21. */
  22. type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
  23. interface FakeHostOptions {
  24. beforeGraphRead?: () => void
  25. rebuilt?: (id: string) => string | undefined
  26. }
  27. function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
  28. const graphListeners = new Set<() => void>()
  29. const rebuiltCalls: string[] = []
  30. const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
  31. rebuiltCalls,
  32. fireGraphChanged: () => { for (const l of graphListeners) l() },
  33. graph: (): WebBootGraph => {
  34. options.beforeGraphRead?.()
  35. return {
  36. rev: 'r',
  37. entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
  38. }
  39. },
  40. clientPath: id => rows.get(id),
  41. rebuilt: (id) => {
  42. rebuiltCalls.push(id)
  43. return options.rebuilt?.(id) ?? 'r2'
  44. },
  45. onRebuilt: () => () => {},
  46. onGraphChanged: (listener) => {
  47. graphListeners.add(listener)
  48. return () => { graphListeners.delete(listener) }
  49. },
  50. }
  51. return fake as FakeHost
  52. }
  53. // Structural fake: the plugin only touches register(); the service class
  54. // carries private state a literal cannot (and need not) reproduce.
  55. function fakeHttpServer(routes: WebRoute[]): HttpServerService {
  56. const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
  57. register(route) {
  58. routes.push(route)
  59. return () => { routes.splice(routes.indexOf(route), 1) }
  60. },
  61. tapIndex: () => () => {},
  62. port: 0,
  63. }
  64. return fake as HttpServerService
  65. }
  66. async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
  67. const ctx = new Context()
  68. ctx.provide('clientModuleHost', clientModuleHost)
  69. ctx.provide('httpServer', httpServer)
  70. const fiber = ctx.plugin(
  71. { inject: [...inject], Config, apply },
  72. { pollIntervalMs: POLL_MS },
  73. )
  74. await fiber.await()
  75. return fiber
  76. }
  77. describe('hmr node half', () => {
  78. it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
  79. const bundle = join(dir, 'a.js')
  80. writeFileSync(bundle, 'v1')
  81. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
  82. const routes: WebRoute[] = []
  83. const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
  84. expect(routes).toHaveLength(1)
  85. expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
  86. expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
  87. clientModuleHost.rebuiltCalls.length = 0
  88. // Nudge mtime past stat granularity so the poller sees a content signal.
  89. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  90. writeFileSync(bundle, 'v2-longer')
  91. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
  92. await fiber.dispose()
  93. expect(routes).toHaveLength(0)
  94. // Watcher gone: further file changes report nothing.
  95. clientModuleHost.rebuiltCalls.length = 0
  96. writeFileSync(bundle, 'v3-even-longer')
  97. await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
  98. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  99. })
  100. it('follows graph changes: rows added after activation get watched', async () => {
  101. const early = join(dir, 'early.js')
  102. const late = join(dir, 'late.js')
  103. writeFileSync(early, 'v1')
  104. const rows = new Map([['pkg-early', early]])
  105. const clientModuleHost = fakeClientModuleHost(rows)
  106. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  107. clientModuleHost.rebuiltCalls.length = 0
  108. writeFileSync(late, 'v1')
  109. rows.set('pkg-late', late)
  110. clientModuleHost.fireGraphChanged()
  111. expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-late'])
  112. clientModuleHost.rebuiltCalls.length = 0
  113. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  114. writeFileSync(late, 'v2-longer')
  115. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
  116. rows.delete('pkg-late')
  117. clientModuleHost.fireGraphChanged()
  118. clientModuleHost.rebuiltCalls.length = 0
  119. writeFileSync(late, 'v3-even-longer')
  120. await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
  121. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  122. await fiber.dispose()
  123. })
  124. it('rehashes after baseline capture so a construction-window write cannot become the baseline', async () => {
  125. const bundle = join(dir, 'construction.js')
  126. writeFileSync(bundle, 'v1')
  127. let rewrite = true
  128. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
  129. beforeGraphRead: () => {
  130. if (!rewrite) return
  131. rewrite = false
  132. // The graph carries the hash from before this write. The old
  133. // fs.watchFile registration asynchronously captured the new file as
  134. // its first baseline and never requested a re-hash.
  135. writeFileSync(bundle, 'v2-written-during-watch-construction')
  136. },
  137. })
  138. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  139. expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
  140. clientModuleHost.rebuiltCalls.length = 0
  141. await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
  142. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  143. await fiber.dispose()
  144. })
  145. it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
  146. const bundle = join(dir, 'replace.js')
  147. writeFileSync(bundle, 'seed')
  148. const fixedTime = new Date(1_600_000_000_000)
  149. utimesSync(bundle, fixedTime, fixedTime)
  150. const baseline = statSync(bundle)
  151. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
  152. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  153. clientModuleHost.rebuiltCalls.length = 0
  154. unlinkSync(bundle)
  155. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  156. writeFileSync(bundle, 'x'.repeat(baseline.size))
  157. utimesSync(bundle, fixedTime, fixedTime)
  158. const restored = statSync(bundle)
  159. expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
  160. mtimeMs: baseline.mtimeMs,
  161. size: baseline.size,
  162. })
  163. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
  164. await fiber.dispose()
  165. })
  166. it('retains a dirty baseline when the immediate re-hash races a rename', async () => {
  167. const bundle = join(dir, 'rename.js')
  168. writeFileSync(bundle, 'v1')
  169. let first = true
  170. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
  171. rebuilt: () => {
  172. if (!first) return 'r2'
  173. first = false
  174. throw Object.assign(new Error('bundle renamed'), { code: 'ENOENT' })
  175. },
  176. })
  177. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  178. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a', 'pkg-a']) }, { timeout: 3_000 })
  179. await fiber.dispose()
  180. })
  181. })