import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { loadInternalHooks } from './loader.js'; import { clearInternalHooks, getRegisteredEventKeys, triggerInternalHook, createInternalHookEvent } from './internal-hooks.js'; import type { ClawdbotConfig } from '../config/config.js'; describe('loader', () => { let tmpDir: string; let originalBundledDir: string | undefined; beforeEach(async () => { clearInternalHooks(); // Create a temp directory for test modules tmpDir = path.join(os.tmpdir(), `clawdbot-test-${Date.now()}`); await fs.mkdir(tmpDir, { recursive: true }); // Disable bundled hooks during tests by setting env var to non-existent directory originalBundledDir = process.env.CLAWDBOT_BUNDLED_HOOKS_DIR; process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = '/nonexistent/bundled/hooks'; }); afterEach(async () => { clearInternalHooks(); // Restore original env var if (originalBundledDir === undefined) { delete process.env.CLAWDBOT_BUNDLED_HOOKS_DIR; } else { process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = originalBundledDir; } // Clean up temp directory try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }); describe('loadInternalHooks', () => { it('should return 0 when internal hooks are not enabled', async () => { const cfg: ClawdbotConfig = { hooks: { internal: { enabled: false, }, }, }; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(0); }); it('should return 0 when hooks config is missing', async () => { const cfg: ClawdbotConfig = {}; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(0); }); it('should load a handler from a module', async () => { // Create a test handler module const handlerPath = path.join(tmpDir, 'test-handler.js'); const handlerCode = ` export default async function(event) { // Test handler } `; await fs.writeFile(handlerPath, handlerCode, 'utf-8'); const cfg: ClawdbotConfig = { hooks: { internal: { enabled: true, handlers: [ { event: 'command:new', module: handlerPath, }, ], }, }, }; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(1); const keys = getRegisteredEventKeys(); expect(keys).toContain('command:new'); }); it('should load multiple handlers', async () => { // Create test handler modules const handler1Path = path.join(tmpDir, 'handler1.js'); const handler2Path = path.join(tmpDir, 'handler2.js'); await fs.writeFile(handler1Path, 'export default async function() {}', 'utf-8'); await fs.writeFile(handler2Path, 'export default async function() {}', 'utf-8'); const cfg: ClawdbotConfig = { hooks: { internal: { enabled: true, handlers: [ { event: 'command:new', module: handler1Path }, { event: 'command:stop', module: handler2Path }, ], }, }, }; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(2); const keys = getRegisteredEventKeys(); expect(keys).toContain('command:new'); expect(keys).toContain('command:stop'); }); it('should support named exports', async () => { // Create a handler module with named export const handlerPath = path.join(tmpDir, 'named-export.js'); const handlerCode = ` export const myHandler = async function(event) { // Named export handler } `; await fs.writeFile(handlerPath, handlerCode, 'utf-8'); const cfg: ClawdbotConfig = { hooks: { internal: { enabled: true, handlers: [ { event: 'command:new', module: handlerPath, export: 'myHandler', }, ], }, }, }; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(1); }); it('should handle module loading errors gracefully', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const cfg: ClawdbotConfig = { hooks: { internal: { enabled: true, handlers: [ { event: 'command:new', module: '/nonexistent/path/handler.js', }, ], }, }, }; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(0); expect(consoleError).toHaveBeenCalledWith( expect.stringContaining('Failed to load internal hook handler'), expect.any(String) ); consoleError.mockRestore(); }); it('should handle non-function exports', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); // Create a module with a non-function export const handlerPath = path.join(tmpDir, 'bad-export.js'); await fs.writeFile(handlerPath, 'export default "not a function";', 'utf-8'); const cfg: ClawdbotConfig = { hooks: { internal: { enabled: true, handlers: [ { event: 'command:new', module: handlerPath, }, ], }, }, }; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(0); expect(consoleError).toHaveBeenCalledWith( expect.stringContaining('is not a function') ); consoleError.mockRestore(); }); it('should handle relative paths', async () => { // Create a handler module const handlerPath = path.join(tmpDir, 'relative-handler.js'); await fs.writeFile(handlerPath, 'export default async function() {}', 'utf-8'); // Get relative path from cwd const relativePath = path.relative(process.cwd(), handlerPath); const cfg: ClawdbotConfig = { hooks: { internal: { enabled: true, handlers: [ { event: 'command:new', module: relativePath, }, ], }, }, }; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(1); }); it('should actually call the loaded handler', async () => { // Create a handler that we can verify was called const handlerPath = path.join(tmpDir, 'callable-handler.js'); const handlerCode = ` let callCount = 0; export default async function(event) { callCount++; } export function getCallCount() { return callCount; } `; await fs.writeFile(handlerPath, handlerCode, 'utf-8'); const cfg: ClawdbotConfig = { hooks: { internal: { enabled: true, handlers: [ { event: 'command:new', module: handlerPath, }, ], }, }, }; await loadInternalHooks(cfg, tmpDir); // Trigger the hook const event = createInternalHookEvent('command', 'new', 'test-session'); await triggerInternalHook(event); // The handler should have been called, but we can't directly verify // the call count from this context without more complex test infrastructure // This test mainly verifies that loading and triggering doesn't crash expect(getRegisteredEventKeys()).toContain('command:new'); }); }); });