162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { configSchema } from '../config/schema.js';
|
|
import { Lifecycle } from './lifecycle.js';
|
|
import { initSkills } from './services.js';
|
|
|
|
describe('initSkills watcher wiring', () => {
|
|
const roots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
for (const root of roots.splice(0)) {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function writeSkill(rootDir: string, name: string, instructions = `# ${name}\n\nTest skill.`): void {
|
|
const skillDir = join(rootDir, name);
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), instructions);
|
|
}
|
|
|
|
function makeConfig(overrides: Record<string, unknown> = {}) {
|
|
return configSchema.parse({
|
|
telegram: { bot_token: 'test-token', allowed_chat_ids: [1] },
|
|
models: { default: { provider: 'anthropic', model: 'claude-3' } },
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
it('does not create a watcher when disabled', () => {
|
|
const config = makeConfig();
|
|
const lifecycle = new Lifecycle();
|
|
|
|
const result = initSkills(config, lifecycle);
|
|
|
|
expect(result.skillsWatcher).toBeUndefined();
|
|
});
|
|
|
|
it('starts watcher and stops it on lifecycle shutdown when enabled', async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'flynn-services-'));
|
|
roots.push(root);
|
|
const managedDir = join(root, 'skills');
|
|
mkdirSync(managedDir, { recursive: true });
|
|
|
|
const config = makeConfig({
|
|
skills: {
|
|
managed_dir: managedDir,
|
|
load: { watch: true, watch_debounce_ms: 100 },
|
|
},
|
|
});
|
|
const lifecycle = new Lifecycle();
|
|
|
|
const result = initSkills(config, lifecycle);
|
|
|
|
expect(result.skillsWatcher?.isRunning).toBe(true);
|
|
expect(result.skillsWatcher?.watchedDirectoryCount).toBe(1);
|
|
|
|
await lifecycle.shutdown();
|
|
expect(result.skillsWatcher?.isRunning).toBe(false);
|
|
});
|
|
|
|
it('applies targeted add/update changes for a mapped skill path', () => {
|
|
vi.useFakeTimers();
|
|
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
const root = mkdtempSync(join(tmpdir(), 'flynn-services-'));
|
|
roots.push(root);
|
|
const managedDir = join(root, 'skills');
|
|
mkdirSync(managedDir, { recursive: true });
|
|
writeSkill(managedDir, 'alpha');
|
|
|
|
const config = makeConfig({
|
|
skills: {
|
|
managed_dir: managedDir,
|
|
load: { watch: true, watch_debounce_ms: 20 },
|
|
},
|
|
});
|
|
const lifecycle = new Lifecycle();
|
|
|
|
const result = initSkills(config, lifecycle);
|
|
expect(result.skillRegistry.get('alpha')).toBeDefined();
|
|
expect(result.skillRegistry.get('beta')).toBeUndefined();
|
|
|
|
writeSkill(managedDir, 'beta');
|
|
result.skillsWatcher?.notifyPathChanged(join(managedDir, 'beta', 'SKILL.md'));
|
|
vi.advanceTimersByTime(20);
|
|
expect(result.skillRegistry.get('beta')).toBeDefined();
|
|
|
|
writeSkill(managedDir, 'beta', '# beta\n\nUpdated instructions.');
|
|
result.skillsWatcher?.notifyPathChanged(join(managedDir, 'beta', 'SKILL.md'));
|
|
vi.advanceTimersByTime(20);
|
|
expect(result.skillRegistry.get('beta')?.instructions).toContain('Updated instructions.');
|
|
expect(consoleLog.mock.calls.some((call) => call[0]?.includes('mode=targeted'))).toBe(true);
|
|
|
|
result.skillsWatcher?.stop();
|
|
});
|
|
|
|
it('unregisters a removed mapped skill path', () => {
|
|
vi.useFakeTimers();
|
|
const root = mkdtempSync(join(tmpdir(), 'flynn-services-'));
|
|
roots.push(root);
|
|
const managedDir = join(root, 'skills');
|
|
mkdirSync(managedDir, { recursive: true });
|
|
writeSkill(managedDir, 'alpha');
|
|
writeSkill(managedDir, 'beta');
|
|
|
|
const config = makeConfig({
|
|
skills: {
|
|
managed_dir: managedDir,
|
|
load: { watch: true, watch_debounce_ms: 20 },
|
|
},
|
|
});
|
|
const lifecycle = new Lifecycle();
|
|
|
|
const result = initSkills(config, lifecycle);
|
|
expect(result.skillRegistry.get('alpha')).toBeDefined();
|
|
expect(result.skillRegistry.get('beta')).toBeDefined();
|
|
|
|
rmSync(join(managedDir, 'alpha'), { recursive: true, force: true });
|
|
result.skillsWatcher?.notifyPathChanged(join(managedDir, 'alpha'));
|
|
vi.advanceTimersByTime(20);
|
|
|
|
expect(result.skillRegistry.get('alpha')).toBeUndefined();
|
|
expect(result.skillRegistry.get('beta')).toBeDefined();
|
|
|
|
result.skillsWatcher?.stop();
|
|
});
|
|
|
|
it('falls back to full reload for ambiguous paths', () => {
|
|
vi.useFakeTimers();
|
|
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
const root = mkdtempSync(join(tmpdir(), 'flynn-services-'));
|
|
roots.push(root);
|
|
const managedDir = join(root, 'skills');
|
|
mkdirSync(managedDir, { recursive: true });
|
|
writeSkill(managedDir, 'alpha');
|
|
|
|
const config = makeConfig({
|
|
skills: {
|
|
managed_dir: managedDir,
|
|
load: { watch: true, watch_debounce_ms: 20 },
|
|
},
|
|
});
|
|
const lifecycle = new Lifecycle();
|
|
|
|
const result = initSkills(config, lifecycle);
|
|
expect(result.skillRegistry.get('alpha')).toBeDefined();
|
|
expect(result.skillRegistry.get('beta')).toBeUndefined();
|
|
|
|
writeSkill(managedDir, 'beta');
|
|
result.skillsWatcher?.notifyPathChanged(join(root, 'not-a-skill-path'));
|
|
vi.advanceTimersByTime(20);
|
|
|
|
expect(result.skillRegistry.get('beta')).toBeDefined();
|
|
expect(consoleLog.mock.calls.some((call) => call[0]?.includes('fallback triggered (reason=unmapped_path'))).toBe(true);
|
|
result.skillsWatcher?.stop();
|
|
});
|
|
});
|