diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index b3b54126b..cf3d3c959 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -108,23 +108,18 @@ describe('preload/index', () => { }); it('app.version returns dev in development', async () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; + vi.stubEnv('NODE_ENV', 'development'); const api = getExposedApi(); await expect(api.app.version()).resolves.toBe('dev'); - - process.env.NODE_ENV = originalEnv; }); it('app.version prefixes production version', async () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; + vi.stubEnv('NODE_ENV', 'production'); invokeMainEventMock.mockResolvedValueOnce('1.2.3'); const api = getExposedApi(); await expect(api.app.version()).resolves.toBe('v1.2.3'); - process.env.NODE_ENV = originalEnv; }); it('raiseNativeNotification without url calls app.show', async () => { diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index 0cf7bca4c..9b0d08544 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom'; import { mockAuth, mockSettings } from '../__mocks__/state-mocks'; -import { AppContext, type AppContextState } from '../context/App'; +import { AppContext, type AppContextState } from '../context/context'; import { type FiltersStore, useFiltersStore } from '../stores'; export { navigateMock } from './vitest.setup'; diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 0ea8e1574..4589132c4 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -15,54 +15,82 @@ vi.mock('../utils/core/random', () => ({ randomElement: vi.fn((arr: unknown[]) => arr[0]), })); -// Sets timezone to UTC for consistent date/time in tests and snapshots -process.env.TZ = 'UTC'; +function getRequestTarget(input: RequestInfo | URL): string { + if (typeof input === 'string') { + return input; + } + + if (input instanceof URL) { + return input.href; + } + + if (typeof Request !== 'undefined' && input instanceof Request) { + return input.url; + } + + return String(input); +} + +function createGitifyBridgeApi(): Window['gitify'] { + return { + app: { + version: vi.fn().mockResolvedValue('v0.0.1'), + hide: vi.fn(), + quit: vi.fn(), + show: vi.fn(), + }, + twemojiDirectory: vi.fn().mockResolvedValue('/mock/images/assets'), + openExternalLink: vi.fn(), + decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }), + encryptValue: vi.fn().mockResolvedValue('encrypted'), + platform: { + isLinux: vi.fn().mockReturnValue(false), + isMacOS: vi.fn().mockReturnValue(true), + isWindows: vi.fn().mockReturnValue(false), + }, + zoom: { + getLevel: vi.fn(), + setLevel: vi.fn(), + }, + tray: { + updateColor: vi.fn(), + updateTitle: vi.fn(), + useAlternateIdleIcon: vi.fn(), + useUnreadActiveIcon: vi.fn(), + }, + notificationSoundPath: vi.fn(), + onAuthCallback: vi.fn(), + onResetApp: vi.fn(), + setAutoLaunch: vi.fn(), + setKeepWindowOnBlur: vi.fn(), + applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), + raiseNativeNotification: vi.fn(), + }; +} + +window.gitify = createGitifyBridgeApi(); /** * Reset stores */ beforeEach(() => { + vi.stubEnv('TZ', 'UTC'); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + throw new Error( + `Unexpected network request in test: ${getRequestTarget(input)}. Mock the network boundary explicitly.`, + ); + }), + ); useFiltersStore.getState().reset(); navigateMock.mockReset(); + window.gitify = createGitifyBridgeApi(); }); -/** - * Gitify context bridge API - */ -window.gitify = { - app: { - version: vi.fn().mockResolvedValue('v0.0.1'), - hide: vi.fn(), - quit: vi.fn(), - show: vi.fn(), - }, - twemojiDirectory: vi.fn().mockResolvedValue('/mock/images/assets'), - openExternalLink: vi.fn(), - decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }), - encryptValue: vi.fn().mockResolvedValue('encrypted'), - platform: { - isLinux: vi.fn().mockReturnValue(false), - isMacOS: vi.fn().mockReturnValue(true), - isWindows: vi.fn().mockReturnValue(false), - }, - zoom: { - getLevel: vi.fn(), - setLevel: vi.fn(), - }, - tray: { - updateColor: vi.fn(), - updateTitle: vi.fn(), - useAlternateIdleIcon: vi.fn(), - useUnreadActiveIcon: vi.fn(), - }, - notificationSoundPath: vi.fn(), - onAuthCallback: vi.fn(), - onResetApp: vi.fn(), - setAutoLaunch: vi.fn(), - setKeepWindowOnBlur: vi.fn(), - applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), - raiseNativeNotification: vi.fn(), -}; +afterEach(() => { + vi.useRealTimers(); +}); // Mock clipboard API Object.defineProperty(navigator, 'clipboard', { diff --git a/src/renderer/components/Sidebar.test.tsx b/src/renderer/components/Sidebar.test.tsx index 56e16f50a..8a13d642b 100644 --- a/src/renderer/components/Sidebar.test.tsx +++ b/src/renderer/components/Sidebar.test.tsx @@ -2,7 +2,6 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { navigateMock, renderWithProviders } from '../__helpers__/test-utils'; -import { mockMultipleAccountNotifications } from '../__mocks__/notifications-mocks'; import { mockSettings } from '../__mocks__/state-mocks'; import * as comms from '../utils/system/comms'; @@ -60,7 +59,9 @@ describe('renderer/components/Sidebar.tsx', () => { it('renders correct icon when there are notifications', () => { renderWithProviders(, { - notifications: mockMultipleAccountNotifications, + notificationCount: 2, + hasNotifications: true, + hasUnreadNotifications: false, }); expect(screen.getByTestId('sidebar-notifications')).toMatchSnapshot(); @@ -132,9 +133,7 @@ describe('renderer/components/Sidebar.tsx', () => { describe('quick links', () => { it('opens my github issues page', async () => { - renderWithProviders(, { - notifications: mockMultipleAccountNotifications, - }); + renderWithProviders(); await userEvent.click(screen.getByTestId('sidebar-my-issues')); @@ -143,9 +142,7 @@ describe('renderer/components/Sidebar.tsx', () => { }); it('opens my github pull requests page', async () => { - renderWithProviders(, { - notifications: mockMultipleAccountNotifications, - }); + renderWithProviders(); await userEvent.click(screen.getByTestId('sidebar-my-pull-requests')); diff --git a/src/renderer/components/settings/SettingsReset.test.tsx b/src/renderer/components/settings/SettingsReset.test.tsx index 0b8250028..74bc4f12b 100644 --- a/src/renderer/components/settings/SettingsReset.test.tsx +++ b/src/renderer/components/settings/SettingsReset.test.tsx @@ -12,7 +12,10 @@ describe('renderer/components/settings/SettingsReset.tsx', () => { it('should reset default settings when `OK`', async () => { const rendererLogInfoSpy = vi.spyOn(logger, 'rendererLogInfo').mockImplementation(vi.fn()); - globalThis.confirm = vi.fn(() => true); // always click 'OK' + vi.stubGlobal( + 'confirm', + vi.fn(() => true), + ); // always click 'OK' await act(async () => { renderWithProviders(, { @@ -28,7 +31,10 @@ describe('renderer/components/settings/SettingsReset.tsx', () => { }); it('should skip reset default settings when `cancelled`', async () => { - globalThis.confirm = vi.fn(() => false); // always click 'cancel' + vi.stubGlobal( + 'confirm', + vi.fn(() => false), + ); // always click 'cancel' await act(async () => { renderWithProviders(, { diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index 7ad8420e6..0a076d5cf 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -19,7 +19,8 @@ import { getAdapter } from '../utils/forges/registry'; import * as notifications from '../utils/notifications/notifications'; import * as comms from '../utils/system/comms'; import * as tray from '../utils/system/tray'; -import { type AppContextState, AppProvider } from './App'; +import { AppProvider } from './App'; +import { type AppContextState } from './context'; import { defaultSettings } from './defaults'; vi.mock('../hooks/useNotifications'); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 8c13f0dd3..f9f5ce4ff 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -1,12 +1,4 @@ -import { - createContext, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTheme } from '@primer/react'; @@ -19,15 +11,12 @@ import { useFiltersStore } from '../stores'; import type { Account, - AccountNotifications, AuthState, Forge, - GitifyError, GitifyNotification, Hostname, SettingsState, SettingsValue, - Status, Token, } from '../types'; import { FetchType } from '../types'; @@ -66,6 +55,7 @@ import { mapThemeModeToColorScheme, } from '../utils/ui/theme'; import { zoomLevelToPercentage, zoomPercentageToLevel } from '../utils/ui/zoom'; +import { AppContext, type AppContextState } from './context'; import { defaultAuth, defaultSettings } from './defaults'; /** @@ -96,47 +86,6 @@ function migrateLegacyAuthState(auth: AuthState): AuthState { }; } -export interface AppContextState { - auth: AuthState; - isLoggedIn: boolean; - loginWithDeviceFlowStart: ( - forge: Forge, - hostname?: Hostname, - scopes?: string[], - ) => Promise; - loginWithDeviceFlowPoll: (forge: Forge, session: DeviceFlowSession) => Promise; - loginWithDeviceFlowComplete: (forge: Forge, token: Token, hostname: Hostname) => Promise; - loginWithOAuthApp: (forge: Forge, data: LoginOAuthWebOptions) => Promise; - loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => Promise; - logoutFromAccount: (account: Account) => Promise; - - status: Status; - globalError: GitifyError | undefined; - - notifications: AccountNotifications[]; - notificationCount: number; - unreadNotificationCount: number; - hasNotifications: boolean; - hasUnreadNotifications: boolean; - - fetchNotifications: () => Promise; - removeAccountNotifications: (account: Account) => Promise; - - markNotificationsAsRead: (notifications: GitifyNotification[]) => Promise; - markNotificationsAsDone: (notifications: GitifyNotification[]) => Promise; - unsubscribeNotification: (notification: GitifyNotification) => Promise; - - settings: SettingsState; - resetSettings: () => void; - updateSetting: (name: keyof SettingsState, value: SettingsValue) => void; - - /** Shown when the OS could not register the chosen global shortcut. */ - shortcutRegistrationError: string | null; - clearShortcutRegistrationError: () => void; -} - -export const AppContext = createContext | undefined>(undefined); - export const AppProvider = ({ children }: { children: ReactNode }) => { const existingState = loadState(); diff --git a/src/renderer/context/context.ts b/src/renderer/context/context.ts new file mode 100644 index 000000000..1ac2b80e7 --- /dev/null +++ b/src/renderer/context/context.ts @@ -0,0 +1,60 @@ +import { createContext } from 'react'; + +import type { + Account, + AccountNotifications, + AuthState, + Forge, + GitifyError, + GitifyNotification, + Hostname, + SettingsState, + SettingsValue, + Status, + Token, +} from '../types'; +import type { + DeviceFlowSession, + LoginOAuthWebOptions, + LoginPersonalAccessTokenOptions, +} from '../utils/auth/types'; + +export interface AppContextState { + auth: AuthState; + isLoggedIn: boolean; + loginWithDeviceFlowStart: ( + forge: Forge, + hostname?: Hostname, + scopes?: string[], + ) => Promise; + loginWithDeviceFlowPoll: (forge: Forge, session: DeviceFlowSession) => Promise; + loginWithDeviceFlowComplete: (forge: Forge, token: Token, hostname: Hostname) => Promise; + loginWithOAuthApp: (forge: Forge, data: LoginOAuthWebOptions) => Promise; + loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => Promise; + logoutFromAccount: (account: Account) => Promise; + + status: Status; + globalError: GitifyError | undefined; + + notifications: AccountNotifications[]; + notificationCount: number; + unreadNotificationCount: number; + hasNotifications: boolean; + hasUnreadNotifications: boolean; + + fetchNotifications: () => Promise; + removeAccountNotifications: (account: Account) => Promise; + + markNotificationsAsRead: (notifications: GitifyNotification[]) => Promise; + markNotificationsAsDone: (notifications: GitifyNotification[]) => Promise; + unsubscribeNotification: (notification: GitifyNotification) => Promise; + + settings: SettingsState; + resetSettings: () => void; + updateSetting: (name: keyof SettingsState, value: SettingsValue) => void; + + shortcutRegistrationError: string | null; + clearShortcutRegistrationError: () => void; +} + +export const AppContext = createContext | undefined>(undefined); diff --git a/src/renderer/hooks/useAppContext.ts b/src/renderer/hooks/useAppContext.ts index e85db920a..207aca93e 100644 --- a/src/renderer/hooks/useAppContext.ts +++ b/src/renderer/hooks/useAppContext.ts @@ -1,6 +1,6 @@ import { useContext } from 'react'; -import { AppContext, type AppContextState } from '../context/App'; +import { AppContext, type AppContextState } from '../context/context'; /** * Custom hook that provides type-safe access to AppContext. diff --git a/src/renderer/routes/LoginWithDeviceFlow.test.tsx b/src/renderer/routes/LoginWithDeviceFlow.test.tsx index 48f8b0a71..435f55d24 100644 --- a/src/renderer/routes/LoginWithDeviceFlow.test.tsx +++ b/src/renderer/routes/LoginWithDeviceFlow.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { navigateMock, renderWithProviders } from '../__helpers__/test-utils'; +import * as logger from '../utils/core/logger'; import * as comms from '../utils/system/comms'; import { LoginWithDeviceFlowRoute } from './LoginWithDeviceFlow'; @@ -117,6 +118,7 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { }); it('should handle device flow errors during initialization', async () => { + const rendererLogErrorSpy = vi.spyOn(logger, 'rendererLogError').mockImplementation(vi.fn()); const loginWithDeviceFlowStartMock = vi.fn().mockRejectedValueOnce(new Error('Network error')); renderWithProviders(, { @@ -126,6 +128,7 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { await userEvent.click(screen.getByTestId('device-scope-full')); await screen.findByText(/Failed to start authentication/); + expect(rendererLogErrorSpy).toHaveBeenCalledTimes(1); }); it('should navigate back on cancel from scope choice', async () => { diff --git a/src/renderer/utils/forges/gitea/client.test.ts b/src/renderer/utils/forges/gitea/client.test.ts index 0297cfa6f..896380e7e 100644 --- a/src/renderer/utils/forges/gitea/client.test.ts +++ b/src/renderer/utils/forges/gitea/client.test.ts @@ -12,12 +12,7 @@ import { } from './client'; describe('renderer/utils/forges/gitea/client.ts', () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); - - beforeEach(() => { - fetchSpy.mockReset(); - vi.spyOn(comms, 'decryptValue').mockResolvedValue({ token: 'decrypted' }); - }); + const fetchMock = () => vi.mocked(globalThis.fetch); function jsonResponse(body: T, init: ResponseInit = { status: 200 }) { return new Response(JSON.stringify(body), { @@ -26,6 +21,11 @@ describe('renderer/utils/forges/gitea/client.ts', () => { }); } + beforeEach(() => { + fetchMock().mockReset(); + vi.spyOn(comms, 'decryptValue').mockResolvedValue({ token: 'decrypted' }); + }); + describe('getGiteaApiBaseUrl', () => { it('builds https api v1 base', () => { const url = getGiteaApiBaseUrl('gitea.example.com' as Hostname); @@ -35,7 +35,7 @@ describe('renderer/utils/forges/gitea/client.ts', () => { describe('listGiteaNotifications', () => { it('fetches a single page when fetchAllNotifications is false', async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse([{ id: 1 }])); + fetchMock().mockResolvedValueOnce(jsonResponse([{ id: 1 }])); const result = await listGiteaNotifications(mockGiteaAccount, { fetchAllNotifications: false, @@ -43,8 +43,8 @@ describe('renderer/utils/forges/gitea/client.ts', () => { } as SettingsState); expect(result).toEqual([{ id: 1 }]); - expect(fetchSpy).toHaveBeenCalledTimes(1); - const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(fetchMock()).toHaveBeenCalledTimes(1); + const calledUrl = fetchMock().mock.calls[0][0] as string; expect(calledUrl).toContain('https://gitea.example.com/api/v1/'); expect(calledUrl).toContain('status-types=unread'); expect(calledUrl).toContain('page=1'); @@ -52,20 +52,20 @@ describe('renderer/utils/forges/gitea/client.ts', () => { }); it('includes read status when fetchReadNotifications is true', async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse([])); + fetchMock().mockResolvedValueOnce(jsonResponse([])); await listGiteaNotifications(mockGiteaAccount, { fetchAllNotifications: false, fetchReadNotifications: true, } as SettingsState); - const calledUrl = fetchSpy.mock.calls[0][0] as string; + const calledUrl = fetchMock().mock.calls[0][0] as string; expect(calledUrl).toContain('status-types=unread'); expect(calledUrl).toContain('status-types=read'); }); it('paginates until an empty page is returned', async () => { - fetchSpy + fetchMock() .mockResolvedValueOnce(jsonResponse(Array.from({ length: 100 }, (_, i) => ({ id: i })))) .mockResolvedValueOnce(jsonResponse([{ id: 100 }])) .mockResolvedValueOnce(jsonResponse([])); @@ -76,11 +76,11 @@ describe('renderer/utils/forges/gitea/client.ts', () => { } as SettingsState); expect(result).toHaveLength(101); - expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchMock()).toHaveBeenCalledTimes(2); }); it('throws on a non-ok status without echoing the response body', async () => { - fetchSpy.mockResolvedValueOnce( + fetchMock().mockResolvedValue( new Response('Authorization: token leaked-pat', { status: 403, statusText: 'Forbidden', @@ -106,22 +106,22 @@ describe('renderer/utils/forges/gitea/client.ts', () => { describe('fetchGiteaAuthenticatedUser', () => { it('returns the user payload', async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse({ id: 7, login: 'octocat' })); + fetchMock().mockResolvedValueOnce(jsonResponse({ id: 7, login: 'octocat' })); const result = await fetchGiteaAuthenticatedUser(mockGiteaAccount); expect(result).toEqual({ id: 7, login: 'octocat' }); - expect(fetchSpy.mock.calls[0][0]).toContain('/api/v1/user'); + expect(fetchMock().mock.calls[0][0]).toContain('/api/v1/user'); }); }); describe('patchGiteaNotificationThread', () => { it('sends a PATCH with to-status query and resolves on 204', async () => { - fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); + fetchMock().mockResolvedValueOnce(new Response(null, { status: 204 })); await patchGiteaNotificationThread(mockGiteaAccount, '42', 'read'); - const [url, init] = fetchSpy.mock.calls[0]; + const [url, init] = fetchMock().mock.calls[0]; expect(url).toContain('/notifications/threads/42?to-status=read'); expect((init as RequestInit).method).toBe('PATCH'); }); @@ -129,7 +129,7 @@ describe('renderer/utils/forges/gitea/client.ts', () => { describe('giteaGetJson', () => { it('GETs the supplied URL with auth headers and parses JSON', async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse({ html_url: 'x' })); + fetchMock().mockResolvedValueOnce(jsonResponse({ html_url: 'x' })); const result = await giteaGetJson<{ html_url: string }>( mockGiteaAccount, @@ -137,12 +137,15 @@ describe('renderer/utils/forges/gitea/client.ts', () => { ); expect(result).toEqual({ html_url: 'x' }); - const headers = (fetchSpy.mock.calls[0][1] as RequestInit).headers as Record; + const headers = (fetchMock().mock.calls[0][1] as RequestInit).headers as Record< + string, + string + >; expect(headers.Authorization).toBe('token decrypted'); }); it('throws on a non-ok response without echoing the body', async () => { - fetchSpy.mockResolvedValueOnce( + fetchMock().mockResolvedValueOnce( new Response('echoed Authorization: token leaked-pat', { status: 500, statusText: 'Server Error', @@ -158,21 +161,21 @@ describe('renderer/utils/forges/gitea/client.ts', () => { await expect(giteaGetJson(mockGiteaAccount, 'https://attacker.com/api/v1/x')).rejects.toThrow( /cross-origin Gitea URL/, ); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(fetchMock()).not.toHaveBeenCalled(); }); it('refuses non-https URLs without sending a request', async () => { await expect(giteaGetJson(mockGiteaAccount, 'http://gitea.example.com/x')).rejects.toThrow( /cross-origin Gitea URL/, ); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(fetchMock()).not.toHaveBeenCalled(); }); it('refuses malformed URLs without sending a request', async () => { await expect(giteaGetJson(mockGiteaAccount, 'not-a-url')).rejects.toThrow( /malformed Gitea URL/, ); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(fetchMock()).not.toHaveBeenCalled(); }); }); }); diff --git a/src/renderer/utils/forges/github/client.test.ts b/src/renderer/utils/forges/github/client.test.ts index 2b74f0f7b..0c795e8a3 100644 --- a/src/renderer/utils/forges/github/client.test.ts +++ b/src/renderer/utils/forges/github/client.test.ts @@ -36,6 +36,15 @@ import type { OctokitClient } from './octokit'; import * as octokitModule from './octokit'; import * as apiRequests from './request'; +vi.mock('./request', async () => { + const actual = await vi.importActual('./request'); + return { + ...actual, + performGraphQLRequest: vi.fn(), + performGraphQLRequestString: vi.fn(), + }; +}); + const mockThreadId = '1234'; describe('renderer/utils/forges/github/client.ts', () => { @@ -59,6 +68,9 @@ describe('renderer/utils/forges/github/client.ts', () => { const createOctokitClientUncachedSpy = vi.spyOn(octokitModule, 'createOctokitClientUncached'); beforeEach(() => { + vi.mocked(apiRequests.performGraphQLRequest).mockReset(); + vi.mocked(apiRequests.performGraphQLRequestString).mockReset(); + // Mock createOctokitClient to return our mock createOctokitClientSpy.mockResolvedValue(mockOctokit as unknown as OctokitClient); createOctokitClientUncachedSpy.mockResolvedValue(mockOctokit as unknown as OctokitClient); @@ -270,7 +282,7 @@ describe('renderer/utils/forges/github/client.ts', () => { }); it('fetchDiscussionByNumber calls performGraphQLRequest with correct args', async () => { - const performGraphQLRequestSpy = vi.spyOn(apiRequests, 'performGraphQLRequest'); + const performGraphQLRequestSpy = vi.mocked(apiRequests.performGraphQLRequest); const mockNotification = mockPartialGitifyNotification({ title: 'Some discussion', @@ -298,7 +310,7 @@ describe('renderer/utils/forges/github/client.ts', () => { }); it('fetchIssueByNumber calls performGraphQLRequest with correct args', async () => { - const performGraphQLRequestSpy = vi.spyOn(apiRequests, 'performGraphQLRequest'); + const performGraphQLRequestSpy = vi.mocked(apiRequests.performGraphQLRequest); const mockNotification = mockPartialGitifyNotification({ title: 'Some issue', @@ -324,7 +336,7 @@ describe('renderer/utils/forges/github/client.ts', () => { }); it('fetchPullByNumber calls performGraphQLRequest with correct args', async () => { - const performGraphQLRequestSpy = vi.spyOn(apiRequests, 'performGraphQLRequest'); + const performGraphQLRequestSpy = vi.mocked(apiRequests.performGraphQLRequest); const mockNotification = mockPartialGitifyNotification({ title: 'Some pull request', @@ -355,7 +367,7 @@ describe('renderer/utils/forges/github/client.ts', () => { describe('fetchNotificationDetailsForList', () => { it('fetchNotificationDetailsForList returns empty map if no notifications', async () => { - const performGraphQLRequestStringSpy = vi.spyOn(apiRequests, 'performGraphQLRequestString'); + const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString); const mockNotification = mockPartialGitifyNotification({ title: 'Some commit', @@ -371,7 +383,7 @@ describe('renderer/utils/forges/github/client.ts', () => { }); it('fetchNotificationDetailsForList returns empty map if no supported notifications', async () => { - const performGraphQLRequestStringSpy = vi.spyOn(apiRequests, 'performGraphQLRequestString'); + const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString); performGraphQLRequestStringSpy.mockResolvedValue({} as ExecutionResult); @@ -381,7 +393,7 @@ describe('renderer/utils/forges/github/client.ts', () => { }); it('fetchNotificationDetailsForList returns empty map if no notifications', async () => { - const performGraphQLRequestStringSpy = vi.spyOn(apiRequests, 'performGraphQLRequestString'); + const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString); performGraphQLRequestStringSpy.mockResolvedValue({ data: {}, diff --git a/src/renderer/utils/forges/github/enrich.test.ts b/src/renderer/utils/forges/github/enrich.test.ts index 8c21d08dc..7f468c0f9 100644 --- a/src/renderer/utils/forges/github/enrich.test.ts +++ b/src/renderer/utils/forges/github/enrich.test.ts @@ -7,6 +7,15 @@ import * as logger from '../../core/logger'; import * as client from './client'; import { enrichGitHubNotifications } from './enrich'; +vi.mock('./client', async () => { + const actual = await vi.importActual('./client'); + return { + ...actual, + fetchNotificationDetailsForList: vi.fn(), + fetchIssueByNumber: vi.fn(), + }; +}); + describe('renderer/utils/forges/github/enrich.ts', () => { it('logs and continues when a per-notification handler throws', async () => { const rendererLogErrorSpy = vi.spyOn(logger, 'rendererLogError').mockImplementation(vi.fn()); @@ -14,7 +23,7 @@ describe('renderer/utils/forges/github/enrich.ts', () => { // No batched fragments — each handler will fall back to its single-fetch // path, which we make fail. - vi.spyOn(client, 'fetchNotificationDetailsForList').mockResolvedValue(new Map()); + vi.mocked(client.fetchNotificationDetailsForList).mockResolvedValue(new Map()); const mockError = new Error('Test error'); const mockNotification = mockPartialGitifyNotification({ @@ -34,7 +43,7 @@ describe('renderer/utils/forges/github/enrich.ts', () => { }; mockNotification.repository = mockRepository; - vi.spyOn(client, 'fetchIssueByNumber').mockRejectedValue(mockError); + vi.mocked(client.fetchIssueByNumber).mockRejectedValue(mockError); const [result] = await enrichGitHubNotifications([mockNotification], mockSettings); diff --git a/src/renderer/utils/forges/github/enrich.ts b/src/renderer/utils/forges/github/enrich.ts index 89b579b03..babfd57c6 100644 --- a/src/renderer/utils/forges/github/enrich.ts +++ b/src/renderer/utils/forges/github/enrich.ts @@ -38,12 +38,15 @@ async function fetchInBatches( notifications: RawGitifyNotification[], ): Promise> { const merged = new Map(); + const supportedNotifications = notifications.filter( + (notification) => createNotificationHandler(notification).supportsMergedQueryEnrichment, + ); const batchSize = GITHUB_API_MERGE_BATCH_SIZE; - for (let start = 0; start < notifications.length; start += batchSize) { + for (let start = 0; start < supportedNotifications.length; start += batchSize) { const batchIndex = Math.floor(start / batchSize) + 1; - const slice = notifications.slice(start, start + batchSize); + const slice = supportedNotifications.slice(start, start + batchSize); try { const batchResults = await fetchNotificationDetailsForList(slice); diff --git a/src/renderer/utils/forges/github/flows.test.ts b/src/renderer/utils/forges/github/flows.test.ts index 8d18913f5..ce8c6c1fb 100644 --- a/src/renderer/utils/forges/github/flows.test.ts +++ b/src/renderer/utils/forges/github/flows.test.ts @@ -37,6 +37,7 @@ const exchangeWebFlowCodeMock = exchangeWebFlowCode as unknown as MockedFunction describe('renderer/utils/forges/github/flows.ts', () => { vi.spyOn(logger, 'rendererLogInfo').mockImplementation(vi.fn()); + const rendererLogErrorSpy = vi.spyOn(logger, 'rendererLogError').mockImplementation(vi.fn()); const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); beforeEach(() => { @@ -136,6 +137,7 @@ describe('renderer/utils/forges/github/flows.ts', () => { await expect( async () => await flows.pollGitHubDeviceFlow(baseSession as DeviceFlowSession), ).rejects.toThrow('boom'); + expect(rendererLogErrorSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/renderer/utils/forges/github/handlers/discussion.test.ts b/src/renderer/utils/forges/github/handlers/discussion.test.ts index 6e8f0fe41..a8b3b9a4a 100644 --- a/src/renderer/utils/forges/github/handlers/discussion.test.ts +++ b/src/renderer/utils/forges/github/handlers/discussion.test.ts @@ -20,6 +20,14 @@ import * as apiClient from '../client'; import type { FetchDiscussionByNumberQuery } from '../graphql/generated/graphql'; import { discussionHandler } from './discussion'; +vi.mock('../client', async () => { + const actual = await vi.importActual('../client'); + return { + ...actual, + fetchDiscussionByNumber: vi.fn(), + }; +}); + describe('renderer/utils/notifications/handlers/discussion.ts', () => { describe('supportsMergedQueryEnrichment', () => { it('should support merge query', () => { @@ -28,7 +36,7 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { }); describe('enrich', () => { - const fetchDiscussionByNumberSpy = vi.spyOn(apiClient, 'fetchDiscussionByNumber'); + const fetchDiscussionByNumberSpy = vi.mocked(apiClient.fetchDiscussionByNumber); const mockNotification = mockPartialGitifyNotification({ title: 'This is a mock discussion', diff --git a/src/renderer/utils/forges/github/handlers/issue.test.ts b/src/renderer/utils/forges/github/handlers/issue.test.ts index ae6c1dc97..fe1cecf19 100644 --- a/src/renderer/utils/forges/github/handlers/issue.test.ts +++ b/src/renderer/utils/forges/github/handlers/issue.test.ts @@ -14,6 +14,14 @@ import * as apiClient from '../client'; import type { FetchIssueByNumberQuery } from '../graphql/generated/graphql'; import { issueHandler } from './issue'; +vi.mock('../client', async () => { + const actual = await vi.importActual('../client'); + return { + ...actual, + fetchIssueByNumber: vi.fn(), + }; +}); + describe('renderer/utils/notifications/handlers/issue.ts', () => { describe('supportsMergedQueryEnrichment', () => { it('should support merge query', () => { @@ -22,7 +30,7 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { }); describe('enrich', () => { - const fetchIssueByNumberSpy = vi.spyOn(apiClient, 'fetchIssueByNumber'); + const fetchIssueByNumberSpy = vi.mocked(apiClient.fetchIssueByNumber); const mockNotification = mockPartialGitifyNotification({ title: 'This is a mock issue', diff --git a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts index 908507fb0..4b039f03f 100644 --- a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts +++ b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts @@ -22,6 +22,14 @@ import type { } from '../graphql/generated/graphql'; import { getLatestReviewForReviewers, pullRequestHandler } from './pullRequest'; +vi.mock('../client', async () => { + const actual = await vi.importActual('../client'); + return { + ...actual, + fetchPullByNumber: vi.fn(), + }; +}); + describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { describe('mergeQueryConfig', () => { describe('supportsMergedQueryEnrichment', () => { @@ -32,7 +40,7 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { }); describe('enrich', () => { - const fetchPullByNumberSpy = vi.spyOn(apiClient, 'fetchPullByNumber'); + const fetchPullByNumberSpy = vi.mocked(apiClient.fetchPullByNumber); const mockNotification = mockPartialGitifyNotification({ title: 'This is a mock pull request', diff --git a/src/renderer/utils/notifications/notifications.test.ts b/src/renderer/utils/notifications/notifications.test.ts index 01985ed9c..54d7953a9 100644 --- a/src/renderer/utils/notifications/notifications.test.ts +++ b/src/renderer/utils/notifications/notifications.test.ts @@ -26,7 +26,23 @@ import { stabilizeNotificationsOrder, } from './notifications'; +vi.mock('../forges/github/client', async () => { + const actual = + await vi.importActual('../forges/github/client'); + return { + ...actual, + fetchNotificationDetailsForList: vi.fn(), + fetchIssueByNumber: vi.fn(), + }; +}); + describe('renderer/utils/notifications/notifications.ts', () => { + beforeEach(() => { + vi.mocked(apiClient.fetchNotificationDetailsForList).mockReset(); + vi.mocked(apiClient.fetchNotificationDetailsForList).mockResolvedValue(new Map()); + vi.mocked(apiClient.fetchIssueByNumber).mockReset(); + }); + it('getNotificationCount', () => { const result = getNotificationCount(mockSingleAccountNotifications); @@ -122,6 +138,7 @@ describe('renderer/utils/notifications/notifications.ts', () => { expect(result).toHaveLength(1); expect(result[0].subject.title).toBe('CI workflow run'); + expect(apiClient.fetchNotificationDetailsForList).not.toHaveBeenCalled(); }); it('should handle empty notifications array', async () => { @@ -133,12 +150,15 @@ describe('renderer/utils/notifications/notifications.ts', () => { const result = await enrichNotifications([], settings); expect(result).toEqual([]); + expect(apiClient.fetchNotificationDetailsForList).not.toHaveBeenCalled(); }); it('should batch notifications by GITHUB_API_MERGE_BATCH_SIZE', async () => { - const fetchNotificationDetailsForListSpy = vi - .spyOn(apiClient, 'fetchNotificationDetailsForList') - .mockResolvedValue(new Map()); + const fetchNotificationDetailsForListSpy = vi.mocked( + apiClient.fetchNotificationDetailsForList, + ); + vi.mocked(apiClient.fetchIssueByNumber).mockResolvedValue({ repository: {} } as never); + fetchNotificationDetailsForListSpy.mockResolvedValue(new Map()); const notificationCount = GITHUB_API_MERGE_BATCH_SIZE * 2.5; const notifications = Array.from({ length: notificationCount }, (_, i) => @@ -170,9 +190,11 @@ describe('renderer/utils/notifications/notifications.ts', () => { }); it('should handle single batch of notifications', async () => { - const fetchNotificationDetailsForListSpy = vi - .spyOn(apiClient, 'fetchNotificationDetailsForList') - .mockResolvedValue(new Map()); + const fetchNotificationDetailsForListSpy = vi.mocked( + apiClient.fetchNotificationDetailsForList, + ); + vi.mocked(apiClient.fetchIssueByNumber).mockResolvedValue({ repository: {} } as never); + fetchNotificationDetailsForListSpy.mockResolvedValue(new Map()); const notifications = Array.from({ length: 50 }, (_, i) => mockPartialGitifyNotification({ diff --git a/vitest.config.ts b/vitest.config.ts index 9cfa4954b..3fcebed24 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ pool: 'vmThreads', isolate: false, clearMocks: true, + unstubEnvs: true, + unstubGlobals: true, // Pre-bundle @primer/react rather than re-transforming the raw package on every thread load. // See https://vitest.dev/guide/profiling-test-performance.html#use-the-dependency-optimizer deps: {