diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7565b98fa..c27efc5f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,9 +27,6 @@ jobs: cache: true node-version-file: '.nvmrc' - - name: Check TypeScript - run: vp exec tsc --noEmit - - name: Run tests with coverage run: vp run test diff --git a/package.json b/package.json index f69633f55..b351f0cf7 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "electron-builder": "26.17.0", "final-form": "5.0.1", "graphql": "17.0.2", + "graphql-config": "5.1.8", "happy-dom": "20.14.6", "husky": "9.1.7", "playwright": "1.63.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d31c8cb0..1745c8e75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,6 +337,9 @@ importers: graphql: specifier: 17.0.2 version: 17.0.2 + graphql-config: + specifier: 5.1.8 + version: 5.1.8(@types/node@24.19.1)(graphql@17.0.2)(typescript@7.0.2) happy-dom: specifier: 20.14.6 version: 20.14.6 diff --git a/src/main/handlers/storage.test.ts b/src/main/handlers/storage.test.ts index 01cf4ab10..5045f07d1 100644 --- a/src/main/handlers/storage.test.ts +++ b/src/main/handlers/storage.test.ts @@ -5,7 +5,7 @@ import { EVENTS } from '../../shared/events'; import { registerStorageHandlers } from './storage'; -type IpcHandler = (event: unknown, ...args: unknown[]) => unknown | Promise; +type IpcHandler = (event: unknown, ...args: unknown[]) => unknown; const handleMock = vi.fn(); diff --git a/src/main/lifecycle/window.test.ts b/src/main/lifecycle/window.test.ts index 8756f468e..7a7e2caa2 100644 --- a/src/main/lifecycle/window.test.ts +++ b/src/main/lifecycle/window.test.ts @@ -96,7 +96,7 @@ describe('main/lifecycle/window.ts', () => { }); it('configureWindowEvents returns early if no window', () => { - const mbNoWindow = { ...menubar, window: null }; + const mbNoWindow = Object.assign({}, menubar, { window: null }); expect(() => configureWindowEvents(mbNoWindow as unknown as Menubar, menuBuilder), diff --git a/src/main/utils.test.ts b/src/main/utils.test.ts index c8af44b5c..3eac57b15 100644 --- a/src/main/utils.test.ts +++ b/src/main/utils.test.ts @@ -74,8 +74,9 @@ describe('main/utils', () => { describe('takeScreenshot', () => { it('writes file and logs info', async () => { const mb = createMb(); - await takeScreenshot(mb as unknown as Menubar); - expect(writeFile).toHaveBeenCalled(); + takeScreenshot(mb as unknown as Menubar); + + await vi.waitFor(() => expect(writeFile).toHaveBeenCalled()); const writtenPath = (writeFile.mock.calls[0] as unknown[])[0] as string; expect(writtenPath.startsWith(path.join('/home/test'))).toBe(true); expect(logInfoMock).toHaveBeenCalledWith( @@ -89,7 +90,7 @@ describe('main/utils', () => { window: { capturePage: () => Promise.reject(new Error('capture failed')) }, }; - await takeScreenshot(mb as unknown as Menubar); + takeScreenshot(mb as unknown as Menubar); await vi.waitFor(() => expect(logErrorMock).toHaveBeenCalledWith( diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts index 988cfcbe7..c6bda06ec 100644 --- a/src/renderer/__helpers__/visual.setup.ts +++ b/src/renderer/__helpers__/visual.setup.ts @@ -145,8 +145,10 @@ beforeEach(async () => { vi.stubGlobal( 'fetch', vi.fn(async (input: RequestInfo | URL) => { + const target = + input instanceof URL ? input.href : typeof input === 'string' ? input : input.url; throw new Error( - `Unexpected network request in visual test: ${String(input)}. Mock the network boundary explicitly.`, + `Unexpected network request in visual test: ${target}. Mock the network boundary explicitly.`, ); }), ); diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index b9e46155b..87d475fa6 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -80,7 +80,7 @@ function getRequestTarget(input: RequestInfo | URL): string { return input.url; } - return String(input); + return (input as Request).url; } function createGitifyBridgeApi(): Window['gitify'] { diff --git a/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx index 38226b38b..e1fe1d8f0 100644 --- a/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx +++ b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx @@ -124,7 +124,7 @@ export const LoginWithPersonalAccessTokenForm: FC { - const tree = await renderRoute( + const tree = renderRoute(
, diff --git a/src/renderer/components/notifications/NotificationList.visual.test.tsx b/src/renderer/components/notifications/NotificationList.visual.test.tsx index 76409854d..20d63cfc8 100644 --- a/src/renderer/components/notifications/NotificationList.visual.test.tsx +++ b/src/renderer/components/notifications/NotificationList.visual.test.tsx @@ -41,7 +41,7 @@ async function renderList( settings: Partial = {}, accounts: AccountNotifications[] = [{ account, notifications, error: null }], ) { - const tree = await renderRoute(, { + const tree = renderRoute(, { theme: Theme.LIGHT, designLanguage: DesignLanguage.CLASSIC, accounts: accounts.map((entry) => entry.account), @@ -225,6 +225,7 @@ describe('notification list in a browser', () => { error: { title: 'Account failed', descriptions: ['Try again'], emojis: ['🔥'] }, }, ]); + // oxlint-disable-next-line typescript/await-thenable -- Vitest types omit `expect.element`'s poll promise await expect.element(page.getByText('Account failed')).toBeVisible(); }); @@ -312,6 +313,7 @@ describe('notification list in a browser', () => { finish(true); await pending; }); + // oxlint-disable-next-line typescript/await-thenable -- Vitest types omit `expect.element`'s poll promise await expect.element(page.getByTestId('repository-mark-as-read')).toBeVisible(); expect(scrollContainer()).toBe(scroller); expect(document.getElementById('new-notification')?.classList.contains('opacity-0')).toBe( diff --git a/src/renderer/components/primitives/Header.tsx b/src/renderer/components/primitives/Header.tsx index 05aa53dde..b026a95ab 100644 --- a/src/renderer/components/primitives/Header.tsx +++ b/src/renderer/components/primitives/Header.tsx @@ -27,7 +27,7 @@ export const Header: FC = (props: HeaderProps) => { data-testid="header-nav-back" icon={ArrowLeftIcon} onClick={() => { - navigate(-1); + void navigate(-1); if (props.fetchOnBack) { void fetchNotifications(); } diff --git a/src/renderer/hooks/useAccounts.ts b/src/renderer/hooks/useAccounts.ts index 58e53d6c6..8b3683794 100644 --- a/src/renderer/hooks/useAccounts.ts +++ b/src/renderer/hooks/useAccounts.ts @@ -59,7 +59,7 @@ export const useAccounts = (): AccountsState => { // unlocks their screen, without waiting for the next scheduled interval. useEffect(() => { return window.gitify.onSystemWake(() => { - refetch(); + void refetch(); }); }, [refetch]); diff --git a/src/renderer/hooks/useLogins.test.tsx b/src/renderer/hooks/useLogins.test.tsx index 7ebc05fbd..28f23fbfd 100644 --- a/src/renderer/hooks/useLogins.test.tsx +++ b/src/renderer/hooks/useLogins.test.tsx @@ -61,7 +61,7 @@ describe('renderer/hooks/useLogins.ts', () => { const { result } = renderLoginsHook(); await act(async () => { - result.current.loginWithDeviceFlowStart('github'); + void result.current.loginWithDeviceFlowStart('github'); }); expect(startSpy).toHaveBeenCalled(); @@ -104,7 +104,10 @@ describe('renderer/hooks/useLogins.ts', () => { const { result } = renderLoginsHook(); await act(async () => { - result.current.loginWithDeviceFlowPoll('github', 'session' as unknown as DeviceFlowSession); + void result.current.loginWithDeviceFlowPoll( + 'github', + 'session' as unknown as DeviceFlowSession, + ); }); expect(pollSpy).toHaveBeenCalledWith('session'); @@ -131,7 +134,7 @@ describe('renderer/hooks/useLogins.ts', () => { const { result } = renderLoginsHook(); await act(async () => { - result.current.loginWithOAuthApp('github', { + void result.current.loginWithOAuthApp('github', { clientId: 'id' as ClientID, clientSecret: 'secret' as ClientSecret, hostname: Constants.GITHUB_HOSTNAME, diff --git a/src/renderer/hooks/useNotifications.test.tsx b/src/renderer/hooks/useNotifications.test.tsx index 362264d4a..091d8456e 100644 --- a/src/renderer/hooks/useNotifications.test.tsx +++ b/src/renderer/hooks/useNotifications.test.tsx @@ -212,14 +212,14 @@ describe('renderer/hooks/useNotifications.ts', () => { await waitFor(() => expect(result.current.status).toBe('success')); act(() => { - result.current.refetchNotifications(); + void result.current.refetchNotifications(); }); await waitFor(() => expect(result.current.status).toBe('error')); expect(result.current.isFetching).toBe(false); // Trigger a retry that stays unsettled. act(() => { - result.current.refetchNotifications(); + void result.current.refetchNotifications(); }); await waitFor(() => expect(result.current.isFetching).toBe(true)); @@ -251,7 +251,7 @@ describe('renderer/hooks/useNotifications.ts', () => { // Trigger a background refetch that stays unsettled. act(() => { - result.current.refetchNotifications(); + void result.current.refetchNotifications(); }); await waitFor(() => expect(result.current.isFetching).toBe(true)); @@ -792,7 +792,7 @@ describe('renderer/hooks/useNotifications.ts', () => { const enterpriseCount = mockMultipleAccountNotifications[1].notifications.length; - await act(() => { + act(() => { result.current.removeAccountNotifications(mockGitHubCloudAccount); }); diff --git a/src/renderer/hooks/useShortcutActions.ts b/src/renderer/hooks/useShortcutActions.ts index 853426299..082e961c4 100644 --- a/src/renderer/hooks/useShortcutActions.ts +++ b/src/renderer/hooks/useShortcutActions.ts @@ -72,9 +72,9 @@ export function useShortcutActions(): { shortcuts: ShortcutConfigs } { isAllowed: isLoggedIn, action: () => { if (isOnFiltersRoute) { - navigate('/', { replace: true }); + void navigate('/', { replace: true }); } else { - navigate('/filters'); + void navigate('/filters'); } }, }, @@ -95,7 +95,7 @@ export function useShortcutActions(): { shortcuts: ShortcutConfigs } { if (isLoading) { return; } - navigate('/', { replace: true }); + void navigate('/', { replace: true }); void fetchNotifications(); }, }, @@ -104,10 +104,10 @@ export function useShortcutActions(): { shortcuts: ShortcutConfigs } { isAllowed: isLoggedIn, action: () => { if (isOnSettingsRoute) { - navigate('/', { replace: true }); + void navigate('/', { replace: true }); void fetchNotifications(); } else { - navigate('/settings'); + void navigate('/settings'); } }, }, diff --git a/src/renderer/icons/LogoIcon.tsx b/src/renderer/icons/LogoIcon.tsx index 9ccda8052..d1d78ceba 100644 --- a/src/renderer/icons/LogoIcon.tsx +++ b/src/renderer/icons/LogoIcon.tsx @@ -9,7 +9,7 @@ import { Size } from '../types'; interface LogoIconProps { isDark?: boolean; onClick?: () => void; - size: Size.SMALL | Size.MEDIUM | Size.LARGE; + size?: Size.SMALL | Size.MEDIUM | Size.LARGE; } const LIGHT_GRADIENT_START = '#CCCCCC'; diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index 066af7164..3992fe1d2 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -87,7 +87,7 @@ export const AccountsRoute: FC = () => { useAccountsStore.setState({ accounts: [account, ...accounts.filter((a) => a !== account)], }); - navigate('/accounts', { replace: true }); + void navigate('/accounts', { replace: true }); }; const handleRefresh = async (account: Account) => { @@ -109,7 +109,7 @@ export const AccountsRoute: FC = () => { })); } - navigate('/accounts', { replace: true }); + void navigate('/accounts', { replace: true }); /** * Typically the above refresh API call completes very quickly, @@ -146,7 +146,7 @@ export const AccountsRoute: FC = () => { return; } - navigate(loginMethod.route, { + void navigate(loginMethod.route, { replace: true, state: { account }, }); @@ -381,7 +381,7 @@ export const AccountsRoute: FC = () => { keepMenuOpenRef.current = true; setSelectedForge(adapter); } else { - navigate(adapter.loginMethods[0].route, { replace: true }); + void navigate(adapter.loginMethods[0].route, { replace: true }); } }} > diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index 525e5dab1..9e4ab97b3 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -70,7 +70,7 @@ export const LoginRoute: FC = () => { useEffect(() => { if (isLoggedIn) { showWindow(); - navigate('/', { replace: true }); + void navigate('/', { replace: true }); } // oxlint-disable-next-line react/exhaustive-deps -- navigate is stable }, [isLoggedIn]); diff --git a/src/renderer/routes/gitea/LoginWithPersonalAccessToken.visual.test.tsx b/src/renderer/routes/gitea/LoginWithPersonalAccessToken.visual.test.tsx index dbb9c51bf..1556eea97 100644 --- a/src/renderer/routes/gitea/LoginWithPersonalAccessToken.visual.test.tsx +++ b/src/renderer/routes/gitea/LoginWithPersonalAccessToken.visual.test.tsx @@ -26,7 +26,7 @@ describe('Gitea HTTP login', () => { headers: { 'content-type': 'application/json' }, }), ); - await renderRoute(, { + renderRoute(, { theme: Theme.LIGHT, designLanguage: DesignLanguage.CLASSIC, }); @@ -56,15 +56,18 @@ describe('Gitea HTTP login', () => { }); it('shows validation and API errors', async () => { - await renderRoute(, { + renderRoute(, { theme: Theme.LIGHT, designLanguage: DesignLanguage.CLASSIC, }); await page.getByTestId('login-submit').click(); + // oxlint-disable-next-line typescript/await-thenable -- Vitest types omit `expect.element`'s poll promise await expect.element(page.getByText('Hostname is required')).toBeVisible(); await page.getByTestId('login-hostname').fill('http://user:secret@git.internal/path'); + // oxlint-disable-next-line typescript/await-thenable -- Vitest types omit `expect.element`'s poll promise await expect.element(page.getByTestId('login-create-token')).toBeDisabled(); await page.getByTestId('login-submit').click(); + // oxlint-disable-next-line typescript/await-thenable -- Vitest types omit `expect.element`'s poll promise await expect.element(page.getByText('Hostname format is invalid')).toBeVisible(); expect(fetch).not.toHaveBeenCalled(); diff --git a/src/renderer/routes/github/LoginWithDeviceFlow.tsx b/src/renderer/routes/github/LoginWithDeviceFlow.tsx index 49b26f855..9145281b7 100644 --- a/src/renderer/routes/github/LoginWithDeviceFlow.tsx +++ b/src/renderer/routes/github/LoginWithDeviceFlow.tsx @@ -99,7 +99,7 @@ export const GitHubLoginWithDeviceFlowRoute: FC = () => { if (token && isActive) { await loginWithDeviceFlowComplete(forge, token, session.hostname); - navigate('/'); + void navigate('/'); return; } diff --git a/src/renderer/routes/github/LoginWithGitHubCLI.tsx b/src/renderer/routes/github/LoginWithGitHubCLI.tsx index 84f7e35e6..425bfc1c3 100644 --- a/src/renderer/routes/github/LoginWithGitHubCLI.tsx +++ b/src/renderer/routes/github/LoginWithGitHubCLI.tsx @@ -47,7 +47,7 @@ export const GitHubLoginWithCLIRoute: FC = () => { try { await loginWithCli('github', hostname); - navigate('/'); + void navigate('/'); } catch (err) { rendererLogError('loginWithCli', 'Failed to login with GitHub CLI', toError(err)); setError(summarizeLoginError(err) ?? `Failed to login with GitHub CLI against ${hostname}`); diff --git a/src/renderer/routes/github/LoginWithOAuthApp.tsx b/src/renderer/routes/github/LoginWithOAuthApp.tsx index 969ebdd1d..01ec40651 100644 --- a/src/renderer/routes/github/LoginWithOAuthApp.tsx +++ b/src/renderer/routes/github/LoginWithOAuthApp.tsx @@ -73,7 +73,7 @@ export const GitHubLoginWithOAuthAppRoute: FC = () => { async (data: OAuthAppFormData) => { try { await loginWithOAuthApp(forge, data); - navigate('/'); + void navigate('/'); } catch (err) { rendererLogError('loginWithOAuthApp', 'Failed to login with OAuth App', toError(err)); setErrors({ diff --git a/src/renderer/routes/routes.visual.test.tsx b/src/renderer/routes/routes.visual.test.tsx index 1b5afe38e..bb95e71b5 100644 --- a/src/renderer/routes/routes.visual.test.tsx +++ b/src/renderer/routes/routes.visual.test.tsx @@ -128,7 +128,7 @@ const ACCESSIBILITY_THEMES = [ ]; async function captureRoute(route: RouteCase, theme: Theme, designLanguage: DesignLanguage) { - await renderRoute(route.element, { ...route.state, theme, designLanguage }); + renderRoute(route.element, { ...route.state, theme, designLanguage }); await expect(page.getByTestId('visual-root')).toMatchScreenshot( `${route.name}-${theme.toLowerCase()}-${designLanguage}`, diff --git a/src/renderer/utils/forges/gitea/client.test.ts b/src/renderer/utils/forges/gitea/client.test.ts index 2925966f4..36611063a 100644 --- a/src/renderer/utils/forges/gitea/client.test.ts +++ b/src/renderer/utils/forges/gitea/client.test.ts @@ -216,7 +216,7 @@ describe('renderer/utils/forges/gitea/client.ts', () => { await patchGiteaNotificationThread(httpAccount, '42'); expect(fetchMock()).toHaveBeenCalledTimes(3); for (const [url, init] of fetchMock().mock.calls) { - expect(String(url)).toMatch(/^http:\/\/git\.internal:3000\/api\/v1\//); + expect(url as string).toMatch(/^http:\/\/git\.internal:3000\/api\/v1\//); expect(init?.headers).toMatchObject({ Authorization: 'token decrypted' }); } }); diff --git a/src/renderer/utils/forges/gitea/client.ts b/src/renderer/utils/forges/gitea/client.ts index 3000b7db6..7cbf6dedc 100644 --- a/src/renderer/utils/forges/gitea/client.ts +++ b/src/renderer/utils/forges/gitea/client.ts @@ -4,6 +4,7 @@ import type { Account, Hostname } from '../../../types'; import type { GiteaNotificationThread, GiteaUser } from './types'; import { decryptValue } from '../../system/comms'; +import { mergeHeaders } from '../headers'; import { getGiteaOrigin } from './auth'; const PAGE_SIZE = 100; @@ -12,7 +13,7 @@ export function getGiteaApiBaseUrl(hostname: Hostname): URL { return new URL('/api/v1/', getGiteaOrigin(hostname)); } -async function authHeaders(account: Account): Promise { +async function authHeaders(account: Account): Promise> { const { token } = await decryptValue(account.token); return { Accept: 'application/json', @@ -35,10 +36,7 @@ async function giteaRequest(account: Account, pathname: string, init?: Reques const response = await fetch(url.toString(), { ...init, - headers: { - ...(await authHeaders(account)), - ...init?.headers, - }, + headers: mergeHeaders(await authHeaders(account), init?.headers), }); if (!response.ok) { diff --git a/src/renderer/utils/forges/github/handlers/checkSuite.test.ts b/src/renderer/utils/forges/github/handlers/checkSuite.test.ts index 559a8070b..93a1e3d9d 100644 --- a/src/renderer/utils/forges/github/handlers/checkSuite.test.ts +++ b/src/renderer/utils/forges/github/handlers/checkSuite.test.ts @@ -7,13 +7,13 @@ import { checkSuiteHandler, getCheckSuiteAttributes } from './checkSuite'; describe('renderer/utils/notifications/handlers/checkSuite.ts', () => { describe('enrich', () => { - it('cancelled check suite state', async () => { + it('cancelled check suite state', () => { const mockNotification = mockPartialGitifyNotification({ title: 'Demo workflow run cancelled for main branch', type: 'CheckSuite', }); - const result = await checkSuiteHandler.enrich(mockNotification); + const result = checkSuiteHandler.enrich(mockNotification); expect(result).toEqual({ state: 'CANCELLED', @@ -33,13 +33,13 @@ describe('renderer/utils/notifications/handlers/checkSuite.ts', () => { 'multiple attempts failed check suite state', 'Demo workflow run, Attempt #3 failed for main branch', ], - ])('%s', async (_name, title) => { + ])('%s', (_name, title) => { const mockNotification = mockPartialGitifyNotification({ title, type: 'CheckSuite', }); - const result = await checkSuiteHandler.enrich(mockNotification); + const result = checkSuiteHandler.enrich(mockNotification); expect(result).toEqual({ state: 'FAILURE', @@ -49,13 +49,13 @@ describe('renderer/utils/notifications/handlers/checkSuite.ts', () => { }); }); - it('skipped check suite state', async () => { + it('skipped check suite state', () => { const mockNotification = mockPartialGitifyNotification({ title: 'Demo workflow run skipped for main branch', type: 'CheckSuite', }); - const result = await checkSuiteHandler.enrich(mockNotification); + const result = checkSuiteHandler.enrich(mockNotification); expect(result).toEqual({ state: 'SKIPPED', @@ -65,13 +65,13 @@ describe('renderer/utils/notifications/handlers/checkSuite.ts', () => { }); }); - it('successful check suite state', async () => { + it('successful check suite state', () => { const mockNotification = mockPartialGitifyNotification({ title: 'Demo workflow run succeeded for main branch', type: 'CheckSuite', }); - const result = await checkSuiteHandler.enrich(mockNotification); + const result = checkSuiteHandler.enrich(mockNotification); expect(result).toEqual({ state: 'SUCCESS', @@ -81,25 +81,25 @@ describe('renderer/utils/notifications/handlers/checkSuite.ts', () => { }); }); - it('unknown check suite state', async () => { + it('unknown check suite state', () => { const mockNotification = mockPartialGitifyNotification({ title: 'Demo workflow run unknown-status for main branch', type: 'CheckSuite', }); - const result = await checkSuiteHandler.enrich(mockNotification); + const result = checkSuiteHandler.enrich(mockNotification); // Returns empty object when state cannot be determined expect(result).toEqual({}); }); - it('unhandled check suite title', async () => { + it('unhandled check suite title', () => { const mockNotification = mockPartialGitifyNotification({ title: 'A title that is not in the structure we expect', type: 'CheckSuite', }); - const result = await checkSuiteHandler.enrich(mockNotification); + const result = checkSuiteHandler.enrich(mockNotification); // Returns empty object when title cannot be parsed expect(result).toEqual({}); diff --git a/src/renderer/utils/forges/github/handlers/workflowRun.test.ts b/src/renderer/utils/forges/github/handlers/workflowRun.test.ts index 659c2fc4b..064a5c9f0 100644 --- a/src/renderer/utils/forges/github/handlers/workflowRun.test.ts +++ b/src/renderer/utils/forges/github/handlers/workflowRun.test.ts @@ -6,13 +6,13 @@ import { getWorkflowRunAttributes, workflowRunHandler } from './workflowRun'; describe('renderer/utils/notifications/handlers/workflowRun.ts', () => { describe('enrich', () => { - it('deploy review workflow run state', async () => { + it('deploy review workflow run state', () => { const mockNotification = mockPartialGitifyNotification({ title: 'some-user requested your review to deploy to an environment', type: 'WorkflowRun', }); - const result = await workflowRunHandler.enrich(mockNotification); + const result = workflowRunHandler.enrich(mockNotification); expect(result).toEqual({ state: 'WAITING', @@ -21,25 +21,25 @@ describe('renderer/utils/notifications/handlers/workflowRun.ts', () => { }); }); - it('unknown workflow run state', async () => { + it('unknown workflow run state', () => { const mockNotification = mockPartialGitifyNotification({ title: 'some-user requested your unknown-state to deploy to an environment', type: 'WorkflowRun', }); - const result = await workflowRunHandler.enrich(mockNotification); + const result = workflowRunHandler.enrich(mockNotification); // Returns empty object when state cannot be determined expect(result).toEqual({}); }); - it('unhandled workflow run title', async () => { + it('unhandled workflow run title', () => { const mockNotification = mockPartialGitifyNotification({ title: 'unhandled workflow run structure', type: 'WorkflowRun', }); - const result = await workflowRunHandler.enrich(mockNotification); + const result = workflowRunHandler.enrich(mockNotification); // Returns empty object when title cannot be parsed expect(result).toEqual({}); diff --git a/src/renderer/utils/forges/gitlab/client.ts b/src/renderer/utils/forges/gitlab/client.ts index b225f64f1..dda98e3a8 100644 --- a/src/renderer/utils/forges/gitlab/client.ts +++ b/src/renderer/utils/forges/gitlab/client.ts @@ -12,6 +12,7 @@ import type { import { isValidHostname } from '../../auth/utils'; import { rendererLogWarn } from '../../core/logger'; import { decryptValue } from '../../system/comms'; +import { mergeHeaders } from '../headers'; const PAGE_SIZE = 100; @@ -29,7 +30,7 @@ export function getGitLabApiBaseUrl(hostname: Hostname): URL { return new URL(`https://${hostname}/api/v4/`); } -async function authHeaders(account: Account): Promise { +async function authHeaders(account: Account): Promise> { const { token } = await decryptValue(account.token); return { Accept: 'application/json', @@ -57,10 +58,7 @@ async function gitlabRequest( const response = await fetch(url.toString(), { ...init, - headers: { - ...(await authHeaders(account)), - ...init?.headers, - }, + headers: mergeHeaders(await authHeaders(account), init?.headers), }); if (!response.ok) { diff --git a/src/renderer/utils/forges/headers.test.ts b/src/renderer/utils/forges/headers.test.ts new file mode 100644 index 000000000..74c121cff --- /dev/null +++ b/src/renderer/utils/forges/headers.test.ts @@ -0,0 +1,46 @@ +import { mergeHeaders } from './headers'; + +describe('renderer/utils/forges/headers.ts', () => { + it('returns the base headers verbatim when there are no extra headers', () => { + const base = { Authorization: 'token secret' }; + + expect(mergeHeaders(base)).toBe(base); + }); + + it('merges an object of extra headers, keeping base keys', () => { + expect( + mergeHeaders({ Accept: 'application/json' }, { 'Content-Type': 'application/json' }), + ).toEqual({ + Accept: 'application/json', + 'Content-Type': 'application/json', + }); + }); + + it('normalises a Headers instance', () => { + const extra = new Headers({ 'X-Test': 'yes' }); + + expect(mergeHeaders({ Accept: 'application/json' }, extra)).toEqual({ + Accept: 'application/json', + 'X-Test': 'yes', + }); + }); + + it('normalises an entry array', () => { + expect( + mergeHeaders({ Accept: 'application/json' }, [ + ['X-Test', 'yes'], + ['X-Other', 'no'], + ]), + ).toEqual({ + Accept: 'application/json', + 'X-Test': 'yes', + 'X-Other': 'no', + }); + }); + + it('lets extra headers override the base', () => { + expect(mergeHeaders({ Accept: 'application/json' }, { Accept: 'text/plain' })).toEqual({ + Accept: 'text/plain', + }); + }); +}); diff --git a/src/renderer/utils/forges/headers.ts b/src/renderer/utils/forges/headers.ts new file mode 100644 index 000000000..a964f40e0 --- /dev/null +++ b/src/renderer/utils/forges/headers.ts @@ -0,0 +1,26 @@ +/** + * Merge a base set of headers with optional per-request headers. + * + * `HeadersInit` may be a `Headers` instance, an entry array, or a plain + * object, so it cannot be spread directly. Each form is normalised into a + * plain record instead. Keys from `base` are preserved verbatim (such as + * `Authorization`) so request assertions stay stable. + */ +export function mergeHeaders( + base: Record, + extra?: HeadersInit, +): Record { + if (!extra) { + return base; + } + + if (extra instanceof Headers) { + return { ...base, ...Object.fromEntries(extra.entries()) }; + } + + if (Array.isArray(extra)) { + return { ...base, ...Object.fromEntries(extra) }; + } + + return { ...base, ...extra }; +} diff --git a/src/renderer/utils/forges/registry.ts b/src/renderer/utils/forges/registry.ts index d13cb76cb..87796631f 100644 --- a/src/renderer/utils/forges/registry.ts +++ b/src/renderer/utils/forges/registry.ts @@ -47,40 +47,29 @@ export function getAdapter(forgeOrAccount: Forge | Account): ForgeAdapter { /** Create a view for this account snapshot; resolve a new view when the account changes. */ export function getAccountAdapter(account: Account): ForgeAccountAdapter { - const { - capabilities, - formatNotificationUser, - fetchAuthenticatedUser, - onAccountTokenChange, - listNotifications, - markThreadAsRead, - markThreadAsDone, - unsubscribeThread, - followUrl, - getAccountSettingsUrl, - getIssuesUrl, - getPullRequestsUrl, - getNotificationsUrl, - oauthScopes, - } = getAdapter(account).accountOps; + const { accountOps } = getAdapter(account); + const { capabilities, oauthScopes } = accountOps; + // Optional method, so bind it to keep a single reference and avoid an unbound + // method reference (the adapter ops are otherwise called as `accountOps.x(...)`). + const onAccountTokenChange = accountOps.onAccountTokenChange?.bind(accountOps); return { capabilities: { markAsDone: () => capabilities.markAsDone(account), unsubscribeThread: () => capabilities.unsubscribeThread(account), }, - formatNotificationUser: (user) => formatNotificationUser(account, user), - fetchAuthenticatedUser: () => fetchAuthenticatedUser(account), + formatNotificationUser: (user) => accountOps.formatNotificationUser(account, user), + fetchAuthenticatedUser: () => accountOps.fetchAuthenticatedUser(account), onAccountTokenChange: onAccountTokenChange ? () => onAccountTokenChange(account) : undefined, - listNotifications: () => listNotifications(account), - markThreadAsRead: (threadId) => markThreadAsRead(account, threadId), - markThreadAsDone: (threadId) => markThreadAsDone(account, threadId), - unsubscribeThread: (threadId) => unsubscribeThread(account, threadId), - followUrl: (url: Link) => followUrl(account, url), - getAccountSettingsUrl: () => getAccountSettingsUrl(account), - getIssuesUrl: () => getIssuesUrl(account), - getPullRequestsUrl: () => getPullRequestsUrl(account), - getNotificationsUrl: () => getNotificationsUrl(account), + listNotifications: () => accountOps.listNotifications(account), + markThreadAsRead: (threadId) => accountOps.markThreadAsRead(account, threadId), + markThreadAsDone: (threadId) => accountOps.markThreadAsDone(account, threadId), + unsubscribeThread: (threadId) => accountOps.unsubscribeThread(account, threadId), + followUrl: (url: Link) => accountOps.followUrl(account, url), + getAccountSettingsUrl: () => accountOps.getAccountSettingsUrl(account), + getIssuesUrl: () => accountOps.getIssuesUrl(account), + getPullRequestsUrl: () => accountOps.getPullRequestsUrl(account), + getNotificationsUrl: () => accountOps.getNotificationsUrl(account), oauthScopes: oauthScopes ? { hasRequired: () => oauthScopes.hasRequired(account), diff --git a/src/renderer/utils/notifications/notifications.test.ts b/src/renderer/utils/notifications/notifications.test.ts index 0a74af30d..d72342ca3 100644 --- a/src/renderer/utils/notifications/notifications.test.ts +++ b/src/renderer/utils/notifications/notifications.test.ts @@ -1,4 +1,5 @@ import { + mockGiteaAccount, mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount, } from '../../__mocks__/account-mocks'; @@ -141,6 +142,20 @@ describe('renderer/utils/notifications/notifications.ts', () => { expect(apiClient.fetchNotificationDetailsForList).not.toHaveBeenCalled(); }); + it('should return notifications unchanged when the adapter has no enrichment', async () => { + // Gitea has no `enrichNotifications` implementation. + const notification = { + ...mockPartialGitifyNotification({ title: 'Issue #1', type: 'Issue' }), + account: mockGiteaAccount, + } as GitifyNotification; + useSettingsStore.setState({ detailedNotifications: true }); + + const result = await enrichNotifications([notification]); + + expect(result).toEqual([notification]); + expect(apiClient.fetchNotificationDetailsForList).not.toHaveBeenCalled(); + }); + it('should batch notifications by GITHUB_API_MERGE_BATCH_SIZE', async () => { const fetchNotificationDetailsForListSpy = vi.mocked( apiClient.fetchNotificationDetailsForList, diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts index fa03d3fd6..2ab200b76 100644 --- a/src/renderer/utils/notifications/notifications.ts +++ b/src/renderer/utils/notifications/notifications.ts @@ -140,7 +140,8 @@ export async function enrichNotifications( // Adapters that do not implement enrichment (e.g. Gitea) leave // notifications unchanged. - const enrich = getAdapter(notifications[0].account).enrichNotifications; + const adapter = getAdapter(notifications[0].account); + const enrich = adapter.enrichNotifications?.bind(adapter); if (!enrich) { return notifications; } diff --git a/src/renderer/utils/system/comms.test.ts b/src/renderer/utils/system/comms.test.ts index c2dd1c9be..fc482c8de 100644 --- a/src/renderer/utils/system/comms.test.ts +++ b/src/renderer/utils/system/comms.test.ts @@ -175,7 +175,7 @@ describe('renderer/utils/comms.ts', () => { }); it('copy to clipboard', async () => { - copyToClipboard('some-value'); + void copyToClipboard('some-value'); expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); expect(navigator.clipboard.writeText).toHaveBeenCalledWith('some-value'); diff --git a/src/renderer/utils/system/native.test.ts b/src/renderer/utils/system/native.test.ts index d1cf59020..888316687 100644 --- a/src/renderer/utils/system/native.test.ts +++ b/src/renderer/utils/system/native.test.ts @@ -14,7 +14,7 @@ describe('renderer/utils/system/native.ts', () => { vi.spyOn(url, 'generateNotificationWebUrl').mockImplementation(async () => mockHtmlUrl); it('should raise a native notification for a single new notification', async () => { - native.raiseNativeNotification(mockSingleAccountNotifications[0].notifications); + void native.raiseNativeNotification(mockSingleAccountNotifications[0].notifications); await waitFor(() => expect(window.gitify.raiseNativeNotification).toHaveBeenCalledTimes(1)); @@ -29,7 +29,7 @@ describe('renderer/utils/system/native.ts', () => { }); it('should raise a native notification for multiple new notifications', async () => { - native.raiseNativeNotification(mockMultipleAccountNotifications[0].notifications); + void native.raiseNativeNotification(mockMultipleAccountNotifications[0].notifications); await waitFor(() => expect(window.gitify.raiseNativeNotification).toHaveBeenCalledTimes(1)); diff --git a/src/renderer/utils/ui/keyboard.test.ts b/src/renderer/utils/ui/keyboard.test.ts index ff36f558d..5dcaba3c2 100644 --- a/src/renderer/utils/ui/keyboard.test.ts +++ b/src/renderer/utils/ui/keyboard.test.ts @@ -9,13 +9,13 @@ describe('renderer/utils/keyboard.ts', () => { metaKey: false, ctrlKey: false, altKey: false, - } as KeyboardEvent; + }; it('returns true for HTMLInputElement target', () => { const event = { ...baseEvent, target: document.createElement('input'), - } as KeyboardEvent; + } as unknown as KeyboardEvent; expect(shouldIgnoreKeyboardEvent(event)).toBe(true); }); @@ -24,7 +24,7 @@ describe('renderer/utils/keyboard.ts', () => { const event = { ...baseEvent, target: document.createElement('textarea'), - } as KeyboardEvent; + } as unknown as KeyboardEvent; expect(shouldIgnoreKeyboardEvent(event)).toBe(true); }); @@ -50,7 +50,7 @@ describe('renderer/utils/keyboard.ts', () => { const event = { ...baseEvent, target: document.createElement('div'), - } as KeyboardEvent; + } as unknown as KeyboardEvent; expect(shouldIgnoreKeyboardEvent(event)).toBe(false); }); diff --git a/src/shared/logger.ts b/src/shared/logger.ts index e8ac90d86..ead385be6 100644 --- a/src/shared/logger.ts +++ b/src/shared/logger.ts @@ -1,6 +1,6 @@ import log from 'electron-log'; -type AllowedLogFunction = typeof log.info | typeof log.warn | typeof log.error; +type LogLevel = 'info' | 'warn' | 'error'; /** * Safely coerce an unknown caught value into an Error instance. @@ -17,7 +17,7 @@ export function toError(err: unknown): Error { * @param contexts - Optional array of context strings appended to the log entry. */ export function logInfo(type: string, message: string, contexts: string[] = []) { - logMessage(log.info, type, message, undefined, contexts); + logMessage('info', type, message, undefined, contexts); } /** @@ -28,7 +28,7 @@ export function logInfo(type: string, message: string, contexts: string[] = []) * @param contexts - Optional array of context strings appended to the log entry. */ export function logWarn(type: string, message: string, contexts: string[] = []) { - logMessage(log.warn, type, message, undefined, contexts); + logMessage('warn', type, message, undefined, contexts); } /** @@ -40,11 +40,11 @@ export function logWarn(type: string, message: string, contexts: string[] = []) * @param contexts - Optional array of context strings appended to the log entry. */ export function logError(type: string, message: string, err: Error, contexts: string[] = []) { - logMessage(log.error, type, message, err, contexts); + logMessage('error', type, message, err, contexts); } function logMessage( - logFunction: AllowedLogFunction, + level: LogLevel, type: string, message: string, err?: Error, @@ -61,5 +61,5 @@ function logMessage( args.push(err); } - logFunction(...args); + log[level](...args); } diff --git a/tailwind.config.mts b/tailwind.config.mts index 8c5b712a9..8fcdd94a9 100644 --- a/tailwind.config.mts +++ b/tailwind.config.mts @@ -94,13 +94,13 @@ const config: Config = { }, }, plugins: [ - plugin(({ addBase, addComponents }) => { - addComponents({ + plugin((api) => { + api.addComponents({ '.gitify-scope-row': { '@apply rounded-md bg-gitify-accounts': {}, }, }); - addBase({ + api.addBase({ '[data-color-mode]': { '--gitify-background': 'light-dark(var(--bgColor-default), var(--bgColor-muted))', '--gitify-account-error-bg': diff --git a/vite.config.mts b/vite.config.mts index 9aaf77e5f..2a15d73cd 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -166,6 +166,13 @@ export default defineConfig({ }, lint: { plugins: ['typescript', 'react', 'unicorn', 'oxc', 'import'], + // Enable the type-aware path so `vp check` runs TypeScript type checks + // natively (via the TS Go toolchain / tsgolint). `typeCheck` requires + // `typeAware`; enabling both also turns on the type-aware lint rules. + options: { + typeAware: true, + typeCheck: true, + }, categories: { correctness: 'error', }, @@ -326,6 +333,11 @@ export default defineConfig({ rules: { 'typescript/no-explicit-any': 'off', 'no-console': 'off', + // Mocks are typed as the real Electron/interface classes, so + // `expect(dialog.showMessageBox)` reads as an unbound class method + // even though the runtime value is a `vi.fn()`. The rule is a false + // positive for test doubles, so scope it to non-test code. + 'typescript/unbound-method': 'off', }, }, {