chokidar.spec.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. /** Upstream Chokidar running unchanged through the shipped Worker module loader. */
  2. import { existsSync, readFileSync } from 'node:fs'
  3. import { createRequire } from 'node:module'
  4. import { dirname, join } from 'node:path'
  5. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  6. import { lowerModuleSource } from '../../src/compile/transform.ts'
  7. import { WorkerModuleLoader } from '../../src/module-system/module-loader.ts'
  8. import { createNodeBuiltins } from '../../src/node/builtins.ts'
  9. import { MemoryVfs } from '../../src/storage/memory.ts'
  10. import { setActiveVfs } from '../../src/storage/active.ts'
  11. const ROOT = '/dsh/workspace/skills'
  12. let vfs: MemoryVfs
  13. let chokidar: typeof import('chokidar')
  14. const openWatchers: import('chokidar').FSWatcher[] = []
  15. interface ChokidarFixture {
  16. readonly label: string
  17. readonly consumerManifest: string
  18. readonly chokidarFiles: readonly string[]
  19. readonly readdirpFiles: readonly string[]
  20. }
  21. const CHOKIDAR_FIXTURES: readonly ChokidarFixture[] = [
  22. {
  23. label: 'Chokidar 4 from settings and credentials',
  24. consumerManifest: 'packages/settings/settings-file/package.json',
  25. chokidarFiles: ['package.json', 'esm/package.json', 'esm/index.js', 'esm/handler.js'],
  26. readdirpFiles: ['package.json', 'esm/package.json', 'esm/index.js'],
  27. },
  28. {
  29. label: 'Chokidar 5 from skill-filesystem',
  30. consumerManifest: 'packages/skill/skill-filesystem/package.json',
  31. chokidarFiles: ['package.json', 'index.js', 'handler.js'],
  32. readdirpFiles: ['package.json', 'index.js'],
  33. },
  34. ]
  35. /** Copy one installed JavaScript package into the VFS exactly as the packer does. */
  36. function packageRoot(name: string, entry: string): string {
  37. for (let directory = dirname(entry);;) {
  38. const manifest = join(directory, 'package.json')
  39. if (existsSync(manifest)) {
  40. const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }
  41. if (parsed.name === name) return directory
  42. }
  43. const parent = dirname(directory)
  44. if (parent === directory) throw new Error(`cannot locate package root for ${name}`)
  45. directory = parent
  46. }
  47. }
  48. /** Copy the package files selected by the packer's import condition. */
  49. function mountPackage(name: string, directory: string, files: readonly string[]): void {
  50. for (const file of files) {
  51. const source = readFileSync(join(directory, file), 'utf8')
  52. const path = `/dsh/node_modules/${name}/${file}`
  53. vfs.seed(path, file.endsWith('.js') ? lowerModuleSource({ filename: path, source }).code : source)
  54. }
  55. }
  56. /** Load one consumer's exact Chokidar and readdirp versions through the Worker loader. */
  57. function loadChokidar(fixture: ChokidarFixture): typeof import('chokidar') {
  58. const consumerManifest = join(process.cwd(), fixture.consumerManifest)
  59. const chokidarEntry = createRequire(consumerManifest).resolve('chokidar')
  60. const readdirpEntry = createRequire(chokidarEntry).resolve('readdirp')
  61. mountPackage('chokidar', packageRoot('chokidar', chokidarEntry), fixture.chokidarFiles)
  62. mountPackage('readdirp', packageRoot('readdirp', readdirpEntry), fixture.readdirpFiles)
  63. const loader = new WorkerModuleLoader({ vfs, staticModules: createNodeBuiltins() })
  64. return loader.createRequire('/dsh/')('chokidar') as typeof import('chokidar')
  65. }
  66. beforeEach(() => {
  67. vfs = new MemoryVfs()
  68. setActiveVfs(vfs)
  69. vfs.mkdirSync(ROOT, { recursive: true })
  70. })
  71. afterEach(async () => {
  72. await Promise.all(openWatchers.splice(0).map(async (watcher) => { await watcher.close() }))
  73. })
  74. /** Await one emitter event while rejecting hangs deterministically. */
  75. function onceEvent<T>(watcher: import('chokidar').FSWatcher, event: string): Promise<T> {
  76. return new Promise<T>((resolve, reject) => {
  77. const timeout = setTimeout(() => { reject(new Error(`timed out waiting for chokidar ${event}`)) }, 2_000)
  78. const emitter = watcher as unknown as {
  79. once(name: string, listener: (...args: unknown[]) => void): void
  80. }
  81. emitter.once(event, (...args: unknown[]) => {
  82. clearTimeout(timeout)
  83. resolve(args[0] as T)
  84. })
  85. })
  86. }
  87. /** Let watcher timers and promise-based stats reach a stable point. */
  88. async function delay(ms: number): Promise<void> {
  89. await new Promise<void>((resolve) => { setTimeout(resolve, ms) })
  90. }
  91. /** Construct one tracked watcher with deterministic event normalization. */
  92. function watchPath(path: string, options: import('chokidar').ChokidarOptions = {}): import('chokidar').FSWatcher {
  93. const watcher = chokidar.watch(path, {
  94. ignoreInitial: true,
  95. atomic: false,
  96. awaitWriteFinish: false,
  97. ...options,
  98. })
  99. openWatchers.push(watcher)
  100. return watcher
  101. }
  102. describe.each(CHOKIDAR_FIXTURES)('$label running unchanged', (fixture) => {
  103. beforeEach(() => {
  104. chokidar = loadChokidar(fixture)
  105. })
  106. it('reaches ready and reports a file lifecycle through fs.watch', async () => {
  107. const watcher = watchPath(ROOT, { depth: 1 })
  108. await onceEvent(watcher, 'ready')
  109. const directory = `${ROOT}/sample`
  110. const file = `${directory}/SKILL.md`
  111. const addDirectory = onceEvent<string>(watcher, 'addDir')
  112. const addFile = onceEvent<string>(watcher, 'add')
  113. vfs.mkdirSync(directory)
  114. vfs.writeFileSync(file, '# sample\n')
  115. await expect(addDirectory).resolves.toBe(directory)
  116. await expect(addFile).resolves.toBe(file)
  117. const changed = onceEvent<string>(watcher, 'change')
  118. vfs.writeFileSync(file, '# changed\n')
  119. await expect(changed).resolves.toBe(file)
  120. await new Promise((resolve) => { setTimeout(resolve, 10) })
  121. const removed = onceEvent<string>(watcher, 'unlink')
  122. vfs.rmSync(file)
  123. await expect(removed).resolves.toBe(file)
  124. })
  125. it('watches a missing file through its existing parent', async () => {
  126. const path = '/dsh/home/settings.yaml'
  127. vfs.mkdirSync('/dsh/home', { recursive: true })
  128. const watcher = watchPath(path)
  129. await onceEvent(watcher, 'ready')
  130. const added = onceEvent<string>(watcher, 'add')
  131. vfs.writeFileSync(path, 'theme: dark\n')
  132. await expect(added).resolves.toBe(path)
  133. const removed = onceEvent<string>(watcher, 'unlink')
  134. vfs.rmSync(path)
  135. await expect(removed).resolves.toBe(path)
  136. })
  137. it('discovers directory children through watchFile polling mode', async () => {
  138. const watcher = watchPath(ROOT, { usePolling: true, interval: 5 })
  139. await onceEvent(watcher, 'ready')
  140. const path = `${ROOT}/standalone.md`
  141. const added = onceEvent<string>(watcher, 'add')
  142. vfs.writeFileSync(path, '# standalone\n')
  143. await expect(added).resolves.toBe(path)
  144. })
  145. it('normalizes a short unlink/add replacement into one atomic change', async () => {
  146. const path = `${ROOT}/atomic.md`
  147. vfs.writeFileSync(path, 'before')
  148. const watcher = watchPath(path, { atomic: 40 })
  149. await onceEvent(watcher, 'ready')
  150. const events: string[] = []
  151. watcher.on('all', (event) => { events.push(event) })
  152. const changed = onceEvent<string>(watcher, 'change')
  153. vfs.rmSync(path)
  154. await delay(5)
  155. vfs.writeFileSync(path, 'after')
  156. await expect(changed).resolves.toBe(path)
  157. await delay(60)
  158. expect(events).toEqual(['change'])
  159. })
  160. it('waits for a write burst to stabilize before publishing one add', async () => {
  161. const path = `${ROOT}/settling.md`
  162. const watcher = watchPath(ROOT, {
  163. awaitWriteFinish: { stabilityThreshold: 30, pollInterval: 5 },
  164. })
  165. await onceEvent(watcher, 'ready')
  166. const events: string[] = []
  167. watcher.on('all', (event) => { events.push(event) })
  168. const added = onceEvent<string>(watcher, 'add')
  169. vfs.writeFileSync(path, 'a')
  170. await delay(10)
  171. vfs.appendFileSync(path, 'b')
  172. await delay(10)
  173. vfs.appendFileSync(path, 'c')
  174. await expect(added).resolves.toBe(path)
  175. expect(events).toEqual(['add'])
  176. })
  177. it('emits nothing after close has reached quiescence', async () => {
  178. const watcher = watchPath(ROOT)
  179. const events: string[] = []
  180. watcher.on('all', (event) => { events.push(event) })
  181. await onceEvent(watcher, 'ready')
  182. await watcher.close()
  183. vfs.writeFileSync(`${ROOT}/after.md`, '# after\n')
  184. await Promise.resolve()
  185. expect(events).toEqual([])
  186. })
  187. })