import { beforeEach, describe, expect, it, vi } from "vitest"; import { createHookRunner } from "./hooks.js"; import { addTestHook } from "./hooks.test-helpers.js"; import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js"; import type { PluginHookBeforeInstallContext, PluginHookBeforeInstallEvent, PluginHookBeforeInstallResult, PluginHookRegistration, } from "./types.js"; function addBeforeInstallHook( registry: PluginRegistry, pluginId: string, handler: | (() => PluginHookBeforeInstallResult | Promise) | PluginHookRegistration["handler"], priority?: number, ) { addTestHook({ registry, pluginId, hookName: "before_install", handler: handler as PluginHookRegistration["handler"], priority, }); } const stubCtx: PluginHookBeforeInstallContext = { origin: "openclaw-workspace", targetType: "skill", requestKind: "skill-install", }; const stubEvent: PluginHookBeforeInstallEvent = { targetName: "demo-skill", targetType: "skill", sourcePath: "/tmp/demo-skill", sourcePathKind: "directory", origin: "openclaw-workspace", request: { kind: "skill-install", mode: "install", }, builtinScan: { status: "ok", scannedFiles: 1, critical: 0, warn: 0, info: 0, findings: [], }, skill: { installId: "deps", }, }; describe("before_install hook merger", () => { let registry: PluginRegistry; beforeEach(() => { registry = createEmptyPluginRegistry(); }); it("accumulates findings across handlers in priority order", async () => { addBeforeInstallHook( registry, "plugin-a", (): PluginHookBeforeInstallResult => ({ findings: [ { ruleId: "first", severity: "warn", file: "a.ts", line: 1, message: "first finding", }, ], }), 100, ); addBeforeInstallHook( registry, "plugin-b", (): PluginHookBeforeInstallResult => ({ findings: [ { ruleId: "second", severity: "critical", file: "b.ts", line: 2, message: "second finding", }, ], }), 50, ); const runner = createHookRunner(registry); const result = await runner.runBeforeInstall(stubEvent, stubCtx); expect(result).toEqual({ findings: [ { ruleId: "first", severity: "warn", file: "a.ts", line: 1, message: "first finding", }, { ruleId: "second", severity: "critical", file: "b.ts", line: 2, message: "second finding", }, ], block: undefined, blockReason: undefined, }); }); it("short-circuits after block=true and preserves earlier findings", async () => { const blocker = vi.fn( (): PluginHookBeforeInstallResult => ({ findings: [ { ruleId: "blocker", severity: "critical", file: "block.ts", line: 3, message: "blocked finding", }, ], block: true, blockReason: "policy blocked", }), ); const skipped = vi.fn( (): PluginHookBeforeInstallResult => ({ findings: [ { ruleId: "skipped", severity: "warn", file: "skip.ts", line: 4, message: "should not appear", }, ], }), ); addBeforeInstallHook( registry, "plugin-a", (): PluginHookBeforeInstallResult => ({ findings: [ { ruleId: "first", severity: "warn", file: "a.ts", line: 1, message: "first finding", }, ], }), 100, ); addBeforeInstallHook(registry, "plugin-block", blocker, 50); addBeforeInstallHook(registry, "plugin-skipped", skipped, 10); const runner = createHookRunner(registry); const result = await runner.runBeforeInstall(stubEvent, stubCtx); expect(result).toEqual({ findings: [ { ruleId: "first", severity: "warn", file: "a.ts", line: 1, message: "first finding", }, { ruleId: "blocker", severity: "critical", file: "block.ts", line: 3, message: "blocked finding", }, ], block: true, blockReason: "policy blocked", }); expect(blocker).toHaveBeenCalledTimes(1); expect(skipped).not.toHaveBeenCalled(); }); });