/** * @file Unit tests for cc-watcher's inotify budget. Node's recursive fs.watch is * a userland tree walk on Linux that costs one inotify watch per directory, so * recursively watching ~/.claude/plugins (plugin cache: git checkouts with * node_modules) exhausted fs.inotify.max_user_watches and starved the desktop. * These tests pin which watched subdirs may be recursive. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach, afterEach } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("fs"); const path = require("path"); const os = require("os"); let tmpHome; let realWatch; let calls; describe("cc-watcher inotify budget", () => { beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "cc-watcher-")); process.env.CLAUDE_HOME = tmpHome; for (const d of ["agents", "commands", "skills", "output-styles", "hooks", "plugins"]) { fs.mkdirSync(path.join(tmpHome, d), { recursive: true }); } // The real shape that caused the leak: a deep tree under plugins/. fs.mkdirSync(path.join(tmpHome, "plugins", "cache", "x", "y", "node_modules", "z"), { recursive: true, }); calls = []; realWatch = fs.watch; fs.watch = (target, opts, cb) => { calls.push({ target, recursive: !!(opts && opts.recursive) }); return realWatch( target, typeof opts === "function" ? {} : opts, typeof opts === "function" ? opts : cb ); }; delete require.cache[require.resolve("../lib/cc-watcher")]; delete require.cache[require.resolve("../lib/claude-home")]; }); afterEach(() => { require("../lib/cc-watcher").stopCcWatcher(); fs.watch = realWatch; delete process.env.CLAUDE_HOME; fs.rmSync(tmpHome, { recursive: true, force: true }); }); it("never watches plugins/ recursively", () => { require("../lib/cc-watcher").startCcWatcher({ broadcast: () => {} }); const pluginsWatches = calls.filter((c) => c.target === path.join(tmpHome, "plugins")); assert.equal(pluginsWatches.length, 1, "plugins/ should get exactly one watch"); assert.equal(pluginsWatches[0].recursive, false, "plugins/ must not be watched recursively"); }); it("still watches plugins/ so its top-level config files fire", () => { require("../lib/cc-watcher").startCcWatcher({ broadcast: () => {} }); assert.ok(calls.some((c) => c.target === path.join(tmpHome, "plugins"))); }); it("keeps recursion for the small config subdirs", () => { require("../lib/cc-watcher").startCcWatcher({ broadcast: () => {} }); for (const sub of ["agents", "commands", "skills", "output-styles", "hooks"]) { const w = calls.find((c) => c.target === path.join(tmpHome, sub)); assert.ok(w, `${sub} should be watched`); assert.equal(w.recursive, true, `${sub} should stay recursive`); } }); });