This commit is contained in:
121
packages/agent/tests/skill-watcher.test.ts
Normal file
121
packages/agent/tests/skill-watcher.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Skills 2.0 Tier 3b — fs.watch-based SKILL.md watcher.
|
||||
*
|
||||
* fs.watch semantics are OS-dependent (inotify/FSEvents/ReadDirectoryChangesW)
|
||||
* so tests verify the public contract rather than exact event counts:
|
||||
* - callback fires for .md changes
|
||||
* - non-.md files are ignored
|
||||
* - callback is debounced (single burst → one call)
|
||||
* - close() is idempotent and silences further events
|
||||
* - unsupported filesystems don't crash
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { watchSkillDirectory } from '../src/skill-watcher.js';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
describe('watchSkillDirectory — Tier 3b', () => {
|
||||
let tmpDir: string;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-skill-watch-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the directory if it does not exist', () => {
|
||||
const newDir = path.join(tmpDir, 'not-yet-created');
|
||||
expect(fs.existsSync(newDir)).toBe(false);
|
||||
const handle = watchSkillDirectory(newDir, { onChange: () => {} });
|
||||
expect(fs.existsSync(newDir)).toBe(true);
|
||||
handle.close();
|
||||
});
|
||||
|
||||
it('fires onChange when a .md file is written', async () => {
|
||||
const events: string[][] = [];
|
||||
const handle = watchSkillDirectory(tmpDir, {
|
||||
onChange: (changed) => events.push(changed),
|
||||
debounceMs: 50,
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'fresh.md'), 'body');
|
||||
await sleep(200); // let the debounced callback fire
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
const allChanged = events.flat();
|
||||
expect(allChanged).toContain('fresh.md');
|
||||
handle.close();
|
||||
});
|
||||
|
||||
it('ignores non-.md files', async () => {
|
||||
const events: string[][] = [];
|
||||
const handle = watchSkillDirectory(tmpDir, {
|
||||
onChange: (changed) => events.push(changed),
|
||||
debounceMs: 50,
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'body');
|
||||
fs.writeFileSync(path.join(tmpDir, 'config.json'), '{}');
|
||||
await sleep(200);
|
||||
|
||||
const allChanged = events.flat();
|
||||
expect(allChanged).not.toContain('notes.txt');
|
||||
expect(allChanged).not.toContain('config.json');
|
||||
handle.close();
|
||||
});
|
||||
|
||||
it('debounces rapid .md writes into a single callback', async () => {
|
||||
const events: string[][] = [];
|
||||
const handle = watchSkillDirectory(tmpDir, {
|
||||
onChange: (changed) => events.push(changed),
|
||||
debounceMs: 100,
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'x');
|
||||
fs.writeFileSync(path.join(tmpDir, 'b.md'), 'x');
|
||||
fs.writeFileSync(path.join(tmpDir, 'c.md'), 'x');
|
||||
await sleep(250);
|
||||
|
||||
// One debounced callback with all changed files
|
||||
expect(events.length).toBeLessThanOrEqual(2); // 1 debounce window + possible tail
|
||||
const allChanged = new Set(events.flat());
|
||||
expect(allChanged.has('a.md')).toBe(true);
|
||||
expect(allChanged.has('b.md')).toBe(true);
|
||||
expect(allChanged.has('c.md')).toBe(true);
|
||||
handle.close();
|
||||
});
|
||||
|
||||
it('close() is idempotent and silences further callbacks', async () => {
|
||||
let calls = 0;
|
||||
const handle = watchSkillDirectory(tmpDir, {
|
||||
onChange: () => { calls++; },
|
||||
debounceMs: 30,
|
||||
});
|
||||
|
||||
handle.close();
|
||||
handle.close(); // second close must not throw
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'post-close.md'), 'x');
|
||||
await sleep(150);
|
||||
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('does not throw if onChange callback throws', async () => {
|
||||
const handle = watchSkillDirectory(tmpDir, {
|
||||
onChange: () => { throw new Error('callback explosion'); },
|
||||
debounceMs: 30,
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'explode.md'), 'x');
|
||||
// If this crashes the process, the test runner will fail globally.
|
||||
await sleep(150);
|
||||
handle.close();
|
||||
expect(true).toBe(true); // reached without unhandled exception
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user