diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index ed28b9a494..a566370f5f 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -18,3 +18,24 @@ on a document-level `pointerdown` outside it. - Affected files: src/ui/components/SettingsView/settingRowHover.ts and any row using Fuselage `Select`. + +## RTL auto-cleanup vs manual `document.body.innerHTML = ''` in `afterEach` orphans portal anchors +- Status: Confirmed (RTL 14.3.1, @kayahr/jest-electron-runner, single shared `src/.jest/setup.ts`). +- Symptom: A renderer spec that renders a portal/anchor component (e.g. TooltipProvider -> + TooltipPortal -> createAnchor's `#tooltip-root`) crashes the ENTIRE `yarn test:coverage` run with + `process.exit(1)` from `src/.jest/setup.ts` (uncaughtException handler), stack originating in + React `safelyCallDestroy` / `commitPassiveUnmountInsideDeletedTreeOnFiber`. The thrown error is + `NotFoundError: The node to be removed is not a child of this node` from + `document.body.removeChild(a)` in `src/ui/components/utils/createAnchor.ts`. +- Root cause: A spec adds `afterEach(() => { document.body.innerHTML = ''; })`. Jest runs afterEach + hooks LIFO; RTL's auto-cleanup `afterEach(cleanup)` is registered at import time so it runs LAST. + The manual `innerHTML=''` runs FIRST and removes body-appended portal anchors WITHOUT going through + their `deleteAnchor`/effect-cleanup path. RTL `cleanup()` then unmounts the React tree, the portal's + unmount effect calls `removeChild` on the already-detached node, and it throws. Because the shared + setup converts any uncaughtException into `process.exit(1)`, one orphaned anchor kills the whole run. +- Workaround / rule: Do NOT manually wipe `document.body.innerHTML` in renderer-spec `afterEach`. RTL + auto-cleanup already unmounts the React tree and lets components remove their own anchors. If a test + needs a clean body, call the render result's `unmount()` explicitly instead. +- Affected files: src/ui/components/utils/TooltipProvider.spec.tsx, + src/ui/components/utils/ReparentingContainer.spec.tsx, src/ui/components/utils/createAnchor.ts, + src/ui/components/utils/TooltipPortal.tsx, src/.jest/setup.ts. diff --git a/jest.config.js b/jest.config.js index 85d23d1884..d5643ec555 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,10 +12,10 @@ module.exports = { coveragePathIgnorePatterns: ['/node_modules/', '/app/', '/dist/'], coverageThreshold: { global: { - lines: 25, - statements: 25, - branches: 22, - functions: 18, + lines: 32, + statements: 32, + branches: 28, + functions: 26, }, }, projects: [ diff --git a/package.json b/package.json index 0afe8910ed..3ba992b8f2 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,10 @@ "@rollup/plugin-json": "~6.1.0", "@rollup/plugin-node-resolve": "~15.2.3", "@rollup/plugin-replace": "~5.0.5", + "@testing-library/dom": "^9.3.4", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^14.3.1", + "@testing-library/user-event": "^14.5.2", "@types/archiver": "~7.0.0", "@types/dompurify": "~3.2.0", "@types/electron-devtools-installer": "~2.2.5", diff --git a/src/.jest/setup.ts b/src/.jest/setup.ts index 80474ce76e..d9685faae6 100644 --- a/src/.jest/setup.ts +++ b/src/.jest/setup.ts @@ -1,5 +1,6 @@ import path from 'path'; +import '@testing-library/jest-dom'; import { app } from 'electron'; expect.extend({ diff --git a/src/jest-dom.d.ts b/src/jest-dom.d.ts new file mode 100644 index 0000000000..4db929ad4a --- /dev/null +++ b/src/jest-dom.d.ts @@ -0,0 +1,22 @@ +// Registers @testing-library/jest-dom matcher types on jest's global +// `expect` for type-checking (`tsc --noEmit`). The package ships the same +// augmentation, but it is only reachable through a nested triple-slash +// reference that TypeScript does not propagate under this project's classic +// `node` module resolution. It also cannot live under `src/.jest/`, because +// TypeScript excludes dot-prefixed directories from the compilation program. +// The matching runtime side-effect import lives in `src/.jest/setup.ts`. +import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + // The signature must mirror @types/jest's `Matchers` exactly so + // the declarations merge; renaming or retyping the parameters breaks it. + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-types + interface Matchers + extends TestingLibraryMatchers< + ReturnType, + R + > {} + } +} diff --git a/src/logging/main/index.main.spec.ts b/src/logging/main/index.main.spec.ts index e53b5e846a..d796bbc82b 100644 --- a/src/logging/main/index.main.spec.ts +++ b/src/logging/main/index.main.spec.ts @@ -127,7 +127,10 @@ const loadModule = (): typeof LoggingModule => { describe('logging/index', () => { beforeEach(() => { jest.clearAllMocks(); - jest.useRealTimers(); + // Fake timers so the error-buffer flush setInterval scheduled by + // configureLogging() is a fake (non-ref'd) handle rather than a real timer + // that would keep the process alive and block jest --forceExit. + jest.useFakeTimers(); fakeLog = makeFakeLog(true); selectImpl = jest.fn(() => false); watchImpl = jest.fn(); @@ -141,6 +144,15 @@ describe('logging/index', () => { afterEach(() => { setProcessType(originalProcessType); + // configureLogging() schedules a real, ref'd setInterval (the error-buffer + // flush timer) on every module load. It is only cleared on 'before-quit', + // which most tests never fire — left alone, each load leaks a live timer + // that keeps the libuv loop alive and blocks jest --forceExit. Switching to + // fake timers in beforeEach makes that interval a fake handle, and + // clearAllTimers here disposes it. + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); }); describe('configureLogging at import (main / browser process)', () => { diff --git a/src/screenSharing/main/ScreenSharingRequestTracker.main.spec.ts b/src/screenSharing/main/ScreenSharingRequestTracker.main.spec.ts index f849786c55..2d9529d687 100644 --- a/src/screenSharing/main/ScreenSharingRequestTracker.main.spec.ts +++ b/src/screenSharing/main/ScreenSharingRequestTracker.main.spec.ts @@ -41,11 +41,20 @@ const getRegisteredListener = (): ResponseListener => { beforeEach(() => { jest.clearAllMocks(); + // Every createRequest() schedules a real setTimeout (default 60s). Tests that + // neither fire the response listener nor call cleanup would leak a live, + // ref'd timer that keeps the process alive and blocks jest --forceExit. Fake + // timers make those handles fake; clearAllTimers in afterEach disposes them. + // setImmediate is left real so flushPromises() (used by the async response + // listener tests) still resolves without manual timer advancement. + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); jest.spyOn(console, 'warn').mockImplementation(() => undefined); jest.spyOn(console, 'error').mockImplementation(() => undefined); }); afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); jest.restoreAllMocks(); }); diff --git a/src/screenSharing/main/desktopCapturerCache.main.spec.ts b/src/screenSharing/main/desktopCapturerCache.main.spec.ts index 4806ac7727..c759ab3b74 100644 --- a/src/screenSharing/main/desktopCapturerCache.main.spec.ts +++ b/src/screenSharing/main/desktopCapturerCache.main.spec.ts @@ -58,6 +58,10 @@ describe('screenSharing/desktopCapturerCache', () => { }); afterEach(() => { + // The module schedules no timers of its own, but two tests opt into fake + // timers; clear any pending fake handle before restoring real timers so + // nothing survives into the next test or blocks jest --forceExit. + jest.clearAllTimers(); jest.useRealTimers(); clearDesktopCapturerCache(); }); diff --git a/src/ui/components/AddServerView/index.spec.tsx b/src/ui/components/AddServerView/index.spec.tsx new file mode 100644 index 0000000000..c755b1b0e5 --- /dev/null +++ b/src/ui/components/AddServerView/index.spec.tsx @@ -0,0 +1,192 @@ +import { AddServerView } from '.'; +import { ServerUrlResolutionStatus } from '../../../servers/common'; +import { ADD_SERVER_VIEW_SERVER_ADDED } from '../../actions'; +import { + renderWithStore, + screen, + userEvent, + fireEvent, + waitFor, +} from '../../test-utils'; + +// Under the jest-electron renderer, `userEvent.type` mutates the DOM input but +// React's controlled-input value tracker does not pick the change up, so the +// component's `useState` value (and therefore the submit handler) never sees the +// typed text. `fireEvent.change` dispatches a native change event that React's +// value tracker does recognise, faithfully driving `onChange` -> `setInput`. +const typeUrl = (value: string): void => { + fireEvent.change(screen.getByRole('textbox'), { target: { value } }); +}; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + __esModule: true, + ...actual, + useDispatch: () => mockDispatch, + }; +}); + +const mockRequest = jest.fn(); + +jest.mock('../../../store', () => ({ + request: (...args: unknown[]) => mockRequest(...args), +})); + +const defaultServerHref = 'https://open.rocket.chat/'; + +const visibleState = { currentView: 'add-new-server' } as any; + +beforeEach(() => { + mockDispatch.mockClear(); + mockRequest.mockReset(); + // jsdom defaults navigator.onLine to true; assert it explicitly. + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: true, + }); +}); + +describe('AddServerView', () => { + it('renders nothing when the current view is not add-new-server', () => { + const { container } = renderWithStore(, { + preloadedState: { currentView: 'add-new-server-disabled' } as any, + }); + + expect(container).toBeEmptyDOMElement(); + }); + + describe('online', () => { + it('renders the url input and connect button', () => { + renderWithStore(, { preloadedState: visibleState }); + + expect(screen.getByText('landing.inputUrl')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'landing.connect' }) + ).toBeInTheDocument(); + }); + + it('adds the default server when submitting an empty input', async () => { + const user = userEvent.setup(); + renderWithStore(, { preloadedState: visibleState }); + + await user.click(screen.getByRole('button', { name: 'landing.connect' })); + + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith({ + type: ADD_SERVER_VIEW_SERVER_ADDED, + payload: defaultServerHref, + }); + }); + + it('resolves a typed url and dispatches the resolved server on success', async () => { + const user = userEvent.setup(); + mockRequest.mockResolvedValue([ + 'https://chat.example.com/', + ServerUrlResolutionStatus.OK, + ]); + + renderWithStore(, { preloadedState: visibleState }); + + typeUrl('chat.example.com'); + await user.click(screen.getByRole('button', { name: 'landing.connect' })); + + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith({ + type: ADD_SERVER_VIEW_SERVER_ADDED, + payload: 'https://chat.example.com/', + }) + ); + + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('shows an invalid-url error when resolution fails', async () => { + const user = userEvent.setup(); + mockRequest.mockResolvedValue([ + 'bad-url', + ServerUrlResolutionStatus.INVALID_URL, + ]); + + renderWithStore(, { preloadedState: visibleState }); + + typeUrl('bad-url'); + await user.click(screen.getByRole('button', { name: 'landing.connect' })); + + expect( + await screen.findByText('error.noValidServerFound') + ).toBeInTheDocument(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('shows a timeout error when resolution times out', async () => { + const user = userEvent.setup(); + mockRequest.mockResolvedValue([ + 'https://slow.example.com/', + ServerUrlResolutionStatus.TIMEOUT, + ]); + + renderWithStore(, { preloadedState: visibleState }); + + typeUrl('slow.example.com'); + await user.click(screen.getByRole('button', { name: 'landing.connect' })); + + expect( + await screen.findByText('error.connectTimeout') + ).toBeInTheDocument(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('clears the validation error when the input is edited again', async () => { + const user = userEvent.setup(); + mockRequest.mockResolvedValue([ + 'bad-url', + ServerUrlResolutionStatus.INVALID, + ]); + + renderWithStore(, { preloadedState: visibleState }); + + typeUrl('bad-url'); + await user.click(screen.getByRole('button', { name: 'landing.connect' })); + + expect( + await screen.findByText('error.noValidServerFound') + ).toBeInTheDocument(); + + // Editing the input again resets the validation state and clears the error. + typeUrl('bad-urlx'); + + await waitFor(() => + expect( + screen.queryByText('error.noValidServerFound') + ).not.toBeInTheDocument() + ); + }); + }); + + describe('offline', () => { + it('shows the offline callout instead of the form', () => { + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: false, + }); + + renderWithStore(, { preloadedState: visibleState }); + + expect(screen.getByText('error.offline')).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/ui/components/ClearCacheDialog/index.spec.tsx b/src/ui/components/ClearCacheDialog/index.spec.tsx new file mode 100644 index 0000000000..51d492bf04 --- /dev/null +++ b/src/ui/components/ClearCacheDialog/index.spec.tsx @@ -0,0 +1,158 @@ +import { ClearCacheDialog } from '.'; +import { + CLEAR_CACHE_DIALOG_DELETE_LOGIN_DATA_CLICKED, + CLEAR_CACHE_DIALOG_DISMISSED, + CLEAR_CACHE_DIALOG_KEEP_LOGIN_DATA_CLICKED, + CLEAR_CACHE_TRIGGERED, +} from '../../actions'; +import { renderWithStore, screen, userEvent, act } from '../../test-utils'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + __esModule: true, + ...actual, + useDispatch: () => mockDispatch, + }; +}); + +// `listen` registers a callback per action type. Capture them so the test can +// drive the dialog's local state (visibility + webContents id) by invoking the +// registered listener, mirroring how the real store dispatches actions. +const listeners = new Map void>(); + +jest.mock('../../../store', () => ({ + listen: (type: string, listener: (action: unknown) => void) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, +})); + +const triggerOpen = (webContentsId = 7) => { + act(() => { + listeners.get(CLEAR_CACHE_TRIGGERED)?.({ + type: CLEAR_CACHE_TRIGGERED, + payload: webContentsId, + }); + }); +}; + +describe('ClearCacheDialog', () => { + beforeEach(() => { + mockDispatch.mockClear(); + listeners.clear(); + // The component's clearingCacheState() schedules a 2s `setTimeout` that + // updates state after the clear. Under real timers that callback fires + // after RTL's auto-unmount, updating an unmounted tree and triggering the + // global `uncaughtException` -> `process.exit(1)` in src/.jest/setup.ts, + // which kills the whole suite. Fake timers keep the 2s callback from ever + // firing for real. + jest.useFakeTimers(); + }); + + afterEach(() => { + // Drop any pending timers BEFORE RTL cleanup unmounts the tree, then + // restore real timers so nothing leaks into the next test. + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('renders the announcement, title and message', () => { + renderWithStore(); + + expect( + screen.getByText('dialog.clearCache.announcement') + ).toBeInTheDocument(); + expect(screen.getByText('dialog.clearCache.title')).toBeInTheDocument(); + expect(screen.getByText('dialog.clearCache.message')).toBeInTheDocument(); + }); + + it('renders the keep, delete and cancel buttons', () => { + renderWithStore(); + + expect( + screen.getByText('dialog.clearCache.keepLoginData') + ).toBeInTheDocument(); + expect( + screen.getByText('dialog.clearCache.deleteLoginData') + ).toBeInTheDocument(); + expect(screen.getByText('dialog.clearCache.cancel')).toBeInTheDocument(); + }); + + it('does not dispatch keep/delete before a webContents id is known', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderWithStore(); + + await user.click(screen.getByText('dialog.clearCache.keepLoginData')); + + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: CLEAR_CACHE_DIALOG_KEEP_LOGIN_DATA_CLICKED, + }) + ); + }); + + it('dispatches keep-login-data with the triggered webContents id', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderWithStore(); + + triggerOpen(7); + await user.click(screen.getByText('dialog.clearCache.keepLoginData')); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: CLEAR_CACHE_DIALOG_KEEP_LOGIN_DATA_CLICKED, + payload: 7, + }); + }); + + it('dispatches delete-login-data with the triggered webContents id', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderWithStore(); + + triggerOpen(9); + await user.click(screen.getByText('dialog.clearCache.deleteLoginData')); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: CLEAR_CACHE_DIALOG_DELETE_LOGIN_DATA_CLICKED, + payload: 9, + }); + }); + + it('dispatches dismissed from the cancel button', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderWithStore(); + + triggerOpen(); + await user.click(screen.getByText('dialog.clearCache.cancel')); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: CLEAR_CACHE_DIALOG_DISMISSED, + }); + }); + + it('shows the clearing throbber and hides the buttons after confirming', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderWithStore(); + + triggerOpen(); + await user.click(screen.getByText('dialog.clearCache.keepLoginData')); + + expect( + screen.getByText('dialog.clearCache.clearingWait') + ).toBeInTheDocument(); + expect( + screen.queryByText('dialog.clearCache.keepLoginData') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/DownloadsManagerView/DownloadItem.spec.tsx b/src/ui/components/DownloadsManagerView/DownloadItem.spec.tsx new file mode 100644 index 0000000000..6a81051834 --- /dev/null +++ b/src/ui/components/DownloadsManagerView/DownloadItem.spec.tsx @@ -0,0 +1,201 @@ +import { invoke } from '../../../ipc/renderer'; +import { renderWithStore, screen, userEvent } from '../../test-utils'; +import DownloadItem from './DownloadItem'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + language: 'en', + changeLanguage: jest.fn(), + format: (value: unknown, format: string) => `${format}:${value}`, + }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +jest.mock('../../../ipc/renderer', () => ({ + invoke: jest.fn(), +})); + +const invokeMock = invoke as jest.MockedFunction; + +const baseDownload = { + itemId: 42, + status: 'All' as const, + fileName: 'report.pdf', + receivedBytes: 500, + totalBytes: 1000, + startTime: 0, + endTime: 1000, + url: 'https://example.com/report.pdf', + serverUrl: 'https://chat.example.com', + serverTitle: 'Example Server', + savePath: '/downloads/report.pdf', + mimeType: 'application/pdf', +}; + +const renderItem = (overrides: Record = {}) => + renderWithStore(); + +beforeEach(() => { + invokeMock.mockClear(); +}); + +describe('DownloadItem', () => { + it('renders the file name and server title', () => { + renderItem({ state: 'progressing' }); + + expect(screen.getByText('report.pdf')).toBeInTheDocument(); + expect(screen.getByText('Example Server')).toBeInTheDocument(); + }); + + it('renders the file icon label from the extension', () => { + renderItem({ state: 'progressing' }); + + expect(screen.getByAltText('pdf')).toBeInTheDocument(); + }); + + describe('progressing state', () => { + it('shows copy link, pause and cancel actions', () => { + renderItem({ state: 'progressing' }); + + expect( + screen.getByRole('button', { name: 'downloads.item.copyLink' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'downloads.item.pause' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'downloads.item.cancel' }) + ).toBeInTheDocument(); + }); + + it('invokes downloads/pause and downloads/cancel with the item id', async () => { + renderItem({ state: 'progressing' }); + + await userEvent.click( + screen.getByRole('button', { name: 'downloads.item.pause' }) + ); + expect(invokeMock).toHaveBeenCalledWith('downloads/pause', 42); + + await userEvent.click( + screen.getByRole('button', { name: 'downloads.item.cancel' }) + ); + expect(invokeMock).toHaveBeenCalledWith('downloads/cancel', 42); + }); + + it('invokes downloads/copy-link when copy link is clicked', async () => { + renderItem({ state: 'progressing' }); + + await userEvent.click( + screen.getByRole('button', { name: 'downloads.item.copyLink' }) + ); + + expect(invokeMock).toHaveBeenCalledWith('downloads/copy-link', 42); + }); + }); + + describe('paused state', () => { + it('shows resume and cancel actions', () => { + renderItem({ state: 'paused' }); + + expect( + screen.getByRole('button', { name: 'downloads.item.resume' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'downloads.item.cancel' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'downloads.item.pause' }) + ).not.toBeInTheDocument(); + }); + + it('invokes downloads/resume when resume is clicked', async () => { + renderItem({ state: 'paused' }); + + await userEvent.click( + screen.getByRole('button', { name: 'downloads.item.resume' }) + ); + + expect(invokeMock).toHaveBeenCalledWith('downloads/resume', 42); + }); + }); + + describe('completed state', () => { + it('shows show in folder and remove actions', () => { + renderItem({ state: 'completed', receivedBytes: 1000 }); + + expect( + screen.getByRole('button', { name: 'downloads.item.showInFolder' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'downloads.item.remove' }) + ).toBeInTheDocument(); + }); + + it('invokes downloads/show-in-folder and downloads/remove', async () => { + renderItem({ state: 'completed', receivedBytes: 1000 }); + + await userEvent.click( + screen.getByRole('button', { name: 'downloads.item.showInFolder' }) + ); + expect(invokeMock).toHaveBeenCalledWith('downloads/show-in-folder', 42); + + await userEvent.click( + screen.getByRole('button', { name: 'downloads.item.remove' }) + ); + expect(invokeMock).toHaveBeenCalledWith('downloads/remove', 42); + }); + }); + + describe('errored state', () => { + it.each(['interrupted', 'cancelled'])( + 'shows retry and remove actions when %s', + (state) => { + renderItem({ state }); + + expect( + screen.getByRole('button', { name: 'downloads.item.retry' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'downloads.item.remove' }) + ).toBeInTheDocument(); + } + ); + + it('invokes downloads/retry when retry is clicked', async () => { + renderItem({ state: 'interrupted' }); + + await userEvent.click( + screen.getByRole('button', { name: 'downloads.item.retry' }) + ); + + expect(invokeMock).toHaveBeenCalledWith('downloads/retry', 42); + }); + }); + + describe('expired state', () => { + it('shows only a remove action and no copy link', () => { + renderItem({ state: 'expired' }); + + expect( + screen.getByRole('button', { name: 'downloads.item.remove' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'downloads.item.copyLink' }) + ).not.toBeInTheDocument(); + }); + + it('invokes downloads/remove when remove is clicked', async () => { + renderItem({ state: 'expired' }); + + await userEvent.click( + screen.getByRole('button', { name: 'downloads.item.remove' }) + ); + + expect(invokeMock).toHaveBeenCalledWith('downloads/remove', 42); + }); + }); +}); diff --git a/src/ui/components/DownloadsManagerView/index.spec.tsx b/src/ui/components/DownloadsManagerView/index.spec.tsx new file mode 100644 index 0000000000..941de809e6 --- /dev/null +++ b/src/ui/components/DownloadsManagerView/index.spec.tsx @@ -0,0 +1,209 @@ +import DownloadsManagerView from '.'; +import type { Download } from '../../../downloads/common'; +import { DOWNLOADS_BACK_BUTTON_CLICKED } from '../../actions'; +import { renderWithStore, screen, userEvent } from '../../test-utils'; + +// The filter values are persisted through fuselage's `useLocalStorage`, which +// is backed by `useSyncExternalStore` + a module-level event emitter. Under the +// jest-electron renderer that subscription does not re-render the component when +// the value is written at runtime, so typing into the search box updates the DOM +// input but never the React state the list filters on. Seeding the persisted +// value before render reproduces the production filter behaviour deterministically +// (the hook reads its initial value straight from localStorage on mount). The key +// prefix is fuselage's internal `fuselage-localStorage-` format. +const seedFilter = (key: string, value: string): void => { + window.localStorage.setItem( + `fuselage-localStorage-${key}`, + JSON.stringify(value) + ); +}; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +const mockDispatch = jest.fn(); + +jest.mock('../../../store', () => ({ + dispatch: (action: unknown) => mockDispatch(action), +})); + +// Stub the child item so the list renders cheaply and is easy to assert on. +jest.mock('./DownloadItem', () => ({ + __esModule: true, + default: ({ itemId, fileName }: { itemId: number; fileName: string }) => ( +
{fileName}
+ ), +})); + +const baseDownload: Download = { + itemId: 1, + state: 'completed', + status: 'All', + fileName: 'alpha.pdf', + receivedBytes: 1000, + totalBytes: 1000, + startTime: 0, + endTime: 1000, + url: 'https://example.com/alpha.pdf', + serverUrl: 'https://chat.example.com', + serverTitle: 'Example Server', + savePath: '/downloads/alpha.pdf', + mimeType: 'application/pdf', +}; + +const makeDownload = (overrides: Partial): Download => ({ + ...baseDownload, + ...overrides, +}); + +const visibleState = { + currentView: 'downloads', + isSideBarEnabled: false, + lastSelectedServerUrl: 'https://chat.example.com', + downloads: {}, +} as any; + +beforeEach(() => { + mockDispatch.mockClear(); + window.localStorage.clear(); +}); + +describe('DownloadsManagerView', () => { + it('renders the title', () => { + renderWithStore(, { + preloadedState: visibleState, + }); + + expect(screen.getByText('downloads.title')).toBeInTheDocument(); + }); + + describe('empty state', () => { + it('shows the empty title and subtitle when there are no downloads', () => { + renderWithStore(, { + preloadedState: { ...visibleState, downloads: {} }, + }); + + expect(screen.getByText('downloads.empty.title')).toBeInTheDocument(); + expect(screen.getByText('downloads.empty.subtitle')).toBeInTheDocument(); + }); + }); + + describe('populated state', () => { + const populatedState = { + ...visibleState, + downloads: { + 1: makeDownload({ itemId: 1, fileName: 'alpha.pdf' }), + 2: makeDownload({ itemId: 2, fileName: 'beta.png' }), + }, + } as any; + + it('renders one item per download', () => { + renderWithStore(, { + preloadedState: populatedState, + }); + + expect(screen.getByTestId('download-item-1')).toBeInTheDocument(); + expect(screen.getByTestId('download-item-2')).toBeInTheDocument(); + expect(screen.getByText('alpha.pdf')).toBeInTheDocument(); + expect(screen.getByText('beta.png')).toBeInTheDocument(); + }); + + it('does not show the empty state when there are downloads', () => { + renderWithStore(, { + preloadedState: populatedState, + }); + + expect( + screen.queryByText('downloads.empty.title') + ).not.toBeInTheDocument(); + }); + + it('filters the list by the search input (file name substring)', () => { + seedFilter('download-search', 'beta'); + renderWithStore(, { + preloadedState: populatedState, + }); + + const search = screen.getByLabelText( + 'downloads.filters.search' + ) as HTMLInputElement; + expect(search.value).toBe('beta'); + expect(screen.queryByTestId('download-item-1')).not.toBeInTheDocument(); + expect(screen.getByTestId('download-item-2')).toBeInTheDocument(); + }); + + it('shows the no-results state when search matches nothing', () => { + seedFilter('download-search', 'zzz-nomatch'); + renderWithStore(, { + preloadedState: populatedState, + }); + + expect(screen.getByText('downloads.noResults.title')).toBeInTheDocument(); + expect(screen.queryByTestId('download-item-1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('download-item-2')).not.toBeInTheDocument(); + }); + + it('clears the search filter when the clear-all button is clicked', async () => { + const user = userEvent.setup(); + renderWithStore(, { + preloadedState: populatedState, + }); + + const search = screen.getByLabelText( + 'downloads.filters.search' + ) as HTMLInputElement; + await user.type(search, 'beta'); + expect(search.value).toBe('beta'); + + await user.click( + screen.getByRole('button', { name: 'downloads.filters.clear' }) + ); + + expect(search.value).toBe(''); + expect(screen.getByTestId('download-item-1')).toBeInTheDocument(); + expect(screen.getByTestId('download-item-2')).toBeInTheDocument(); + }); + }); + + describe('back button', () => { + // The back IconButton renders only its `arrow-back` icon with no `title` / + // `aria-label`, so it has no accessible name and cannot be queried by role + // name. It is located through the rendered icon's button ancestor instead. + // (Component a11y gap: the back button should expose an accessible label — + // flagged, not changed here.) + const getBackButton = (container: HTMLElement): HTMLButtonElement | null => + container + .querySelector('.rcx-icon--name-arrow-back') + ?.closest('button') ?? null; + + it('renders the back button and dispatches on click when the sidebar is disabled', async () => { + const user = userEvent.setup(); + const { container } = renderWithStore(, { + preloadedState: { ...visibleState, isSideBarEnabled: false }, + }); + + const backButton = getBackButton(container); + expect(backButton).not.toBeNull(); + await user.click(backButton as HTMLButtonElement); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: DOWNLOADS_BACK_BUTTON_CLICKED, + payload: 'https://chat.example.com', + }); + }); + + it('hides the back button when the sidebar is enabled', () => { + const { container } = renderWithStore(, { + preloadedState: { ...visibleState, isSideBarEnabled: true }, + }); + + expect(getBackButton(container)).toBeNull(); + }); + }); +}); diff --git a/src/ui/components/Modal/ModalBackdrop.spec.tsx b/src/ui/components/Modal/ModalBackdrop.spec.tsx new file mode 100644 index 0000000000..d015875f37 --- /dev/null +++ b/src/ui/components/Modal/ModalBackdrop.spec.tsx @@ -0,0 +1,147 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import ModalBackdrop from './ModalBackdrop'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +describe('ModalBackdrop', () => { + it('renders its children', () => { + render( + +
modal body
+
+ ); + + expect(screen.getByTestId('content')).toBeInTheDocument(); + expect(screen.getByText('modal body')).toBeInTheDocument(); + }); + + it('exposes the backdrop element with the expected class', () => { + const { container } = render( + +
body
+
+ ); + + expect(container.querySelector('.rcx-modal__backdrop')).toBeInTheDocument(); + }); + + it('calls onDismiss on a complete click on the backdrop itself', () => { + const onDismiss = jest.fn(); + const { container } = render( + +
body
+
+ ); + + const backdrop = container.querySelector( + '.rcx-modal__backdrop' + ) as HTMLElement; + + fireEvent.mouseDown(backdrop); + fireEvent.mouseUp(backdrop); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('does not call onDismiss when the click lands on a child of the backdrop', () => { + const onDismiss = jest.fn(); + render( + +
body
+
+ ); + + const child = screen.getByTestId('content'); + + fireEvent.mouseDown(child); + fireEvent.mouseUp(child); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not call onDismiss when mousedown starts inside and mouseup ends on the backdrop', () => { + const onDismiss = jest.fn(); + const { container } = render( + +
body
+
+ ); + + const backdrop = container.querySelector( + '.rcx-modal__backdrop' + ) as HTMLElement; + const child = screen.getByTestId('content'); + + fireEvent.mouseDown(child); + fireEvent.mouseUp(backdrop); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('calls onDismiss when the Escape key is pressed', () => { + const onDismiss = jest.fn(); + render( + +
body
+
+ ); + + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('ignores non-Escape key presses', () => { + const onDismiss = jest.fn(); + render( + +
body
+
+ ); + + fireEvent.keyDown(window, { key: 'Enter' }); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('removes the Escape listener on unmount', () => { + const onDismiss = jest.fn(); + const { unmount } = render( + +
body
+
+ ); + + unmount(); + + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not throw when dismissed without an onDismiss handler', () => { + const { container } = render( + +
body
+
+ ); + + const backdrop = container.querySelector( + '.rcx-modal__backdrop' + ) as HTMLElement; + + expect(() => { + fireEvent.mouseDown(backdrop); + fireEvent.mouseUp(backdrop); + fireEvent.keyDown(window, { key: 'Escape' }); + }).not.toThrow(); + }); +}); diff --git a/src/ui/components/OutlookCredentialsDialog/index.spec.tsx b/src/ui/components/OutlookCredentialsDialog/index.spec.tsx new file mode 100644 index 0000000000..b633cc36bb --- /dev/null +++ b/src/ui/components/OutlookCredentialsDialog/index.spec.tsx @@ -0,0 +1,194 @@ +import { OutlookCredentialsDialog } from '.'; +import { + OUTLOOK_CALENDAR_ASK_CREDENTIALS, + OUTLOOK_CALENDAR_DIALOG_DISMISSED, + OUTLOOK_CALENDAR_SET_CREDENTIALS, +} from '../../../outlookCalendar/actions'; +import { + renderWithStore, + screen, + userEvent, + act, + waitFor, + fireEvent, +} from '../../test-utils'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + __esModule: true, + ...actual, + useDispatch: () => mockDispatch, + }; +}); + +// `listen` registers a callback per action type. Capture it so the test can +// drive the dialog's local state (server, userId, encryption availability) the +// way OUTLOOK_CALENDAR_ASK_CREDENTIALS would in the real store. +const listeners = new Map void>(); + +jest.mock('../../../store', () => ({ + listen: (type: string, listener: (action: unknown) => void) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, +})); + +const SERVER_URL = 'https://chat.example.com/'; +const REQUEST_ID = 'req-1'; + +const askCredentials = ({ + isEncryptionAvailable = true, +}: { isEncryptionAvailable?: boolean } = {}) => { + act(() => { + listeners.get(OUTLOOK_CALENDAR_ASK_CREDENTIALS)?.({ + type: OUTLOOK_CALENDAR_ASK_CREDENTIALS, + payload: { + server: { + url: SERVER_URL, + outlookCredentials: { serverUrl: 'https://exchange.example.com' }, + }, + userId: 'user-42', + isEncryptionAvailable, + }, + meta: { request: true, id: REQUEST_ID }, + }); + }); +}; + +const preloadedState = { openDialog: 'outlook-credentials' } as any; + +describe('OutlookCredentialsDialog', () => { + beforeEach(() => { + mockDispatch.mockClear(); + listeners.clear(); + }); + + it('renders the title, both inputs and the remember-credentials checkbox', () => { + renderWithStore(, { preloadedState }); + + expect( + screen.getByText('dialog.outlookCalendar.title') + ).toBeInTheDocument(); + expect(screen.getByText('Login')).toBeInTheDocument(); + expect(screen.getByText('Password')).toBeInTheDocument(); + expect( + screen.getByText('dialog.outlookCalendar.remember_credentials') + ).toBeInTheDocument(); + }); + + it('shows required-field errors for empty inputs', async () => { + renderWithStore(, { preloadedState }); + askCredentials(); + + // The form validates with react-hook-form in `onChange` mode. Touching then + // clearing each required field deterministically drives validation and + // surfaces the required-field errors. Submitting untouched-empty fields and + // relying on the submit-time validation pass is racy under the jest-electron + // renderer (the async validation result intermittently fails to flush), so + // the per-field onChange path is used instead. `userEvent.type` is avoided + // because its synthetic keystrokes do not reach react-hook-form's value + // tracker in this environment — `fireEvent.change` dispatches the native + // change event the tracker recognises. + const [login] = screen.getAllByRole('textbox'); + const password = document.querySelector( + 'input[type="password"]' + ) as HTMLInputElement; + fireEvent.change(login, { target: { value: 'a' } }); + fireEvent.change(login, { target: { value: '' } }); + fireEvent.change(password, { target: { value: 'a' } }); + fireEvent.change(password, { target: { value: '' } }); + + await waitFor(() => + expect( + screen.queryAllByText('dialog.outlookCalendar.field_required') + ).toHaveLength(2) + ); + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: OUTLOOK_CALENDAR_SET_CREDENTIALS }) + ); + }); + + it('dispatches the credentials with the captured server, userId and request id', async () => { + const user = userEvent.setup(); + renderWithStore(, { preloadedState }); + askCredentials(); + + // Login renders as a textbox; PasswordInput renders type=password, which is + // not exposed with the textbox role, so it is queried by its input type. + // `fireEvent.change` is used over `userEvent.type` because the latter's + // keystrokes do not propagate to react-hook-form's value tracker under the + // jest-electron renderer. + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'jane' }, + }); + const password = document.querySelector( + 'input[type="password"]' + ) as HTMLInputElement; + fireEvent.change(password, { target: { value: 's3cret' } }); + + await user.click(screen.getByText('dialog.outlookCalendar.submit')); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: OUTLOOK_CALENDAR_SET_CREDENTIALS, + payload: expect.objectContaining({ + url: SERVER_URL, + saveCredentials: true, + outlookCredentials: expect.objectContaining({ + login: 'jane', + password: 's3cret', + userId: 'user-42', + serverUrl: 'https://exchange.example.com', + }), + }), + meta: { response: true, id: REQUEST_ID }, + }) + ); + }); + + it('dispatches the dismissed action with the request id on cancel', async () => { + const user = userEvent.setup(); + renderWithStore(, { preloadedState }); + askCredentials(); + + await user.click(screen.getByText('dialog.outlookCalendar.cancel')); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: OUTLOOK_CALENDAR_DIALOG_DISMISSED, + payload: { dismissDialog: true }, + meta: { response: true, id: REQUEST_ID }, + }) + ); + }); + + it('warns when encryption is unavailable and remember-credentials is on', async () => { + renderWithStore(, { preloadedState }); + askCredentials({ isEncryptionAvailable: false }); + + expect( + await screen.findByText('dialog.outlookCalendar.encryptionUnavailable') + ).toBeInTheDocument(); + }); + + it('hides the encryption warning while encryption is available', () => { + renderWithStore(, { preloadedState }); + askCredentials({ isEncryptionAvailable: true }); + + expect( + screen.queryByText('dialog.outlookCalendar.encryptionUnavailable') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/ScreenSharingDialog/index.spec.tsx b/src/ui/components/ScreenSharingDialog/index.spec.tsx new file mode 100644 index 0000000000..e501de57cd --- /dev/null +++ b/src/ui/components/ScreenSharingDialog/index.spec.tsx @@ -0,0 +1,117 @@ +import { desktopCapturer } from 'electron'; + +import { ScreenSharingDialog } from '.'; +import { SCREEN_SHARING_DIALOG_DISMISSED } from '../../../screenSharing/actions'; +import { WEBVIEW_SCREEN_SHARING_SOURCE_RESPONDED } from '../../actions'; +import { renderWithStore, screen, userEvent, act } from '../../test-utils'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +jest.mock('electron', () => ({ + desktopCapturer: { + getSources: jest.fn(), + }, +})); + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + __esModule: true, + ...actual, + useDispatch: () => mockDispatch, + }; +}); + +const getSourcesMock = desktopCapturer.getSources as jest.MockedFunction< + typeof desktopCapturer.getSources +>; + +const makeSource = (id: string, name: string) => + ({ + id, + name, + thumbnail: { toDataURL: () => `data:image/png;base64,${id}` }, + }) as unknown as Electron.DesktopCapturerSource; + +const preloadedState = { openDialog: 'screen-sharing' } as any; + +const flushSources = async () => { + await act(async () => { + jest.advanceTimersByTime(1000); + // allow the awaited getSources promise to resolve + await Promise.resolve(); + }); +}; + +describe('ScreenSharingDialog', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockDispatch.mockClear(); + getSourcesMock.mockReset(); + getSourcesMock.mockResolvedValue([ + makeSource('screen:1', 'Entire Screen'), + makeSource('window:1', 'Some Window'), + ]); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('renders the announcement title', () => { + renderWithStore(, { preloadedState }); + + expect( + screen.getByText('dialog.screenshare.announcement') + ).toBeInTheDocument(); + }); + + it('lists capturer sources fetched on the polling interval', async () => { + renderWithStore(, { preloadedState }); + + await flushSources(); + + expect(screen.getByText('Entire Screen')).toBeInTheDocument(); + expect(screen.getByText('Some Window')).toBeInTheDocument(); + expect(getSourcesMock).toHaveBeenCalledWith({ + types: ['window', 'screen'], + }); + }); + + it('dispatches the source-responded action when a source is clicked', async () => { + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + renderWithStore(, { preloadedState }); + + await flushSources(); + + await user.click(screen.getByText('Entire Screen')); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: WEBVIEW_SCREEN_SHARING_SOURCE_RESPONDED, + payload: 'screen:1', + }); + }); + + it('dispatches dismissed when the dialog is closed', () => { + renderWithStore(, { preloadedState }); + + const dialog = document.querySelector('dialog') as HTMLDialogElement; + dialog.dispatchEvent(new Event('close')); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: SCREEN_SHARING_DIALOG_DISMISSED, + }); + }); +}); diff --git a/src/ui/components/Shell/index.spec.tsx b/src/ui/components/Shell/index.spec.tsx new file mode 100644 index 0000000000..34fa8ce185 --- /dev/null +++ b/src/ui/components/Shell/index.spec.tsx @@ -0,0 +1,224 @@ +import { Shell } from '.'; +import { renderWithStore, screen } from '../../test-utils'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +// Shell is a top-level layout that mounts every view and dialog (webviews, +// portals, IPC-backed effects). We stub each child to a marker node so the test +// exercises Shell's OWN logic — selectors, the theme effect, the appPath +// stylesheet effect and the conditional layout — without dragging in the heavy +// subtrees. TooltipProvider is kept as a passthrough so its children render. +jest.mock('../utils/TooltipProvider', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock('../ServersView', () => ({ + __esModule: true, + ServersView: () =>
, +})); + +jest.mock('../AddServerView', () => ({ + __esModule: true, + AddServerView: () =>
, +})); + +jest.mock('../DownloadsManagerView', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../SettingsView', () => ({ + __esModule: true, + SettingsView: () =>
, +})); + +jest.mock('../SideBar', () => ({ + __esModule: true, + SideBar: () =>
, +})); + +jest.mock('../TopBar', () => ({ + __esModule: true, + TopBar: () =>
, +})); + +jest.mock('../AboutDialog', () => ({ + __esModule: true, + AboutDialog: () =>
, +})); + +jest.mock('../ServerInfoModal', () => ({ + __esModule: true, + ServerInfoModal: () =>
, +})); + +jest.mock('../SupportedVersionDialog', () => ({ + __esModule: true, + SupportedVersionDialog: () =>
, +})); + +jest.mock('../ScreenSharingDialog', () => ({ + __esModule: true, + ScreenSharingDialog: () =>
, +})); + +jest.mock('../RootScreenSharePicker', () => ({ + __esModule: true, + RootScreenSharePicker: () =>
, +})); + +jest.mock('../SelectClientCertificateDialog', () => ({ + __esModule: true, + SelectClientCertificateDialog: () => ( +
+ ), +})); + +jest.mock('../UpdateDialog', () => ({ + __esModule: true, + UpdateDialog: () =>
, +})); + +jest.mock('../ClearCacheDialog', () => ({ + __esModule: true, + ClearCacheDialog: () =>
, +})); + +jest.mock('../OutlookCredentialsDialog', () => ({ + __esModule: true, + OutlookCredentialsDialog: () => ( +
+ ), +})); + +// PaletteStyleTag / GlobalStyles / WindowDragBar emit raw