modules.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. /** Module graph replacement through a real Loader and controlled Node cache. */
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath, pathToFileURL } from 'node:url'
  6. import NodeModule, { createRequire } from 'node:module'
  7. import { Context, type Plugin } from '@deepseek-ai/cordis'
  8. import Loader, { type ModuleJob, type ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
  9. import Timer from '@deepseek-ai/cordis-plugin-timer'
  10. import { expect, it, onTestFinished, vi } from 'vitest'
  11. import Hmr from '../src/index.ts'
  12. interface ModuleReload {
  13. internal: ModuleLoader
  14. externals: Set<string>
  15. accepted: Set<string>
  16. declined: Set<string>
  17. stashed: Set<string>
  18. analyzeChanges(): Promise<void>
  19. partialReload(): Promise<void>
  20. }
  21. async function fixture(version: 'v1' | 'v2' = 'v2') {
  22. const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-graph-'))
  23. const ctx = new Context()
  24. ctx.baseUrl = pathToFileURL(dir).href + '/'
  25. onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(dir, { recursive: true, force: true }) })
  26. await ctx.plugin(Loader)
  27. await ctx.plugin(Timer)
  28. await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
  29. const cache = new Map<string, ModuleJob>()
  30. const imports = new Map<string, Plugin>()
  31. const resolve = vi.fn((name: string) => ({ url: name }))
  32. // The adapter supplies only the Node loader operations exercised by HMR.
  33. const internal = { version, loadCache: cache, resolve,
  34. resolveSync: (_parent: string, request: { specifier: string }) => resolve(request.specifier),
  35. } as unknown as ModuleLoader
  36. const reload = ctx.hmr as unknown as ModuleReload
  37. reload.internal = internal
  38. reload.externals = new Set()
  39. const imported = vi.spyOn(ctx.loader, 'import').mockImplementation(async (name: string) => {
  40. const plugin = imports.get(name)
  41. if (plugin === undefined) throw new Error(`missing module: ${name}`)
  42. return plugin
  43. })
  44. const url = (name: string) => pathToFileURL(join(dir, name)).href
  45. function module(name: string, plugin?: Plugin, children: ModuleJob[] = []): ModuleJob {
  46. const location = name.startsWith('node:') ? name : url(name)
  47. // ModuleJob's execution methods belong to Node; this fixture owns its graph and namespace.
  48. const job = { url: location, linked: Promise.resolve(children),
  49. module: { url: location, getNamespace: () => plugin },
  50. } as ModuleJob
  51. cache.set(location, job)
  52. if (plugin !== undefined) imports.set(location, plugin)
  53. return job
  54. }
  55. return { ctx, cache, imports, imported, reload, module, url, resolve }
  56. }
  57. it.each(['v1', 'v2'] as const)('replaces a %s module and retains the latest Loader entry configuration', async (version) => {
  58. const { ctx, module, imports, reload } = await fixture(version)
  59. const mounted: string[] = []
  60. const disposed: string[] = []
  61. const before = { apply(ctx: Context, config: { value: string }) {
  62. mounted.push(`before:${config.value}`)
  63. ctx.effect(() => () => { disposed.push('before') })
  64. } }
  65. const after = { apply(ctx: Context, config: { value: string }) {
  66. mounted.push(`after:${config.value}`)
  67. ctx.effect(() => () => { disposed.push('after') })
  68. } }
  69. const job = module('plugin.mjs', before)
  70. const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url, config: { value: 'initial' } }))
  71. await ctx.loader.await()
  72. await entry.update({ config: { value: 'current' } })
  73. await ctx.loader.await()
  74. imports.set(job.url, after)
  75. const event = vi.fn()
  76. ctx.on('hmr/reload', event)
  77. reload.stashed.add(job.url)
  78. await reload.partialReload()
  79. expect(mounted.at(-1)).toBe('after:current')
  80. expect(disposed).toEqual(['before', 'before'])
  81. expect(event).toHaveBeenCalledOnce()
  82. await entry.update({ config: { value: 'next' } })
  83. await ctx.loader.await()
  84. expect(mounted.at(-1)).toBe('after:next')
  85. })
  86. it('reactivates a source-replaced consumer after provider and consumer configuration changes', async () => {
  87. const { ctx, module, imports, reload } = await fixture()
  88. const mounted: string[] = []
  89. const provider = module('provider.mjs', { apply(ctx: Context, config: { revision: number }) {
  90. ctx.provide('test.revision', config.revision)
  91. } })
  92. const before = { inject: ['test.revision'], apply() {} }
  93. const after = { inject: ['test.revision'], apply(ctx: Context, config: { value: string }) {
  94. mounted.push(`${config.value}:${ctx.get('test.revision')}`)
  95. } }
  96. const consumer = module('consumer.mjs', before)
  97. const providerEntry = ctx.loader.resolve(await ctx.loader.create({ name: provider.url, config: { revision: 1 } }))
  98. const consumerEntry = ctx.loader.resolve(await ctx.loader.create({ name: consumer.url, config: { value: 'initial' } }))
  99. await ctx.loader.await()
  100. imports.set(consumer.url, after)
  101. reload.stashed.add(consumer.url)
  102. await reload.partialReload()
  103. expect(mounted).toEqual(['initial:1'])
  104. await Promise.all([
  105. providerEntry.update({ config: { revision: 2 } }),
  106. consumerEntry.update({ config: { value: 'updated' } }),
  107. ])
  108. await ctx.loader.await()
  109. expect(mounted.at(-1)).toBe('updated:2')
  110. })
  111. it('restores cached modules and keeps the old plugin when replacement import fails', async () => {
  112. const { ctx, module, imported, cache, reload } = await fixture()
  113. const apply = vi.fn()
  114. const job = module('plugin.mjs', { apply })
  115. const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url }))
  116. await ctx.loader.await()
  117. const previous = entry.fiber
  118. imported.mockRejectedValueOnce(new Error('compile failed'))
  119. reload.stashed.add(job.url)
  120. await expect(reload.partialReload()).rejects.toThrow('compile failed')
  121. expect(cache.get(job.url)).toBe(job)
  122. expect(entry.fiber).toBe(previous)
  123. expect(apply).toHaveBeenCalledOnce()
  124. })
  125. it('does not replace a plugin whose dependencies have not changed', async () => {
  126. const { ctx, module, imported, reload } = await fixture()
  127. const job = module('plugin.mjs', { apply() {} })
  128. await ctx.loader.create({ name: job.url })
  129. await ctx.loader.await()
  130. const unrelated = module('unrelated.mjs')
  131. imported.mockClear()
  132. reload.stashed.add(unrelated.url)
  133. await reload.partialReload()
  134. expect(imported).not.toHaveBeenCalled()
  135. })
  136. it('replaces a plugin when a linked dependency changes, excluding framework modules', async () => {
  137. const { ctx, module, imports, reload } = await fixture()
  138. const dependency = module('dependency.mjs')
  139. const framework = module('framework.mjs')
  140. const job = module('plugin.mjs', { apply() {} }, [dependency, framework])
  141. await ctx.loader.create({ name: job.url })
  142. await ctx.loader.await()
  143. const apply = vi.fn()
  144. imports.set(job.url, { apply })
  145. reload.externals.add(framework.url)
  146. reload.stashed.add(dependency.url)
  147. await reload.partialReload()
  148. expect(apply).toHaveBeenCalledOnce()
  149. expect(reload.accepted).toContain(job.url)
  150. expect(reload.accepted).not.toContain(framework.url)
  151. })
  152. it('classifies dependency cycles and excludes builtins and unchanged leaf modules', async () => {
  153. const { module, reload } = await fixture()
  154. const changed = module('changed.mjs')
  155. const cycleA = module('a.mjs')
  156. const cycleB = module('b.mjs', undefined, [cycleA])
  157. cycleA.linked = Promise.resolve([cycleB])
  158. const parent = module('parent.mjs', undefined, [changed])
  159. const leaf = module('leaf.mjs')
  160. const builtin = module('node:fs')
  161. parent.linked = Promise.resolve([builtin, changed])
  162. changed.linked = Promise.resolve([parent, cycleA, leaf, builtin])
  163. reload.stashed.add(changed.url)
  164. await reload.analyzeChanges()
  165. expect(reload.accepted).toEqual(new Set([changed.url, parent.url]))
  166. expect(reload.declined).toEqual(new Set([leaf.url, cycleA.url, cycleB.url]))
  167. })
  168. it('rejects failed replacement activation and restores the prior plugin', async () => {
  169. const { ctx, module, imports, cache, reload } = await fixture()
  170. const mounted: string[] = []
  171. const original = { apply: () => { mounted.push('original') } }
  172. const job = module('plugin.mjs', original)
  173. const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url }))
  174. await ctx.loader.await()
  175. imports.set(job.url, { apply() { throw new Error('activation failed') } })
  176. const event = vi.fn()
  177. ctx.on('hmr/reload', event)
  178. reload.stashed.add(job.url)
  179. await expect(reload.partialReload()).rejects.toThrow('activation failed')
  180. expect(cache.get(job.url)).toBe(job)
  181. expect(mounted).toEqual(['original', 'original'])
  182. expect(entry.fiber?.runtime?.callback).toBe(original.apply)
  183. expect(event).not.toHaveBeenCalled()
  184. })
  185. it('leaves disposed child instances to their replacing parent', async () => {
  186. const { ctx, module, imports, reload } = await fixture()
  187. const seen: string[] = []
  188. const original = { apply(_ctx: Context, config: { value: string }) { seen.push(config.value) } }
  189. const job = module('plugin.mjs', original)
  190. const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url, config: { value: 'entry' } }))
  191. await ctx.loader.await()
  192. await entry.fiber!.ctx.plugin(original, { value: 'child' })
  193. await ctx.plugin(original, { value: 'independent' })
  194. imports.set(job.url, { apply(_ctx: Context, config: { value: string }) { seen.push(`new:${config.value}`) } })
  195. reload.stashed.add(job.url)
  196. await reload.partialReload()
  197. expect(seen).toContain('new:entry')
  198. expect(seen).not.toContain('new:child')
  199. expect(seen).toContain('new:independent')
  200. expect(entry.fiber?._config).toEqual({ value: 'entry' })
  201. })
  202. it('invalidates a disabled module without activating it', async () => {
  203. const { ctx, module, imports, reload } = await fixture()
  204. const job = module('disabled.mjs', { apply() {} })
  205. const id = await ctx.loader.create({ name: job.url, disabled: true })
  206. const apply = vi.fn()
  207. imports.set(job.url, { apply })
  208. reload.stashed.add(job.url)
  209. await reload.partialReload()
  210. expect(ctx.loader.resolve(id).fiber).toBeUndefined()
  211. expect(apply).not.toHaveBeenCalled()
  212. })
  213. it('does not disturb an unchanged runtime after an earlier replacement fails', async () => {
  214. const { ctx, module, imports, reload } = await fixture()
  215. const first = module('first.mjs', { apply() {} })
  216. const untouched = vi.fn()
  217. const second = module('second.mjs', { apply: untouched })
  218. await ctx.loader.root.update([{ id: 'first', name: first.url }, { id: 'second', name: second.url }])
  219. await ctx.loader.await()
  220. imports.set(first.url, { apply() { throw new Error('failed before second') } })
  221. reload.stashed.add(first.url)
  222. reload.stashed.add(second.url)
  223. await expect(reload.partialReload()).rejects.toThrow('failed before second')
  224. expect(untouched).toHaveBeenCalledOnce()
  225. })
  226. it('restores the prior plugin when the replacement does not export a plugin', async () => {
  227. const { ctx, module, imported, reload } = await fixture()
  228. const original = { apply: vi.fn() }
  229. const job = module('plugin.mjs', original)
  230. await ctx.loader.create({ name: job.url })
  231. await ctx.loader.await()
  232. imported.mockResolvedValueOnce({ notAPlugin: true })
  233. reload.stashed.add(job.url)
  234. await expect(reload.partialReload()).rejects.toThrow('invalid plugin')
  235. expect(original.apply).toHaveBeenCalledTimes(2)
  236. })
  237. it('reports a missing entry base URL without changing the running module', async () => {
  238. const { ctx, module, reload } = await fixture()
  239. const job = module('plugin.mjs', { apply() {} })
  240. const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url }))
  241. await ctx.loader.await()
  242. const missingBase = new Context()
  243. onTestFinished(() => missingBase.fiber.dispose())
  244. entry.parent.tree.ctx = missingBase
  245. reload.stashed.add(job.url)
  246. await expect(reload.partialReload()).rejects.toThrow('no base URL')
  247. })
  248. it('ignores framework entries and reports unresolved entry modules', async () => {
  249. const { ctx, module, resolve, reload } = await fixture()
  250. const framework = module('framework.mjs', { apply() {} })
  251. const uncached = module('uncached.mjs', { apply() {} })
  252. await ctx.loader.create({ name: framework.url })
  253. await ctx.loader.create({ name: uncached.url })
  254. await ctx.loader.await()
  255. reload.externals.add(framework.url)
  256. resolve.mockImplementation((name) => {
  257. if (name === uncached.url) throw new Error('entry gone')
  258. return { url: name }
  259. })
  260. reload.stashed.add(module('dependency.mjs').url)
  261. const warn = vi.spyOn(ctx.logger, 'warn')
  262. await reload.partialReload()
  263. expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'entry gone' }))
  264. expect(await ctx.hmr.getLinked('missing')).toEqual([])
  265. })
  266. it.each(['uncached', 'no-plugin'])('leaves an entry alone when its cache is %s', async (state) => {
  267. const { ctx, module, cache, imported, reload } = await fixture()
  268. const job = module('plugin.mjs', { apply() {} })
  269. await ctx.loader.create({ name: job.url })
  270. await ctx.loader.await()
  271. if (state === 'uncached') cache.delete(job.url)
  272. else job.module!.getNamespace = () => undefined
  273. imported.mockClear()
  274. reload.stashed.add(module('changed.mjs').url)
  275. await reload.partialReload()
  276. expect(imported).not.toHaveBeenCalled()
  277. })
  278. it('restores the CommonJS cache when an ESM replacement import fails', async () => {
  279. const { ctx, module, imported, reload } = await fixture()
  280. const job = module('plugin.cjs', { apply() {} })
  281. await ctx.loader.create({ name: job.url })
  282. await ctx.loader.await()
  283. const require = createRequire(import.meta.url)
  284. const filename = fileURLToPath(job.url)
  285. const cached = new NodeModule(filename)
  286. require.cache[filename] = cached
  287. onTestFinished(() => { Reflect.deleteProperty(require.cache, filename) })
  288. imported.mockRejectedValueOnce(new Error('compile failed'))
  289. reload.stashed.add(job.url)
  290. await expect(reload.partialReload()).rejects.toThrow('compile failed')
  291. expect(require.cache[filename]).toBe(cached)
  292. })
  293. it('can replace a plugin that has an earlier activation failure', async () => {
  294. const { ctx, module, imports, reload } = await fixture()
  295. const job = module('plugin.mjs', { apply() { throw new Error('earlier activation failed') } })
  296. await ctx.loader.create({ name: job.url })
  297. await ctx.loader.await()
  298. const apply = vi.fn()
  299. imports.set(job.url, { apply })
  300. reload.stashed.add(job.url)
  301. await reload.partialReload()
  302. expect(apply).toHaveBeenCalledOnce()
  303. })
  304. it('reports the original replacement failure when restoring the old plugin also fails', async () => {
  305. const { ctx, module, imports, reload } = await fixture()
  306. let restoring = false
  307. const job = module('plugin.mjs', { apply() {
  308. if (restoring) throw new Error('restore failed')
  309. } })
  310. await ctx.loader.create({ name: job.url })
  311. await ctx.loader.await()
  312. restoring = true
  313. imports.set(job.url, { apply() { throw new Error('replacement failed') } })
  314. const warn = vi.spyOn(ctx.logger, 'warn')
  315. reload.stashed.add(job.url)
  316. await expect(reload.partialReload()).rejects.toThrow('replacement failed')
  317. expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'restore failed' }))
  318. })