feat(FN-1070): restore desktop Electron scaffolding
- Reintroduce the @fusion/desktop workspace package with Electron main, preload, and renderer entrypoints - Add desktop build/test tooling (TypeScript, Vitest, electron-builder) and update workspace build dependency settings for Electron - Wire secure IPC bridge APIs for app version/quit flows with exported preload typings - Add README usage docs and unit tests covering main-process window/tray behavior and preload API wiring
This commit is contained in:
170
packages/desktop/src/__tests__/main.test.ts
Normal file
170
packages/desktop/src/__tests__/main.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const browserWindowInstance = {
|
||||
loadURL: vi.fn(),
|
||||
on: vi.fn(),
|
||||
show: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
};
|
||||
|
||||
const BrowserWindow = vi.fn(() => browserWindowInstance) as unknown as {
|
||||
(...args: unknown[]): typeof browserWindowInstance;
|
||||
getAllWindows: () => unknown[];
|
||||
};
|
||||
BrowserWindow.getAllWindows = vi.fn(() => []);
|
||||
|
||||
const app = {
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
getVersion: vi.fn(() => "0.1.0"),
|
||||
quit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
const ipcMain = {
|
||||
handle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
const trayInstance = {
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
};
|
||||
|
||||
const Tray = vi.fn(() => trayInstance);
|
||||
const Menu = { buildFromTemplate: vi.fn(() => ({ id: "mock-menu" })) };
|
||||
const nativeImage = { createEmpty: vi.fn(() => ({ id: "mock-image" })) };
|
||||
|
||||
return {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
trayInstance,
|
||||
Tray,
|
||||
Menu,
|
||||
nativeImage,
|
||||
browserWindowInstance,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: mocks.app,
|
||||
BrowserWindow: mocks.BrowserWindow,
|
||||
ipcMain: mocks.ipcMain,
|
||||
Tray: mocks.Tray,
|
||||
Menu: mocks.Menu,
|
||||
nativeImage: mocks.nativeImage,
|
||||
}));
|
||||
|
||||
async function importMainModule() {
|
||||
return import("../main.ts");
|
||||
}
|
||||
|
||||
describe("main process", () => {
|
||||
const originalDashboardUrl = process.env.FUSION_DASHBOARD_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalDashboardUrl === undefined) {
|
||||
delete process.env.FUSION_DASHBOARD_URL;
|
||||
} else {
|
||||
process.env.FUSION_DASHBOARD_URL = originalDashboardUrl;
|
||||
}
|
||||
});
|
||||
|
||||
it("DASHBOARD_URL defaults to localhost:4040", async () => {
|
||||
delete process.env.FUSION_DASHBOARD_URL;
|
||||
|
||||
const { DASHBOARD_URL } = await importMainModule();
|
||||
|
||||
expect(DASHBOARD_URL).toBe("http://localhost:4040");
|
||||
});
|
||||
|
||||
it("DASHBOARD_URL uses env override", async () => {
|
||||
process.env.FUSION_DASHBOARD_URL = "http://localhost:5050";
|
||||
|
||||
const { DASHBOARD_URL } = await importMainModule();
|
||||
|
||||
expect(DASHBOARD_URL).toBe("http://localhost:5050");
|
||||
});
|
||||
|
||||
it("createMainWindow creates BrowserWindow with secure preferences", async () => {
|
||||
const { createMainWindow } = await importMainModule();
|
||||
|
||||
createMainWindow();
|
||||
|
||||
expect(mocks.BrowserWindow).toHaveBeenCalledTimes(1);
|
||||
const [options] = mocks.BrowserWindow.mock.calls[0] as [
|
||||
{
|
||||
webPreferences: {
|
||||
contextIsolation: boolean;
|
||||
nodeIntegration: boolean;
|
||||
preload: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
expect(options.webPreferences.contextIsolation).toBe(true);
|
||||
expect(options.webPreferences.nodeIntegration).toBe(false);
|
||||
expect(options.webPreferences.preload).toContain("preload.ts");
|
||||
});
|
||||
|
||||
it("createMainWindow loads the dashboard URL", async () => {
|
||||
const { createMainWindow, DASHBOARD_URL } = await importMainModule();
|
||||
|
||||
createMainWindow();
|
||||
|
||||
expect(mocks.browserWindowInstance.loadURL).toHaveBeenCalledWith(DASHBOARD_URL);
|
||||
});
|
||||
|
||||
it("registerIpcHandlers registers app:get-version via handle", async () => {
|
||||
const { registerIpcHandlers } = await importMainModule();
|
||||
|
||||
registerIpcHandlers();
|
||||
|
||||
expect(mocks.ipcMain.handle).toHaveBeenCalledWith(
|
||||
"app:get-version",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("registerIpcHandlers registers app:quit via on", async () => {
|
||||
const { registerIpcHandlers } = await importMainModule();
|
||||
|
||||
registerIpcHandlers();
|
||||
|
||||
expect(mocks.ipcMain.on).toHaveBeenCalledWith("app:quit", expect.any(Function));
|
||||
});
|
||||
|
||||
it("importing main does not auto-start", async () => {
|
||||
await importMainModule();
|
||||
|
||||
expect(mocks.app.whenReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setupTray creates tray menu and hides window on close", async () => {
|
||||
const { setupTray } = await importMainModule();
|
||||
|
||||
setupTray(mocks.browserWindowInstance as never);
|
||||
|
||||
expect(mocks.Tray).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.nativeImage.createEmpty).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.trayInstance.setToolTip).toHaveBeenCalledWith("Fusion");
|
||||
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);
|
||||
});
|
||||
});
|
||||
94
packages/desktop/src/__tests__/preload.test.ts
Normal file
94
packages/desktop/src/__tests__/preload.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const contextBridge = {
|
||||
exposeInMainWorld: vi.fn(),
|
||||
};
|
||||
|
||||
const ipcRenderer = {
|
||||
invoke: vi.fn(),
|
||||
send: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
};
|
||||
|
||||
return { contextBridge, ipcRenderer };
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
contextBridge: mocks.contextBridge,
|
||||
ipcRenderer: mocks.ipcRenderer,
|
||||
}));
|
||||
|
||||
async function importPreloadModule() {
|
||||
await import("../preload.ts");
|
||||
}
|
||||
|
||||
function getExposedApi() {
|
||||
const call = mocks.contextBridge.exposeInMainWorld.mock.calls[0] as
|
||||
| [string, {
|
||||
getAppVersion: () => Promise<string>;
|
||||
quit: () => void;
|
||||
onDashboardReady: (callback: () => void) => () => void;
|
||||
}]
|
||||
| undefined;
|
||||
|
||||
return call?.[1];
|
||||
}
|
||||
|
||||
describe("preload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("contextBridge.exposeInMainWorld called with fusionDesktop", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
|
||||
"fusionDesktop",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("getAppVersion calls ipcRenderer.invoke", async () => {
|
||||
mocks.ipcRenderer.invoke.mockResolvedValue("0.1.0");
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getExposedApi();
|
||||
const version = await api?.getAppVersion();
|
||||
|
||||
expect(version).toBe("0.1.0");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:get-version");
|
||||
});
|
||||
|
||||
it("quit calls ipcRenderer.send", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getExposedApi();
|
||||
api?.quit();
|
||||
|
||||
expect(mocks.ipcRenderer.send).toHaveBeenCalledWith("app:quit");
|
||||
});
|
||||
|
||||
it("onDashboardReady returns unsubscribe function", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getExposedApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onDashboardReady(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith(
|
||||
"dashboard:ready",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(typeof unsubscribe).toBe("function");
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"dashboard:ready",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user