Files
kandev/apps/web/hooks/use-plugin-shortcuts.test.ts
T
2026-08-12 20:29:18 +01:00

513 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import type { PluginRecord } from "@/lib/types/plugins";
import { pluginRegistry } from "@/lib/plugins/registry";
import { CommandRegistryProvider } from "@/lib/commands/command-registry";
let mockOverrides: StoredShortcutOverrides = {};
let mockItems: PluginRecord[] = [];
const mockToggleAppSidebar = vi.fn();
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({
getState: () => ({
userSettings: { keyboardShortcuts: mockOverrides },
toggleAppSidebar: mockToggleAppSidebar,
}),
}),
}));
vi.mock("@/hooks/domains/plugins/use-plugins", () => ({
usePlugins: () => ({ items: mockItems, loaded: true, loading: false, error: null }),
}));
import { usePluginShortcuts } from "./use-plugin-shortcuts";
import { useAppShortcuts } from "./use-app-shortcuts";
import { useKeyboardShortcut } from "./use-keyboard-shortcut";
import type { KeyboardShortcut } from "@/lib/keyboard/constants";
const PLUGIN_ID = "session-cost";
function makePlugin(overrides: Partial<PluginRecord> = {}): PluginRecord {
return {
id: PLUGIN_ID,
api_version: 1,
version: "1.0.0",
display_name: "Session Cost",
description: "",
author: "",
categories: [],
capabilities: {},
status: "active",
install_path: "",
signed: true,
installed_at: "",
restart_count: 0,
...overrides,
};
}
function pressKey(
key: string,
init: KeyboardEventInit = {},
target: EventTarget = window,
): KeyboardEvent {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
}
const TOGGLE_KEYBINDING = { id: "toggle", default: "mod+shift+e", description: "Toggle" };
/** A free combo (not used by any core shortcut) shared by two plugins bound to the same "open" id. */
const OPEN_COMBO = "mod+shift+o";
function commandRegistryWrapper({ children }: { children: ReactNode }) {
return createElement(CommandRegistryProvider, null, children);
}
function withToggleKeybinding(overrides: Partial<PluginRecord> = {}): PluginRecord {
return makePlugin({ ui: { keybindings: [TOGGLE_KEYBINDING] }, ...overrides });
}
describe("usePluginShortcuts", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
mockToggleAppSidebar.mockClear();
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
pluginRegistry.unregisterPlugin("plugin-a");
pluginRegistry.unregisterPlugin("plugin-b");
});
it("invokes the bound handler when the manifest default combo is pressed", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
const event = pressKey("e", { ctrlKey: true, shiftKey: true });
expect(handler).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(true);
});
it("does not invoke the handler when no key matches", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
pressKey("j", { ctrlKey: true });
expect(handler).not.toHaveBeenCalled();
});
it("skips dispatch while typing in an editable element", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
const input = document.createElement("input");
document.body.appendChild(input);
pressKey("e", { ctrlKey: true, shiftKey: true }, input);
input.remove();
expect(handler).not.toHaveBeenCalled();
});
it("does nothing when the plugin bundle has not registered a handler yet", () => {
mockItems = [withToggleKeybinding()];
renderHook(() => usePluginShortcuts());
expect(() => pressKey("e", { ctrlKey: true, shiftKey: true })).not.toThrow();
});
it("respects a user override combo instead of the manifest default", () => {
mockItems = [withToggleKeybinding()];
mockOverrides = {
[`plugin:${PLUGIN_ID}:toggle`]: { key: "u", modifiers: { ctrlOrCmd: true } },
};
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
pressKey("e", { ctrlKey: true, shiftKey: true }); // manifest default — no longer bound
expect(handler).not.toHaveBeenCalled();
pressKey("u", { ctrlKey: true }); // user override — now bound
expect(handler).toHaveBeenCalledTimes(1);
});
it("dispatches to every plugin bound to the same combo, in registration order", () => {
mockItems = [
makePlugin({
id: "plugin-a",
display_name: "Plugin A",
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
}),
makePlugin({
id: "plugin-b",
display_name: "Plugin B",
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
}),
];
const callOrder: string[] = [];
pluginRegistry.forPlugin("plugin-a").registerKeybinding("open", () => callOrder.push("a"));
pluginRegistry.forPlugin("plugin-b").registerKeybinding("open", () => callOrder.push("b"));
renderHook(() => usePluginShortcuts());
pressKey("o", { ctrlKey: true, shiftKey: true });
expect(callOrder).toEqual(["a", "b"]);
});
it("removes its listener on unmount", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
const { unmount } = renderHook(() => usePluginShortcuts());
unmount();
pressKey("e", { ctrlKey: true, shiftKey: true });
expect(handler).not.toHaveBeenCalled();
});
});
describe("usePluginShortcuts editable-target opt-in", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
it("dispatches inside an editable element when the binding declared allow_in_editor", () => {
mockItems = [
makePlugin({ ui: { keybindings: [{ ...TOGGLE_KEYBINDING, allow_in_editor: true }] } }),
];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
const input = document.createElement("input");
document.body.appendChild(input);
pressKey("e", { ctrlKey: true, shiftKey: true }, input);
input.remove();
expect(handler).toHaveBeenCalledTimes(1);
});
it("dispatches inside a contenteditable composer when the binding declared allow_in_editor", () => {
mockItems = [
makePlugin({ ui: { keybindings: [{ ...TOGGLE_KEYBINDING, allow_in_editor: true }] } }),
];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
const editor = document.createElement("div");
editor.contentEditable = "true";
Object.defineProperty(editor, "isContentEditable", { value: true });
document.body.appendChild(editor);
pressKey("e", { ctrlKey: true, shiftKey: true }, editor);
editor.remove();
expect(handler).toHaveBeenCalledTimes(1);
});
it("ignores the editor opt-in when a user override dropped the modifier", () => {
// The shortcut recorder accepts a bare key, so an override can strip the
// modifier the manifest validator required. Honouring allow_in_editor
// against that resolved combo would swallow every "e" typed into an input.
mockItems = [
makePlugin({ ui: { keybindings: [{ ...TOGGLE_KEYBINDING, allow_in_editor: true }] } }),
];
mockOverrides = { [`plugin:${PLUGIN_ID}:toggle`]: { key: "e" } };
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
const input = document.createElement("input");
document.body.appendChild(input);
pressKey("e", {}, input);
input.remove();
expect(handler).not.toHaveBeenCalled();
});
it("still honours the opt-in when the override keeps a modifier", () => {
mockItems = [
makePlugin({ ui: { keybindings: [{ ...TOGGLE_KEYBINDING, allow_in_editor: true }] } }),
];
mockOverrides = {
[`plugin:${PLUGIN_ID}:toggle`]: { key: "u", modifiers: { ctrlOrCmd: true } },
};
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(() => usePluginShortcuts());
const input = document.createElement("input");
document.body.appendChild(input);
pressKey("u", { ctrlKey: true }, input);
input.remove();
expect(handler).toHaveBeenCalledTimes(1);
});
it("keeps skipping editables for a sibling binding that did not opt in", () => {
mockItems = [
makePlugin({
ui: {
keybindings: [
{ ...TOGGLE_KEYBINDING, allow_in_editor: true },
{ id: "open", default: OPEN_COMBO, description: "Open" },
],
},
}),
];
const optedIn = vi.fn();
const optedOut = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", optedIn);
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", optedOut);
renderHook(() => usePluginShortcuts());
const input = document.createElement("input");
document.body.appendChild(input);
pressKey("e", { ctrlKey: true, shiftKey: true }, input);
pressKey("o", { ctrlKey: true, shiftKey: true }, input);
input.remove();
expect(optedIn).toHaveBeenCalledTimes(1);
expect(optedOut).not.toHaveBeenCalled();
});
});
describe("usePluginShortcuts handler exceptions", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
mockToggleAppSidebar.mockClear();
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin("plugin-a");
pluginRegistry.unregisterPlugin("plugin-b");
});
it("logs and continues past a throwing handler so later handlers still run", () => {
mockItems = [
makePlugin({
id: "plugin-a",
display_name: "Plugin A",
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
}),
makePlugin({
id: "plugin-b",
display_name: "Plugin B",
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
}),
];
const secondHandler = vi.fn();
pluginRegistry.forPlugin("plugin-a").registerKeybinding("open", () => {
throw new Error("boom");
});
pluginRegistry.forPlugin("plugin-b").registerKeybinding("open", secondHandler);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
renderHook(() => usePluginShortcuts());
expect(() => pressKey("o", { ctrlKey: true, shiftKey: true })).not.toThrow();
expect(secondHandler).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("plugin-a"),
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
});
describe("core vs plugin shortcut precedence", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
mockToggleAppSidebar.mockClear();
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
it("fires only the core action when a combo matches both a core shortcut and a plugin keybinding", () => {
mockOverrides = { TOGGLE_SIDEBAR: { key: "b", modifiers: { ctrlOrCmd: true } } };
mockItems = [
makePlugin({
ui: { keybindings: [{ id: "open", default: "mod+b", description: "Open" }] },
}),
];
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", pluginHandler);
renderHook(
() => {
useAppShortcuts();
usePluginShortcuts();
},
{ wrapper: commandRegistryWrapper },
);
const event = pressKey("b", { ctrlKey: true });
expect(mockToggleAppSidebar).toHaveBeenCalledTimes(1);
expect(pluginHandler).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
it("still fires the plugin handler for a plugin-only combo (no core shortcut match)", () => {
mockItems = [withToggleKeybinding()];
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
renderHook(
() => {
useAppShortcuts();
usePluginShortcuts();
},
{ wrapper: commandRegistryWrapper },
);
pressKey("e", { ctrlKey: true, shiftKey: true });
expect(handler).toHaveBeenCalledTimes(1);
expect(mockToggleAppSidebar).not.toHaveBeenCalled();
});
it("fires only the plugin action when a combo matches both a plugin keybinding and a per-component core shortcut bound via useKeyboardShortcut", () => {
// Regression for Greptile P1 (PR #1895, comment 3638620657): a
// per-component core shortcut registered through `useKeyboardShortcut`
// runs in the bubble phase, after this capture-phase plugin dispatcher.
// Without `useKeyboardShortcut` respecting `event.defaultPrevented`, the
// plugin handler AND the core action would both fire for the same combo.
mockItems = [withToggleKeybinding()];
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", pluginHandler);
const coreShortcut: KeyboardShortcut = {
key: "e",
modifiers: { ctrlOrCmd: true, shift: true },
};
const coreCallback = vi.fn();
renderHook(() => {
// usePluginShortcuts registers its capture-phase listener on mount,
// which — like the real app (`components/global-commands.tsx`) —
// fires before the bubble-phase useKeyboardShortcut listener below.
usePluginShortcuts();
useKeyboardShortcut(coreShortcut, coreCallback);
});
const event = pressKey("e", { ctrlKey: true, shiftKey: true });
expect(pluginHandler).toHaveBeenCalledTimes(1);
expect(coreCallback).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
});
describe("core shortcut combo shadowing (robust, non-registry-aware core shortcuts)", () => {
beforeEach(() => {
mockOverrides = {};
mockItems = [];
mockToggleAppSidebar.mockClear();
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
it("does not invoke or preventDefault a plugin bound to FIND_IN_PANEL's combo, leaving core panel-search to handle it", () => {
// Regression for Greptile P1 (PR #1895, comment 3638902528): FIND_IN_PANEL
// (Cmd/Ctrl+F) is handled by independent per-panel capture-phase
// listeners (`use-panel-search.ts`) that never call `preventDefault` up
// front and don't check it either. The old "bail on defaultPrevented"
// guard couldn't protect this combo at all — only skipping the plugin
// handler outright (by combo, not by event state) fixes it.
mockItems = [
makePlugin({
ui: { keybindings: [{ id: "find", default: "mod+f", description: "Find" }] },
}),
];
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("find", pluginHandler);
renderHook(() => usePluginShortcuts());
const event = pressKey("f", { ctrlKey: true });
expect(pluginHandler).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it("still fires a plugin bound to a genuinely free combo (no core shortcut uses it)", () => {
mockItems = [
makePlugin({
ui: { keybindings: [{ id: "open", default: "mod+shift+u", description: "Open" }] },
}),
];
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", pluginHandler);
renderHook(() => usePluginShortcuts());
const event = pressKey("u", { ctrlKey: true, shiftKey: true });
expect(pluginHandler).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(true);
});
it("shadows the plugin by a user's REMAPPED core combo, not the old default", () => {
// A user remaps TASK_SWITCHER (default Cmd/Ctrl+Space) to Cmd/Ctrl+U. A
// plugin bound to the OLD default (mod+space) should now fire freely,
// and a plugin bound to the NEW combo (mod+u) should be shadowed.
mockOverrides = { TASK_SWITCHER: { key: "u", modifiers: { ctrlOrCmd: true } } };
mockItems = [
makePlugin({
id: "plugin-old-combo",
display_name: "Old Combo Plugin",
ui: { keybindings: [{ id: "open", default: "mod+space", description: "Open" }] },
}),
makePlugin({
id: "plugin-new-combo",
display_name: "New Combo Plugin",
ui: { keybindings: [{ id: "open", default: "mod+u", description: "Open" }] },
}),
];
const oldComboHandler = vi.fn();
const newComboHandler = vi.fn();
pluginRegistry.forPlugin("plugin-old-combo").registerKeybinding("open", oldComboHandler);
pluginRegistry.forPlugin("plugin-new-combo").registerKeybinding("open", newComboHandler);
renderHook(() => usePluginShortcuts());
pressKey(" ", { ctrlKey: true }); // old TASK_SWITCHER default — no longer core, plugin fires
expect(oldComboHandler).toHaveBeenCalledTimes(1);
const event = pressKey("u", { ctrlKey: true }); // new TASK_SWITCHER combo — shadows the plugin
expect(newComboHandler).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
pluginRegistry.unregisterPlugin("plugin-old-combo");
pluginRegistry.unregisterPlugin("plugin-new-combo");
});
});