import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { clearInternalHooks, getRegisteredEventKeys, triggerInternalHook, createInternalHookEvent, } from "./internal-hooks.js"; import { loadInternalHooks } from "./loader.js"; describe("loader", () => { let fixtureRoot = ""; let caseId = 0; let tmpDir: string; let originalBundledDir: string | undefined; beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hooks-loader-")); }); beforeEach(async () => { clearInternalHooks(); // Create a temp directory for test modules tmpDir = path.join(fixtureRoot, `case-${caseId++}`); await fs.mkdir(tmpDir, { recursive: true }); // Disable bundled hooks during tests by setting env var to non-existent directory originalBundledDir = process.env.OPENCLAW_BUNDLED_HOOKS_DIR; process.env.OPENCLAW_BUNDLED_HOOKS_DIR = "/nonexistent/bundled/hooks"; }); afterEach(async () => { clearInternalHooks(); // Restore original env var if (originalBundledDir === undefined) { delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR; } else { process.env.OPENCLAW_BUNDLED_HOOKS_DIR = originalBundledDir; } }); afterAll(async () => { if (!fixtureRoot) { return; } await fs.rm(fixtureRoot, { recursive: true, force: true }); }); describe("loadInternalHooks", () => { it("should return 0 when hooks are not enabled", async () => { const cfg: OpenClawConfig = { 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: OpenClawConfig = {}; 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: OpenClawConfig = { hooks: { internal: { enabled: true, handlers: [ { event: "command:new", module: path.basename(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: OpenClawConfig = { hooks: { internal: { enabled: true, handlers: [ { event: "command:new", module: path.basename(handler1Path) }, { event: "command:stop", module: path.basename(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: OpenClawConfig = { hooks: { internal: { enabled: true, handlers: [ { event: "command:new", module: path.basename(handlerPath), export: "myHandler", }, ], }, }, }; const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(1); }); it("should handle module loading errors gracefully", async () => { const cfg: OpenClawConfig = { hooks: { internal: { enabled: true, handlers: [ { event: "command:new", module: "missing-handler.js", }, ], }, }, }; // Should not throw and should return 0 (handler failed to load) const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(0); }); it("should handle non-function exports", async () => { // 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: OpenClawConfig = { hooks: { internal: { enabled: true, handlers: [ { event: "command:new", module: path.basename(handlerPath), }, ], }, }, }; // Should not throw and should return 0 (handler is not a function) const count = await loadInternalHooks(cfg, tmpDir); expect(count).toBe(0); }); 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"); // Relative to workspaceDir (tmpDir) const relativePath = path.relative(tmpDir, handlerPath); const cfg: OpenClawConfig = { 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: OpenClawConfig = { hooks: { internal: { enabled: true, handlers: [ { event: "command:new", module: path.basename(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"); }); }); });