profile.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. /** Profile watches share HMR's queue and readiness barrier without waiting for package installation. */
  2. import { mkdtempSync, mkdirSync, readFileSync, 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 from '@deepseek-ai/cordis-plugin-loader'
  8. import Timer from '@deepseek-ai/cordis-plugin-timer'
  9. import { boot, initProfile, readProfileManifest, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
  10. import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
  11. import { FSWatcher } from 'chokidar'
  12. import { expect, it, onTestFinished, vi } from 'vitest'
  13. import Hmr from '../src/index.ts'
  14. const watchers = vi.hoisted(() => [] as FSWatcher[])
  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(() => watcher.emit('ready'))
  21. return watcher
  22. } }
  23. })
  24. async function fixture(beforeWatch?: (profile: ProfileContext) => void) {
  25. const home = realpathSync(mkdtempSync(join(tmpdir(), 'hmr-profile-')))
  26. const dir = join(home, 'profiles', 'test')
  27. initProfile(dir, [])
  28. writeFileSync(join(dir, 'cordis.yml'), '[]\n')
  29. const profile: ProfileContext = {
  30. name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), home, cwd: home,
  31. installAnchor: join(home, 'package.json'), startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
  32. }
  33. const rows = [{ insert: [
  34. { id: 'timer', name: 'cordis:timer' },
  35. { id: 'hmr', name: 'cordis:hmr', config: { root: [] } },
  36. { id: 'probe', name: 'cordis:probe', config: { value: 'initial' } },
  37. ] }]
  38. writeFileSync(profile.patchPath, JSON.stringify(rows))
  39. let commit: (() => void) | undefined
  40. const start = watchers.length
  41. const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (host) => {
  42. beforeWatch?.(profile)
  43. host.provide('profileContext', profile)
  44. host.provide('appReady', { onReady(listener) { commit = listener; return () => { commit = undefined } } })
  45. host.loader.builtins.timer = Timer
  46. host.loader.builtins.hmr = Hmr
  47. host.loader.builtins.probe = { apply(ctx: Context, config: { value: string }) { ctx.provide('profileProbe', config.value) } }
  48. })
  49. onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
  50. const configWatches = watchers.slice(start, start + 3)
  51. const emit = (index: number, filename: string) => { configWatches[index]!.emit('change', filename) }
  52. const drain = () => ctx.hmr.runExclusive(async () => {})
  53. return { ctx, profile, dir, home, emit, drain, commit: () => commit?.() }
  54. }
  55. it('waits for application readiness and applies profile, home and manifest changes', async () => {
  56. const f = await fixture()
  57. const patches = [{ insert: [
  58. { id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } },
  59. { id: 'probe', name: 'cordis:probe', config: { value: 'edited' } },
  60. ] }]
  61. writeFileSync(f.profile.patchPath, JSON.stringify(patches))
  62. f.emit(0, f.profile.patchPath)
  63. let completed = false
  64. const drained = f.drain().then(() => { completed = true })
  65. await Promise.resolve(undefined)
  66. expect(completed).toBe(false)
  67. expect(f.ctx.get('profileProbe')).toBe('initial')
  68. f.commit()
  69. await drained
  70. expect(f.ctx.get('profileProbe')).toBe('edited')
  71. const homePatch = join(f.home, 'cordis.patch.yml')
  72. writeFileSync(homePatch, '- id: probe\n config: { value: home }\n')
  73. f.emit(1, homePatch)
  74. await f.drain()
  75. expect(f.ctx.get('profileProbe')).toBe('home')
  76. rmSync(homePatch)
  77. const manifest = join(f.dir, 'package.json')
  78. f.emit(1, homePatch)
  79. await f.drain()
  80. expect(f.ctx.get('profileProbe')).toBe('edited')
  81. f.emit(2, manifest)
  82. await f.drain()
  83. expect(f.ctx.get('profileProbe')).toBe('edited')
  84. })
  85. it('applies configuration while a CLI operation holds the package writer lock', async () => {
  86. const f = await fixture()
  87. f.commit()
  88. await withFileLock(join(f.dir, 'package.json'), async () => {
  89. const original = readFileSync(f.profile.patchPath, 'utf8')
  90. writeFileSync(f.profile.patchPath, original.replace('initial', 'during-install'))
  91. f.emit(0, f.profile.patchPath)
  92. await f.drain()
  93. expect(f.ctx.get('profileProbe')).toBe('during-install')
  94. })
  95. })
  96. it('ignores dependency-only manifest changes and reloads a changed bundle list', async () => {
  97. const f = await fixture()
  98. f.commit()
  99. const include = [...f.ctx.loader.entries()].find(entry => entry.id === 'include')!
  100. const update = vi.spyOn(include, 'update')
  101. onTestFinished(() => { update.mockRestore() })
  102. const manifestPath = join(f.dir, 'package.json')
  103. const manifest = readProfileManifest('test', f.dir)
  104. manifest.dependencies = { added: '1.0.0' }
  105. delete manifest.dsh!.profile!.bundles
  106. writeFileSync(manifestPath, JSON.stringify(manifest))
  107. f.emit(2, manifestPath)
  108. await f.drain()
  109. expect(update).not.toHaveBeenCalled()
  110. const packageDir = join(f.dir, 'node_modules', 'added')
  111. mkdirSync(packageDir, { recursive: true })
  112. writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'added', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  113. writeFileSync(join(packageDir, 'cordis.patch.yml'), '- insert:\n - id: bundled\n name: cordis:probe\n disabled: true\n')
  114. manifest.dsh!.profile!.bundles = ['added']
  115. writeFileSync(manifestPath, JSON.stringify(manifest))
  116. f.emit(2, manifestPath)
  117. await f.drain()
  118. expect(update).toHaveBeenCalledOnce()
  119. f.emit(0, f.profile.patchPath)
  120. await f.drain()
  121. expect(update).toHaveBeenCalledOnce()
  122. expect([...f.ctx.loader.entries()].some(entry => entry.id === 'include:bundled')).toBe(true)
  123. })
  124. it('cancels queued reloads when the application exits before readiness', async () => {
  125. const f = await fixture()
  126. f.emit(0, f.profile.patchPath)
  127. await f.ctx.fiber.dispose()
  128. f.commit()
  129. expect(f.ctx.get('hmr')).toBeUndefined()
  130. })
  131. it('reports unreadable inputs and recovers after the file is corrected', async () => {
  132. const f = await fixture()
  133. f.commit()
  134. const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {})
  135. onTestFinished(() => { warn.mockRestore() })
  136. const original = readFileSync(f.profile.patchPath, 'utf8')
  137. rmSync(f.profile.patchPath)
  138. mkdirSync(f.profile.patchPath)
  139. f.emit(0, f.profile.patchPath)
  140. await f.drain()
  141. expect(warn).toHaveBeenCalled()
  142. rmSync(f.profile.patchPath, { recursive: true })
  143. writeFileSync(f.profile.patchPath, original)
  144. f.emit(0, f.profile.patchPath)
  145. await f.drain()
  146. expect(readFileSync(f.profile.patchPath, 'utf8')).toBe(original)
  147. })
  148. it('rejects a profile launcher that omits application readiness', async () => {
  149. const ctx = new Context()
  150. ctx.baseUrl = pathToFileURL(process.cwd() + '/').href
  151. onTestFinished(() => ctx.fiber.dispose())
  152. await ctx.plugin(Loader)
  153. await ctx.plugin(Timer)
  154. ctx.provide('profileContext', {} as ProfileContext)
  155. await expect(ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })).rejects.toThrow('Profile HMR requires application readiness')
  156. })
  157. it('cancels a queued module notification when startup is interrupted', async () => {
  158. const f = await fixture()
  159. const queued = Promise.withResolvers<undefined>()
  160. const hmr = f.ctx.hmr
  161. const run = hmr.runExclusive.bind(hmr)
  162. vi.spyOn(hmr, 'runExclusive').mockImplementation((operation) => { queued.resolve(undefined); return run(operation) })
  163. watchers.at(-1)!.emit('change', 'pending.mjs')
  164. await queued.promise
  165. await f.ctx.fiber.dispose()
  166. expect(f.ctx.get('hmr')).toBeUndefined()
  167. })
  168. it('logs unchanged inactive entries while applying an unrelated file edit', async () => {
  169. const f = await fixture()
  170. f.commit()
  171. const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {})
  172. onTestFinished(() => { warn.mockRestore() })
  173. const initial = readFileSync(f.profile.patchPath, 'utf8')
  174. const rows = JSON.parse(initial) as { insert: { id: string; name: string }[] }[]
  175. rows[0]!.insert.push({ id: 'missing', name: './missing.mjs' })
  176. writeFileSync(f.profile.patchPath, JSON.stringify(rows))
  177. f.emit(0, f.profile.patchPath)
  178. await f.drain()
  179. warn.mockClear()
  180. writeFileSync(f.profile.patchPath, JSON.stringify(rows).replace('initial', 'edited'))
  181. f.emit(0, f.profile.patchPath)
  182. await f.drain()
  183. expect(f.ctx.get('profileProbe')).toBe('edited')
  184. expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing ('))
  185. })
  186. it('applies a patch edited after boot parsing but before watcher registration', async () => {
  187. const f = await fixture((profile) => {
  188. const original = readFileSync(profile.patchPath, 'utf8')
  189. writeFileSync(profile.patchPath, original.replace('initial', 'edited-during-boot'))
  190. })
  191. expect(f.ctx.get('profileProbe')).toBe('initial')
  192. f.commit()
  193. f.emit(0, f.profile.patchPath)
  194. await f.drain()
  195. expect(f.ctx.get('profileProbe')).toBe('edited-during-boot')
  196. })