feat(FN-3398): update desktop README with multi-project setup and troublesh
The merge lands Step 7 of FN-3398, adding documentation and delivery artifacts across the mobile and desktop packages with updated READMEs, mobile-specific docs, and architecture references. Fusion-Task-Id: FN-3398
This commit is contained in:
@@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => {
|
||||
const showExportSettingsDialog = vi.fn();
|
||||
const showImportSettingsDialog = vi.fn();
|
||||
const setupAutoUpdater = vi.fn();
|
||||
const readShellSettings = vi.fn(async () => ({ desktopMode: "remote", activeProfileId: null, profiles: [] }));
|
||||
const writeShellSettings = vi.fn(async () => undefined);
|
||||
|
||||
return {
|
||||
ipcMain,
|
||||
@@ -26,6 +28,8 @@ const mocks = vi.hoisted(() => {
|
||||
showExportSettingsDialog,
|
||||
showImportSettingsDialog,
|
||||
setupAutoUpdater,
|
||||
readShellSettings,
|
||||
writeShellSettings,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -44,6 +48,11 @@ vi.mock("../native.js", () => ({
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
}));
|
||||
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: mocks.readShellSettings,
|
||||
writeShellSettings: mocks.writeShellSettings,
|
||||
}));
|
||||
|
||||
function createWindowMock() {
|
||||
return {
|
||||
minimize: vi.fn(),
|
||||
@@ -51,6 +60,7 @@ function createWindowMock() {
|
||||
unmaximize: vi.fn(),
|
||||
close: vi.fn(),
|
||||
isMaximized: vi.fn(() => false),
|
||||
webContents: { send: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,11 +71,11 @@ function createTrayMock() {
|
||||
};
|
||||
}
|
||||
|
||||
async function registerHandlers() {
|
||||
async function registerHandlers(options: Record<string, unknown> = {}) {
|
||||
const { registerIpcHandlers } = await import("../ipc.ts");
|
||||
const window = createWindowMock();
|
||||
const tray = createTrayMock();
|
||||
registerIpcHandlers(window as never, tray as never);
|
||||
registerIpcHandlers(window as never, tray as never, options as never);
|
||||
return { window, tray };
|
||||
}
|
||||
|
||||
@@ -74,169 +84,34 @@ describe("ipc handlers", () => {
|
||||
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 () => {
|
||||
it("registers shell 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",
|
||||
"app:getServerPort",
|
||||
"tray:updateStatus",
|
||||
"native:showExportDialog",
|
||||
"native:showImportDialog",
|
||||
]));
|
||||
expect(channels.has("shell:getState")).toBe(true);
|
||||
expect(channels.has("shell:saveProfile")).toBe(true);
|
||||
expect(channels.has("shell:setDesktopMode")).toBe(true);
|
||||
expect(channels.has("platform:get")).toBe(true);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it("shell:getState returns desktop shell state", async () => {
|
||||
await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:getSystemInfo");
|
||||
const handler = mocks.ipcHandlers.get("shell:getState");
|
||||
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",
|
||||
});
|
||||
expect(result).toMatchObject({ host: "desktop-shell", desktopMode: "remote" });
|
||||
});
|
||||
|
||||
it("app:checkForUpdates calls setupAutoUpdater and returns checking", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
it("shell:setDesktopMode persists mode and emits state", async () => {
|
||||
const onDesktopModeChange = vi.fn(async () => undefined);
|
||||
const { window } = await registerHandlers({ onDesktopModeChange });
|
||||
const handler = mocks.ipcHandlers.get("shell:setDesktopMode");
|
||||
await handler?.({}, "local");
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("app:getServerPort returns port from environment", async () => {
|
||||
process.env.FUSION_SERVER_PORT = "4545";
|
||||
await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:getServerPort");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toBe(4545);
|
||||
delete process.env.FUSION_SERVER_PORT;
|
||||
});
|
||||
|
||||
it("app:getServerPort returns undefined when env var not set", async () => {
|
||||
delete process.env.FUSION_SERVER_PORT;
|
||||
await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("app:getServerPort");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mocks.writeShellSettings).toHaveBeenCalled();
|
||||
expect(onDesktopModeChange).toHaveBeenCalledWith("local");
|
||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
111
packages/desktop/src/__tests__/local-server.test.ts
Normal file
111
packages/desktop/src/__tests__/local-server.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
class SimpleEmitter {
|
||||
private listeners = new Map<string, Handler[]>();
|
||||
on(event: string, handler: Handler) {
|
||||
const current = this.listeners.get(event) ?? [];
|
||||
current.push(handler);
|
||||
this.listeners.set(event, current);
|
||||
return this;
|
||||
}
|
||||
once(event: string, handler: Handler) {
|
||||
const wrapped: Handler = (...args) => {
|
||||
this.removeListener(event, wrapped);
|
||||
handler(...args);
|
||||
};
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
removeListener(event: string, handler: Handler) {
|
||||
const current = this.listeners.get(event) ?? [];
|
||||
this.listeners.set(event, current.filter((item) => item !== handler));
|
||||
return this;
|
||||
}
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
const current = this.listeners.get(event) ?? [];
|
||||
for (const handler of current) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const store = {
|
||||
init: vi.fn(async () => undefined),
|
||||
watch: vi.fn(async () => undefined),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
class TaskStore {
|
||||
constructor(_rootDir: string) {}
|
||||
init = store.init;
|
||||
watch = store.watch;
|
||||
close = store.close;
|
||||
}
|
||||
|
||||
const server = Object.assign(new SimpleEmitter(), {
|
||||
address: vi.fn(() => ({ port: 4545 })),
|
||||
close: vi.fn((cb: () => void) => cb()),
|
||||
});
|
||||
|
||||
const listen = vi.fn(() => {
|
||||
queueMicrotask(() => server.emit("listening"));
|
||||
return server;
|
||||
});
|
||||
|
||||
const createServer = vi.fn(() => ({ listen }));
|
||||
|
||||
return { TaskStore, createServer, store, listen };
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore }));
|
||||
vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServer }));
|
||||
|
||||
describe("DesktopLocalServerManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts local runtime and exposes port", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
const runtime = await manager.start();
|
||||
|
||||
expect(runtime.port).toBe(4545);
|
||||
expect(manager.getPort()).toBe(4545);
|
||||
expect(manager.getState().status).toBe("ready");
|
||||
});
|
||||
|
||||
it("stops local runtime and resets state", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
await manager.start();
|
||||
|
||||
await manager.stop();
|
||||
|
||||
expect(mocks.store.close).toHaveBeenCalled();
|
||||
expect(manager.getState().status).toBe("idle");
|
||||
expect(manager.getPort()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets error state when startup fails", async () => {
|
||||
mocks.store.init.mockRejectedValueOnce(new Error("init failed"));
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
await expect(manager.start()).rejects.toThrow("init failed");
|
||||
expect(manager.getState()).toMatchObject({ status: "error", error: "init failed" });
|
||||
});
|
||||
|
||||
it("returns existing runtime when start is called twice", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
const first = await manager.start();
|
||||
const second = await manager.start();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(mocks.listen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -280,7 +280,7 @@ describe("main integration", () => {
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
const [trayInstance] = mocks.trayInstances;
|
||||
expect(mocks.registerIpcHandlers).toHaveBeenCalledWith(instance, trayInstance);
|
||||
expect(mocks.registerIpcHandlers).toHaveBeenCalledWith(instance, trayInstance, expect.any(Object));
|
||||
});
|
||||
|
||||
it("window close hides to tray when app is not quitting", async () => {
|
||||
|
||||
68
packages/desktop/src/__tests__/main-local-mode.test.ts
Normal file
68
packages/desktop/src/__tests__/main-local-mode.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const appHandlers = new Map<string, (...args: unknown[]) => void>();
|
||||
const app = {
|
||||
whenReady: vi.fn(async () => undefined),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
appHandlers.set(event, handler);
|
||||
return app;
|
||||
}),
|
||||
quit: vi.fn(),
|
||||
};
|
||||
|
||||
const browserWindow = {
|
||||
on: vi.fn(),
|
||||
loadURL: vi.fn(),
|
||||
loadFile: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 800, height: 600 })),
|
||||
isMaximized: vi.fn(() => false),
|
||||
hide: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
webContents: { send: vi.fn() },
|
||||
};
|
||||
|
||||
const BrowserWindow = vi.fn(() => browserWindow);
|
||||
const Tray = vi.fn(() => ({ destroy: vi.fn() }));
|
||||
|
||||
const localServerManager = {
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
getState: vi.fn(() => ({ status: "idle", error: null })),
|
||||
getPort: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
return { app, appHandlers, BrowserWindow, Tray, browserWindow, localServerManager };
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: mocks.app,
|
||||
BrowserWindow: mocks.BrowserWindow,
|
||||
Tray: mocks.Tray,
|
||||
nativeImage: { createEmpty: vi.fn(() => ({})) },
|
||||
}));
|
||||
|
||||
vi.mock("../renderer.js", () => ({ isUrlRenderer: vi.fn(() => true), getRendererUrl: vi.fn(() => "http://localhost"), getRendererFilePath: vi.fn(() => "index.html") }));
|
||||
vi.mock("../menu.js", () => ({ buildAppMenu: vi.fn() }));
|
||||
vi.mock("../tray.js", () => ({ setupTray: vi.fn() }));
|
||||
vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() }));
|
||||
vi.mock("../native.js", () => ({ DEFAULT_WINDOW_STATE: { width: 1000, height: 800 }, loadWindowState: vi.fn(async () => null), saveWindowState: vi.fn(), setupAutoUpdater: vi.fn() }));
|
||||
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
||||
vi.mock("../shell-settings.js", () => ({ readShellSettings: vi.fn(async () => ({ desktopMode: "local", activeProfileId: null, profiles: [] })) }));
|
||||
vi.mock("../local-server.js", () => ({ DesktopLocalServerManager: vi.fn(() => mocks.localServerManager) }));
|
||||
|
||||
describe("main local mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.appHandlers.clear();
|
||||
});
|
||||
|
||||
it("starts local server manager when restored desktop mode is local", async () => {
|
||||
const { initializeApp } = await import("../main.ts");
|
||||
await initializeApp();
|
||||
|
||||
expect(mocks.localServerManager.start).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,26 +23,8 @@ async function importPreloadModule() {
|
||||
await import("../preload.ts");
|
||||
}
|
||||
|
||||
function getFusionApi() {
|
||||
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
|
||||
([name]) => name === "fusionAPI",
|
||||
) as [string, {
|
||||
minimize: () => Promise<void>;
|
||||
maximize: () => Promise<boolean>;
|
||||
close: () => Promise<void>;
|
||||
isMaximized: () => Promise<boolean>;
|
||||
getSystemInfo: () => Promise<unknown>;
|
||||
checkForUpdates: () => Promise<unknown>;
|
||||
getServerPort: () => Promise<number | undefined>;
|
||||
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];
|
||||
function getExposed<T = unknown>(name: string): T | undefined {
|
||||
return mocks.contextBridge.exposeInMainWorld.mock.calls.find(([key]) => key === name)?.[1] as T | undefined;
|
||||
}
|
||||
|
||||
describe("preload", () => {
|
||||
@@ -51,153 +33,32 @@ describe("preload", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("contextBridge.exposeInMainWorld is called with fusionAPI", async () => {
|
||||
it("exposes electronAPI and fusionShell", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
|
||||
"fusionAPI",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(getExposed("electronAPI")).toBeTruthy();
|
||||
expect(getExposed("fusionAPI")).toBeTruthy();
|
||||
expect(getExposed("fusionShell")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("minimize invokes window:minimize", async () => {
|
||||
it("electronAPI delegates getServerPort to IPC", async () => {
|
||||
await importPreloadModule();
|
||||
const api = getExposed<{ getServerPort: () => Promise<number | undefined> }>("electronAPI");
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.minimize();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:minimize");
|
||||
});
|
||||
|
||||
it("maximize invokes window:maximize", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.maximize();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:maximize");
|
||||
});
|
||||
|
||||
it("close invokes window:close", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
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("getServerPort invokes app:getServerPort", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
await api?.getServerPort();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:getServerPort");
|
||||
});
|
||||
|
||||
it("updateTrayStatus invokes tray:updateStatus with status argument", async () => {
|
||||
it("fusionShell subscribes and unsubscribes state listener", async () => {
|
||||
await importPreloadModule();
|
||||
const shell = getExposed<{ subscribe: (listener: (state: unknown) => void) => () => void }>("fusionShell");
|
||||
|
||||
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?.onDeepLink(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
|
||||
const unsubscribe = shell?.subscribe(() => undefined);
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("shell:state", expect.any(Function));
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"deep-link",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("onUpdateAvailable subscribes to update-available and returns unsubscribe", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onUpdateAvailable(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-available", expect.any(Function));
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"update-available",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("onUpdateDownloaded subscribes to update-downloaded and returns unsubscribe", async () => {
|
||||
await importPreloadModule();
|
||||
|
||||
const api = getFusionApi();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = api?.onUpdateDownloaded(callback);
|
||||
|
||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-downloaded", expect.any(Function));
|
||||
|
||||
unsubscribe?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||
"update-downloaded",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("shell:state", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
72
packages/desktop/src/__tests__/shell-settings.test.ts
Normal file
72
packages/desktop/src/__tests__/shell-settings.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
content: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/fusion"),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: vi.fn(async (path: string) => {
|
||||
const value = mockState.content.get(path);
|
||||
if (!value) {
|
||||
const err = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
writeFile: vi.fn(async (path: string, value: string) => {
|
||||
mockState.content.set(path, value);
|
||||
}),
|
||||
rename: vi.fn(async (from: string, to: string) => {
|
||||
const value = mockState.content.get(from) ?? "";
|
||||
mockState.content.set(to, value);
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("shell-settings", () => {
|
||||
beforeEach(() => {
|
||||
mockState.content.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns defaults when file missing", async () => {
|
||||
const { readShellSettings } = await import("../shell-settings.ts");
|
||||
await expect(readShellSettings()).resolves.toEqual({
|
||||
desktopMode: "remote",
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("writes and reads persisted settings", async () => {
|
||||
const { writeShellSettings, readShellSettings } = await import("../shell-settings.ts");
|
||||
|
||||
await writeShellSettings({
|
||||
desktopMode: "local",
|
||||
activeProfileId: "p1",
|
||||
profiles: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Local",
|
||||
serverUrl: "http://127.0.0.1",
|
||||
authToken: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(readShellSettings()).resolves.toMatchObject({
|
||||
desktopMode: "local",
|
||||
activeProfileId: "p1",
|
||||
profiles: [{ id: "p1" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user