Files
Recordly/electron/editorCommandMenu.test.ts
T
webadderall 08075f161b Fix Recordly 2.0 part 8 follow-up review findings
Preserve existing PR history and integrate validated current-cycle fixes.
2026-11-08 14:28:59 +11:00

435 lines
14 KiB
TypeScript

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,
]);
});
});