feat(FN-5022): merge fusion/fn-5022

This commit is contained in:
gsxdsm
2026-05-18 05:46:37 -07:00
parent 54bb1651b7
commit dd0d4b74b7
15 changed files with 400 additions and 53 deletions

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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() }));

View File

@@ -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),
}));

View File

@@ -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;

View File

@@ -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",

View File

@@ -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();

View File

@@ -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>;