feat(FN-1074): add desktop tray and application menu

- Add dedicated desktop tray and menu modules, including platform-aware menu templates and tray status/visibility controls
- Integrate tray setup, app menu initialization, and tray status exports into the Electron main process bootstrap
- Add tray icon assets plus an icon generation script and update desktop package dependencies/lockfile
- Expand desktop test coverage with new menu/tray test suites and main process integration assertions
- Update desktop README with tray and application menu usage/documentation
This commit is contained in:
gsxdsm
2026-04-07 23:11:28 -07:00
parent 738c0c9abc
commit c1fa49c719
14 changed files with 1214 additions and 41 deletions

View File

@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => {
const browserWindowInstance = {
loadURL: vi.fn(),
on: vi.fn(),
isVisible: vi.fn(() => true),
show: vi.fn(),
focus: vi.fn(),
hide: vi.fn(),
@@ -28,13 +29,27 @@ const mocks = vi.hoisted(() => {
};
const trayInstance = {
setImage: vi.fn(),
setToolTip: vi.fn(),
setContextMenu: vi.fn(),
on: vi.fn(),
};
const Tray = vi.fn(() => trayInstance);
const Menu = { buildFromTemplate: vi.fn(() => ({ id: "mock-menu" })) };
const nativeImage = { createEmpty: vi.fn(() => ({ id: "mock-image" })) };
const Menu = {
buildFromTemplate: vi.fn(() => ({ id: "mock-menu" })),
setApplicationMenu: vi.fn(),
};
const nativeImage = {
createEmpty: vi.fn(() => ({ id: "mock-image" })),
createFromPath: vi.fn(() => ({
resize: vi.fn(() => ({ id: "resized-image" })),
})),
};
const shell = {
openExternal: vi.fn(() => Promise.resolve()),
};
return {
app,
@@ -44,6 +59,7 @@ const mocks = vi.hoisted(() => {
Tray,
Menu,
nativeImage,
shell,
browserWindowInstance,
};
});
@@ -55,6 +71,7 @@ vi.mock("electron", () => ({
Tray: mocks.Tray,
Menu: mocks.Menu,
nativeImage: mocks.nativeImage,
shell: mocks.shell,
}));
async function importMainModule() {
@@ -144,14 +161,14 @@ describe("main process", () => {
expect(mocks.app.whenReady).not.toHaveBeenCalled();
});
it("setupTray creates tray menu and hides window on close", async () => {
it("setupTray configures tray interactions with provided tray instance", async () => {
const { setupTray } = await importMainModule();
setupTray(mocks.browserWindowInstance as never);
setupTray(mocks.browserWindowInstance as never, mocks.trayInstance as never);
expect(mocks.Tray).toHaveBeenCalledTimes(1);
expect(mocks.nativeImage.createEmpty).toHaveBeenCalledTimes(1);
expect(mocks.trayInstance.setToolTip).toHaveBeenCalledWith("Fusion");
expect(mocks.nativeImage.createFromPath).toHaveBeenCalledTimes(1);
expect(mocks.trayInstance.setImage).toHaveBeenCalledTimes(1);
expect(mocks.trayInstance.setToolTip).toHaveBeenCalledWith("Fusion — Running");
expect(mocks.Menu.buildFromTemplate).toHaveBeenCalledTimes(1);
const closeCall = mocks.browserWindowInstance.on.mock.calls.find(

View File

@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MenuItemConstructorOptions } from "electron";
const mocks = vi.hoisted(() => {
const menuInstance = { id: "app-menu" };
return {
Menu: {
buildFromTemplate: vi.fn(() => menuInstance),
setApplicationMenu: vi.fn(),
},
shell: {
openExternal: vi.fn(() => Promise.resolve()),
},
menuInstance,
};
});
vi.mock("electron", () => ({
Menu: mocks.Menu,
shell: mocks.shell,
}));
function createMainWindowMock() {
return {
webContents: {
reload: vi.fn(),
reloadIgnoringCache: vi.fn(),
toggleDevTools: vi.fn(),
getZoomLevel: vi.fn(() => 1),
setZoomLevel: vi.fn(),
},
};
}
function findMenuItem(
template: MenuItemConstructorOptions[],
label: string,
): MenuItemConstructorOptions | undefined {
for (const item of template) {
if (item.label === label) {
return item;
}
if (Array.isArray(item.submenu)) {
const nested = findMenuItem(item.submenu, label);
if (nested) {
return nested;
}
}
}
return undefined;
}
function collectAccelerators(template: MenuItemConstructorOptions[]): string[] {
const accelerators: string[] = [];
for (const item of template) {
if (typeof item.accelerator === "string") {
accelerators.push(item.accelerator);
}
if (Array.isArray(item.submenu)) {
accelerators.push(...collectAccelerators(item.submenu));
}
}
return accelerators;
}
describe("application menu", () => {
const originalPlatform = process.platform;
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(process, "platform", {
value: "darwin",
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
});
it("macOS template includes App menu with About, Preferences and Quit", async () => {
const { buildMenuTemplate } = await import("../menu.ts");
const template = buildMenuTemplate({
mainWindow: createMainWindowMock() as never,
appName: "Fusion",
});
expect(template[0]?.label).toBe("Fusion");
const appMenu = template[0]?.submenu as MenuItemConstructorOptions[];
expect(appMenu.some((item) => item.label === "About Fusion")).toBe(true);
expect(appMenu.some((item) => item.label === "Preferences")).toBe(true);
const preferences = appMenu.find((item) => item.label === "Preferences");
const quit = appMenu.find((item) => item.label === "Quit Fusion");
expect(preferences?.accelerator).toBe("CmdOrCtrl+,");
expect(quit?.accelerator).toBe("CmdOrCtrl+Q");
});
it("Edit menu includes standard editing shortcuts", async () => {
const { buildMenuTemplate } = await import("../menu.ts");
const template = buildMenuTemplate({
mainWindow: createMainWindowMock() as never,
appName: "Fusion",
});
const editMenu = findMenuItem(template, "Edit");
const editItems = (editMenu?.submenu ?? []) as MenuItemConstructorOptions[];
expect(editItems.some((item) => item.label === "Undo" && item.accelerator === "CmdOrCtrl+Z")).toBe(
true,
);
expect(editItems.some((item) => item.label === "Cut" && item.accelerator === "CmdOrCtrl+X")).toBe(true);
expect(editItems.some((item) => item.label === "Copy" && item.accelerator === "CmdOrCtrl+C")).toBe(true);
expect(editItems.some((item) => item.label === "Paste" && item.accelerator === "CmdOrCtrl+V")).toBe(
true,
);
expect(
editItems.some((item) => item.label === "Select All" && item.accelerator === "CmdOrCtrl+A"),
).toBe(true);
});
it("View menu includes zoom controls and dev tools shortcut", async () => {
const { buildMenuTemplate } = await import("../menu.ts");
const template = buildMenuTemplate({
mainWindow: createMainWindowMock() as never,
appName: "Fusion",
});
const viewMenu = findMenuItem(template, "View");
const viewItems = (viewMenu?.submenu ?? []) as MenuItemConstructorOptions[];
expect(
viewItems.some((item) => item.label === "Zoom In" && item.accelerator === "CmdOrCtrl+Plus"),
).toBe(true);
expect(viewItems.some((item) => item.label === "Zoom Out" && item.accelerator === "CmdOrCtrl+-")).toBe(
true,
);
expect(viewItems.some((item) => item.label === "Reset Zoom" && item.accelerator === "CmdOrCtrl+0")).toBe(
true,
);
expect(
viewItems.some(
(item) => item.label === "Toggle Dev Tools" && item.accelerator === "Alt+CmdOrCtrl+I",
),
).toBe(true);
});
it("Window menu includes Minimize and Close shortcuts", async () => {
const { buildMenuTemplate } = await import("../menu.ts");
const template = buildMenuTemplate({
mainWindow: createMainWindowMock() as never,
appName: "Fusion",
});
const windowMenu = findMenuItem(template, "Window");
const windowItems = (windowMenu?.submenu ?? []) as MenuItemConstructorOptions[];
expect(
windowItems.some((item) => item.label === "Minimize" && item.accelerator === "CmdOrCtrl+M"),
).toBe(true);
expect(windowItems.some((item) => item.label === "Close" && item.accelerator === "CmdOrCtrl+W")).toBe(
true,
);
});
it("non-macOS template omits App menu and app-specific labels", async () => {
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
const { buildMenuTemplate } = await import("../menu.ts");
const template = buildMenuTemplate({
mainWindow: createMainWindowMock() as never,
appName: "Fusion",
});
expect(template[0]?.label).toBe("Edit");
expect(findMenuItem(template, "About Fusion")).toBeUndefined();
expect(findMenuItem(template, "Hide Fusion")).toBeUndefined();
});
it("Help menu contains Fusion Documentation link", async () => {
const { buildMenuTemplate } = await import("../menu.ts");
const template = buildMenuTemplate({
mainWindow: createMainWindowMock() as never,
appName: "Fusion",
});
const docsItem = findMenuItem(template, "Fusion Documentation");
expect(docsItem).toBeDefined();
docsItem?.click?.({} as never, {} as never, {} as never);
expect(mocks.shell.openExternal).toHaveBeenCalledWith(
"https://github.com/eclipxe/fusion#readme",
);
});
it("all keyboard shortcuts use CmdOrCtrl prefix convention", async () => {
const { buildMenuTemplate } = await import("../menu.ts");
const template = buildMenuTemplate({
mainWindow: createMainWindowMock() as never,
appName: "Fusion",
});
const accelerators = collectAccelerators(template);
for (const accelerator of accelerators) {
expect(accelerator).not.toMatch(/(^|[+])Cmd\+/);
expect(accelerator).not.toMatch(/(^|[+])Ctrl\+/);
}
});
it("buildAppMenu builds and sets the application menu", async () => {
const { buildAppMenu } = await import("../menu.ts");
const menu = buildAppMenu({
mainWindow: createMainWindowMock() as never,
appName: "Fusion",
});
expect(mocks.Menu.buildFromTemplate).toHaveBeenCalledTimes(1);
expect(mocks.Menu.setApplicationMenu).toHaveBeenCalledWith(mocks.menuInstance);
expect(menu).toBe(mocks.menuInstance);
});
});

View File

@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const app = {
on: vi.fn(),
quit: vi.fn(),
};
const menu = {
buildFromTemplate: vi.fn((template) => ({ template })),
};
const nativeImage = {
createFromPath: vi.fn(() => ({
resize: vi.fn(() => ({ id: "resized-image" })),
})),
};
return {
app,
menu,
nativeImage,
};
});
vi.mock("electron", () => ({
app: mocks.app,
Menu: mocks.menu,
nativeImage: mocks.nativeImage,
BrowserWindow: vi.fn(),
Tray: vi.fn(),
}));
function createMainWindowMock(isVisible = true) {
const listeners = new Map<string, (...args: unknown[]) => void>();
return {
isVisible: vi.fn(() => isVisible),
show: vi.fn(),
focus: vi.fn(),
hide: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
listeners.set(event, handler);
return undefined;
}),
getListener(event: string) {
return listeners.get(event);
},
};
}
function createTrayMock() {
const listeners = new Map<string, (...args: unknown[]) => void>();
return {
setImage: vi.fn(),
setToolTip: vi.fn(),
setContextMenu: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
listeners.set(event, handler);
return undefined;
}),
getListener(event: string) {
return listeners.get(event);
},
};
}
describe("tray module", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("getTrayTooltip returns running label", async () => {
const { getTrayTooltip } = await import("../tray.ts");
expect(getTrayTooltip("running")).toBe("Fusion — Running");
});
it("getTrayTooltip returns paused label", async () => {
const { getTrayTooltip } = await import("../tray.ts");
expect(getTrayTooltip("paused")).toBe("Fusion — Paused");
});
it("getTrayTooltip returns stopped label", async () => {
const { getTrayTooltip } = await import("../tray.ts");
expect(getTrayTooltip("stopped")).toBe("Fusion — Stopped");
});
it("buildTrayContextMenu toggles show/hide label based on visibility", async () => {
const { buildTrayContextMenu } = await import("../tray.ts");
const hiddenMenu = buildTrayContextMenu({
isWindowVisible: false,
engineStatus: "running",
});
const visibleMenu = buildTrayContextMenu({
isWindowVisible: true,
engineStatus: "running",
});
expect(hiddenMenu[0]).toMatchObject({ label: "Show Window" });
expect(visibleMenu[0]).toMatchObject({ label: "Hide Window" });
});
it("buildTrayContextMenu shows Pause/Resume labels and enables toggles for running/paused", async () => {
const { buildTrayContextMenu } = await import("../tray.ts");
const runningMenu = buildTrayContextMenu({
isWindowVisible: true,
engineStatus: "running",
});
const pausedMenu = buildTrayContextMenu({
isWindowVisible: true,
engineStatus: "paused",
});
expect(runningMenu[2]).toMatchObject({ label: "Pause Engine", enabled: true });
expect(pausedMenu[2]).toMatchObject({ label: "Resume Engine", enabled: true });
});
it("buildTrayContextMenu disables engine toggle when stopped and includes separators and quit", async () => {
const { buildTrayContextMenu } = await import("../tray.ts");
const stoppedMenu = buildTrayContextMenu({
isWindowVisible: true,
engineStatus: "stopped",
});
const separatorCount = stoppedMenu.filter((item) => item.type === "separator").length;
expect(stoppedMenu[2]).toMatchObject({ enabled: false });
expect(separatorCount).toBe(2);
expect(stoppedMenu[4]).toMatchObject({ label: "Quit Fusion" });
});
it("setupTray sets tooltip and context menu", async () => {
const { setupTray } = await import("../tray.ts");
const mainWindow = createMainWindowMock(true);
const tray = createTrayMock();
setupTray(mainWindow as never, tray as never);
expect(tray.setImage).toHaveBeenCalledTimes(1);
expect(tray.setToolTip).toHaveBeenCalledWith("Fusion — Running");
expect(mocks.menu.buildFromTemplate).toHaveBeenCalledTimes(1);
expect(tray.setContextMenu).toHaveBeenCalledTimes(1);
});
it("updateTrayStatus updates tooltip and menu", async () => {
const { setupTray, updateTrayStatus } = await import("../tray.ts");
const mainWindow = createMainWindowMock(true);
const tray = createTrayMock();
setupTray(mainWindow as never, tray as never);
updateTrayStatus(tray as never, "paused");
expect(tray.setToolTip).toHaveBeenLastCalledWith("Fusion — Paused");
expect(mocks.menu.buildFromTemplate).toHaveBeenCalledTimes(2);
});
it("updateTrayStatus still updates tooltip when tray was not initialized", async () => {
const { updateTrayStatus } = await import("../tray.ts");
const tray = createTrayMock();
updateTrayStatus(tray as never, "stopped");
expect(tray.setToolTip).toHaveBeenCalledWith("Fusion — Stopped");
expect(tray.setContextMenu).toHaveBeenCalledTimes(1);
});
});