import type { BrowserWindow, MenuItemConstructorOptions } from "electron"; import { afterEach, describe, expect, it, vi } from "vitest"; import { nativeT, setNativeLocale } from "./nativeI18n"; import { isInternalRendererTarget } from "./navigationPolicy"; import en from "../src/i18n/locales/en/native.json"; import itNative from "../src/i18n/locales/it/native.json"; import { captureEditorMenuTarget, createEditorCommandMenus, withHiddenDockEditorMenus, } from "./editorCommandMenu"; const translate = nativeT; afterEach(() => { setNativeLocale("en"); vi.useRealTimers(); }); const click = (item: MenuItemConstructorOptions) => (item.click as () => void)(); function fixture() { const events: string[] = []; let alive = true; let contentsAlive = true; let focused = false; let contentsFocused = false; let url = "http://127.0.0.1:5173/?windowType=editor"; const contents = { isDestroyed: () => !contentsAlive, getURL: () => url, focus: vi.fn(() => { events.push("contents-focus"); contentsFocused = true; }), isFocused: () => contentsFocused, ...Object.fromEntries( ["undo", "redo", "cut", "copy", "paste", "selectAll"].map((action) => [ action, vi.fn(() => events.push(action)), ]), ), }; const window = { id: 1, isDestroyed: () => !alive, isMinimized: () => true, restore: vi.fn(() => events.push("restore")), show: vi.fn(() => events.push("show")), focus: vi.fn(() => { events.push("focus"); focused = true; }), isFocused: () => focused, webContents: contents, } as unknown as BrowserWindow; const registry = new Map([[1, window]]); const ownsEditor = (candidate: BrowserWindow) => !candidate.isDestroyed() && registry.get(candidate.id) === candidate && !candidate.webContents.isDestroyed() && candidate.webContents.getURL().includes("windowType=editor") && isInternalRendererTarget("http://127.0.0.1:5173/", candidate.webContents.getURL()); const onProjectAction = vi.fn((action: string) => events.push(action)); const target = captureEditorMenuTarget(window, ownsEditor); const menus = createEditorCommandMenus({ translate, target, onProjectAction }); return { window, contents, registry, target, menus, onProjectAction, events, setWindowFocused: (value: boolean) => { focused = value; }, setContentsFocused: (value: boolean) => { contentsFocused = value; }, close: () => { alive = false; }, destroyContents: () => { contentsAlive = false; }, navigate: () => { url = "https://untrusted.invalid/?windowType=editor"; }, }; } describe("hidden-Dock editor command access", () => { it.each([ "darwin", "win32", "linux", ] as const)("preserves ordinary %s tray contents", (platform) => { const items = [ { label: "Open", click: vi.fn() }, { label: "Quit", click: vi.fn() }, ]; const result = withHiddenDockEditorMenus(items, { platform, dockVisible: platform === "darwin", translate, onProjectAction: vi.fn(), target: captureEditorMenuTarget(null, () => false), }); expect(result).toBe(items); }); it("adds commands alongside the same recording controls only for a hidden macOS Dock", () => { const f = fixture(); const items = [ { label: "Show controls", click: vi.fn() }, { label: "Stop recording", click: vi.fn() }, ]; const result = withHiddenDockEditorMenus(items, { platform: "darwin", dockVisible: false, translate, target: f.target, onProjectAction: f.onProjectAction, }); expect(result.slice(0, 2)).toEqual(items); expect(result[0]).toBe(items[0]); expect(result[1]).toBe(items[1]); expect(result.slice(2).map((item) => item.type || item.label)).toEqual([ "separator", en.file, en.edit, ]); click(result[1]); expect(items[1].click).toHaveBeenCalledOnce(); expect(f.onProjectAction).not.toHaveBeenCalled(); }); it("keeps the same project action channels, shortcuts and native Edit roles in the regular application menu", () => { const send = vi.fn(); const [file, edit] = createEditorCommandMenus({ translate, onProjectAction: send }); for (const item of file.submenu) click(item); expect(send.mock.calls).toEqual([ ["menu-load-project"], ["menu-save-project"], ["menu-save-project-as"], ]); expect(file.submenu.map((item) => item.accelerator)).toEqual([ "CmdOrCtrl+O", "CmdOrCtrl+S", "CmdOrCtrl+Shift+S", ]); expect( edit.submenu.filter((item) => item.type !== "separator").map((item) => item.role), ).toEqual(["undo", "redo", "cut", "copy", "paste", "selectAll"]); }); it.each([ [0, "menu-load-project"], [1, "menu-save-project"], [2, "menu-save-project-as"], ] as const)("focuses the captured editor before File item %s sends its existing action", (index, channel) => { const f = fixture(); click(f.menus[0].submenu[index]); expect(f.events).toEqual(["restore", "show", "focus", "contents-focus", channel]); expect(f.onProjectAction).toHaveBeenCalledExactlyOnceWith(channel, f.window); }); it.each([ "undo", "redo", "cut", "copy", "paste", "selectAll", ])("targets the captured WebContents for %s after focus, without a global responder role", (action) => { const f = fixture(); const item = f.menus[1].submenu.find( (item) => item.label === translate(action as keyof typeof en), )!; expect(item.role).toBeUndefined(); click(item); expect(f.events).toEqual(["restore", "show", "focus", "contents-focus", action]); expect(f.onProjectAction).not.toHaveBeenCalled(); }); it("disables every command without an owned editor and does not dispatch even if a stale callback is invoked", () => { const send = vi.fn(); const menus = createEditorCommandMenus({ translate, onProjectAction: send, target: captureEditorMenuTarget(null, () => false), }); for (const item of menus .flatMap((menu) => menu.submenu) .filter((item) => item.type !== "separator")) { expect(item.enabled).toBe(false); click(item); } expect(send).not.toHaveBeenCalled(); }); it.each([ "closed", "contents-destroyed", "replaced-window", "navigation", ])("does not retarget commands after %s", (change) => { const f = fixture(); if (change === "closed") f.close(); if (change === "contents-destroyed") f.destroyContents(); if (change === "replaced-window") f.registry.set(1, {} as BrowserWindow); if (change === "navigation") f.navigate(); click(f.menus[0].submenu[2]); click(f.menus[1].submenu[0]); expect(f.events).toEqual([]); expect(f.onProjectAction).not.toHaveBeenCalled(); }); it.each([ "restore", "show", "focus", ] as const)("revalidates if %s closes the target", (phase) => { const f = fixture(); vi.mocked(f.window[phase]).mockImplementation(() => { f.events.push(phase); f.close(); }); click(f.menus[0].submenu[0]); expect(f.events.at(-1)).toBe(phase); expect(f.onProjectAction).not.toHaveBeenCalled(); }); it("does nothing if native window focus does not arrive", async () => { vi.useFakeTimers(); const f = fixture(); vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-denied")); click(f.menus[1].submenu[4]); await vi.advanceTimersByTimeAsync(400); expect(f.events).toEqual(["restore", "show", "focus-denied"]); expect(vi.getTimerCount()).toBe(0); }); it("does not focus or edit when the captured contents cannot receive focus", async () => { vi.useFakeTimers(); const f = fixture(); vi.mocked(f.window.webContents.focus).mockImplementation(() => f.events.push("contents-focus-denied"), ); click(f.menus[1].submenu[4]); await vi.advanceTimersByTimeAsync(400); expect(f.events).toEqual(["restore", "show", "focus", "contents-focus-denied"]); expect(vi.getTimerCount()).toBe(0); }); it.each([ "unowned", "untrusted-document", ])("disables commands when the candidate is initially %s", (change) => { const f = fixture(); if (change === "unowned") f.registry.clear(); else f.navigate(); const target = captureEditorMenuTarget(f.window, (window) => f.target.isValid(window)); const menus = createEditorCommandMenus({ translate, target, onProjectAction: f.onProjectAction, }); for (const item of menus .flatMap((menu) => menu.submenu) .filter((item) => item.type !== "separator")) { expect(item.enabled).toBe(false); click(item); } expect(f.events).toEqual([]); expect(f.onProjectAction).not.toHaveBeenCalled(); }); it("waits for late native window focus, then executes exactly once without refocusing", async () => { vi.useFakeTimers(); const f = fixture(); vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-request")); click(f.menus[0].submenu[2]); await vi.advanceTimersByTimeAsync(39); expect(f.onProjectAction).not.toHaveBeenCalled(); f.setWindowFocused(true); await vi.advanceTimersByTimeAsync(1); expect(f.onProjectAction).toHaveBeenCalledExactlyOnceWith("menu-save-project-as", f.window); await vi.advanceTimersByTimeAsync(1000); expect(f.window.focus).toHaveBeenCalledOnce(); expect(f.window.show).toHaveBeenCalledOnce(); expect(f.window.webContents.focus).toHaveBeenCalledOnce(); expect(f.onProjectAction).toHaveBeenCalledOnce(); expect(vi.getTimerCount()).toBe(0); }); it("waits for late contents focus after the native window, then targets the same contents", async () => { vi.useFakeTimers(); const f = fixture(); vi.mocked(f.window.webContents.focus).mockImplementation(() => f.events.push("contents-focus-request"), ); click(f.menus[1].submenu[0]); await vi.advanceTimersByTimeAsync(59); expect(f.events).not.toContain("undo"); f.setContentsFocused(true); await vi.advanceTimersByTimeAsync(1); expect(f.events.at(-1)).toBe("undo"); await vi.advanceTimersByTimeAsync(1000); expect(f.events.filter((event) => event === "undo")).toHaveLength(1); expect(f.window.focus).toHaveBeenCalledOnce(); expect(f.window.webContents.focus).toHaveBeenCalledOnce(); expect(vi.getTimerCount()).toBe(0); }); it("does not run a timed-out command even if focus arrives later", async () => { vi.useFakeTimers(); const f = fixture(); vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-request")); click(f.menus[0].submenu[1]); await vi.advanceTimersByTimeAsync(400); expect(vi.getTimerCount()).toBe(0); f.setWindowFocused(true); f.setContentsFocused(true); await vi.advanceTimersByTimeAsync(1000); expect(f.onProjectAction).not.toHaveBeenCalled(); expect(f.window.focus).toHaveBeenCalledOnce(); }); it.each([ "closed", "contents-destroyed", "owner-lost", "navigation", ])("cancels a delayed command when the target becomes %s", async (change) => { vi.useFakeTimers(); const f = fixture(); vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-request")); click(f.menus[0].submenu[1]); await vi.advanceTimersByTimeAsync(10); if (change === "closed") f.close(); if (change === "contents-destroyed") f.destroyContents(); if (change === "owner-lost") f.registry.clear(); if (change === "navigation") f.navigate(); f.setWindowFocused(true); await vi.advanceTimersByTimeAsync(10); expect(f.onProjectAction).not.toHaveBeenCalled(); expect(f.window.webContents.focus).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); it("does not reclaim native focus lost while waiting for contents focus", async () => { vi.useFakeTimers(); const f = fixture(); vi.mocked(f.window.webContents.focus).mockImplementation(() => f.events.push("contents-focus-request"), ); click(f.menus[1].submenu[0]); await vi.advanceTimersByTimeAsync(10); f.setWindowFocused(false); await vi.advanceTimersByTimeAsync(10); expect(vi.getTimerCount()).toBe(0); f.setWindowFocused(true); f.setContentsFocused(true); await vi.advanceTimersByTimeAsync(1000); expect(f.events).not.toContain("undo"); expect(f.window.focus).toHaveBeenCalledOnce(); expect(f.window.webContents.focus).toHaveBeenCalledOnce(); }); it("revalidates after contents focus before executing a deferred action", async () => { vi.useFakeTimers(); const f = fixture(); vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-request")); vi.mocked(f.window.webContents.focus).mockImplementation(() => { f.events.push("contents-focus-and-navigate"); f.setContentsFocused(true); f.navigate(); }); click(f.menus[0].submenu[0]); f.setWindowFocused(true); await vi.advanceTimersByTimeAsync(20); expect(f.onProjectAction).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); it("does not dispatch if a native focus call returns after the deadline", () => { vi.useFakeTimers(); const f = fixture(); const started = Date.now(); vi.mocked(f.window.webContents.focus).mockImplementation(() => { f.setContentsFocused(true); vi.setSystemTime(started + 401); }); click(f.menus[0].submenu[1]); expect(f.onProjectAction).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); it("keeps an already-focused command immediate with no timer", () => { vi.useFakeTimers(); const f = fixture(); f.setWindowFocused(true); f.setContentsFocused(true); click(f.menus[0].submenu[0]); expect(f.onProjectAction).toHaveBeenCalledExactlyOnceWith("menu-load-project", f.window); expect(vi.getTimerCount()).toBe(0); }); it("uses the current existing native translations for both menus", () => { const send = vi.fn(); setNativeLocale("it"); const [file, edit] = createEditorCommandMenus({ translate: nativeT, onProjectAction: send, }); expect(file.label).toBe(itNative.file); expect(file.submenu.map((item) => item.label)).toEqual([ itNative.openProjects, itNative.saveProject, itNative.saveProjectAs, ]); expect(edit.label).toBe(itNative.edit); expect( edit.submenu.filter((item) => item.type !== "separator").map((item) => item.label), ).toEqual([ itNative.undo, itNative.redo, itNative.cut, itNative.copy, itNative.paste, itNative.selectAll, ]); }); });