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 = {}) { 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(); }); });