feat(FN-1076): integrate desktop IPC bridge and lifecycle modules
- Extract main-process IPC registration into src/ipc.ts for window controls, system info, tray status, updater checks, and native dialogs - Refactor main.ts startup into initializeApp/run and wire menu, tray, deep-link, updater, and window-state restore/quit cleanup - Rework preload bridge to expose typed window.fusionAPI subscriptions and replace preload.d.ts with shared src/types.d.ts global declarations - Add comprehensive tests for IPC handlers, preload contracts, and main-process integration behavior - Document IPC channels, preload API, and lifecycle sequencing in packages/desktop/README.md
This commit is contained in:
220
packages/desktop/src/__tests__/ipc.test.ts
Normal file
220
packages/desktop/src/__tests__/ipc.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
ipcHandlers.set(channel, handler);
|
||||
}),
|
||||
};
|
||||
|
||||
const app = {
|
||||
getVersion: vi.fn(() => "1.2.3"),
|
||||
};
|
||||
|
||||
const updateTrayStatus = vi.fn();
|
||||
const showExportSettingsDialog = vi.fn();
|
||||
const showImportSettingsDialog = vi.fn();
|
||||
const setupAutoUpdater = vi.fn();
|
||||
|
||||
return {
|
||||
ipcMain,
|
||||
ipcHandlers,
|
||||
app,
|
||||
updateTrayStatus,
|
||||
showExportSettingsDialog,
|
||||
showImportSettingsDialog,
|
||||
setupAutoUpdater,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
ipcMain: mocks.ipcMain,
|
||||
app: mocks.app,
|
||||
}));
|
||||
|
||||
vi.mock("../tray.js", () => ({
|
||||
updateTrayStatus: mocks.updateTrayStatus,
|
||||
}));
|
||||
|
||||
vi.mock("../native.js", () => ({
|
||||
showExportSettingsDialog: mocks.showExportSettingsDialog,
|
||||
showImportSettingsDialog: mocks.showImportSettingsDialog,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
}));
|
||||
|
||||
function createWindowMock() {
|
||||
return {
|
||||
minimize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
unmaximize: vi.fn(),
|
||||
close: vi.fn(),
|
||||
isMaximized: vi.fn(() => false),
|
||||
};
|
||||
}
|
||||
|
||||
function createTrayMock() {
|
||||
return {
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function registerHandlers() {
|
||||
const { registerIpcHandlers } = await import("../ipc.ts");
|
||||
const window = createWindowMock();
|
||||
const tray = createTrayMock();
|
||||
registerIpcHandlers(window as never, tray as never);
|
||||
return { window, tray };
|
||||
}
|
||||
|
||||
describe("ipc handlers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mocks.ipcHandlers.clear();
|
||||
mocks.app.getVersion.mockReturnValue("1.2.3");
|
||||
mocks.setupAutoUpdater.mockImplementation(() => undefined);
|
||||
mocks.showExportSettingsDialog.mockResolvedValue(null);
|
||||
mocks.showImportSettingsDialog.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("registers all expected channels", async () => {
|
||||
await registerHandlers();
|
||||
|
||||
const channels = new Set(mocks.ipcMain.handle.mock.calls.map(([channel]) => channel));
|
||||
|
||||
expect(channels).toEqual(new Set([
|
||||
"window:minimize",
|
||||
"window:maximize",
|
||||
"window:close",
|
||||
"window:isMaximized",
|
||||
"app:getSystemInfo",
|
||||
"app:checkForUpdates",
|
||||
"tray:updateStatus",
|
||||
"native:showExportDialog",
|
||||
"native:showImportDialog",
|
||||
]));
|
||||
});
|
||||
|
||||
it("window:minimize calls mainWindow.minimize", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:minimize");
|
||||
await handler?.({});
|
||||
|
||||
expect(window.minimize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("window:maximize maximizes when currently unmaximized", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
window.isMaximized.mockReturnValue(false);
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:maximize");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(window.maximize).toHaveBeenCalledTimes(1);
|
||||
expect(window.unmaximize).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("window:maximize restores when currently maximized", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
window.isMaximized.mockReturnValue(true);
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:maximize");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(window.unmaximize).toHaveBeenCalledTimes(1);
|
||||
expect(window.maximize).not.toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("window:close calls mainWindow.close", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:close");
|
||||
await handler?.({});
|
||||
|
||||
expect(window.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("window:isMaximized returns current maximized state", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
window.isMaximized.mockReturnValue(true);
|
||||
|
||||
const handler = mocks.ipcHandlers.get("window:isMaximized");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("app:getSystemInfo returns process and app metadata", async () => {
|
||||
await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:getSystemInfo");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toEqual({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
appVersion: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("app:checkForUpdates calls setupAutoUpdater and returns checking", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:checkForUpdates");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(mocks.setupAutoUpdater).toHaveBeenCalledWith(window);
|
||||
expect(result).toEqual({ status: "checking" });
|
||||
});
|
||||
|
||||
it("app:checkForUpdates returns error when updater throws", async () => {
|
||||
await registerHandlers();
|
||||
mocks.setupAutoUpdater.mockImplementationOnce(() => {
|
||||
throw new Error("updater failed");
|
||||
});
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:checkForUpdates");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toEqual({ status: "error", error: "updater failed" });
|
||||
});
|
||||
|
||||
it("native:showExportDialog calls showExportSettingsDialog with mainWindow", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
mocks.showExportSettingsDialog.mockResolvedValueOnce("/path/to/file.json");
|
||||
|
||||
const handler = mocks.ipcHandlers.get("native:showExportDialog");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(mocks.showExportSettingsDialog).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe("/path/to/file.json");
|
||||
});
|
||||
|
||||
it("native:showImportDialog calls showImportSettingsDialog with mainWindow", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
mocks.showImportSettingsDialog.mockResolvedValueOnce(null);
|
||||
|
||||
const handler = mocks.ipcHandlers.get("native:showImportDialog");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(mocks.showImportSettingsDialog).toHaveBeenCalledWith(window);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("tray:updateStatus forwards status and tray instance", async () => {
|
||||
const { tray } = await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("tray:updateStatus");
|
||||
await handler?.({}, "paused");
|
||||
|
||||
expect(mocks.updateTrayStatus).toHaveBeenCalledWith(tray, "paused");
|
||||
});
|
||||
});
|
||||
341
packages/desktop/src/__tests__/main-integration.test.ts
Normal file
341
packages/desktop/src/__tests__/main-integration.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const callLog: string[] = [];
|
||||
const appEvents = new Map<string, (...args: unknown[]) => void>();
|
||||
const windowInstances: Array<{
|
||||
instance: ReturnType<typeof createWindowMock>;
|
||||
options: Record<string, unknown>;
|
||||
}> = [];
|
||||
const trayInstances: Array<ReturnType<typeof createTrayMock>> = [];
|
||||
|
||||
function createWindowMock() {
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
return {
|
||||
loadURL: vi.fn(() => Promise.resolve()),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
listeners.set(event, handler);
|
||||
}),
|
||||
hide: vi.fn(),
|
||||
show: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getBounds: vi.fn(() => ({ x: 50, y: 80, width: 1280, height: 900 })),
|
||||
isMaximized: vi.fn(() => false),
|
||||
getListener: (event: string) => listeners.get(event),
|
||||
};
|
||||
}
|
||||
|
||||
function createTrayMock() {
|
||||
return {
|
||||
destroy: vi.fn(),
|
||||
setImage: vi.fn(),
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const app = {
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
appEvents.set(event, handler);
|
||||
}),
|
||||
quit: vi.fn(),
|
||||
isQuitting: false,
|
||||
};
|
||||
|
||||
const BrowserWindow = vi.fn((options: Record<string, unknown>) => {
|
||||
callLog.push("createMainWindow");
|
||||
const instance = createWindowMock();
|
||||
windowInstances.push({ instance, options });
|
||||
return instance;
|
||||
});
|
||||
|
||||
const Tray = vi.fn(() => {
|
||||
const tray = createTrayMock();
|
||||
trayInstances.push(tray);
|
||||
return tray;
|
||||
});
|
||||
|
||||
const nativeImage = {
|
||||
createEmpty: vi.fn(() => ({ id: "empty" })),
|
||||
};
|
||||
|
||||
const buildAppMenu = vi.fn(() => {
|
||||
callLog.push("buildAppMenu");
|
||||
});
|
||||
|
||||
const setupTray = vi.fn(() => {
|
||||
callLog.push("setupTray");
|
||||
});
|
||||
|
||||
const registerIpcHandlers = vi.fn(() => {
|
||||
callLog.push("registerIpcHandlers");
|
||||
});
|
||||
|
||||
const registerDeepLinkProtocol = vi.fn(() => {
|
||||
callLog.push("registerDeepLinkProtocol");
|
||||
});
|
||||
|
||||
const setupDeepLinkHandler = vi.fn(() => {
|
||||
callLog.push("setupDeepLinkHandler");
|
||||
});
|
||||
|
||||
const setupAutoUpdater = vi.fn(() => {
|
||||
callLog.push("setupAutoUpdater");
|
||||
});
|
||||
|
||||
const loadWindowState = vi.fn(async () => {
|
||||
callLog.push("loadWindowState");
|
||||
return null;
|
||||
});
|
||||
|
||||
const saveWindowState = vi.fn();
|
||||
|
||||
const DEFAULT_WINDOW_STATE = {
|
||||
width: 1280,
|
||||
height: 900,
|
||||
isMaximized: false,
|
||||
};
|
||||
|
||||
return {
|
||||
callLog,
|
||||
appEvents,
|
||||
windowInstances,
|
||||
trayInstances,
|
||||
app,
|
||||
BrowserWindow,
|
||||
Tray,
|
||||
nativeImage,
|
||||
buildAppMenu,
|
||||
setupTray,
|
||||
registerIpcHandlers,
|
||||
registerDeepLinkProtocol,
|
||||
setupDeepLinkHandler,
|
||||
setupAutoUpdater,
|
||||
loadWindowState,
|
||||
saveWindowState,
|
||||
DEFAULT_WINDOW_STATE,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: mocks.app,
|
||||
BrowserWindow: mocks.BrowserWindow,
|
||||
Tray: mocks.Tray,
|
||||
nativeImage: mocks.nativeImage,
|
||||
}));
|
||||
|
||||
vi.mock("../menu.js", () => ({
|
||||
buildAppMenu: mocks.buildAppMenu,
|
||||
}));
|
||||
|
||||
vi.mock("../tray.js", () => ({
|
||||
setupTray: mocks.setupTray,
|
||||
}));
|
||||
|
||||
vi.mock("../ipc.js", () => ({
|
||||
registerIpcHandlers: mocks.registerIpcHandlers,
|
||||
}));
|
||||
|
||||
vi.mock("../deep-link.js", () => ({
|
||||
registerDeepLinkProtocol: mocks.registerDeepLinkProtocol,
|
||||
setupDeepLinkHandler: mocks.setupDeepLinkHandler,
|
||||
}));
|
||||
|
||||
vi.mock("../native.js", () => ({
|
||||
loadWindowState: mocks.loadWindowState,
|
||||
saveWindowState: mocks.saveWindowState,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
DEFAULT_WINDOW_STATE: mocks.DEFAULT_WINDOW_STATE,
|
||||
}));
|
||||
|
||||
async function importMainModule() {
|
||||
return import("../main.ts");
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("main integration", () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mocks.callLog.length = 0;
|
||||
mocks.appEvents.clear();
|
||||
mocks.windowInstances.length = 0;
|
||||
mocks.trayInstances.length = 0;
|
||||
mocks.app.isQuitting = false;
|
||||
mocks.loadWindowState.mockImplementation(async () => {
|
||||
mocks.callLog.push("loadWindowState");
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it("initializeApp calls modules in the expected order", async () => {
|
||||
const { initializeApp } = await importMainModule();
|
||||
|
||||
await initializeApp();
|
||||
|
||||
expect(mocks.callLog).toEqual([
|
||||
"loadWindowState",
|
||||
"createMainWindow",
|
||||
"buildAppMenu",
|
||||
"setupTray",
|
||||
"registerIpcHandlers",
|
||||
"registerDeepLinkProtocol",
|
||||
"setupDeepLinkHandler",
|
||||
"setupAutoUpdater",
|
||||
]);
|
||||
});
|
||||
|
||||
it("createMainWindow uses restored window state", async () => {
|
||||
mocks.loadWindowState.mockImplementationOnce(async () => ({
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
isMaximized: false,
|
||||
}));
|
||||
|
||||
const { initializeApp } = await importMainModule();
|
||||
await initializeApp();
|
||||
|
||||
const [{ options }] = mocks.windowInstances;
|
||||
expect(options).toMatchObject({
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
});
|
||||
});
|
||||
|
||||
it("createMainWindow falls back to DEFAULT_WINDOW_STATE when no saved state exists", async () => {
|
||||
mocks.loadWindowState.mockImplementationOnce(async () => null);
|
||||
|
||||
const { initializeApp } = await importMainModule();
|
||||
await initializeApp();
|
||||
|
||||
const [{ options }] = mocks.windowInstances;
|
||||
expect(options).toMatchObject({
|
||||
width: mocks.DEFAULT_WINDOW_STATE.width,
|
||||
height: mocks.DEFAULT_WINDOW_STATE.height,
|
||||
});
|
||||
});
|
||||
|
||||
it("initializeApp maximizes the window when restored state is maximized", async () => {
|
||||
mocks.loadWindowState.mockImplementationOnce(async () => ({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
isMaximized: true,
|
||||
}));
|
||||
|
||||
const { initializeApp } = await importMainModule();
|
||||
await initializeApp();
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
expect(instance.maximize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("buildAppMenu is called with mainWindow and Fusion app name", async () => {
|
||||
const { initializeApp } = await importMainModule();
|
||||
await initializeApp();
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
expect(mocks.buildAppMenu).toHaveBeenCalledWith({
|
||||
mainWindow: instance,
|
||||
appName: "Fusion",
|
||||
});
|
||||
});
|
||||
|
||||
it("setupTray is called with mainWindow and tray instance", async () => {
|
||||
const { initializeApp } = await importMainModule();
|
||||
await initializeApp();
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
const [trayInstance] = mocks.trayInstances;
|
||||
expect(mocks.setupTray).toHaveBeenCalledWith(instance, trayInstance);
|
||||
});
|
||||
|
||||
it("registerIpcHandlers is called with mainWindow and tray", async () => {
|
||||
const { initializeApp } = await importMainModule();
|
||||
await initializeApp();
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
const [trayInstance] = mocks.trayInstances;
|
||||
expect(mocks.registerIpcHandlers).toHaveBeenCalledWith(instance, trayInstance);
|
||||
});
|
||||
|
||||
it("window close hides to tray when app is not quitting", async () => {
|
||||
const { initializeApp } = await importMainModule();
|
||||
await initializeApp();
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
const closeHandler = instance.getListener("close") as ((event: { preventDefault: () => void }) => void) | undefined;
|
||||
const event = { preventDefault: vi.fn() };
|
||||
|
||||
closeHandler?.(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(instance.hide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("window close saves window state before hiding", async () => {
|
||||
const { initializeApp } = await importMainModule();
|
||||
await initializeApp();
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
const closeHandler = instance.getListener("close") as ((event: { preventDefault: () => void }) => void) | undefined;
|
||||
|
||||
closeHandler?.({ preventDefault: vi.fn() });
|
||||
|
||||
expect(mocks.saveWindowState).toHaveBeenCalledWith(instance);
|
||||
});
|
||||
|
||||
it("before-quit destroys tray and marks app as quitting", async () => {
|
||||
const { run } = await importMainModule();
|
||||
|
||||
run();
|
||||
await flushPromises();
|
||||
|
||||
const beforeQuitHandler = mocks.appEvents.get("before-quit");
|
||||
beforeQuitHandler?.();
|
||||
|
||||
const [trayInstance] = mocks.trayInstances;
|
||||
expect(mocks.app.isQuitting).toBe(true);
|
||||
expect(trayInstance.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("window-all-closed does not quit on macOS", async () => {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "darwin",
|
||||
});
|
||||
}
|
||||
|
||||
const { run } = await importMainModule();
|
||||
run();
|
||||
|
||||
const windowAllClosedHandler = mocks.appEvents.get("window-all-closed");
|
||||
windowAllClosedHandler?.();
|
||||
|
||||
expect(mocks.app.quit).not.toHaveBeenCalled();
|
||||
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it("importing main module does not auto-start app lifecycle", async () => {
|
||||
await importMainModule();
|
||||
|
||||
expect(mocks.app.whenReady).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -136,23 +136,19 @@ describe("main process", () => {
|
||||
expect(mocks.browserWindowInstance.loadURL).toHaveBeenCalledWith(DASHBOARD_URL);
|
||||
});
|
||||
|
||||
it("registerIpcHandlers registers app:get-version via handle", async () => {
|
||||
const { registerIpcHandlers } = await importMainModule();
|
||||
it("exports initializeApp for lifecycle orchestration", async () => {
|
||||
const mainModule = await importMainModule();
|
||||
|
||||
registerIpcHandlers();
|
||||
|
||||
expect(mocks.ipcMain.handle).toHaveBeenCalledWith(
|
||||
"app:get-version",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(typeof mainModule.initializeApp).toBe("function");
|
||||
});
|
||||
|
||||
it("registerIpcHandlers registers app:quit via on", async () => {
|
||||
const { registerIpcHandlers } = await importMainModule();
|
||||
it("createMainWindow registers close and closed handlers", async () => {
|
||||
const { createMainWindow } = await importMainModule();
|
||||
|
||||
registerIpcHandlers();
|
||||
createMainWindow();
|
||||
|
||||
expect(mocks.ipcMain.on).toHaveBeenCalledWith("app:quit", expect.any(Function));
|
||||
expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("close", expect.any(Function));
|
||||
expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("closed", expect.any(Function));
|
||||
});
|
||||
|
||||
it("importing main does not auto-start", async () => {
|
||||
@@ -161,27 +157,9 @@ describe("main process", () => {
|
||||
expect(mocks.app.whenReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setupTray configures tray interactions with provided tray instance", async () => {
|
||||
const { setupTray } = await importMainModule();
|
||||
it("exports run for app entrypoint wiring", async () => {
|
||||
const mainModule = await importMainModule();
|
||||
|
||||
setupTray(mocks.browserWindowInstance as never, mocks.trayInstance as never);
|
||||
|
||||
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(
|
||||
(call) => call[0] === "close",
|
||||
);
|
||||
expect(closeCall).toBeDefined();
|
||||
|
||||
const closeHandler = closeCall?.[1] as (event: { preventDefault: () => void }) => void;
|
||||
const event = { preventDefault: vi.fn() };
|
||||
|
||||
closeHandler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.browserWindowInstance.hide).toHaveBeenCalledTimes(1);
|
||||
expect(typeof mainModule.run).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => {
|
||||
|
||||
const ipcRenderer = {
|
||||
invoke: vi.fn(),
|
||||
send: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
};
|
||||
@@ -24,30 +23,22 @@ async function importPreloadModule() {
|
||||
await import("../preload.ts");
|
||||
}
|
||||
|
||||
function getExposedFusionDesktopApi() {
|
||||
function getFusionApi() {
|
||||
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
|
||||
(entry) => entry[0] === "fusionDesktop",
|
||||
([name]) => name === "fusionAPI",
|
||||
) as [string, {
|
||||
getAppVersion: () => Promise<string>;
|
||||
quit: () => void;
|
||||
onDashboardReady: (callback: () => void) => () => void;
|
||||
}] | undefined;
|
||||
|
||||
return call?.[1];
|
||||
}
|
||||
|
||||
function getExposedElectronApi() {
|
||||
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
|
||||
(entry) => entry[0] === "electronAPI",
|
||||
) as [string, {
|
||||
invoke: (channel: string, payload?: unknown) => Promise<unknown>;
|
||||
apiRequest: (method: string, path: string, body?: unknown) => Promise<unknown>;
|
||||
getServerPort: () => Promise<number>;
|
||||
windowControl: (action: string) => Promise<boolean | void>;
|
||||
onUpdateAvailable: (callback: (info: Record<string, unknown>) => void) => () => void;
|
||||
installUpdate: () => Promise<void>;
|
||||
onDeepLink: (callback: (url: string) => void) => () => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
minimize: () => Promise<void>;
|
||||
maximize: () => Promise<boolean>;
|
||||
close: () => Promise<void>;
|
||||
isMaximized: () => Promise<boolean>;
|
||||
getSystemInfo: () => Promise<unknown>;
|
||||
checkForUpdates: () => Promise<unknown>;
|
||||
updateTrayStatus: (status: string) => Promise<void>;
|
||||
showExportDialog: () => Promise<string | null>;
|
||||
showImportDialog: () => Promise<string | null>;
|
||||
onDeepLink: (callback: (result: unknown) => void) => () => void;
|
||||
onUpdateAvailable: (callback: (info: { version: string }) => void) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
}] | undefined;
|
||||
|
||||
return call?.[1];
|
||||
@@ -59,96 +50,144 @@ describe("preload", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("exposes fusionDesktop and electronAPI", async () => {
|
||||
it("contextBridge.exposeInMainWorld is called with fusionAPI", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
|
||||
"fusionDesktop",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
|
||||
"electronAPI",
|
||||
"fusionAPI",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("fusionDesktop.getAppVersion calls ipcRenderer.invoke", async () => {
|
||||
mocks.ipcRenderer.invoke.mockResolvedValue("0.1.0");
|
||||
it("minimize invokes window:minimize", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getExposedFusionDesktopApi();
|
||||
const version = await api?.getAppVersion();
|
||||
const api = getFusionApi();
|
||||
await api?.minimize();
|
||||
|
||||
expect(version).toBe("0.1.0");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:get-version");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:minimize");
|
||||
});
|
||||
|
||||
it("fusionDesktop.quit calls ipcRenderer.send", async () => {
|
||||
it("maximize invokes window:maximize", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getExposedFusionDesktopApi();
|
||||
api?.quit();
|
||||
const api = getFusionApi();
|
||||
await api?.maximize();
|
||||
|
||||
expect(mocks.ipcRenderer.send).toHaveBeenCalledWith("app:quit");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:maximize");
|
||||
});
|
||||
|
||||
it("fusionDesktop.onDashboardReady returns unsubscribe function", async () => {
|
||||
it("close invokes window:close", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getExposedFusionDesktopApi();
|
||||
const api = getFusionApi();
|
||||
await api?.close();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:close");
|
||||
});
|
||||
|
||||
it("isMaximized invokes window:isMaximized", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.isMaximized();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:isMaximized");
|
||||
});
|
||||
|
||||
it("getSystemInfo invokes app:getSystemInfo", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.getSystemInfo();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:getSystemInfo");
|
||||
});
|
||||
|
||||
it("checkForUpdates invokes app:checkForUpdates", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.checkForUpdates();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:checkForUpdates");
|
||||
});
|
||||
|
||||
it("updateTrayStatus invokes tray:updateStatus with status argument", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.updateTrayStatus("paused");
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("tray:updateStatus", "paused");
|
||||
});
|
||||
|
||||
it("showExportDialog invokes native:showExportDialog", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.showExportDialog();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("native:showExportDialog");
|
||||
});
|
||||
|
||||
it("showImportDialog invokes native:showImportDialog", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.showImportDialog();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("native:showImportDialog");
|
||||
});
|
||||
|
||||
it("onDeepLink subscribes to deep-link and returns unsubscribe", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onDashboardReady(callback);
|
||||
const unsubscribe = api?.onDeepLink(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith(
|
||||
"dashboard:ready",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(typeof unsubscribe).toBe("function");
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"dashboard:ready",
|
||||
"deep-link",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("electronAPI methods invoke expected IPC channels", async () => {
|
||||
it("onUpdateAvailable subscribes to update-available and returns unsubscribe", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getExposedElectronApi();
|
||||
await api?.invoke("api-request", { method: "GET", path: "/tasks" });
|
||||
await api?.apiRequest("POST", "/tasks", { title: "Task" });
|
||||
await api?.getServerPort();
|
||||
await api?.windowControl("maximize");
|
||||
await api?.installUpdate();
|
||||
await api?.getPlatform();
|
||||
const api = getFusionApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onUpdateAvailable(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("api-request", { method: "GET", path: "/tasks" });
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("api-request", { method: "POST", path: "/tasks", body: { title: "Task" } });
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("server:get-port");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:control", "maximize");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("update:install");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("system:get-platform");
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-available", expect.any(Function));
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"update-available",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("electronAPI event subscriptions provide unsubscribe functions", async () => {
|
||||
it("onUpdateDownloaded subscribes to update-downloaded and returns unsubscribe", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getExposedElectronApi();
|
||||
const onUpdate = vi.fn();
|
||||
const onDeepLink = vi.fn();
|
||||
const api = getFusionApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onUpdateDownloaded(callback);
|
||||
|
||||
const unsubscribeUpdate = api?.onUpdateAvailable(onUpdate);
|
||||
const unsubscribeDeepLink = api?.onDeepLink(onDeepLink);
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-downloaded", expect.any(Function));
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update:available", expect.any(Function));
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
|
||||
unsubscribe?.();
|
||||
|
||||
unsubscribeUpdate?.();
|
||||
unsubscribeDeepLink?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("update:available", expect.any(Function));
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("deep-link", expect.any(Function));
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"update-downloaded",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user