Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/test/shortcut-controller.test.ts

typescript347 行查看原文
import { describe, expect, it, vi } from "vitest";
import {
  forwardBrowserShortcut,
  ShortcutController,
  setWindowMenuShortcutsIgnored,
  toggleShortcutWindow,
} from "../src/main/shortcut-controller.ts";
import { defaultShortcuts, shortcutCommands } from "../src/shared/shortcuts.ts";

function fixture(testMode = false) {
  const callbacks = new Map<string, () => void>();
  const registry = {
    register: vi.fn((key: string, callback: () => void) => {
      if (callbacks.has(key)) return false;
      callbacks.set(key, callback);
      return true;
    }),
    unregister: vi.fn((key: string) => {
      callbacks.delete(key);
    }),
  };
  const execute = vi.fn();
  const ignoreMenuShortcuts = vi.fn();
  const controller = new ShortcutController(registry, "darwin", execute, testMode, ignoreMenuShortcuts);
  const settings = defaultShortcuts("darwin");
  controller.initialize(settings);
  return {
    callbacks,
    registry,
    execute,
    controller,
    settings,
    ignoreMenuShortcuts,
    original: settings.bindings["toggle-window"] ?? "",
  };
}
describe("global shortcut transaction", () => {
  it("registers the new binding before persistence and releases the old only after success", () => {
    const { controller, settings, registry, callbacks, execute, original } = fixture();
    const next = "Control+Command+T";
    settings.bindings["toggle-window"] = next;
    controller.update(settings, () => {
      expect([...callbacks.keys()]).toEqual([original, next]);
      expect(registry.unregister).not.toHaveBeenCalled();
      callbacks.get(next)?.();
      expect(execute).not.toHaveBeenCalled();
      return undefined;
    });
    expect([...callbacks.keys()]).toEqual([next]);
    callbacks.get(next)?.();
    expect(execute).toHaveBeenCalledTimes(1);
  });
  it.each(["false", "throws"])("keeps the old shortcut and never persists if registration %s", (failure) => {
    const { controller, settings, registry, callbacks, execute, original } = fixture();
    registry.register.mockImplementationOnce(() => {
      if (failure === "throws") throw Error("rejected");
      return false;
    });
    settings.bindings["toggle-window"] = "Control+Command+T";
    const persist = vi.fn(() => undefined);
    expect(() => controller.update(settings, persist)).toThrow("全局快捷键注册失败");
    expect(persist).not.toHaveBeenCalled();
    expect([...callbacks.keys()]).toEqual([original]);
    callbacks.get(original)?.();
    expect(execute).toHaveBeenCalledTimes(1);
  });
  it("rolls back the replacement registration when persistence fails", () => {
    const { controller, settings, registry, callbacks, execute, original } = fixture();
    const next = "Control+Command+T";
    settings.bindings["toggle-window"] = next;
    expect(() =>
      controller.update(settings, () => {
        throw Error("disk full");
      }),
    ).toThrow("disk full");
    expect(registry.unregister).toHaveBeenCalledExactlyOnceWith(next);
    expect([...callbacks.keys()]).toEqual([original]);
    callbacks.get(original)?.();
    expect(execute).toHaveBeenCalledTimes(1);
  });
  it("preserves registration when disabling fails, then releases it after successful disable", () => {
    const { controller, settings, callbacks, original } = fixture();
    settings.enabled = false;
    expect(() =>
      controller.update(settings, () => {
        throw Error("disk full");
      }),
    ).toThrow("disk full");
    expect(callbacks.has(original)).toBe(true);
    controller.update(settings, () => undefined);
    expect(callbacks.size).toBe(0);
    settings.enabled = true;
    controller.update(settings, () => undefined);
    expect(callbacks.has(original)).toBe(true);
  });
  it("does not re-register unchanged binding and suspends callbacks during recording", () => {
    const { controller, settings, callbacks, registry, execute, original } = fixture();
    controller.update(settings, () => undefined);
    expect(registry.register).toHaveBeenCalledTimes(1);
    const originalCallback = callbacks.get(original);
    controller.setCapture(true);
    expect(callbacks.has(original)).toBe(false);
    originalCallback?.();
    expect(execute).not.toHaveBeenCalled();
    controller.setCapture(false);
    expect(callbacks.has(original)).toBe(true);
    expect(registry.register).toHaveBeenCalledTimes(2);
    originalCallback?.();
    expect(execute).not.toHaveBeenCalled();
    callbacks.get(original)?.();
    expect(execute).toHaveBeenCalledTimes(1);
    expect(() => controller.setCapture("true" as unknown as boolean)).toThrow("录制状态无效");
    expect(() => controller.update({ ...settings, enabled: "no" } as never, () => undefined)).toThrow(
      "invalid_input",
    );
  });
  it("reports failed capture restoration and can retry without altering stored settings", () => {
    const { controller, callbacks, registry, settings, execute, original } = fixture();
    controller.setCapture(true);
    registry.register.mockReturnValueOnce(false);
    expect(() => controller.setCapture(false)).toThrow("全局快捷键注册失败");
    expect(controller.isCapturing).toBe(false);
    expect(callbacks.has(original)).toBe(false);
    expect(execute).not.toHaveBeenCalled();
    controller.update(settings, () => undefined);
    expect(callbacks.has(original)).toBe(true);
  });
  it("rejects settings writes during capture and does not re-register after disposing a recording", () => {
    const { controller, registry, settings } = fixture();
    controller.setCapture(true);
    expect(() => controller.update(settings, () => undefined)).toThrow("请先结束快捷键录制");
    controller.dispose();
    controller.setCapture(false);
    expect(registry.register).toHaveBeenCalledTimes(1);
    expect(registry.unregister).toHaveBeenCalledTimes(1);
  });
  it("restores native menu shortcuts on finish, reload/reset failure and disposal", () => {
    const { controller, registry, ignoreMenuShortcuts } = fixture();
    controller.setCapture(true);
    expect(ignoreMenuShortcuts).toHaveBeenLastCalledWith(true);
    controller.setCapture(false);
    expect(ignoreMenuShortcuts).toHaveBeenLastCalledWith(false);
    controller.setCapture(true);
    registry.register.mockReturnValueOnce(false);
    expect(() => controller.setCapture(false)).toThrow("全局快捷键注册失败");
    expect(ignoreMenuShortcuts).toHaveBeenLastCalledWith(false);
    controller.setCapture(true);
    controller.dispose();
    expect(ignoreMenuShortcuts).toHaveBeenLastCalledWith(false);
    expect(controller.isCapturing).toBe(false);
  });
  it("cleans up only its own shortcut and ignores stale callbacks after disposal", () => {
    const { controller, callbacks, registry, execute, original, ignoreMenuShortcuts } = fixture();
    const callback = callbacks.get(original);
    controller.dispose();
    controller.dispose();
    callback?.();
    expect(execute).not.toHaveBeenCalled();
    expect(registry.unregister).toHaveBeenCalledExactlyOnceWith(original);
    expect(ignoreMenuShortcuts).toHaveBeenCalledExactlyOnceWith(false);
  });
  it("never registers system shortcuts in test mode", () => {
    const { controller, settings, registry } = fixture(true);
    settings.bindings["toggle-window"] = "Control+Command+T";
    controller.update(settings, () => undefined);
    controller.dispose();
    expect(registry.register).not.toHaveBeenCalled();
    expect(registry.unregister).not.toHaveBeenCalled();
  });
});
describe("window shortcut", () => {
  function window(visible = true, focused = true, minimized = false, destroyed = false) {
    return {
      isVisible: () => visible,
      isFocused: () => focused,
      isMinimized: () => minimized,
      isDestroyed: () => destroyed,
      hide: vi.fn(() => {
        visible = false;
        focused = false;
      }),
      show: vi.fn(() => {
        visible = true;
      }),
      restore: vi.fn(() => {
        minimized = false;
      }),
      focus: vi.fn(() => {
        focused = true;
      }),
      destroy: () => {
        destroyed = true;
      },
    };
  }
  it("hides the foreground window", () => {
    const target = window();
    expect(toggleShortcutWindow(target)).toBe(false);
    expect(target.hide).toHaveBeenCalledOnce();
    expect(target.show).not.toHaveBeenCalled();
  });
  it.each([
    [false, false, false],
    [true, false, false],
    [true, true, true],
  ])("shows and focuses hidden/background/minimized windows: %s, %s, %s", (visible, focused, minimized) => {
    const target = window(visible, focused, minimized);
    expect(toggleShortcutWindow(target)).toBe(true);
    expect(target.show).toHaveBeenCalledOnce();
    expect(target.focus).toHaveBeenCalledOnce();
    expect(target.restore).toHaveBeenCalledTimes(minimized ? 1 : 0);
    expect(target.hide).not.toHaveBeenCalled();
  });
  it.each(["show", "restore", "focus"] as const)(
    "does not report a successful show when destroyed during %s",
    (phase) => {
      const target = window(false, false, phase === "restore");
      target[phase].mockImplementationOnce(() => target.destroy());
      expect(toggleShortcutWindow(target)).toBe(false);
    },
  );
  it("requires a visible focused window before allowing a lost-target fallback", () => {
    const target = window(false, false);
    target.focus.mockImplementationOnce(() => {});
    expect(toggleShortcutWindow(target)).toBe(false);
  });
  it("ignores a destroyed window", () => {
    const target = window(true, true, false, true);
    expect(toggleShortcutWindow(target)).toBe(false);
    expect(target.hide).not.toHaveBeenCalled();
    expect(target.show).not.toHaveBeenCalled();
  });
});

describe("native browser workspace command dispatch", () => {
  it.each(["darwin", "linux"])("hands focus back to the host once before dispatch on %s", (platform) => {
    const order: string[] = [];
    const focusHost = vi.fn(() => order.push("host"));
    const send = vi.fn((command: string) => order.push(command));
    const event = { key: "i", metaKey: platform === "darwin", ctrlKey: platform !== "darwin" };
    const settings = defaultShortcuts(platform);
    expect(forwardBrowserShortcut(event, platform, settings, send, focusHost)).toBe(true);
    expect(order).toEqual(["host", "focus-composer"]);
    expect(forwardBrowserShortcut({ ...event, repeat: true }, platform, settings, send, focusHost)).toBe(
      true,
    );
    expect(focusHost).toHaveBeenCalledOnce();
    expect(send).toHaveBeenCalledOnce();
  });

  it("does not steal guest focus for other commands or intercept its disabled/unbound/IME input", () => {
    const focusHost = vi.fn(),
      send = vi.fn();
    const settings = defaultShortcuts("darwin");
    expect(forwardBrowserShortcut({ key: "[", metaKey: true }, "darwin", settings, send, focusHost)).toBe(
      true,
    );
    expect(send).toHaveBeenCalledExactlyOnceWith("toggle-sidebar");
    expect(focusHost).not.toHaveBeenCalled();
    send.mockClear();
    for (const event of [
      { key: "i", metaKey: true, isComposing: true },
      { key: "i", metaKey: true, altKey: true },
    ])
      expect(forwardBrowserShortcut(event, "darwin", settings, send, focusHost)).toBe(false);
    settings.bindings["focus-composer"] = null;
    expect(forwardBrowserShortcut({ key: "i", metaKey: true }, "darwin", settings, send, focusHost)).toBe(
      false,
    );
    settings.enabled = false;
    expect(forwardBrowserShortcut({ key: "[", metaKey: true }, "darwin", settings, send, focusHost)).toBe(
      false,
    );
    expect(send).not.toHaveBeenCalled();
    expect(focusHost).not.toHaveBeenCalled();
  });
});

describe("menu shortcut window lifecycle", () => {
  function window() {
    const contents = {
      isDestroyed: vi.fn(() => false),
      setIgnoreMenuShortcuts: vi.fn(),
    };
    const target = {
      isDestroyed: vi.fn(() => false),
      get webContents() {
        if (target.isDestroyed()) throw new TypeError("Object has been destroyed");
        return contents;
      },
    };
    const getContents = vi.spyOn(target, "webContents", "get");
    return { target, contents, getContents };
  }

  it.each([true, false])("updates live menu capture state to %s", (ignore) => {
    const { target, contents } = window();
    setWindowMenuShortcutsIgnored(target, ignore);
    expect(contents.setIgnoreMenuShortcuts).toHaveBeenCalledExactlyOnceWith(ignore);
  });

  it("does not read webContents after its window is destroyed", () => {
    const { target, contents, getContents } = window();
    target.isDestroyed.mockReturnValue(true);
    expect(() => setWindowMenuShortcutsIgnored(target, false)).not.toThrow();
    expect(getContents).not.toHaveBeenCalled();
    expect(contents.setIgnoreMenuShortcuts).not.toHaveBeenCalled();
  });

  it("does not update destroyed webContents in a live window", () => {
    const { target, contents } = window();
    contents.isDestroyed.mockReturnValue(true);
    expect(() => setWindowMenuShortcutsIgnored(target, false)).not.toThrow();
    expect(contents.setIgnoreMenuShortcuts).not.toHaveBeenCalled();
  });

  it.each([true, false])("disposes safely after window destruction with recording %s", (recording) => {
    const { target, contents, getContents } = window();
    const registry = { register: vi.fn(() => true), unregister: vi.fn() };
    const controller = new ShortcutController(registry, "darwin", vi.fn(), false, (ignore) =>
      setWindowMenuShortcutsIgnored(target, ignore),
    );
    controller.initialize(defaultShortcuts("darwin"));
    if (recording) controller.setCapture(true);
    target.isDestroyed.mockReturnValue(true);
    getContents.mockClear();
    contents.setIgnoreMenuShortcuts.mockClear();
    expect(() => controller.dispose()).not.toThrow();
    expect(() => controller.dispose()).not.toThrow();
    controller.setCapture(false);
    expect(controller.isCapturing).toBe(false);
    expect(getContents).not.toHaveBeenCalled();
    expect(contents.setIgnoreMenuShortcuts).not.toHaveBeenCalled();
    const globals = shortcutCommands.filter(({ scope }) => scope === "global").length;
    expect(registry.register).toHaveBeenCalledTimes(globals);
    expect(registry.unregister).toHaveBeenCalledTimes(globals);
  });

  it("does not hide unexpected errors from live webContents", () => {
    const { target, contents } = window();
    contents.setIgnoreMenuShortcuts.mockImplementation(() => {
      throw new Error("unexpected failure");
    });
    expect(() => setWindowMenuShortcutsIgnored(target, false)).toThrow("unexpected failure");
  });
});