chokidar-mock.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. /**
  2. * Deterministic chokidar mock for FileWatcher tests.
  3. *
  4. * The real chokidar binding goes through FSEvents (macOS) / inotify (Linux) /
  5. * ReadDirectoryChangesW (Windows). Under parallel vitest execution, those
  6. * OS-level subsystems serve multiple test files simultaneously and event
  7. * delivery latency grows non-deterministically — `should expose edited paths
  8. * via getPendingFiles before sync fires` and the `mcp-staleness-banner` tests
  9. * have observably raced for that reason (consistent ~30% failure rate when
  10. * running the full suite, 0/N when run in isolation).
  11. *
  12. * This mock replaces chokidar with a controllable in-process EventEmitter:
  13. *
  14. * - `chokidar.watch(root, opts)` returns an instance keyed by `root`.
  15. * - The instance fires `ready` on the next microtask, matching the
  16. * real chokidar shape (tests' `waitUntilReady()` resolves promptly).
  17. * - Tests synthesize file events via `triggerFileEvent(root, 'add', rel)`
  18. * instead of `fs.writeFileSync(...)` — no OS-level watcher in the loop,
  19. * no waitFor polling against unpredictable delivery latency.
  20. * - The actual debounce timer in FileWatcher is left untouched (real
  21. * setTimeout). That's the unit under test; deterministic timing
  22. * would change what the test asserts.
  23. *
  24. * Install with `vi.mock('chokidar', () => chokidarMockModule)` at the
  25. * top of each test file (must be hoisted, hence the static export).
  26. *
  27. * All instances live in module scope — clear them in `afterEach` if a
  28. * test creates watchers and needs hard isolation, but in practice the
  29. * `close()` plumbing handles it.
  30. */
  31. import { EventEmitter } from 'node:events';
  32. /** One mock watcher per `chokidar.watch(root, ...)` call. */
  33. class MockChokidarWatcher extends EventEmitter {
  34. private closed = false;
  35. private readyFired = false;
  36. constructor(public readonly root: string) {
  37. super();
  38. // Mirror chokidar: `ready` fires asynchronously after the initial scan.
  39. // We use queueMicrotask so it's deterministic and as fast as possible —
  40. // tests' `await watcher.waitUntilReady()` resolves immediately.
  41. queueMicrotask(() => {
  42. if (this.closed) return;
  43. this.readyFired = true;
  44. this.emit('ready');
  45. });
  46. }
  47. /** chokidar.FSWatcher#close shape. */
  48. close(): Promise<void> {
  49. this.closed = true;
  50. this.removeAllListeners();
  51. instancesByRoot.delete(this.root);
  52. return Promise.resolve();
  53. }
  54. /** Test-only helper to synthesize a file event. */
  55. triggerEvent(event: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir', absPath: string): void {
  56. if (this.closed) return;
  57. // Real chokidar emits both the typed event AND the catch-all 'all'.
  58. // FileWatcher only listens on 'all'.
  59. this.emit('all', event, absPath);
  60. }
  61. /** True once the initial-scan `ready` event has been emitted. */
  62. isReady(): boolean {
  63. return this.readyFired;
  64. }
  65. }
  66. const instancesByRoot = new Map<string, MockChokidarWatcher>();
  67. /**
  68. * The mock module — pass this to `vi.mock('chokidar', () => chokidarMockModule)`.
  69. * The factory must NOT close over outer-scope state because vi.mock hoists.
  70. */
  71. export const chokidarMockModule = {
  72. default: {
  73. watch: (root: string, _opts?: unknown) => {
  74. const inst = new MockChokidarWatcher(root);
  75. instancesByRoot.set(root, inst);
  76. return inst;
  77. },
  78. },
  79. };
  80. /**
  81. * Test-side helper: synthesize a chokidar event on the watcher created for
  82. * `root`. Use after the watcher's `waitUntilReady()` has resolved, since
  83. * FileWatcher only adds events to its pending set when `chokidarReady` is
  84. * true.
  85. *
  86. * `relPath` is path.join'd with `root` before emission, matching how
  87. * chokidar delivers absolute paths to the `all` handler.
  88. */
  89. export function triggerFileEvent(
  90. root: string,
  91. event: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir',
  92. relPath: string,
  93. ): void {
  94. const inst = instancesByRoot.get(root);
  95. if (!inst) {
  96. throw new Error(
  97. `triggerFileEvent: no mock chokidar watcher registered for root '${root}' — did chokidar.watch() get called?`,
  98. );
  99. }
  100. // FileWatcher uses path.relative(root, eventPath) to compute the
  101. // normalized path it stores. We supply the absolute path here so that
  102. // operation produces the relPath the test wrote.
  103. const absPath = require('node:path').join(root, relPath);
  104. inst.triggerEvent(event, absPath);
  105. }
  106. /** Reset all in-memory mock watchers — call in afterEach when needed. */
  107. export function resetChokidarMock(): void {
  108. for (const inst of instancesByRoot.values()) {
  109. inst.removeAllListeners();
  110. }
  111. instancesByRoot.clear();
  112. }