feat(FN-5022): merge fusion/fn-5022
This commit is contained in:
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => {
|
||||
const showExportSettingsDialog = vi.fn();
|
||||
const showImportSettingsDialog = vi.fn();
|
||||
const setupAutoUpdater = vi.fn();
|
||||
const triggerUpdateCheck = vi.fn(async () => ({ status: "checking" as const }));
|
||||
const readShellSettings = vi.fn(async () => ({
|
||||
desktopMode: "remote",
|
||||
hasCompletedModeSelection: true,
|
||||
@@ -33,6 +34,7 @@ const mocks = vi.hoisted(() => {
|
||||
showExportSettingsDialog,
|
||||
showImportSettingsDialog,
|
||||
setupAutoUpdater,
|
||||
triggerUpdateCheck,
|
||||
readShellSettings,
|
||||
writeShellSettings,
|
||||
};
|
||||
@@ -51,6 +53,7 @@ vi.mock("../native.js", () => ({
|
||||
showExportSettingsDialog: mocks.showExportSettingsDialog,
|
||||
showImportSettingsDialog: mocks.showImportSettingsDialog,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
triggerUpdateCheck: mocks.triggerUpdateCheck,
|
||||
}));
|
||||
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
@@ -116,8 +119,17 @@ describe("ipc handlers", () => {
|
||||
mocks.ipcHandlers.clear();
|
||||
});
|
||||
|
||||
it("registers shell channels", async () => {
|
||||
await registerHandlers();
|
||||
it("app:checkForUpdates delegates to triggerUpdateCheck", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
const handler = mocks.ipcHandlers.get("app:checkForUpdates");
|
||||
mocks.triggerUpdateCheck.mockResolvedValueOnce({ status: "checking" });
|
||||
|
||||
await expect(handler?.({})).resolves.toEqual({ status: "checking" });
|
||||
expect(mocks.triggerUpdateCheck).toHaveBeenCalledWith(window);
|
||||
expect(mocks.setupAutoUpdater).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers shell channels", async () => { await registerHandlers();
|
||||
|
||||
const channels = new Set(mocks.ipcMain.handle.mock.calls.map(([channel]) => channel));
|
||||
expect(channels.has("shell:getState")).toBe(true);
|
||||
|
||||
@@ -95,6 +95,11 @@ const mocks = vi.hoisted(() => {
|
||||
const setupAutoUpdater = vi.fn(() => {
|
||||
callLog.push("setupAutoUpdater");
|
||||
});
|
||||
const stopUpdateCheckInterval = vi.fn();
|
||||
const startUpdateCheckInterval = vi.fn(() => {
|
||||
callLog.push("startUpdateCheckInterval");
|
||||
return stopUpdateCheckInterval;
|
||||
});
|
||||
|
||||
const loadWindowState = vi.fn(async () => {
|
||||
callLog.push("loadWindowState");
|
||||
@@ -135,6 +140,8 @@ const mocks = vi.hoisted(() => {
|
||||
registerDeepLinkProtocol,
|
||||
setupDeepLinkHandler,
|
||||
setupAutoUpdater,
|
||||
startUpdateCheckInterval,
|
||||
stopUpdateCheckInterval,
|
||||
loadWindowState,
|
||||
loadDesktopLaunchMode,
|
||||
saveDesktopLaunchMode,
|
||||
@@ -177,6 +184,7 @@ vi.mock("../native.js", () => ({
|
||||
saveDesktopLaunchMode: mocks.saveDesktopLaunchMode,
|
||||
saveWindowState: mocks.saveWindowState,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
startUpdateCheckInterval: mocks.startUpdateCheckInterval,
|
||||
DEFAULT_WINDOW_STATE: mocks.DEFAULT_WINDOW_STATE,
|
||||
normalizeDesktopRemoteLaunch: vi.fn((settings) => {
|
||||
const active = settings.profiles.find((profile: { id: string }) => profile.id === settings.activeProfileId);
|
||||
@@ -258,6 +266,7 @@ describe("main integration", () => {
|
||||
"registerDeepLinkProtocol",
|
||||
"setupDeepLinkHandler",
|
||||
"setupAutoUpdater",
|
||||
"startUpdateCheckInterval",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -319,6 +328,7 @@ describe("main integration", () => {
|
||||
mainWindow: instance,
|
||||
appName: "Fusion",
|
||||
onChangeLaunchMode: expect.any(Function),
|
||||
onCheckForUpdates: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -367,7 +377,7 @@ describe("main integration", () => {
|
||||
expect(mocks.saveWindowState).toHaveBeenCalledWith(instance);
|
||||
});
|
||||
|
||||
it("before-quit destroys tray and marks app as quitting", async () => {
|
||||
it("before-quit disposes update interval, destroys tray and marks app as quitting", async () => {
|
||||
const { run } = await importMainModule();
|
||||
|
||||
run();
|
||||
@@ -378,6 +388,7 @@ describe("main integration", () => {
|
||||
|
||||
const [trayInstance] = mocks.trayInstances;
|
||||
expect(mocks.app.isQuitting).toBe(true);
|
||||
expect(mocks.stopUpdateCheckInterval).toHaveBeenCalledTimes(1);
|
||||
expect(trayInstance.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ vi.mock("../native.js", () => ({
|
||||
saveDesktopLaunchMode: vi.fn(async () => undefined),
|
||||
saveWindowState: vi.fn(),
|
||||
setupAutoUpdater: vi.fn(),
|
||||
startUpdateCheckInterval: vi.fn(() => vi.fn()),
|
||||
clampWindowStateToVisibleDisplay: vi.fn((state) => state),
|
||||
}));
|
||||
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
||||
|
||||
@@ -84,6 +84,7 @@ vi.mock("../native.js", () => ({
|
||||
saveDesktopLaunchMode: mocks.saveDesktopLaunchMode,
|
||||
saveWindowState: mocks.saveWindowState,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
startUpdateCheckInterval: vi.fn(() => vi.fn()),
|
||||
clampWindowStateToVisibleDisplay: vi.fn((state) => state),
|
||||
}));
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ vi.mock("../native.js", () => ({
|
||||
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;
|
||||
|
||||
@@ -87,11 +87,13 @@ describe("application menu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("macOS template includes App menu with About, Preferences and Quit", async () => {
|
||||
it("macOS template includes App menu with About, Preferences, update check and Quit", async () => {
|
||||
const onCheckForUpdates = vi.fn();
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
onCheckForUpdates,
|
||||
});
|
||||
|
||||
expect(template[0]?.label).toBe("Fusion");
|
||||
@@ -99,12 +101,17 @@ describe("application menu", () => {
|
||||
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 checkForUpdates = appMenu.find((item) => item.label === "Check for Updates…");
|
||||
expect(checkForUpdates).toBeDefined();
|
||||
|
||||
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");
|
||||
|
||||
checkForUpdates?.click?.({} as never, {} as never, {} as never);
|
||||
expect(onCheckForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Edit menu includes standard editing shortcuts", async () => {
|
||||
@@ -191,16 +198,26 @@ describe("application menu", () => {
|
||||
expect(findMenuItem(template, "Hide Fusion")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Help menu contains Fusion Documentation link", async () => {
|
||||
it("Help menu contains update check and Fusion Documentation link on non-macOS", async () => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
const onCheckForUpdates = vi.fn();
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
onCheckForUpdates,
|
||||
});
|
||||
|
||||
const docsItem = findMenuItem(template, "Fusion Documentation");
|
||||
const checkForUpdates = findMenuItem(template, "Check for Updates…");
|
||||
|
||||
expect(docsItem).toBeDefined();
|
||||
expect(checkForUpdates).toBeDefined();
|
||||
checkForUpdates?.click?.({} as never, {} as never, {} as never);
|
||||
expect(onCheckForUpdates).toHaveBeenCalledTimes(1);
|
||||
docsItem?.click?.({} as never, {} as never, {} as never);
|
||||
expect(mocks.shell.openExternal).toHaveBeenCalledWith(
|
||||
"https://github.com/Runfusion/Fusion#readme",
|
||||
|
||||
@@ -319,6 +319,7 @@ describe("native integrations", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("update-available", expect.any(Function));
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("update-downloaded", expect.any(Function));
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("update-not-available", expect.any(Function));
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -358,13 +359,33 @@ describe("native integrations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("error handler does not crash", async () => {
|
||||
it("update-not-available triggers notification and renderer IPC", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
mocks.updaterHandlers.get("update-not-available")?.({ version: "1.2.0" });
|
||||
|
||||
const latestNotification = mocks.notificationInstances.at(-1);
|
||||
expect(latestNotification?.options).toMatchObject({
|
||||
title: "Fusion is up to date",
|
||||
body: "You're on the latest version",
|
||||
silent: true,
|
||||
});
|
||||
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith(
|
||||
"update-not-available",
|
||||
expect.objectContaining({ version: "1.2.0" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("error handler sends renderer IPC and does not crash", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
expect(() => mocks.updaterHandlers.get("error")?.(new Error("network"))).not.toThrow();
|
||||
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith("update-error", { message: "network" });
|
||||
});
|
||||
|
||||
it("catches checkForUpdates rejection", async () => {
|
||||
@@ -375,6 +396,22 @@ describe("native integrations", () => {
|
||||
await vi.dynamicImportSettled();
|
||||
});
|
||||
|
||||
it("calling setupAutoUpdater twice does not rebind listeners or rerun initial check", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledTimes(4);
|
||||
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("wraps setup in try/catch when updater throws during registration", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
mocks.autoUpdater.on.mockImplementationOnce(() => {
|
||||
@@ -386,6 +423,62 @@ describe("native integrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerUpdateCheck", () => {
|
||||
it("returns checking when checkForUpdates succeeds", async () => {
|
||||
const { triggerUpdateCheck } = await importNativeModule();
|
||||
|
||||
await expect(triggerUpdateCheck(mocks.browserWindow as never)).resolves.toEqual({ status: "checking" });
|
||||
});
|
||||
|
||||
it("returns error when checkForUpdates rejects", async () => {
|
||||
const { triggerUpdateCheck } = await importNativeModule();
|
||||
mocks.autoUpdater.checkForUpdates.mockRejectedValue(new Error("network down"));
|
||||
|
||||
await expect(triggerUpdateCheck(mocks.browserWindow as never)).resolves.toEqual({
|
||||
status: "error",
|
||||
error: "network down",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unavailable when autoUpdater export is missing", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("electron-updater", () => ({ default: {} }));
|
||||
|
||||
const { triggerUpdateCheck } = await importNativeModule();
|
||||
|
||||
await expect(triggerUpdateCheck(mocks.browserWindow as never)).resolves.toEqual({
|
||||
status: "unavailable",
|
||||
reason: "updater_unavailable",
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("electron-updater", () => ({
|
||||
default: { autoUpdater: mocks.autoUpdater },
|
||||
autoUpdater: mocks.autoUpdater,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("startUpdateCheckInterval", () => {
|
||||
it("schedules interval, triggers checks, and clears timer with stable disposer per window", async () => {
|
||||
const { startUpdateCheckInterval } = await importNativeModule();
|
||||
|
||||
const disposerA = startUpdateCheckInterval(mocks.browserWindow as never, 1_000);
|
||||
const disposerB = startUpdateCheckInterval(mocks.browserWindow as never, 1_000);
|
||||
expect(disposerB).toBe(disposerA);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalled();
|
||||
|
||||
disposerA();
|
||||
const callsAfterDispose = mocks.autoUpdater.checkForUpdates.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(mocks.autoUpdater.checkForUpdates.mock.calls.length).toBe(callsAfterDispose);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop launch mode", () => {
|
||||
it("loadDesktopLaunchMode returns choose when file is missing", async () => {
|
||||
const { loadDesktopLaunchMode } = await importNativeModule();
|
||||
|
||||
@@ -70,8 +70,40 @@ describe("preload", () => {
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:openConnectionManager");
|
||||
});
|
||||
|
||||
it("fusionShell delegates connection-management methods to IPC", async () => {
|
||||
it("electronAPI exposes update-not-available and update-error listeners", async () => {
|
||||
await importPreloadModule();
|
||||
const api = getExposed<{
|
||||
onUpdateNotAvailable: (listener: (info: { version?: string }) => void) => () => void;
|
||||
onUpdateError: (listener: (info: { message: string }) => void) => () => void;
|
||||
}>("electronAPI");
|
||||
|
||||
const onNotAvailable = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
const unsubscribeNotAvailable = api?.onUpdateNotAvailable(onNotAvailable);
|
||||
const unsubscribeError = api?.onUpdateError(onError);
|
||||
|
||||
const notAvailableHandler = mocks.ipcRenderer.on.mock.calls.find(([channel]) => channel === "update-not-available")?.[1] as
|
||||
| ((event: unknown, info: { version?: string }) => void)
|
||||
| undefined;
|
||||
const errorHandler = mocks.ipcRenderer.on.mock.calls.find(([channel]) => channel === "update-error")?.[1] as
|
||||
| ((event: unknown, info: { message: string }) => void)
|
||||
| undefined;
|
||||
|
||||
notAvailableHandler?.({} as never, { version: "1.2.3" });
|
||||
errorHandler?.({} as never, { message: "network" });
|
||||
|
||||
expect(onNotAvailable).toHaveBeenCalledWith({ version: "1.2.3" });
|
||||
expect(onError).toHaveBeenCalledWith({ message: "network" });
|
||||
|
||||
unsubscribeNotAvailable?.();
|
||||
unsubscribeError?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("update-not-available", expect.any(Function));
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("update-error", expect.any(Function));
|
||||
});
|
||||
|
||||
it("fusionShell delegates connection-management methods to IPC", async () => { await importPreloadModule();
|
||||
const shell = getExposed<{
|
||||
getState: () => Promise<unknown>;
|
||||
listProfiles: () => Promise<unknown>;
|
||||
|
||||
Reference in New Issue
Block a user