coordination.spec.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. /** Shared scheduling of caller mutations, exact-file handlers and Include refreshes. */
  2. import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import Loader, { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
  8. import Include from '@deepseek-ai/cordis-plugin-include'
  9. import Timer from '@deepseek-ai/cordis-plugin-timer'
  10. import { FSWatcher } from 'chokidar'
  11. import { expect, it, onTestFinished, vi } from 'vitest'
  12. import Hmr from '../src/index.ts'
  13. const watchers = vi.hoisted(() => [] as FSWatcher[])
  14. const watchState = vi.hoisted(() => ({ error: undefined as Error | undefined }))
  15. vi.mock('chokidar', async (original) => {
  16. const native = await original<typeof import('chokidar')>()
  17. return { ...native, watch: () => {
  18. const watcher = new native.FSWatcher()
  19. watchers.push(watcher)
  20. queueMicrotask(() => watchState.error === undefined ? watcher.emit('ready') : watcher.emit('error', watchState.error))
  21. return watcher
  22. } }
  23. })
  24. async function fixture(config: Partial<Hmr.Config> = {}) {
  25. const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-coordination-'))
  26. const ctx = new Context()
  27. ctx.baseUrl = pathToFileURL(dir).href + '/'
  28. onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(dir, { recursive: true, force: true }) })
  29. await ctx.plugin(Loader)
  30. await ctx.plugin(Timer)
  31. const provider = await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0, ...config })
  32. return { ctx, dir, hmr: ctx.hmr, provider }
  33. }
  34. it('serializes mutations, rejects nesting and keeps the queue usable after failure', async () => {
  35. const { hmr } = await fixture()
  36. const release = Promise.withResolvers<undefined>()
  37. onTestFinished(() => { release.resolve(undefined) })
  38. const entered = Promise.withResolvers<undefined>()
  39. const order: number[] = []
  40. const first = hmr.runExclusive(async () => {
  41. order.push(1)
  42. entered.resolve(undefined)
  43. await release.promise
  44. throw new Error('partial package failure')
  45. })
  46. const rejection = expect(first).rejects.toThrow('partial package failure')
  47. await entered.promise
  48. const second = hmr.runExclusive(async () => {
  49. order.push(2)
  50. await expect(hmr.runExclusive(async () => {})).rejects.toThrow('cannot be nested')
  51. })
  52. expect(order).toEqual([1])
  53. release.resolve(undefined)
  54. await rejection
  55. await second
  56. expect(order).toEqual([1, 2])
  57. })
  58. it('holds configuration handlers behind configuration mutations', async () => {
  59. const { hmr, dir } = await fixture()
  60. const filename = join(dir, 'package.json')
  61. writeFileSync(filename, '{}')
  62. const order: string[] = []
  63. const refreshed = Promise.withResolvers<undefined>()
  64. const dispose = await hmr.watchConfig(filename, async () => {
  65. order.push('refresh')
  66. refreshed.resolve(undefined)
  67. })
  68. await expect(hmr.watchConfig(filename, async () => {})).rejects.toThrow('already registered')
  69. const release = Promise.withResolvers<undefined>()
  70. onTestFinished(() => { release.resolve(undefined) })
  71. const entered = Promise.withResolvers<undefined>()
  72. const change = hmr.runExclusive(async () => {
  73. entered.resolve(undefined)
  74. await release.promise
  75. order.push('write')
  76. })
  77. await entered.promise
  78. watchers.at(-1)!.emit('change', filename)
  79. expect(order).toEqual([])
  80. release.resolve(undefined)
  81. await change
  82. await refreshed.promise
  83. await dispose()
  84. expect(order).toEqual(['write', 'refresh'])
  85. })
  86. it('reports unrelated lock-file notifications without reloading', async () => {
  87. const { ctx, dir, hmr } = await fixture()
  88. const loaded = vi.spyOn(ctx.loader, 'await')
  89. onTestFinished(() => { loaded.mockRestore() })
  90. const observed = Promise.withResolvers<string>()
  91. ctx.on('hmr/change', (url) => { observed.resolve(url) })
  92. watchers.at(-1)!.emit('change', 'package.json.lock')
  93. expect(await observed.promise).toBe(pathToFileURL(join(realpathSync(dir), 'package.json.lock')).href)
  94. await hmr.runExclusive(async () => {})
  95. expect(loaded).not.toHaveBeenCalled()
  96. })
  97. it('can dispose HMR from its own transaction without waiting on itself', async () => {
  98. const { ctx, hmr } = await fixture()
  99. await hmr.runExclusive(async () => { await ctx.fiber.dispose() })
  100. await expect(hmr.runExclusive(async () => {})).rejects.toThrow('disposed')
  101. })
  102. it('refreshes an Include through the queue and skips registered exact paths', async () => {
  103. const { ctx, dir, hmr } = await fixture()
  104. const moduleWatcher = watchers.at(-1)!
  105. const file = join(dir, 'nested.yml')
  106. writeFileSync(file, '[]\n')
  107. const imported = vi.spyOn(ctx.loader, 'import').mockResolvedValue(Include)
  108. onTestFinished(() => { imported.mockRestore() })
  109. const id = await ctx.loader.create({ name: 'include', config: { path: pathToFileURL(file).href } })
  110. await ctx.loader.await()
  111. const include = ctx.loader.resolve(id).subtree as Include
  112. const refresh = vi.spyOn(include, 'refresh')
  113. moduleWatcher.emit('change', file)
  114. await vi.waitFor(() => { expect(refresh).toHaveBeenCalledOnce() })
  115. await hmr.runExclusive(async () => {})
  116. const refreshed = Promise.withResolvers<undefined>()
  117. const registered = await hmr.watchConfig(file, async () => { refreshed.resolve(undefined) })
  118. const observed = Promise.withResolvers<string>()
  119. ctx.on('hmr/change', (url) => { observed.resolve(url) })
  120. moduleWatcher.emit('change', file)
  121. moduleWatcher.emit('change', 'ack.txt')
  122. await observed.promise
  123. watchers.at(-1)!.emit('change', file)
  124. await refreshed.promise
  125. await registered()
  126. await hmr.runExclusive(async () => {})
  127. expect(refresh).toHaveBeenCalledOnce()
  128. })
  129. it('queues cached module replacements behind configuration mutations and recovers after failure', async () => {
  130. const { ctx, dir, hmr } = await fixture()
  131. const file = join(dir, 'source.mjs')
  132. writeFileSync(file, 'export {}')
  133. const cached = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(true)
  134. onTestFinished(() => { cached.mockRestore() })
  135. const complete = Promise.withResolvers<undefined>()
  136. const replacement = vi.spyOn(hmr as unknown as { partialReload(): Promise<void> }, 'partialReload')
  137. .mockImplementation(async () => { complete.resolve(undefined); throw new Error('module failed') })
  138. const warning = Promise.withResolvers<undefined>()
  139. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => { warning.resolve(undefined) })
  140. onTestFinished(() => { replacement.mockRestore(); warn.mockRestore() })
  141. const release = Promise.withResolvers<undefined>()
  142. onTestFinished(() => { release.resolve(undefined) })
  143. const entered = Promise.withResolvers<undefined>()
  144. const operation = hmr.runExclusive(async () => { entered.resolve(undefined); await release.promise })
  145. await entered.promise
  146. watchers.at(-1)!.emit('change', file)
  147. expect(replacement).not.toHaveBeenCalled()
  148. release.resolve(undefined)
  149. await operation
  150. await complete.promise
  151. await warning.promise
  152. await hmr.runExclusive(async () => {})
  153. expect(replacement).toHaveBeenCalledOnce()
  154. })
  155. it('requests the host full-reload hook for framework files', async () => {
  156. const { ctx, dir, hmr } = await fixture()
  157. const file = join(dir, 'framework.mjs')
  158. writeFileSync(file, 'export {}')
  159. ;(hmr as unknown as { externals: Set<string> }).externals.add(pathToFileURL(join(realpathSync(dir), 'framework.mjs')).href)
  160. const called = Promise.withResolvers<undefined>()
  161. const exit = vi.spyOn(ctx.loader, 'exit').mockImplementation(() => { called.resolve(undefined) })
  162. onTestFinished(() => { exit.mockRestore() })
  163. watchers.at(-1)!.emit('change', file)
  164. await called.promise
  165. await hmr.runExclusive(async () => {})
  166. expect(exit).toHaveBeenCalledOnce()
  167. })
  168. it('closes an exact watcher from its running transaction', async () => {
  169. const { hmr, dir } = await fixture()
  170. const dispose = await hmr.watchConfig(join(dir, 'missing.yml'), async () => {})
  171. await hmr.runExclusive(async () => { await dispose() })
  172. })
  173. it('rejects unavailable Node internals before starting a watcher', async () => {
  174. const native = vi.spyOn(ModuleLoader, 'fromInternal').mockReturnValue(undefined)
  175. onTestFinished(() => { native.mockRestore() })
  176. await expect(fixture()).rejects.toThrow('--expose-internals')
  177. })
  178. it('rejects watcher startup failure and logs later watcher errors', async () => {
  179. watchState.error = new Error('watch startup failed')
  180. onTestFinished(() => { watchState.error = undefined })
  181. await expect(fixture({ root: ['.'] })).rejects.toThrow('watch startup failed')
  182. watchState.error = undefined
  183. const { ctx } = await fixture({ base: '.', root: ['.'] })
  184. const warn = vi.spyOn(ctx.logger, 'warn')
  185. watchers.at(-1)!.emit('error', new Error('watch runtime failed'))
  186. expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'watch runtime failed' }))
  187. })
  188. it('starts without a process entry module', async () => {
  189. const argv = process.argv
  190. process.argv = []
  191. onTestFinished(() => { process.argv = argv })
  192. const { hmr } = await fixture()
  193. await hmr.runExclusive(async () => {})
  194. })
  195. it('rebinds configuration handlers when its provider is reconfigured within a reload', async () => {
  196. const { ctx, hmr, dir, provider } = await fixture()
  197. const file = join(dir, 'profile.yml')
  198. writeFileSync(file, '[]')
  199. const refresh = vi.fn(async () => {})
  200. const binding = ctx.inject(['hmr'], async (owner) => {
  201. await owner.effect(() => owner.hmr.watchConfig(file, refresh))
  202. })
  203. await binding.await()
  204. await hmr.runExclusive(async () => {
  205. provider.update({ root: [], ignored: [], debounce: 1 })
  206. await provider.await()
  207. await binding.await()
  208. })
  209. expect(ctx.hmr).not.toBe(hmr)
  210. const current = watchers.at(-1)
  211. current?.emit('change', file)
  212. await vi.waitFor(() => { expect(refresh).toHaveBeenCalledOnce() })
  213. })