From 865151883f280d2f7631b5d8eff052cf44146c5b Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 27 Aug 2025 08:48:43 -0400 Subject: [PATCH] refactor: main module Signed-off-by: Adam Setch --- src/main/first-run.test.ts | 108 ++++++++++++++ src/main/first-run.ts | 9 +- src/main/menu.test.ts | 295 +++++++++++++++++++++++++++++-------- src/main/menu.ts | 20 ++- src/shared/constants.ts | 2 + 5 files changed, 363 insertions(+), 71 deletions(-) create mode 100644 src/main/first-run.test.ts diff --git a/src/main/first-run.test.ts b/src/main/first-run.test.ts new file mode 100644 index 000000000..f301037a2 --- /dev/null +++ b/src/main/first-run.test.ts @@ -0,0 +1,108 @@ +import path from 'node:path'; + +// Mocks +const existsSync = jest.fn(); +const mkdirSync = jest.fn(); +const writeFileSync = jest.fn(); +jest.mock('node:fs', () => ({ + __esModule: true, + default: { + existsSync: (...a: unknown[]) => existsSync(...a), + mkdirSync: (...a: unknown[]) => mkdirSync(...a), + writeFileSync: (...a: unknown[]) => writeFileSync(...a), + }, + existsSync: (...a: unknown[]) => existsSync(...a), + mkdirSync: (...a: unknown[]) => mkdirSync(...a), + writeFileSync: (...a: unknown[]) => writeFileSync(...a), +})); + +const moveToApplicationsFolder = jest.fn(); +const isInApplicationsFolder = jest.fn(() => false); +const getPath = jest.fn(() => '/User/Data'); + +const showMessageBox = jest.fn(async () => ({ response: 0 })); + +jest.mock('electron', () => ({ + app: { + getPath: () => getPath(), + isInApplicationsFolder: () => isInApplicationsFolder(), + moveToApplicationsFolder: () => moveToApplicationsFolder(), + }, + dialog: { showMessageBox: () => showMessageBox() }, +})); + +const logError = jest.fn(); +jest.mock('../shared/logger', () => ({ + logError: (...a: unknown[]) => logError(...a), +})); + +let mac = true; +jest.mock('../shared/platform', () => ({ isMacOS: () => mac })); + +import { APPLICATION } from '../shared/constants'; + +import { onFirstRunMaybe } from './first-run'; + +describe('main/first-run', () => { + beforeEach(() => { + jest.clearAllMocks(); + mac = true; + }); + + function configPath() { + return path.join('/User/Data', 'FirstRun', APPLICATION.FIRST_RUN_FOLDER); + } + + it('creates first-run marker when not existing and returns true', async () => { + existsSync.mockReturnValueOnce(false); // marker absent + existsSync.mockReturnValueOnce(false); // folder absent + await onFirstRunMaybe(); + expect(mkdirSync).toHaveBeenCalledWith(path.dirname(configPath())); + expect(writeFileSync).toHaveBeenCalledWith(configPath(), ''); + }); + + it('skips writing when marker exists', async () => { + existsSync.mockReturnValueOnce(true); // marker present + await onFirstRunMaybe(); + expect(writeFileSync).not.toHaveBeenCalled(); + expect(mkdirSync).not.toHaveBeenCalled(); + }); + + it('handles fs write error gracefully', async () => { + existsSync.mockReturnValueOnce(false); // marker absent + existsSync.mockReturnValueOnce(true); // folder exists + writeFileSync.mockImplementation(() => { + throw new Error('fail'); + }); + await onFirstRunMaybe(); + expect(logError).toHaveBeenCalledWith( + 'isFirstRun', + 'Unable to write firstRun file', + expect.any(Error), + ); + }); + + it('prompts and moves app on macOS when user accepts', async () => { + existsSync.mockReturnValueOnce(false); // marker + existsSync.mockReturnValueOnce(false); // folder + showMessageBox.mockResolvedValueOnce({ response: 0 }); + await onFirstRunMaybe(); + expect(moveToApplicationsFolder).toHaveBeenCalled(); + }); + + it('does not move when user declines', async () => { + existsSync.mockReturnValueOnce(false); + existsSync.mockReturnValueOnce(false); + showMessageBox.mockResolvedValueOnce({ response: 1 }); + await onFirstRunMaybe(); + expect(moveToApplicationsFolder).not.toHaveBeenCalled(); + }); + + it('skips prompt on non-macOS', async () => { + mac = false; + existsSync.mockReturnValueOnce(false); + existsSync.mockReturnValueOnce(false); + await onFirstRunMaybe(); + expect(showMessageBox).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/first-run.ts b/src/main/first-run.ts index 80ef60a65..c5fe6daeb 100644 --- a/src/main/first-run.ts +++ b/src/main/first-run.ts @@ -13,7 +13,9 @@ export async function onFirstRunMaybe() { } } -// Ask user if the app should be moved to the applications folder. +/** + * Ask user if the app should be moved to the applications folder (masOS). + */ async function promptMoveToApplicationsFolder() { if (!isMacOS()) return; @@ -37,7 +39,10 @@ const getConfigPath = () => { return path.join(userDataPath, 'FirstRun', APPLICATION.FIRST_RUN_FOLDER); }; -// Whether or not the app is being run for the first time. +/** + * Determine if this is the first run of the application by checking for the existence of a specific file. + * @returns true if this is the first run, false otherwise + */ function isFirstRun() { const configPath = getConfigPath(); diff --git a/src/main/menu.test.ts b/src/main/menu.test.ts index 5055c6b3b..31f65605c 100644 --- a/src/main/menu.test.ts +++ b/src/main/menu.test.ts @@ -1,100 +1,269 @@ -import { Menu, MenuItem } from 'electron'; +import { Menu, MenuItem, shell } from 'electron'; +import { autoUpdater } from 'electron-updater'; import type { Menubar } from 'menubar'; +import { APPLICATION } from '../shared/constants'; + import MenuBuilder from './menu'; +import { openLogsDirectory, resetApp, takeScreenshot } from './utils'; + +jest.mock('electron', () => { + const MenuItem = jest + .fn() + .mockImplementation((opts: Record) => opts); + return { + Menu: { + buildFromTemplate: jest.fn(), + }, + MenuItem, + shell: { openExternal: jest.fn() }, + }; +}); -jest.mock('electron', () => ({ - Menu: { - buildFromTemplate: jest.fn(), +jest.mock('electron-updater', () => ({ + autoUpdater: { + checkForUpdatesAndNotify: jest.fn(), + quitAndInstall: jest.fn(), }, - MenuItem: jest.fn(), +})); + +jest.mock('./utils', () => ({ + takeScreenshot: jest.fn(), + openLogsDirectory: jest.fn(), + resetApp: jest.fn(), })); describe('main/menu.ts', () => { let menubar: Menubar; let menuBuilder: MenuBuilder; + /** Helper: find MenuItem config captured via MenuItem mock by label */ + const getMenuItemConfigByLabel = (label: string) => + (MenuItem as unknown as jest.Mock).mock.calls.find( + ([arg]) => (arg as { label?: string }).label === label, + )?.[0] as + | { + label?: string; + enabled?: boolean; + visible?: boolean; + click?: () => void; + } + | undefined; + + /** Lightweight type describing the (subset) of fields we inspect on template items */ + type TemplateItem = { + label?: string; + role?: string; + accelerator?: string; + submenu?: TemplateItem[]; + click?: () => void; + }; + + /** Helper: build menu & return template (first arg passed to buildFromTemplate) */ + const buildAndGetTemplate = () => { + menuBuilder.buildMenu(); + return (Menu.buildFromTemplate as jest.Mock).mock.calls.slice( + -1, + )[0][0] as TemplateItem[]; + }; + beforeEach(() => { + jest.clearAllMocks(); + menubar = { app: { quit: jest.fn() } } as unknown as Menubar; menuBuilder = new MenuBuilder(menubar); }); - it('should create menu items correctly', () => { - expect(MenuItem).toHaveBeenCalledWith({ - label: 'Check for updates', - enabled: true, - click: expect.any(Function), + describe('checkForUpdatesMenuItem', () => { + it('default menu configuration', () => { + expect(MenuItem).toHaveBeenCalledWith({ + label: 'Check for updates', + enabled: true, + click: expect.any(Function), + }); }); - expect(MenuItem).toHaveBeenCalledWith({ - label: 'No updates available', - enabled: false, - visible: false, + it('should enable menu item', () => { + menuBuilder.setCheckForUpdatesMenuEnabled(true); + // biome-ignore lint/complexity/useLiteralKeys: This is a test + expect(menuBuilder['checkForUpdatesMenuItem'].enabled).toBe(true); }); - expect(MenuItem).toHaveBeenCalledWith({ - label: 'An update is available', - enabled: false, - visible: false, + it('should disable menu item', () => { + menuBuilder.setCheckForUpdatesMenuEnabled(false); + // biome-ignore lint/complexity/useLiteralKeys: This is a test + expect(menuBuilder['checkForUpdatesMenuItem'].enabled).toBe(false); }); + }); - expect(MenuItem).toHaveBeenCalledWith({ - label: 'Restart to install update', - enabled: true, - visible: false, - click: expect.any(Function), + describe('noUpdateAvailableMenuItem', () => { + it('default menu configuration', () => { + expect(MenuItem).toHaveBeenCalledWith({ + label: 'No updates available', + enabled: false, + visible: false, + }); }); - }); - it('should build menu correctly', () => { - menuBuilder.buildMenu(); - expect(Menu.buildFromTemplate).toHaveBeenCalledWith(expect.any(Array)); - }); + it('should show menu item', () => { + menuBuilder.setNoUpdateAvailableMenuVisibility(true); + // biome-ignore lint/complexity/useLiteralKeys: This is a test + expect(menuBuilder['noUpdateAvailableMenuItem'].visible).toBe(true); + }); - it('should enable check for updates menu item', () => { - menuBuilder.setCheckForUpdatesMenuEnabled(true); - // biome-ignore lint/complexity/useLiteralKeys: This is a test - expect(menuBuilder['checkForUpdatesMenuItem'].enabled).toBe(true); + it('should hide menu item', () => { + menuBuilder.setNoUpdateAvailableMenuVisibility(false); + // biome-ignore lint/complexity/useLiteralKeys: This is a test + expect(menuBuilder['noUpdateAvailableMenuItem'].visible).toBe(false); + }); }); - it('should disable check for updates menu item', () => { - menuBuilder.setCheckForUpdatesMenuEnabled(false); - // biome-ignore lint/complexity/useLiteralKeys: This is a test - expect(menuBuilder['checkForUpdatesMenuItem'].enabled).toBe(false); - }); + describe('updateAvailableMenuItem', () => { + it('default menu configuration', () => { + expect(MenuItem).toHaveBeenCalledWith({ + label: 'An update is available', + enabled: false, + visible: false, + }); + }); - it('should show no update available menu item', () => { - menuBuilder.setNoUpdateAvailableMenuVisibility(true); - // biome-ignore lint/complexity/useLiteralKeys: This is a test - expect(menuBuilder['noUpdateAvailableMenuItem'].visible).toBe(true); - }); + it('should show menu item', () => { + menuBuilder.setUpdateAvailableMenuVisibility(true); + // biome-ignore lint/complexity/useLiteralKeys: This is a test + expect(menuBuilder['updateAvailableMenuItem'].visible).toBe(true); + }); - it('should hide no update available menu item', () => { - menuBuilder.setNoUpdateAvailableMenuVisibility(false); - // biome-ignore lint/complexity/useLiteralKeys: This is a test - expect(menuBuilder['noUpdateAvailableMenuItem'].visible).toBe(false); + it('should hide menu item', () => { + menuBuilder.setUpdateAvailableMenuVisibility(false); + // biome-ignore lint/complexity/useLiteralKeys: This is a test + expect(menuBuilder['updateAvailableMenuItem'].visible).toBe(false); + }); }); - it('should show update available menu item', () => { - menuBuilder.setUpdateAvailableMenuVisibility(true); - // biome-ignore lint/complexity/useLiteralKeys: This is a test - expect(menuBuilder['updateAvailableMenuItem'].visible).toBe(true); - }); + describe('updateReadyForInstallMenuItem', () => { + it('default menu configuration', () => { + expect(MenuItem).toHaveBeenCalledWith({ + label: 'Restart to install update', + enabled: true, + visible: false, + click: expect.any(Function), + }); + }); - it('should hide update available menu item', () => { - menuBuilder.setUpdateAvailableMenuVisibility(false); - // biome-ignore lint/complexity/useLiteralKeys: This is a test - expect(menuBuilder['updateAvailableMenuItem'].visible).toBe(false); + it('should show menu item', () => { + menuBuilder.setUpdateReadyForInstallMenuVisibility(true); + // biome-ignore lint/complexity/useLiteralKeys: This is a test + expect(menuBuilder['updateReadyForInstallMenuItem'].visible).toBe(true); + }); + + it('should hide menu item', () => { + menuBuilder.setUpdateReadyForInstallMenuVisibility(false); + // biome-ignore lint/complexity/useLiteralKeys: This is a test + expect(menuBuilder['updateReadyForInstallMenuItem'].visible).toBe(false); + }); }); - it('should show update ready for install menu item', () => { - menuBuilder.setUpdateReadyForInstallMenuVisibility(true); - // biome-ignore lint/complexity/useLiteralKeys: This is a test - expect(menuBuilder['updateReadyForInstallMenuItem'].visible).toBe(true); + describe('click handlers', () => { + it('invokes autoUpdater.checkForUpdatesAndNotify when clicking "Check for updates"', () => { + const cfg = getMenuItemConfigByLabel('Check for updates'); + expect(cfg).toBeDefined(); + cfg.click(); + expect(autoUpdater.checkForUpdatesAndNotify).toHaveBeenCalled(); + }); + + it('invokes autoUpdater.quitAndInstall when clicking "Restart to install update"', () => { + const cfg = getMenuItemConfigByLabel('Restart to install update'); + expect(cfg).toBeDefined(); + cfg.click(); + expect(autoUpdater.quitAndInstall).toHaveBeenCalled(); + }); + + it('developer submenu click actions execute expected functions', () => { + const template = buildAndGetTemplate(); + const devEntry = template.find( + (item) => item?.label === 'Developer', + ) as TemplateItem; + expect(devEntry).toBeDefined(); + const submenu = devEntry.submenu; + const clickByLabel = (label: string) => + submenu.find((i) => i.label === label)?.click?.(); + + clickByLabel('Take Screenshot'); + expect(takeScreenshot).toHaveBeenCalledWith(menubar); + + clickByLabel('View Application Logs'); + expect(openLogsDirectory).toHaveBeenCalled(); + + clickByLabel('Visit Repository'); + expect(shell.openExternal).toHaveBeenCalledWith( + `https://github.com/${APPLICATION.REPO_SLUG}`, + ); + + clickByLabel(`Reset ${APPLICATION.NAME}`); + expect(resetApp).toHaveBeenCalledWith(menubar); + }); + + it('website menu item opens external URL', () => { + const template = buildAndGetTemplate(); + const item = template.find((i) => i.label === 'Visit Website'); + item.click(); + expect(shell.openExternal).toHaveBeenCalledWith(APPLICATION.WEBSITE); + }); + + it('quit menu item quits the app', () => { + const template = buildAndGetTemplate(); + const item = template.find((i) => i.label === `Quit ${APPLICATION.NAME}`); + item.click(); + expect(menubar.app.quit).toHaveBeenCalled(); + }); + + it('developer submenu includes expected static accelerators', () => { + const template = buildAndGetTemplate(); + const devEntry = template.find( + (item) => item?.label === 'Developer', + ) as TemplateItem; + const reloadItem = devEntry.submenu.find((i) => i.role === 'reload'); + expect(reloadItem?.accelerator).toBe('CommandOrControl+R'); + }); }); - it('should show update ready for install menu item', () => { - menuBuilder.setUpdateReadyForInstallMenuVisibility(false); - // biome-ignore lint/complexity/useLiteralKeys: This is a test - expect(menuBuilder['updateReadyForInstallMenuItem'].visible).toBe(false); + describe('platform-specific accelerators', () => { + // Use isolateModules so we can alter the isMacOS return value before importing MenuBuilder + const buildTemplateWithPlatform = (isMac: boolean) => { + jest.isolateModules(() => { + jest.doMock('../shared/platform', () => ({ isMacOS: () => isMac })); + // re-mock electron for isolated module context (shared mock factory already defined globally) + // Clear prior captured calls + (Menu.buildFromTemplate as jest.Mock).mockClear(); + const MB = require('./menu').default as typeof MenuBuilder; + const mb = new MB({ app: { quit: jest.fn() } } as unknown as Menubar); + mb.buildMenu(); + }); + // Return the newest template captured + return (Menu.buildFromTemplate as jest.Mock).mock.calls.slice( + -1, + )[0][0] as TemplateItem[]; + }; + + it('uses mac accelerator for toggleDevTools when on macOS', () => { + const template = buildTemplateWithPlatform(true); + const devEntry = template.find( + (i) => i?.label === 'Developer', + ) as TemplateItem; + const toggleItem = devEntry.submenu.find( + (i) => i.role === 'toggleDevTools', + ); + expect(toggleItem?.accelerator).toBe('Alt+Cmd+I'); + }); + + it('uses non-mac accelerator for toggleDevTools otherwise', () => { + const template = buildTemplateWithPlatform(false); + const devEntry = template.find( + (i) => i?.label === 'Developer', + ) as TemplateItem; + const toggleItem = devEntry.submenu.find( + (i) => i.role === 'toggleDevTools', + ); + expect(toggleItem?.accelerator).toBe('Ctrl+Shift+I'); + }); }); }); diff --git a/src/main/menu.ts b/src/main/menu.ts index e4be9c4c6..9caeae80e 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -3,7 +3,7 @@ import { autoUpdater } from 'electron-updater'; import type { Menubar } from 'menubar'; import { APPLICATION } from '../shared/constants'; -import { isMacOS, isWindows } from '../shared/platform'; +import { isMacOS } from '../shared/platform'; import { openLogsDirectory, resetApp, takeScreenshot } from './utils'; @@ -22,11 +22,7 @@ export default class MenuBuilder { label: 'Check for updates', enabled: true, click: () => { - if (isMacOS() || isWindows()) { - autoUpdater.checkForUpdatesAndNotify(); - } else { - shell.openExternal(APPLICATION.WEBSITE); - } + autoUpdater.checkForUpdatesAndNotify(); }, }); @@ -79,6 +75,12 @@ export default class MenuBuilder { label: 'View Application Logs', click: () => openLogsDirectory(), }, + { + label: 'Visit Repository', + click: () => { + shell.openExternal(`https://github.com/${APPLICATION.REPO_SLUG}`); + }, + }, { label: `Reset ${APPLICATION.NAME}`, click: () => { @@ -88,6 +90,12 @@ export default class MenuBuilder { ], }, { type: 'separator' }, + { + label: 'Visit Website', + click: () => { + shell.openExternal(APPLICATION.WEBSITE); + }, + }, { label: `Quit ${APPLICATION.NAME}`, accelerator: 'CommandOrControl+Q', diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 84f8754c7..a35f72f7d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -9,5 +9,7 @@ export const APPLICATION = { WEBSITE: 'https://gitify.io', + REPO_SLUG: 'gitify-app/gitify', + UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000, // 24 hours };