Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions src/preload/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/__helpers__/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
106 changes: 67 additions & 39 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
13 changes: 5 additions & 8 deletions src/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,7 +59,9 @@ describe('renderer/components/Sidebar.tsx', () => {

it('renders correct icon when there are notifications', () => {
renderWithProviders(<Sidebar />, {
notifications: mockMultipleAccountNotifications,
notificationCount: 2,
hasNotifications: true,
hasUnreadNotifications: false,
});

expect(screen.getByTestId('sidebar-notifications')).toMatchSnapshot();
Expand Down Expand Up @@ -132,9 +133,7 @@ describe('renderer/components/Sidebar.tsx', () => {

describe('quick links', () => {
it('opens my github issues page', async () => {
renderWithProviders(<Sidebar />, {
notifications: mockMultipleAccountNotifications,
});
renderWithProviders(<Sidebar />);

await userEvent.click(screen.getByTestId('sidebar-my-issues'));

Expand All @@ -143,9 +142,7 @@ describe('renderer/components/Sidebar.tsx', () => {
});

it('opens my github pull requests page', async () => {
renderWithProviders(<Sidebar />, {
notifications: mockMultipleAccountNotifications,
});
renderWithProviders(<Sidebar />);

await userEvent.click(screen.getByTestId('sidebar-my-pull-requests'));

Expand Down
10 changes: 8 additions & 2 deletions src/renderer/components/settings/SettingsReset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SettingsReset />, {
Expand All @@ -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(<SettingsReset />, {
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/context/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
55 changes: 2 additions & 53 deletions src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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';

/**
Expand Down Expand Up @@ -96,47 +86,6 @@ function migrateLegacyAuthState(auth: AuthState): AuthState {
};
}

export interface AppContextState {
auth: AuthState;
isLoggedIn: boolean;
loginWithDeviceFlowStart: (
forge: Forge,
hostname?: Hostname,
scopes?: string[],
) => Promise<DeviceFlowSession>;
loginWithDeviceFlowPoll: (forge: Forge, session: DeviceFlowSession) => Promise<Token | null>;
loginWithDeviceFlowComplete: (forge: Forge, token: Token, hostname: Hostname) => Promise<void>;
loginWithOAuthApp: (forge: Forge, data: LoginOAuthWebOptions) => Promise<void>;
loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => Promise<void>;
logoutFromAccount: (account: Account) => Promise<void>;

status: Status;
globalError: GitifyError | undefined;

notifications: AccountNotifications[];
notificationCount: number;
unreadNotificationCount: number;
hasNotifications: boolean;
hasUnreadNotifications: boolean;

fetchNotifications: () => Promise<void>;
removeAccountNotifications: (account: Account) => Promise<void>;

markNotificationsAsRead: (notifications: GitifyNotification[]) => Promise<void>;
markNotificationsAsDone: (notifications: GitifyNotification[]) => Promise<void>;
unsubscribeNotification: (notification: GitifyNotification) => Promise<void>;

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<Partial<AppContextState> | undefined>(undefined);

export const AppProvider = ({ children }: { children: ReactNode }) => {
const existingState = loadState();

Expand Down
60 changes: 60 additions & 0 deletions src/renderer/context/context.ts
Original file line number Diff line number Diff line change
@@ -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<DeviceFlowSession>;
loginWithDeviceFlowPoll: (forge: Forge, session: DeviceFlowSession) => Promise<Token | null>;
loginWithDeviceFlowComplete: (forge: Forge, token: Token, hostname: Hostname) => Promise<void>;
loginWithOAuthApp: (forge: Forge, data: LoginOAuthWebOptions) => Promise<void>;
loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => Promise<void>;
logoutFromAccount: (account: Account) => Promise<void>;

status: Status;
globalError: GitifyError | undefined;

notifications: AccountNotifications[];
notificationCount: number;
unreadNotificationCount: number;
hasNotifications: boolean;
hasUnreadNotifications: boolean;

fetchNotifications: () => Promise<void>;
removeAccountNotifications: (account: Account) => Promise<void>;

markNotificationsAsRead: (notifications: GitifyNotification[]) => Promise<void>;
markNotificationsAsDone: (notifications: GitifyNotification[]) => Promise<void>;
unsubscribeNotification: (notification: GitifyNotification) => Promise<void>;

settings: SettingsState;
resetSettings: () => void;
updateSetting: (name: keyof SettingsState, value: SettingsValue) => void;

shortcutRegistrationError: string | null;
clearShortcutRegistrationError: () => void;
}

export const AppContext = createContext<Partial<AppContextState> | undefined>(undefined);
2 changes: 1 addition & 1 deletion src/renderer/hooks/useAppContext.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading