fa7d88bee6
Co-authored-by: Kandev Agent <agent@kandev.dev>
513 lines
18 KiB
TypeScript
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");
|
|
});
|
|
});
|