node-half.client.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  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 '@deepseek-ai/cordis'
  9. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  10. import type { ClientArtifactBaseline, ClientModuleRegistry, WebBootGraph } from '@deepseek-ai/dsh-client-modules'
  11. import type { WebRoute, WebServer } 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 = ClientModuleRegistry & { rebuiltCalls: string[]; fireGraphChanged(): void }
  23. interface FakeHostOptions {
  24. beforeGraphRead?: () => void
  25. rebuilt?: (id: string) => string | undefined
  26. }
  27. function artifactBaseline(path: string): ClientArtifactBaseline {
  28. const bundle = statSync(path)
  29. try {
  30. const sourceMap = statSync(`${path}.map`)
  31. return {
  32. path,
  33. mtimeMs: bundle.mtimeMs,
  34. size: bundle.size,
  35. mapMtimeMs: sourceMap.mtimeMs,
  36. mapSize: sourceMap.size,
  37. }
  38. } catch (error) {
  39. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  40. return { path, mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
  41. }
  42. }
  43. function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
  44. const graphListeners = new Set<() => void>()
  45. const rebuiltCalls: string[] = []
  46. const baselines = new Map([...rows].map(([id, path]) => [id, artifactBaseline(path)]))
  47. const fake: Pick<FakeHost, 'graph' | 'artifactBaseline' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
  48. rebuiltCalls,
  49. fireGraphChanged: () => { for (const l of graphListeners) l() },
  50. graph: (): WebBootGraph => {
  51. options.beforeGraphRead?.()
  52. return {
  53. rev: 'r',
  54. entries: [...rows.keys()].map(id => ({ id, url: `/plugins/??${id}/client.js&rev=r`, rev: 'r' })),
  55. batches: [],
  56. }
  57. },
  58. artifactBaseline: (id) => {
  59. const path = rows.get(id)
  60. if (path === undefined) return undefined
  61. let baseline = baselines.get(id)
  62. if (baseline?.path !== path) {
  63. baseline = artifactBaseline(path)
  64. baselines.set(id, baseline)
  65. }
  66. return { ...baseline }
  67. },
  68. rebuilt: (id) => {
  69. rebuiltCalls.push(id)
  70. return options.rebuilt?.(id) ?? 'r2'
  71. },
  72. onRebuilt: () => () => {},
  73. onGraphChanged: (listener) => {
  74. graphListeners.add(listener)
  75. return () => { graphListeners.delete(listener) }
  76. },
  77. }
  78. return fake as FakeHost
  79. }
  80. // Structural fake: the plugin only touches register(); the service class
  81. // carries private state a literal cannot (and need not) reproduce.
  82. function fakeHttpServer(routes: WebRoute[]): WebServer {
  83. const fake: Pick<WebServer, 'register' | 'tapIndex' | 'port'> = {
  84. register(route) {
  85. routes.push(route)
  86. return () => { routes.splice(routes.indexOf(route), 1) }
  87. },
  88. tapIndex: () => () => {},
  89. port: 0,
  90. }
  91. return fake as WebServer
  92. }
  93. async function mount(clientModuleHost: FakeHost, webServer: WebServer) {
  94. const ctx = new Context()
  95. ctx.provide('clientModules', clientModuleHost)
  96. ctx.provide('webServer', webServer)
  97. const fiber = ctx.plugin(
  98. { inject: [...inject], Config, apply },
  99. { pollIntervalMs: POLL_MS },
  100. )
  101. await fiber.await()
  102. return fiber
  103. }
  104. describe('hmr node half', () => {
  105. it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
  106. const bundle = join(dir, 'a.js')
  107. writeFileSync(bundle, 'v1')
  108. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
  109. const routes: WebRoute[] = []
  110. const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
  111. expect(routes).toHaveLength(1)
  112. expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
  113. expect(clientModuleHost.rebuiltCalls).toEqual([])
  114. // Nudge mtime past stat granularity so the poller sees a content signal.
  115. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  116. writeFileSync(bundle, 'v2-longer')
  117. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
  118. clientModuleHost.rebuiltCalls.length = 0
  119. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  120. writeFileSync(`${bundle}.map`, '{"version":3}')
  121. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
  122. await fiber.dispose()
  123. expect(routes).toHaveLength(0)
  124. // Watcher gone: further file changes report nothing.
  125. clientModuleHost.rebuiltCalls.length = 0
  126. writeFileSync(bundle, 'v3-even-longer')
  127. await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
  128. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  129. })
  130. it('follows graph changes: rows added after activation get watched', async () => {
  131. const early = join(dir, 'early.js')
  132. const late = join(dir, 'late.js')
  133. writeFileSync(early, 'v1')
  134. const rows = new Map([['pkg-early', early]])
  135. const clientModuleHost = fakeClientModuleHost(rows)
  136. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  137. clientModuleHost.rebuiltCalls.length = 0
  138. writeFileSync(late, 'v1')
  139. rows.set('pkg-late', late)
  140. clientModuleHost.fireGraphChanged()
  141. expect(clientModuleHost.rebuiltCalls).toEqual([])
  142. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  143. writeFileSync(late, 'v2-longer')
  144. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
  145. rows.delete('pkg-late')
  146. clientModuleHost.fireGraphChanged()
  147. clientModuleHost.rebuiltCalls.length = 0
  148. writeFileSync(late, 'v3-even-longer')
  149. await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
  150. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  151. await fiber.dispose()
  152. })
  153. it('rehashes only a row changed between its startup snapshot and watch installation', async () => {
  154. const bundle = join(dir, 'construction.js')
  155. writeFileSync(bundle, 'v1')
  156. let rewrite = true
  157. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
  158. beforeGraphRead: () => {
  159. if (!rewrite) return
  160. rewrite = false
  161. writeFileSync(bundle, 'v2-written-during-watch-construction')
  162. },
  163. })
  164. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  165. expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
  166. clientModuleHost.rebuiltCalls.length = 0
  167. await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
  168. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  169. await fiber.dispose()
  170. })
  171. it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
  172. const bundle = join(dir, 'replace.js')
  173. writeFileSync(bundle, 'seed')
  174. const fixedTime = new Date(1_600_000_000_000)
  175. utimesSync(bundle, fixedTime, fixedTime)
  176. const baseline = statSync(bundle)
  177. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
  178. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  179. clientModuleHost.rebuiltCalls.length = 0
  180. unlinkSync(bundle)
  181. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  182. writeFileSync(bundle, 'x'.repeat(baseline.size))
  183. utimesSync(bundle, fixedTime, fixedTime)
  184. const restored = statSync(bundle)
  185. expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
  186. mtimeMs: baseline.mtimeMs,
  187. size: baseline.size,
  188. })
  189. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
  190. await fiber.dispose()
  191. })
  192. it('retains a dirty baseline when a catch-up re-hash races a rename', async () => {
  193. const bundle = join(dir, 'rename.js')
  194. writeFileSync(bundle, 'v1')
  195. let first = true
  196. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
  197. beforeGraphRead: () => {
  198. if (!first) return
  199. writeFileSync(bundle, 'v2-written-during-watch-construction')
  200. },
  201. rebuilt: () => {
  202. if (!first) return 'r2'
  203. first = false
  204. throw Object.assign(new Error('bundle renamed'), { code: 'ENOENT' })
  205. },
  206. })
  207. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  208. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a', 'pkg-a']) }, { timeout: 3_000 })
  209. await fiber.dispose()
  210. })
  211. })