singularity-forge/src/tests/file-watcher.test.ts
Jeremy McSpadden 0b3163d297 feat: add /review skill, /test skill, chokidar file watcher, subcommand help
- Add /review skill: reviews staged/unstaged/commit changes for security,
  performance, bugs, and quality with structured findings by severity
- Add /test skill: auto-detects test framework, generates comprehensive
  tests for source files, or runs suites with failure analysis
- Add chokidar file watcher: watches ~/.gsd/agent/ for config changes
  (settings.json, auth.json, models.json, extensions/) with debounced
  events on an EventBus
- Add --help per subcommand: `gsd config --help` and `gsd update --help`
  show subcommand-specific usage information
- 8 new file-watcher tests (start/stop, event emission, debouncing,
  unrelated file filtering)
2026-03-16 13:47:25 -05:00

143 lines
4 KiB
TypeScript

import { test, afterEach } from "node:test";
import assert from "node:assert";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { setTimeout as delay } from "node:timers/promises";
import {
startFileWatcher,
stopFileWatcher,
} from "../resources/extensions/gsd/file-watcher.ts";
function createTempAgentDir(): string {
const tmp = mkdtempSync(join(tmpdir(), "gsd-fw-test-"));
mkdirSync(join(tmp, "extensions"), { recursive: true });
// Seed watched files so chokidar treats writes as "change" not "add"
writeFileSync(join(tmp, "settings.json"), "{}");
writeFileSync(join(tmp, "auth.json"), "{}");
writeFileSync(join(tmp, "models.json"), "{}");
return tmp;
}
function createMockEventBus() {
const events: { channel: string; data: unknown }[] = [];
return {
events,
emit(channel: string, data: unknown) {
events.push({ channel, data });
},
on(_channel: string, _handler: (data: unknown) => void) {
return () => {};
},
};
}
afterEach(async () => {
await stopFileWatcher();
});
test("startFileWatcher and stopFileWatcher run without errors", async () => {
const dir = createTempAgentDir();
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
await stopFileWatcher();
});
test("stopFileWatcher is safe to call when no watcher is active", async () => {
await stopFileWatcher();
});
test("settings.json change emits settings-changed event", async () => {
const dir = createTempAgentDir();
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
writeFileSync(join(dir, "settings.json"), JSON.stringify({ updated: true }));
// Wait for debounce (300ms) + filesystem propagation
await delay(600);
const matched = bus.events.filter((e) => e.channel === "settings-changed");
assert.ok(matched.length > 0, "should emit settings-changed event");
});
test("auth.json change emits auth-changed event", async () => {
const dir = createTempAgentDir();
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
writeFileSync(join(dir, "auth.json"), JSON.stringify({ token: "new" }));
await delay(600);
const matched = bus.events.filter((e) => e.channel === "auth-changed");
assert.ok(matched.length > 0, "should emit auth-changed event");
});
test("models.json change emits models-changed event", async () => {
const dir = createTempAgentDir();
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
writeFileSync(join(dir, "models.json"), JSON.stringify({ model: "new" }));
await delay(600);
const matched = bus.events.filter((e) => e.channel === "models-changed");
assert.ok(matched.length > 0, "should emit models-changed event");
});
test("extensions directory change emits extensions-changed event", async () => {
const dir = createTempAgentDir();
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
writeFileSync(
join(dir, "extensions", "my-ext.json"),
JSON.stringify({ name: "test" }),
);
await delay(600);
const matched = bus.events.filter(
(e) => e.channel === "extensions-changed",
);
assert.ok(matched.length > 0, "should emit extensions-changed event");
});
test("unrelated file changes are ignored", async () => {
const dir = createTempAgentDir();
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
// Wait for watcher to settle, then clear any residual events from setup
await delay(400);
bus.events.length = 0;
writeFileSync(join(dir, "random.txt"), "hello");
await delay(600);
assert.strictEqual(bus.events.length, 0, "should not emit any events");
});
test("debouncing coalesces rapid changes into one event", async () => {
const dir = createTempAgentDir();
const bus = createMockEventBus();
await startFileWatcher(dir, bus);
// Rapid-fire writes
for (let i = 0; i < 5; i++) {
writeFileSync(join(dir, "settings.json"), JSON.stringify({ i }));
}
await delay(600);
const matched = bus.events.filter((e) => e.channel === "settings-changed");
assert.strictEqual(
matched.length,
1,
"rapid changes should be debounced into a single event",
);
});