439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock renderer module - must be hoisted before importing main
|
|
const rendererMocks = vi.hoisted(() => {
|
|
const getRendererUrl = vi.fn(() => "file:///path/to/dist/client/index.html");
|
|
return {
|
|
isDevelopmentMode: vi.fn(() => false),
|
|
getRendererUrl,
|
|
getRendererFilePath: vi.fn(() => "/path/to/dist/client/index.html"),
|
|
isUrlRenderer: vi.fn(() => false),
|
|
IS_DEVELOPMENT: false,
|
|
// DASHBOARD_URL is re-exported as getRendererUrl
|
|
DASHBOARD_URL: getRendererUrl,
|
|
};
|
|
});
|
|
|
|
vi.mock("../renderer.ts", () => rendererMocks);
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const browserWindowHandlers = new Map<string, (...args: unknown[]) => void>();
|
|
const browserWindowInstance = {
|
|
loadURL: vi.fn(),
|
|
loadFile: vi.fn(),
|
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
browserWindowHandlers.set(event, handler);
|
|
}),
|
|
once: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
browserWindowHandlers.set(event, handler);
|
|
}),
|
|
isVisible: vi.fn(() => true),
|
|
show: vi.fn(),
|
|
focus: vi.fn(),
|
|
hide: vi.fn(),
|
|
maximize: 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 = {
|
|
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" })),
|
|
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()),
|
|
};
|
|
|
|
const isDevelopmentMode = vi.fn(() => false);
|
|
const getRendererUrl = vi.fn(() => "file:///path/to/dist/client/index.html");
|
|
const getRendererFilePath = vi.fn(() => "/path/to/dist/client/index.html");
|
|
const isUrlRenderer = vi.fn(() => false);
|
|
|
|
const screen = {
|
|
getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]),
|
|
};
|
|
|
|
return {
|
|
app,
|
|
BrowserWindow,
|
|
ipcMain,
|
|
trayInstance,
|
|
Tray,
|
|
Menu,
|
|
nativeImage,
|
|
shell,
|
|
screen,
|
|
browserWindowInstance,
|
|
browserWindowHandlers,
|
|
isDevelopmentMode,
|
|
getRendererUrl,
|
|
getRendererFilePath,
|
|
isUrlRenderer,
|
|
};
|
|
});
|
|
|
|
vi.mock("electron", () => ({
|
|
app: mocks.app,
|
|
BrowserWindow: mocks.BrowserWindow,
|
|
ipcMain: mocks.ipcMain,
|
|
Tray: mocks.Tray,
|
|
Menu: mocks.Menu,
|
|
nativeImage: mocks.nativeImage,
|
|
shell: mocks.shell,
|
|
screen: mocks.screen,
|
|
}));
|
|
|
|
const mainDeps = vi.hoisted(() => {
|
|
const startLocal = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4545 }));
|
|
const stopLocal = vi.fn(async () => ({ source: "none", state: "stopped" }));
|
|
const getStatus = vi.fn(() => ({ source: "none", state: "stopped" }));
|
|
const getServerPort = vi.fn(() => 0);
|
|
const loadDesktopLaunchMode = vi.fn(async () => "choose");
|
|
const saveDesktopLaunchMode = vi.fn(async () => undefined);
|
|
return {
|
|
registerIpcHandlers: vi.fn(),
|
|
buildAppMenu: vi.fn(),
|
|
setupTray: vi.fn(),
|
|
registerDeepLinkProtocol: vi.fn(),
|
|
setupDeepLinkHandler: vi.fn(),
|
|
setupAutoUpdater: vi.fn(),
|
|
loadWindowState: vi.fn(async () => null),
|
|
loadDesktopLaunchMode,
|
|
saveDesktopLaunchMode,
|
|
saveWindowState: vi.fn(),
|
|
LocalRuntimeManager: vi.fn(() => ({ startLocal, stopLocal, getStatus, getServerPort })),
|
|
startLocal,
|
|
};
|
|
});
|
|
|
|
vi.mock("../ipc.js", () => ({ registerIpcHandlers: mainDeps.registerIpcHandlers }));
|
|
vi.mock("../menu.js", () => ({ buildAppMenu: mainDeps.buildAppMenu }));
|
|
vi.mock("../tray.js", () => ({ setupTray: mainDeps.setupTray }));
|
|
vi.mock("../deep-link.js", () => ({
|
|
registerDeepLinkProtocol: mainDeps.registerDeepLinkProtocol,
|
|
setupDeepLinkHandler: mainDeps.setupDeepLinkHandler,
|
|
}));
|
|
vi.mock("../native.js", () => ({
|
|
DEFAULT_WINDOW_STATE: { width: 1280, height: 900, isMaximized: false },
|
|
loadWindowState: mainDeps.loadWindowState,
|
|
loadDesktopLaunchMode: mainDeps.loadDesktopLaunchMode,
|
|
saveDesktopLaunchMode: mainDeps.saveDesktopLaunchMode,
|
|
saveWindowState: mainDeps.saveWindowState,
|
|
setupAutoUpdater: mainDeps.setupAutoUpdater,
|
|
startUpdateCheckInterval: vi.fn(() => vi.fn()),
|
|
clampWindowStateToVisibleDisplay: vi.fn((state, displays) => {
|
|
if (state.x === undefined || state.y === undefined) {
|
|
return state;
|
|
}
|
|
const minVisible = 64;
|
|
const windowRight = state.x + state.width;
|
|
const windowBottom = state.y + state.height;
|
|
const visible = displays.some((display: { workArea: { x: number; y: number; width: number; height: number } }) => {
|
|
const right = display.workArea.x + display.workArea.width;
|
|
const bottom = display.workArea.y + display.workArea.height;
|
|
const overlapWidth = Math.max(0, Math.min(windowRight, right) - Math.max(state.x, display.workArea.x));
|
|
const overlapHeight = Math.max(0, Math.min(windowBottom, bottom) - Math.max(state.y, display.workArea.y));
|
|
return overlapWidth >= minVisible && overlapHeight >= minVisible;
|
|
});
|
|
if (visible) {
|
|
return state;
|
|
}
|
|
return { width: state.width, height: state.height, isMaximized: state.isMaximized };
|
|
}),
|
|
normalizeDesktopRemoteLaunch: vi.fn((settings) => {
|
|
const active = settings.profiles.find((profile: { id: string }) => profile.id === settings.activeProfileId);
|
|
return active ? { mode: "remote", profileId: active.id, serverBaseUrl: active.serverUrl.replace(/\/$/, ""), serverLabel: active.name, authToken: active.authToken ?? undefined } : null;
|
|
}),
|
|
buildRemoteShellHandoffUrl: vi.fn((launch) => `https://remote.example.com?shellKind=desktop&shellMode=remote&profileId=${launch.profileId}`),
|
|
}));
|
|
vi.mock("../local-runtime.js", () => ({
|
|
LocalRuntimeManager: mainDeps.LocalRuntimeManager,
|
|
}));
|
|
|
|
vi.mock("../shell-settings.js", () => ({
|
|
readShellSettings: vi.fn(async () => ({
|
|
desktopMode: "remote",
|
|
activeProfileId: "profile_1",
|
|
profiles: [{ id: "profile_1", name: "Remote", serverUrl: "https://remote.example.com", authToken: "token" }],
|
|
})),
|
|
}));
|
|
|
|
async function importMainModule() {
|
|
return import("../main.ts");
|
|
}
|
|
|
|
describe("main process", () => {
|
|
const originalDashboardUrl = process.env.FUSION_DASHBOARD_URL;
|
|
const originalNodeEnv = process.env.NODE_ENV;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
delete process.env.FUSION_DESKTOP_MODE;
|
|
if (originalDashboardUrl === undefined) {
|
|
delete process.env.FUSION_DASHBOARD_URL;
|
|
} else {
|
|
process.env.FUSION_DASHBOARD_URL = originalDashboardUrl;
|
|
}
|
|
if (originalNodeEnv === undefined) {
|
|
delete process.env.NODE_ENV;
|
|
} else {
|
|
process.env.NODE_ENV = originalNodeEnv;
|
|
}
|
|
// Ensure we're in production mode for these tests
|
|
rendererMocks.isDevelopmentMode.mockReturnValue(false);
|
|
rendererMocks.getRendererUrl.mockReturnValue("file:///path/to/dist/client/index.html");
|
|
rendererMocks.getRendererFilePath.mockReturnValue("/path/to/dist/client/index.html");
|
|
rendererMocks.isUrlRenderer.mockReturnValue(false);
|
|
mocks.screen.getAllDisplays.mockReturnValue([{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]);
|
|
});
|
|
|
|
it("DASHBOARD_URL defaults to local file URL in production mode", async () => {
|
|
delete process.env.FUSION_DASHBOARD_URL;
|
|
|
|
const { DASHBOARD_URL } = await importMainModule();
|
|
|
|
expect(DASHBOARD_URL()).toMatch(/^file:\/\/.*\/client\/index\.html$/);
|
|
});
|
|
|
|
it("DASHBOARD_URL uses env override in development mode", async () => {
|
|
process.env.FUSION_DASHBOARD_URL = "http://localhost:5050";
|
|
// Mock development mode to use the env var
|
|
rendererMocks.isDevelopmentMode.mockReturnValue(true);
|
|
rendererMocks.getRendererUrl.mockReturnValue("http://localhost:5050");
|
|
rendererMocks.getRendererFilePath.mockReturnValue("");
|
|
rendererMocks.isUrlRenderer.mockReturnValue(true);
|
|
|
|
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.js");
|
|
});
|
|
|
|
it("createMainWindow loads the renderer URL in URL mode", async () => {
|
|
rendererMocks.isUrlRenderer.mockReturnValue(true);
|
|
rendererMocks.getRendererUrl.mockReturnValue("http://localhost:3000/index.html");
|
|
rendererMocks.getRendererFilePath.mockReturnValue("");
|
|
|
|
const { createMainWindow } = await importMainModule();
|
|
|
|
createMainWindow();
|
|
|
|
expect(mocks.browserWindowInstance.loadURL).toHaveBeenCalledWith("http://localhost:3000/index.html");
|
|
expect(mocks.browserWindowInstance.loadFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("createMainWindow loads the renderer file in file mode (production)", async () => {
|
|
rendererMocks.isUrlRenderer.mockReturnValue(false);
|
|
rendererMocks.getRendererUrl.mockReturnValue("file:///path/to/dist/client/index.html");
|
|
rendererMocks.getRendererFilePath.mockReturnValue("/path/to/dist/client/index.html");
|
|
|
|
const { createMainWindow } = await importMainModule();
|
|
|
|
createMainWindow();
|
|
|
|
expect(mocks.browserWindowInstance.loadFile).toHaveBeenCalledWith("/path/to/dist/client/index.html");
|
|
expect(mocks.browserWindowInstance.loadURL).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exports initializeApp for lifecycle orchestration", async () => {
|
|
const mainModule = await importMainModule();
|
|
|
|
expect(typeof mainModule.initializeApp).toBe("function");
|
|
});
|
|
|
|
it("initializeApp starts local runtime when remembered mode is local", async () => {
|
|
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("local");
|
|
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
|
|
|
|
await initializeApp();
|
|
|
|
expect(mainDeps.startLocal).toHaveBeenCalledTimes(1);
|
|
expect(getCurrentDesktopLaunchMode()).toBe("local");
|
|
});
|
|
|
|
it("initializeApp does not start local runtime for remembered choose mode", async () => {
|
|
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("choose");
|
|
const { initializeApp } = await importMainModule();
|
|
|
|
await initializeApp();
|
|
|
|
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("initializeApp routes remembered remote mode to remote dashboard handoff URL", async () => {
|
|
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("remote");
|
|
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
|
|
|
|
await initializeApp();
|
|
|
|
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
|
expect(mocks.browserWindowInstance.loadURL).toHaveBeenCalledWith(
|
|
expect.stringContaining("shellMode=remote"),
|
|
);
|
|
expect(getCurrentDesktopLaunchMode()).toBe("remote");
|
|
});
|
|
|
|
it("initializeApp falls back to choose and persists when remembered local start fails", async () => {
|
|
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("local");
|
|
mainDeps.startLocal.mockRejectedValueOnce(new Error("boom"));
|
|
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
|
|
|
|
await initializeApp();
|
|
|
|
expect(mainDeps.saveDesktopLaunchMode).toHaveBeenCalledWith("choose");
|
|
expect(getCurrentDesktopLaunchMode()).toBe("choose");
|
|
});
|
|
|
|
it("initializeApp avoids duplicate local start when remembered mode and env flag are local", async () => {
|
|
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("local");
|
|
process.env.FUSION_DESKTOP_MODE = "local";
|
|
const { initializeApp } = await importMainModule();
|
|
|
|
await initializeApp();
|
|
|
|
expect(mainDeps.startLocal).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("onDesktopModeChange persists the selected launch mode", async () => {
|
|
const { initializeApp } = await importMainModule();
|
|
|
|
await initializeApp();
|
|
|
|
const options = mainDeps.registerIpcHandlers.mock.calls[0]?.[2] as
|
|
| { onDesktopModeChange?: (mode: "local" | "remote") => Promise<void> }
|
|
| undefined;
|
|
await options?.onDesktopModeChange?.("remote");
|
|
|
|
expect(mainDeps.saveDesktopLaunchMode).toHaveBeenCalledWith("remote");
|
|
});
|
|
|
|
it("createMainWindow registers close and closed handlers", async () => {
|
|
const { createMainWindow } = await importMainModule();
|
|
|
|
createMainWindow();
|
|
|
|
expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("close", expect.any(Function));
|
|
expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("closed", expect.any(Function));
|
|
});
|
|
|
|
it("createMainWindow shows and focuses on ready-to-show", async () => {
|
|
const { createMainWindow } = await importMainModule();
|
|
|
|
createMainWindow();
|
|
mocks.browserWindowHandlers.get("ready-to-show")?.();
|
|
|
|
expect(mocks.browserWindowInstance.show).toHaveBeenCalledTimes(1);
|
|
expect(mocks.browserWindowInstance.focus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("createMainWindow fallback timer shows and focuses when ready-to-show never fires", async () => {
|
|
vi.useFakeTimers();
|
|
const { createMainWindow } = await importMainModule();
|
|
|
|
createMainWindow();
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect(mocks.browserWindowInstance.show).toHaveBeenCalledTimes(1);
|
|
expect(mocks.browserWindowInstance.focus).toHaveBeenCalledTimes(1);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("initializeApp drops off-screen x/y before BrowserWindow construction", async () => {
|
|
mainDeps.loadWindowState.mockResolvedValueOnce({
|
|
x: -9999,
|
|
y: -9999,
|
|
width: 1280,
|
|
height: 900,
|
|
isMaximized: false,
|
|
});
|
|
const { initializeApp } = await importMainModule();
|
|
|
|
await initializeApp();
|
|
|
|
const [options] = mocks.BrowserWindow.mock.calls[0] as Array<Record<string, unknown>>;
|
|
expect(options).not.toHaveProperty("x");
|
|
expect(options).not.toHaveProperty("y");
|
|
});
|
|
|
|
it("initializeApp preserves x/y when state overlaps a visible display", async () => {
|
|
mainDeps.loadWindowState.mockResolvedValueOnce({
|
|
x: 100,
|
|
y: 100,
|
|
width: 1280,
|
|
height: 900,
|
|
isMaximized: false,
|
|
});
|
|
const { initializeApp } = await importMainModule();
|
|
|
|
await initializeApp();
|
|
|
|
const [options] = mocks.BrowserWindow.mock.calls[0] as Array<Record<string, unknown>>;
|
|
expect(options).toMatchObject({ x: 100, y: 100 });
|
|
});
|
|
|
|
it("importing main does not auto-start", async () => {
|
|
await importMainModule();
|
|
|
|
expect(mocks.app.whenReady).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exports run for app entrypoint wiring", async () => {
|
|
const mainModule = await importMainModule();
|
|
|
|
expect(typeof mainModule.run).toBe("function");
|
|
});
|
|
});
|