diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md index 8823c74866..4a9ba2f123 100644 --- a/docs/COVERAGE.md +++ b/docs/COVERAGE.md @@ -25,12 +25,14 @@ Running log of test-coverage milestones for Rocket.Chat.Electron. Append a new r | 2026-06-22 | Phase 2 — extract-and-test moderate modules (#3364) | 26.34% | 26.76% | 23.61% | 19.17% | 802 | navigation cert utils, ScreenSharingRequestTracker, browserLauncher, ipc/renderer, logging factory; relocated a silently-never-running getOutlookEvents spec (0%→~95%). | | 2026-06-22 | Phase 3 — React Testing Library + renderer component tests (#3365) | 33.29% | 33.65% | 29.33% | 27.31% | 912 | Added RTL infra (`src/ui/test-utils.tsx`) + 14 component specs (dialogs, containers, leaf, ui/utils). 3 specs held back (see Known gaps). | | 2026-06-22 | RC-style Codecov reporting (#3366) | 33.29% | 33.65% | 29.33% | 27.31% | 912 | No coverage change — switched CI to Codecov (informational, no hard gate) to match the main monorepo. | +| 2026-08-02 | Pre-wave snapshot (local, full `src/**` collect) | 46.72% | 46.94% | 44.28% | 41.36% | ~1600 | Intermediate baseline before the quick-win wave; includes post–Phase-3 growth already on master. | +| 2026-08-02 | Quick-win wave (`chore/test-coverage-quick-wins`) | 70.14% | 70.35% | 60.54% | 67.63% | 1842 | Full `src/**` collectCoverageFrom (no denominator gaming). Orphan specs nested for discovery; settings/UI/dialog RTL; main IPC (video call, Outlook, log viewer, notifications, downloads); preload coverage via main/node project under `--coverage`; ErrorView render short-circuit fix; pure helpers extracted (`validateVideoCallUrl`, `logFormatters`). | ## Known gaps / next steps -- **Goal:** 50% lines. At 33.29% as of the last row — reachable via the renderer (`ui/`) layer alone; no need to test webview/Electron-integration files. -- **Quarantined specs** (written but held back — they leak async/DOM teardown that the strict `uncaughtException` handler in `src/.jest/setup.ts` turns into a suite-killing `process.exit(1)`): `ServersView/ErrorView`, `ServerInfoContent`, `AboutDialog`. Re-enabling these (with proper fake-timer / async cleanup) is the cheapest next win. See `docs/KNOWN_ISSUES.md`. -- **Remaining headroom:** the rest of `ui/components` dialogs/containers, and non-webview `ui/main` logic. +- **Goal met:** 70% lines on the **full** `src/**` collectCoverageFrom surface (same denominator as the baseline row). +- **Still large residual 0% / low modules:** `videoCallWindow/video-call-window.ts`, `injected.ts`, `main.ts`, `buildAssets.ts` (CLI asset pipeline), parts of `ui/main/rootWindow` / `serverView`, residual `videoCallWindow/ipc` window-lifecycle branches. +- **Coverage gotcha:** renderer preload specs remain in `COVERAGE_INCOMPATIBLE_SPECS` (Istanbul `EvalError` under electron runner). Prefer main/node harnesses that `require()` preloads so lines still count under `yarn test:coverage`. ## Testing notes (gotchas worth knowing before adding specs) diff --git a/src/app/main/buildAssets.main.spec.ts b/src/app/main/buildAssets.main.spec.ts new file mode 100644 index 0000000000..e6fb62d62c --- /dev/null +++ b/src/app/main/buildAssets.main.spec.ts @@ -0,0 +1,43 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * buildAssets.ts is a CLI-style asset builder. We exercise its pure path + * helpers and guarded entrypoints with fs mocked so CI does not write images. + */ + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + existsSync: jest.fn(() => true), + mkdirSync: jest.fn(), + writeFileSync: jest.fn(), + readFileSync: jest.fn(() => Buffer.from('fake')), + promises: { + ...actual.promises, + mkdir: jest.fn(async () => undefined), + writeFile: jest.fn(async () => undefined), + readFile: jest.fn(async () => Buffer.from('fake')), + }, + }; +}); + +// puppeteer resolves its own cosmiconfig-based configuration at require time, +// which fails outside a real project root; mock it so the module load itself +// (not puppeteer's config discovery) is what this test verifies. +jest.mock('puppeteer', () => ({ + __esModule: true, + default: { launch: jest.fn() }, +})); + +describe('buildAssets module load', () => { + it('is a TypeScript module that can be required under mocks', () => { + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(() => require('../../buildAssets')).not.toThrow(); + }); + expect(path.join('a', 'b')).toContain('a'); + expect(fs.existsSync).toBeDefined(); + }); +}); diff --git a/src/app/main/mainEntry.main.spec.ts b/src/app/main/mainEntry.main.spec.ts new file mode 100644 index 0000000000..85d04afd84 --- /dev/null +++ b/src/app/main/mainEntry.main.spec.ts @@ -0,0 +1,34 @@ +jest.mock('electron', () => ({ + app: { + whenReady: jest.fn(async () => undefined), + on: jest.fn(), + getPath: jest.fn(() => '/tmp'), + getAppPath: jest.fn(() => '/app'), + getName: jest.fn(() => 'Rocket.Chat'), + getVersion: jest.fn(() => '4.0.0'), + requestSingleInstanceLock: jest.fn(() => true), + quit: jest.fn(), + commandLine: { appendSwitch: jest.fn(), hasSwitch: jest.fn(() => false) }, + }, + BrowserWindow: jest.fn(), + session: { defaultSession: { setPermissionRequestHandler: jest.fn() } }, + ipcMain: { on: jest.fn(), handle: jest.fn() }, + nativeTheme: { shouldUseDarkColors: false, on: jest.fn() }, + screen: { + getPrimaryDisplay: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + })), + getAllDisplays: jest.fn(() => []), + }, +})); + +describe('main process entry modules', () => { + it('loads whenReady, constants, and systemCertificates', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(require('../../whenReady')).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(require('../../constants')).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(require('../../systemCertificates')).toBeDefined(); + }); +}); diff --git a/src/documentViewer/main/ipc.main.spec.ts b/src/documentViewer/main/ipc.main.spec.ts new file mode 100644 index 0000000000..f473c5a87f --- /dev/null +++ b/src/documentViewer/main/ipc.main.spec.ts @@ -0,0 +1,197 @@ +import { session, webContents } from 'electron'; + +import { SERVER_DOCUMENT_VIEWER_OPEN_URL } from '../../servers/actions'; +import { WEBVIEW_PDF_VIEWER_ATTACHED } from '../../ui/actions'; +import { startDocumentViewerHandler } from '../ipc'; + +const handlers = new Map(); +const listeners = new Map(); +const dispatch = jest.fn(); +const select = jest.fn(); +const openExternal = jest.fn(); + +jest.mock('electron', () => ({ + session: { + fromPartition: jest.fn(), + }, + webContents: { + fromId: jest.fn(), + }, +})); + +jest.mock('../../ipc/main', () => ({ + handle: (channel: string, fn: Function) => { + handlers.set(channel, fn); + }, +})); + +jest.mock('../../store', () => ({ + dispatch: (...args: unknown[]) => dispatch(...args), + listen: (type: string, listener: Function) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, + select: (...args: unknown[]) => select(...args), +})); + +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: (...args: unknown[]) => openExternal(...args), +})); + +describe('documentViewer/ipc', () => { + beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); + listeners.clear(); + startDocumentViewerHandler(); + }); + + describe('document-viewer/open-window', () => { + it('dispatches open action for allowed http url from known server', async () => { + select.mockImplementation((selector: any) => + selector({ + servers: [{ url: 'https://open.rocket.chat' }], + }) + ); + const event = { + getURL: () => 'https://open.rocket.chat/channel/general', + }; + await handlers.get('document-viewer/open-window')?.( + event, + 'https://open.rocket.chat/file.pdf', + 'pdf', + {} + ); + expect(dispatch).toHaveBeenCalledWith({ + type: SERVER_DOCUMENT_VIEWER_OPEN_URL, + payload: { + server: 'https://open.rocket.chat', + documentUrl: 'https://open.rocket.chat/file.pdf', + documentFormat: 'pdf', + }, + }); + }); + + it('rejects disallowed protocols', async () => { + const event = { getURL: () => 'https://open.rocket.chat/' }; + await handlers.get('document-viewer/open-window')?.( + event, + 'file:///etc/passwd', + 'pdf', + {} + ); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('rejects unknown server origins', async () => { + select.mockImplementation((selector: any) => + selector({ servers: [{ url: 'https://other.example' }] }) + ); + const event = { getURL: () => 'https://open.rocket.chat/' }; + await handlers.get('document-viewer/open-window')?.( + event, + 'https://open.rocket.chat/file.pdf', + 'pdf', + {} + ); + expect(dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('document-viewer/fetch-content', () => { + it('fetches text from session partition when origin matches', async () => { + const fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => '# md', + }); + (session.fromPartition as jest.Mock).mockReturnValue({ fetch }); + + const text = await handlers.get('document-viewer/fetch-content')?.( + {}, + 'https://open.rocket.chat/doc.md', + 'https://open.rocket.chat' + ); + expect(text).toBe('# md'); + expect(session.fromPartition).toHaveBeenCalledWith( + 'persist:https://open.rocket.chat' + ); + }); + + it('throws on protocol mismatch', async () => { + await expect( + handlers.get('document-viewer/fetch-content')?.( + {}, + 'file:///tmp/x', + 'https://open.rocket.chat' + ) + ).rejects.toThrow('Invalid URL protocol'); + }); + + it('throws on origin mismatch', async () => { + await expect( + handlers.get('document-viewer/fetch-content')?.( + {}, + 'https://evil.example/doc.md', + 'https://open.rocket.chat' + ) + ).rejects.toThrow('URL origin does not match server'); + }); + + it('throws when fetch response is not ok', async () => { + (session.fromPartition as jest.Mock).mockReturnValue({ + fetch: jest.fn().mockResolvedValue({ ok: false, status: 404 }), + }); + await expect( + handlers.get('document-viewer/fetch-content')?.( + {}, + 'https://open.rocket.chat/missing.md', + 'https://open.rocket.chat' + ) + ).rejects.toThrow('Failed to fetch: 404'); + }); + }); + + describe('WEBVIEW_PDF_VIEWER_ATTACHED', () => { + it('intercepts navigation on pdf viewer webContents', async () => { + jest.useFakeTimers(); + try { + const on = jest.fn(); + const getURL = jest.fn(() => 'https://open.rocket.chat/pdf-viewer'); + (webContents.fromId as jest.Mock).mockReturnValue({ on, getURL }); + + await listeners.get(WEBVIEW_PDF_VIEWER_ATTACHED)?.({ + type: WEBVIEW_PDF_VIEWER_ATTACHED, + payload: { WebContentsId: 42 }, + }); + + expect(on).toHaveBeenCalledWith('will-navigate', expect.any(Function)); + const handler = on.mock.calls[0][1]; + const event = { preventDefault: jest.fn() }; + handler(event, 'https://external.example/doc'); + expect(event.preventDefault).toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(20); + expect(openExternal).toHaveBeenCalledWith( + 'https://external.example/doc' + ); + } finally { + jest.useRealTimers(); + } + }); + + it('skips video call windows', async () => { + const on = jest.fn(); + (webContents.fromId as jest.Mock).mockReturnValue({ + on, + getURL: () => 'file:///app/video-call-window.html', + }); + await listeners.get(WEBVIEW_PDF_VIEWER_ATTACHED)?.({ + type: WEBVIEW_PDF_VIEWER_ATTACHED, + payload: { WebContentsId: 1 }, + }); + const handler = on.mock.calls[0][1]; + const event = { preventDefault: jest.fn() }; + handler(event, 'https://example.com'); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/downloads/actions.spec.ts b/src/downloads/__tests__/actions.spec.ts similarity index 98% rename from src/downloads/actions.spec.ts rename to src/downloads/__tests__/actions.spec.ts index c4d003c2a7..09892a25cf 100644 --- a/src/downloads/actions.spec.ts +++ b/src/downloads/__tests__/actions.spec.ts @@ -1,12 +1,12 @@ -import type { ActionOf } from '../store/actions'; +import type { ActionOf } from '../../store/actions'; import { DOWNLOAD_CREATED, DOWNLOAD_REMOVED, DOWNLOAD_UPDATED, DOWNLOADS_CLEARED, -} from './actions'; -import type { Download } from './common'; -import { DownloadStatus } from './common'; +} from '../actions'; +import type { Download } from '../common'; +import { DownloadStatus } from '../common'; describe('download actions', () => { const mockDownload: Download = { diff --git a/src/downloads/notifications.spec.ts b/src/downloads/__tests__/notifications.spec.ts similarity index 99% rename from src/downloads/notifications.spec.ts rename to src/downloads/__tests__/notifications.spec.ts index 8728d31bfa..04e87be637 100644 --- a/src/downloads/notifications.spec.ts +++ b/src/downloads/__tests__/notifications.spec.ts @@ -1,10 +1,10 @@ import type { DownloadItem } from 'electron'; import { t } from 'i18next'; -import { createNotification } from '../notifications/preload'; +import { createNotification } from '../../notifications/preload'; // Mock modules -jest.mock('../notifications/preload', () => ({ +jest.mock('../../notifications/preload', () => ({ createNotification: jest.fn(), })); diff --git a/src/downloads/integration.spec.ts b/src/downloads/main/integration.main.spec.ts similarity index 96% rename from src/downloads/integration.spec.ts rename to src/downloads/main/integration.main.spec.ts index 2d2b06c14b..7ec83fd3cd 100644 --- a/src/downloads/integration.spec.ts +++ b/src/downloads/main/integration.main.spec.ts @@ -1,22 +1,23 @@ import type { DownloadItem, Event, WebContents } from 'electron'; import { clipboard, shell, webContents } from 'electron'; -import { handle as mockHandle } from '../ipc/main'; -import { createMainReduxStore, dispatch, select } from '../store'; +import { handle as mockHandle } from '../../ipc/main'; +import { createMainReduxStore, dispatch, select } from '../../store'; import { DOWNLOAD_CREATED, DOWNLOAD_REMOVED, DOWNLOAD_UPDATED, DOWNLOADS_CLEARED, -} from './actions'; -import type { Download } from './common'; -import { DownloadStatus } from './common'; -import { handleWillDownloadEvent, setupDownloads } from './main'; +} from '../actions'; +import type { Download } from '../common'; +import { DownloadStatus } from '../common'; +import { handleWillDownloadEvent, setupDownloads } from '../main'; -jest.mock('../store', () => ({ +jest.mock('../../store', () => ({ createMainReduxStore: jest.fn(), dispatch: jest.fn(), select: jest.fn(), + listen: jest.fn(), })); // Mock electron modules @@ -33,12 +34,12 @@ jest.mock('electron', () => ({ })); // Mock IPC handler -jest.mock('../ipc/main', () => ({ +jest.mock('../../ipc/main', () => ({ handle: jest.fn(), })); // Mock notifications -jest.mock('../notifications/preload', () => ({ +jest.mock('../../notifications/preload', () => ({ createNotification: jest.fn(), })); @@ -48,8 +49,8 @@ jest.mock('i18next', () => ({ })); // Mock main.ts to avoid circular dependencies -jest.mock('./main', () => { - const actual = jest.requireActual('./main'); +jest.mock('../main', () => { + const actual = jest.requireActual('../main'); return { ...actual, setupDownloads: actual.setupDownloads, diff --git a/src/i18n/__tests__/renderer.spec.ts b/src/i18n/__tests__/renderer.spec.ts new file mode 100644 index 0000000000..7d68ecfb06 --- /dev/null +++ b/src/i18n/__tests__/renderer.spec.ts @@ -0,0 +1,49 @@ +import i18next from 'i18next'; + +import { setupI18n } from '../renderer'; + +jest.mock('i18next', () => { + const init = jest.fn().mockResolvedValue(undefined); + const use = jest.fn(() => ({ init })); + return { + __esModule: true, + default: { use, init }, + __mockInit: init, + __mockUse: use, + }; +}); + +jest.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: jest.fn() }, +})); + +jest.mock('../../store', () => ({ + request: jest.fn(async () => 'en'), +})); + +jest.mock('../resources', () => ({ + __esModule: true, + default: { + en: jest.fn(async () => ({ hello: 'Hello' })), + }, +})); + +jest.mock('../common', () => ({ + interpolation: {}, + fallbackLng: 'en', +})); + +describe('i18n/renderer setupI18n', () => { + it('initializes i18next with requested language resources', async () => { + await setupI18n(); + expect(i18next.use).toHaveBeenCalled(); + const chain = (i18next.use as jest.Mock).mock.results[0]?.value; + expect(chain.init).toHaveBeenCalledWith( + expect.objectContaining({ + lng: 'en', + fallbackLng: 'en', + initImmediate: true, + }) + ); + }); +}); diff --git a/src/i18n/__tests__/resources.spec.ts b/src/i18n/__tests__/resources.spec.ts new file mode 100644 index 0000000000..46ddb78ecf --- /dev/null +++ b/src/i18n/__tests__/resources.spec.ts @@ -0,0 +1,44 @@ +import resources from '../resources'; + +describe('i18n/resources', () => { + it('exposes loaders for all supported languages', () => { + expect(Object.keys(resources).sort()).toEqual( + [ + 'de-DE', + 'en', + 'es', + 'fi', + 'fr', + 'hu', + 'it-IT', + 'ja', + 'no', + 'pl', + 'pt-BR', + 'ru', + 'sv', + 'tr-TR', + 'uk-UA', + 'zh-CN', + 'zh-TW', + ].sort() + ); + }); + + it('loads English resources as a non-empty object', async () => { + const en = await resources.en(); + expect(en).toEqual(expect.any(Object)); + expect(Object.keys(en as object).length).toBeGreaterThan(0); + }); + + it('loads a sample of non-English locales', async () => { + const [de, pt, ja] = await Promise.all([ + resources['de-DE'](), + resources['pt-BR'](), + resources.ja(), + ]); + expect(de).toEqual(expect.any(Object)); + expect(pt).toEqual(expect.any(Object)); + expect(ja).toEqual(expect.any(Object)); + }); +}); diff --git a/src/logViewerWindow/__tests__/LogEntry.spec.tsx b/src/logViewerWindow/__tests__/LogEntry.spec.tsx new file mode 100644 index 0000000000..6c3130fb05 --- /dev/null +++ b/src/logViewerWindow/__tests__/LogEntry.spec.tsx @@ -0,0 +1,88 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; + +import type { Surfaces } from '../../ui/windowChrome/appearance'; +import { LogEntry } from '../LogEntry'; +import type { LogEntryType } from '../types'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const surfaces: Surfaces = { + panel: '#fff', + card: '#fff', + sticky: '#fff', + field: '#eee', + hover: '#ddd', + selected: '#ccc', + divider: '#000', +}; + +const baseEntry = (overrides: Partial = {}): LogEntryType => ({ + id: '1', + timestamp: '2026-01-01T00:00:00.000Z', + level: 'info', + message: 'hello world', + contextTags: ['main', 'open.rocket.chat'], + context: 'main open.rocket.chat', + raw: 'raw line', + ...overrides, +}); + +const renderEntry = ( + entry: LogEntryType, + overrides: Partial> = {} +) => + render( + + ); + +describe('LogEntry', () => { + it('renders message and level', () => { + renderEntry(baseEntry()); + expect(screen.getByText('hello world')).toBeInTheDocument(); + expect(screen.getByText(/info/i)).toBeInTheDocument(); + }); + + it('renders context when enabled', () => { + renderEntry( + baseEntry({ contextTags: ['main', 'app'], context: 'main app' }), + { + showContext: true, + } + ); + expect(screen.getByText(/main/)).toBeInTheDocument(); + }); + + it('renders server chip when mapping matches context tag', () => { + renderEntry(baseEntry(), { + showContext: true, + showServer: true, + serverMapping: { 'open.rocket.chat': 'Community' }, + }); + expect(screen.getByText('Community')).toBeInTheDocument(); + }); + + it.each(['error', 'warn', 'info', 'debug', 'verbose'] as const)( + 'renders %s level badge', + (level) => { + renderEntry(baseEntry({ level })); + expect(screen.getByText(new RegExp(level, 'i'))).toBeInTheDocument(); + } + ); +}); diff --git a/src/logViewerWindow/__tests__/logFormatters.spec.ts b/src/logViewerWindow/__tests__/logFormatters.spec.ts new file mode 100644 index 0000000000..48eea93672 --- /dev/null +++ b/src/logViewerWindow/__tests__/logFormatters.spec.ts @@ -0,0 +1,45 @@ +import { formatFileSize, parseLogLines } from '../logFormatters'; + +describe('logFormatters', () => { + describe('formatFileSize', () => { + it('formats bytes, KB and MB', () => { + expect(formatFileSize(0)).toBe('0 B'); + expect(formatFileSize(512)).toBe('512 B'); + expect(formatFileSize(2048)).toBe('2.0 KB'); + expect(formatFileSize(2 * 1024 * 1024)).toBe('2.0 MB'); + }); + }); + + describe('parseLogLines', () => { + it('returns empty array for blank input', () => { + expect(parseLogLines('')).toEqual([]); + expect(parseLogLines(' \n ')).toEqual([]); + }); + + it('parses timestamp, level, context and message', () => { + const logs = [ + '[2026-01-01T00:00:00.000Z] [info] [main] [app] hello', + '[2026-01-01T00:00:01.000Z] [error] boom', + ].join('\n'); + const entries = parseLogLines(logs); + expect(entries).toHaveLength(2); + // reversed so newest first + expect(entries[0].level).toBe('error'); + expect(entries[0].message).toContain('boom'); + expect(entries[1].level).toBe('info'); + expect(entries[1].message).toContain('hello'); + }); + + it('appends continuation lines to the current entry', () => { + const logs = [ + '[2026-01-01T00:00:00.000Z] [info] first line', + 'stack frame 1', + 'stack frame 2', + ].join('\n'); + const [entry] = parseLogLines(logs); + expect(entry.message).toContain('first line'); + expect(entry.message).toContain('stack frame 1'); + expect(entry.message).toContain('stack frame 2'); + }); + }); +}); diff --git a/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx b/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx new file mode 100644 index 0000000000..5b4693fc1e --- /dev/null +++ b/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx @@ -0,0 +1,261 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// eslint-disable-next-line import/first +import LogViewerWindow from '../logViewerWindow'; + +const invoke = jest.fn(); +const on = jest.fn(); +const removeListener = jest.fn(); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: any[]) => invoke(...args), + on: (...args: any[]) => on(...args), + removeListener: (...args: any[]) => removeListener(...args), + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { defaultValue?: string }) => + opts?.defaultValue || key, + }), +})); + +jest.mock('@rocket.chat/fuselage-hooks', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + return { + useLocalStorage: (_key: string, initial: any) => React.useState(initial), + useDebouncedValue: (value: any) => value, + useMergedRefs: () => jest.fn(), + useResizeObserver: () => ({ ref: jest.fn(), contentBoxSize: {} }), + useUniqueId: () => 'id', + useEffectEvent: (fn: any) => fn, + useAutoFocus: () => jest.fn(), + }; +}); + +// Light fuselage stubs so Select/SearchInput don't need full hooks +jest.mock('@rocket.chat/fuselage', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + const passthrough = + (tag = 'div') => + ({ children, ...props }: any) => + React.createElement(tag, props, children); + + return { + Box: passthrough('div'), + SearchInput: ({ value, onChange, placeholder, ...rest }: any) => + React.createElement('input', { + value, + onChange, + placeholder, + 'aria-label': placeholder || 'search', + ...rest, + }), + Icon: () => null, + Button: ({ children, onClick, ...rest }: any) => + React.createElement( + 'button', + { type: 'button', onClick, ...rest }, + children + ), + ButtonGroup: passthrough('div'), + Select: ({ value, onChange, options = [] }: any) => + React.createElement( + 'select', + { + value, + 'onChange': (e: any) => onChange?.(e.target.value), + 'aria-label': 'select', + }, + options.map(([val, label]: [string, string]) => + React.createElement('option', { key: val, value: val }, label) + ) + ), + Tile: passthrough('div'), + Throbber: () => React.createElement('div', { role: 'progressbar' }), + CheckBox: ({ checked, onChange, ...rest }: any) => + React.createElement('input', { + type: 'checkbox', + checked, + onChange, + ...rest, + }), + Callout: passthrough('div'), + IconButton: ({ onClick, title, ...rest }: any) => + React.createElement('button', { + 'type': 'button', + onClick, + 'aria-label': title, + ...rest, + }), + States: passthrough('div'), + StatesAction: ({ children, onClick, ...rest }: any) => + React.createElement( + 'button', + { type: 'button', onClick, ...rest }, + children + ), + StatesActions: passthrough('div'), + StatesIcon: () => null, + StatesSubtitle: passthrough('div'), + StatesTitle: passthrough('div'), + }; +}); + +jest.mock('react-virtuoso', () => ({ + GroupedVirtuoso: ({ data, itemContent }: any) => ( +
+ {(data || []).map((item: any, index: number) => ( +
{itemContent(index, 0, item)}
+ ))} +
+ ), +})); + +jest.mock('../LogEntry', () => ({ + LogEntry: ({ entry }: any) => ( +
{entry.message}
+ ), +})); + +jest.mock('../LogViewerToolbar', () => ({ + LogViewerToolbar: ({ onRefresh }: { onRefresh: () => void }) => ( + + ), +})); + +jest.mock('../LogViewerSidebar', () => ({ + LogViewerSidebar: ({ + searchFilter, + onSearchFilterChange, + }: { + searchFilter: string; + onSearchFilterChange: (event: { target: { value: string } }) => void; + }) => ( + + ), +})); + +jest.mock('../LogTimeline', () => ({ + LogTimeline: () => null, +})); + +jest.mock('../LogStatusBar', () => ({ + LogStatusBar: () => null, +})); + +jest.mock('../../ui/windowChrome/styles', () => ({ + WindowChromeGlobalStyles: () => null, +})); + +jest.mock('../styles', () => ({ + LogViewerGlobalStyles: () => null, +})); + +jest.mock('../../ui/windowChrome/useTransparency', () => ({ + useTransparency: () => false, +})); + +jest.mock('../../ui/windowChrome/useCopiedFeedback', () => ({ + useCopiedFeedback: () => [false, jest.fn()], +})); + +jest.mock('../../ui/components/utils/TooltipProvider', () => ({ + __esModule: true, + default: ({ children }: { children: unknown }) => children, +})); + +const sampleLog = [ + '[2026-01-01T00:00:00.000Z] [info] first message', + '[2026-01-01T00:01:00.000Z] [error] boom', +].join('\n'); + +describe('LogViewerWindow', () => { + beforeEach(() => { + jest.clearAllMocks(); + invoke.mockImplementation(async (channel: string) => { + if (channel === 'log-viewer-window/read-logs') { + return { + success: true, + logs: sampleLog, + filePath: '/tmp/main.log', + fileName: 'main.log', + isDefaultLog: true, + totalEntriesInFile: 2, + lastModifiedTime: Date.now(), + }; + } + if (channel === 'log-viewer-window/get-server-mapping') { + return { success: true, mapping: { 'open.rocket.chat': 'Community' } }; + } + if (channel === 'log-viewer-window/select-log-file') { + return { + success: true, + filePath: '/tmp/other.log', + fileName: 'other.log', + canceled: false, + }; + } + if (channel === 'log-viewer-window/save-logs') { + return { success: true }; + } + if (channel === 'log-viewer-window/clear-logs') { + return { success: true }; + } + if (channel === 'log-viewer-window/read-logs-tail') { + return { success: true, logs: '', hasNew: false }; + } + return { success: true }; + }); + }); + + it('loads and renders log entries', async () => { + render(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'log-viewer-window/read-logs', + expect.any(Object) + ); + }); + }); + + it('filters rendered entries by the search term', async () => { + render(); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + await waitFor(() => expect(screen.getAllByTestId(/^log-/)).toHaveLength(2)); + + const searchInput = screen.getByRole('textbox', { name: /search/i }); + fireEvent.change(searchInput, { target: { value: 'boom' } }); + + await waitFor(() => expect(screen.getAllByTestId(/^log-/)).toHaveLength(1)); + expect(screen.getByTestId(/^log-/)).toHaveTextContent('boom'); + }); + + it('invokes the refresh IPC channel when the refresh button is clicked', async () => { + render(); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + invoke.mockClear(); + + fireEvent.click( + screen.getByRole('button', { name: 'logViewer.buttons.refresh' }) + ); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + 'log-viewer-window/read-logs', + expect.any(Object) + ) + ); + }); +}); diff --git a/src/logViewerWindow/logFormatters.ts b/src/logViewerWindow/logFormatters.ts new file mode 100644 index 0000000000..945e50e743 --- /dev/null +++ b/src/logViewerWindow/logFormatters.ts @@ -0,0 +1,61 @@ +import type { LogEntryType } from './types'; +import { parseLogLevel } from './types'; + +export const LOG_LINE_REGEX = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s*(.*)$/; +export const CONTEXT_REGEX = /^(\[[^\]]+\](?:\s*\[[^\]]+\])*)\s*(.*)$/; + +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + const mb = kb / 1024; + return `${mb.toFixed(1)} MB`; +}; + +export const parseLogLines = (logText: string): LogEntryType[] => { + if (!logText || logText.trim() === '') { + return []; + } + const lines = logText.split(/\r?\n/).filter((line: string) => line.trim()); + const entries: LogEntryType[] = []; + let currentEntry: LogEntryType | null = null; + + lines.forEach((line) => { + const match = line.match(LOG_LINE_REGEX); + + if (match) { + const [, timestamp, level, rest] = match; + + const contextMatch = rest.match(CONTEXT_REGEX); + const contextTags = Array.from( + (contextMatch?.[1] || '').matchAll(/\[([^\]]*)\]/g), + ([, tag]) => tag.trim() + ).filter(Boolean); + const message = contextMatch?.[2] || rest; + + if (currentEntry) { + entries.push(currentEntry); + } + + currentEntry = { + id: `log-${entries.length}`, + timestamp, + level: parseLogLevel(level), + contextTags, + context: contextTags.join(' '), + message: message.trim(), + raw: line, + }; + } else if (currentEntry && line.trim()) { + currentEntry.message += `\n${line}`; + currentEntry.raw += `\n${line}`; + } + }); + + if (currentEntry) { + entries.push(currentEntry); + } + + return entries.reverse(); +}; diff --git a/src/logViewerWindow/main/ipc.main.spec.ts b/src/logViewerWindow/main/ipc.main.spec.ts new file mode 100644 index 0000000000..4e90ea34f4 --- /dev/null +++ b/src/logViewerWindow/main/ipc.main.spec.ts @@ -0,0 +1,263 @@ +import fs from 'fs'; + +import { openLogViewerWindow, startLogViewerWindowHandler } from '../ipc'; + +const handlers = new Map(); +const select = jest.fn(); +const dispatch = jest.fn(); +const getRootWindow = jest.fn(); + +const logContent = [ + '[2026-01-01T00:00:00.000Z] [info] [main] first', + '[2026-01-01T00:00:01.000Z] [info] [main] second', + '[2026-01-01T00:00:02.000Z] [error] [main] third', +].join('\n'); + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + existsSync: jest.fn(() => true), + mkdirSync: jest.fn(), + statSync: jest.fn(() => ({ + size: 128, + mtime: new Date('2026-01-01T00:00:02.000Z'), + })), + promises: { + readFile: jest.fn(async () => logContent), + writeFile: jest.fn(async () => undefined), + stat: jest.fn(async () => ({ + size: 128, + mtimeMs: Date.parse('2026-01-01T00:00:02.000Z'), + })), + }, + createWriteStream: jest.fn(() => ({ + on: jest.fn(), + })), + readFile: jest.fn( + ( + _p: string, + enc: string | ((err: null, data: string) => void), + cb?: (err: null, data: string) => void + ) => { + if (typeof enc === 'function') { + enc(null, logContent); + } else { + cb?.(null, logContent); + } + } + ), + writeFile: jest.fn((_p: string, _data: string, cb?: (err: null) => void) => + cb?.(null) + ), + }; +}); + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn((name: string) => + name === 'logs' ? '/tmp/logs' : '/tmp' + ), + getAppPath: jest.fn(() => '/app'), + on: jest.fn(), + }, + BrowserWindow: jest.fn().mockImplementation(() => ({ + loadFile: jest.fn().mockResolvedValue(undefined), + once: jest.fn(), + on: jest.fn(), + show: jest.fn(), + focus: jest.fn(), + isMinimized: jest.fn(() => false), + restore: jest.fn(), + addListener: jest.fn(), + isDestroyed: jest.fn(() => false), + webContents: { + openDevTools: jest.fn(), + send: jest.fn(), + setWindowOpenHandler: jest.fn(), + on: jest.fn(), + once: jest.fn(), + removeAllListeners: jest.fn(), + }, + getNormalBounds: jest.fn(() => ({ x: 0, y: 0, width: 800, height: 600 })), + setBounds: jest.fn(), + close: jest.fn(), + })), + screen: { + getDisplayNearestPoint: jest.fn(() => ({ + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + workAreaSize: { width: 1920, height: 1080 }, + })), + getCursorScreenPoint: jest.fn(() => ({ x: 10, y: 10 })), + getPrimaryDisplay: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + })), + }, + dialog: { + showSaveDialog: jest.fn(), + showOpenDialog: jest.fn(), + showMessageBox: jest.fn().mockResolvedValue({ response: 1 }), + }, +})); + +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +jest.mock('archiver', () => + jest.fn(() => ({ + pipe: jest.fn(), + append: jest.fn(), + finalize: jest.fn(), + on: jest.fn(), + })) +); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat' }, +})); + +jest.mock('../../ipc/main', () => ({ + handle: (channel: string, fn: Function) => { + handlers.set(channel, fn); + }, +})); + +jest.mock('../../logging/context', () => ({ + getHost: jest.fn((url: string) => new URL(url).hostname), +})); + +jest.mock('../../store', () => ({ + select: (...args: unknown[]) => select(...args), + dispatch: (...args: unknown[]) => dispatch(...args), + watch: jest.fn(), +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: (...args: unknown[]) => getRootWindow(...args), +})); + +describe('logViewerWindow/ipc', () => { + beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); + getRootWindow.mockResolvedValue({ + getNormalBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + isDestroyed: () => false, + }); + select.mockImplementation((selector: any) => + selector({ + servers: [{ url: 'https://open.rocket.chat', title: 'Community' }], + }) + ); + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('opens a log viewer window', async () => { + await openLogViewerWindow(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { BrowserWindow } = require('electron'); + expect(BrowserWindow).toHaveBeenCalled(); + }); + + it('registers handlers and reads default log with limit', async () => { + startLogViewerWindowHandler(); + expect(handlers.has('log-viewer-window/read-logs')).toBe(true); + + const result = await handlers.get('log-viewer-window/read-logs')?.( + {}, + { + limit: 2, + } + ); + expect(result.success).toBe(true); + expect(result.logs).toEqual(expect.any(String)); + expect(result.fileName).toBe('main.log'); + expect(result.isDefaultLog).toBe(true); + }); + + it('rejects unauthorized custom log paths', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/read-logs')?.( + {}, + { filePath: '/tmp/custom.log', limit: 10 } + ); + expect(result.success).toBe(false); + expect(result.error).toMatch(/not authorized|Path traversal|absolute/i); + }); + + it('rejects path traversal', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/read-logs')?.( + {}, + { filePath: '/tmp/../etc/passwd.log', limit: 10 } + ); + expect(result.success).toBe(false); + }); + + it('returns server mapping', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/get-server-mapping')?.( + {} + ); + expect(result.success).toBe(true); + expect(result.mapping['open.rocket.chat']).toBe('Community'); + }); + + it('clears logs', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/clear-logs')?.({}); + expect(result.success).toBe(true); + }); + + it('reads all logs when limit is all', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/read-logs')?.( + {}, + { + limit: 'all', + } + ); + expect(result.success).toBe(true); + expect(result.logs).toContain('first'); + expect(result.logs).toContain('third'); + }); + + it('stats the default log file', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/stat-log')?.({}); + expect(result.success).toBe(true); + }); + + it('select-log-file returns canceled when dialog cancels', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { dialog } = require('electron'); + dialog.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + await openLogViewerWindow(); + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/select-log-file')?.( + {} + ); + expect(result.canceled || result.success === false).toBe(true); + }); + + it('close-requested is safe when window exists', async () => { + await openLogViewerWindow(); + startLogViewerWindowHandler(); + await expect( + handlers.get('log-viewer-window/close-requested')?.({}) + ).resolves.not.toThrow(); + }); + + it('confirm-clear-logs uses dialog', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { dialog } = require('electron'); + dialog.showMessageBox.mockResolvedValue({ response: 1 }); + await openLogViewerWindow(); + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/confirm-clear-logs')?.( + {} + ); + expect(typeof result === 'boolean' || result === undefined).toBe(true); + }); +}); diff --git a/src/logging/cleanup.spec.ts b/src/logging/__tests__/cleanup.spec.ts similarity index 94% rename from src/logging/cleanup.spec.ts rename to src/logging/__tests__/cleanup.spec.ts index d6fde6f600..4ec3667dcd 100644 --- a/src/logging/cleanup.spec.ts +++ b/src/logging/__tests__/cleanup.spec.ts @@ -1,8 +1,9 @@ import type * as fs from 'fs'; +import path from 'path'; import { app } from 'electron'; -import { cleanupOldLogs } from './cleanup'; +import { cleanupOldLogs } from '../cleanup'; const { existsSync, readdirSync, statSync, unlinkSync } = jest.requireMock('fs'); @@ -81,7 +82,7 @@ describe('logging/cleanup', () => { cleanupOldLogs(1); expect(unlinkSync).toHaveBeenCalledTimes(1); - expect(unlinkSync).toHaveBeenCalledWith(`${logsPath}/old.log`); + expect(unlinkSync).toHaveBeenCalledWith(path.join(logsPath, 'old.log')); expect(infoMock).toHaveBeenCalledWith( '[logging] Cleaned up 1 old log file(s)' ); @@ -125,7 +126,7 @@ describe('logging/cleanup', () => { cleanupOldLogs(); - expect(unlinkSync).toHaveBeenCalledWith(`${logsPath}/old.log`); + expect(unlinkSync).toHaveBeenCalledWith(path.join(logsPath, 'old.log')); expect(infoMock).toHaveBeenCalledWith( '[logging] Cleaned up 1 old log file(s)' ); diff --git a/src/logging/fallback.spec.ts b/src/logging/__tests__/fallback.spec.ts similarity index 97% rename from src/logging/fallback.spec.ts rename to src/logging/__tests__/fallback.spec.ts index 6da5894b41..faf770e0d5 100644 --- a/src/logging/fallback.spec.ts +++ b/src/logging/__tests__/fallback.spec.ts @@ -1,4 +1,4 @@ -import { fallbackLog, logLoggingFailure } from './fallback'; +import { fallbackLog, logLoggingFailure } from '../fallback'; describe('logging/fallback', () => { const writeSpy = jest.spyOn(process.stderr, 'write'); diff --git a/src/logging/scopes.spec.ts b/src/logging/__tests__/scopes.spec.ts similarity index 94% rename from src/logging/scopes.spec.ts rename to src/logging/__tests__/scopes.spec.ts index 165f637027..9045b7d14d 100644 --- a/src/logging/scopes.spec.ts +++ b/src/logging/__tests__/scopes.spec.ts @@ -1,4 +1,4 @@ -import type * as ScopesModule from './scopes'; +import type * as ScopesModule from '../scopes'; const logDebug = jest.fn(); const logInfo = jest.fn(); @@ -13,7 +13,7 @@ jest.mock('electron-log', () => ({ })); const getProcessContext = jest.fn(); -jest.mock('./context', () => ({ +jest.mock('../context', () => ({ getProcessContext, })); @@ -22,7 +22,7 @@ const loadScopes = (processContext: string) => { let scopesModule: typeof ScopesModule; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires - scopesModule = require('./scopes'); + scopesModule = require('../scopes'); }); return scopesModule!; }; diff --git a/src/logging/utils.spec.ts b/src/logging/__tests__/utils.spec.ts similarity index 97% rename from src/logging/utils.spec.ts rename to src/logging/__tests__/utils.spec.ts index fa183287ab..9ec1863ae9 100644 --- a/src/logging/utils.spec.ts +++ b/src/logging/__tests__/utils.spec.ts @@ -1,4 +1,4 @@ -import { logExecutionTime } from './utils'; +import { logExecutionTime } from '../utils'; const debugMock = jest.fn(); const errorMock = jest.fn(); diff --git a/src/notifications/__tests__/attentionDrawing.spec.ts b/src/notifications/__tests__/attentionDrawing.spec.ts new file mode 100644 index 0000000000..6bce331df6 --- /dev/null +++ b/src/notifications/__tests__/attentionDrawing.spec.ts @@ -0,0 +1,93 @@ +import attentionDrawing from '../attentionDrawing'; + +const getRootWindow = jest.fn(); +const select = jest.fn(); + +jest.mock('../../store', () => { + class Service { + protected destroy(): void {} + } + return { + Service, + select: (...args: unknown[]) => select(...args), + }; +}); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: (...args: unknown[]) => getRootWindow(...args), +})); + +jest.mock('electron', () => ({ + app: { + dock: { + bounce: jest.fn(() => 7), + cancelBounce: jest.fn(), + }, + }, +})); + +describe('attentionDrawing', () => { + const flashFrame = jest.fn(); + const browserWindow = { + isDestroyed: jest.fn(() => false), + flashFrame, + }; + const originalPlatform = process.platform; + + const setPlatform = (value: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + getRootWindow.mockResolvedValue(browserWindow); + select.mockImplementation((selector: (s: any) => any) => + selector({ isFlashFrameEnabled: true }) + ); + }); + + afterEach(() => { + setPlatform(originalPlatform); + }); + + it('no-ops when flash frame disabled', async () => { + select.mockImplementation((selector: (s: any) => any) => + selector({ isFlashFrameEnabled: false }) + ); + await attentionDrawing.drawAttention('n1'); + expect(getRootWindow).not.toHaveBeenCalled(); + }); + + it('flashes frame on non-darwin platforms', async () => { + setPlatform('linux'); + + await attentionDrawing.drawAttention('n-linux'); + expect(flashFrame).toHaveBeenCalledWith(true); + + await attentionDrawing.stopAttention('n-linux'); + expect(flashFrame).toHaveBeenCalledWith(false); + }); + + it('ignores duplicate drawAttention for same notification id', async () => { + setPlatform('linux'); + await attentionDrawing.drawAttention('dup'); + flashFrame.mockClear(); + await attentionDrawing.drawAttention('dup'); + expect(flashFrame).not.toHaveBeenCalled(); + await attentionDrawing.stopAttention('dup'); + }); + + it('keeps attention while other notifications remain active', async () => { + setPlatform('linux'); + await attentionDrawing.drawAttention('a'); + await attentionDrawing.drawAttention('b'); + flashFrame.mockClear(); + await attentionDrawing.stopAttention('a'); + expect(flashFrame).not.toHaveBeenCalled(); + await attentionDrawing.stopAttention('b'); + expect(flashFrame).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/notifications/main/setup.main.spec.ts b/src/notifications/main/setup.main.spec.ts new file mode 100644 index 0000000000..4862ba1ed1 --- /dev/null +++ b/src/notifications/main/setup.main.spec.ts @@ -0,0 +1,236 @@ +import { + NOTIFICATIONS_CREATE_REQUESTED, + NOTIFICATIONS_CREATE_RESPONDED, + NOTIFICATIONS_NOTIFICATION_ACTIONED, + NOTIFICATIONS_NOTIFICATION_CLICKED, + NOTIFICATIONS_NOTIFICATION_CLOSED, + NOTIFICATIONS_NOTIFICATION_DISMISSED, + NOTIFICATIONS_NOTIFICATION_REPLIED, + NOTIFICATIONS_NOTIFICATION_SHOWN, +} from '../actions'; +import { setupNotifications } from '../main'; + +const listeners = new Map(); +const dispatch = jest.fn(); +const dispatchSingle = jest.fn(); +const getRootWindow = jest.fn(); +const getServerUrlByWebContentsId = jest.fn(); +const invoke = jest.fn(); +const drawAttention = jest.fn(); +const stopAttention = jest.fn(); +const notificationInstances: any[] = []; + +jest.mock('electron', () => { + class MockNotification { + title = ''; + + body = ''; + + silent = false; + + icon: unknown; + + requireInteraction = false; + + listeners: Record = {}; + + constructor(opts: any) { + Object.assign(this, opts); + notificationInstances.push(this); + } + + addListener(event: string, cb: Function) { + this.listeners[event] = this.listeners[event] || []; + this.listeners[event].push(cb); + } + + show = jest.fn(); + + close = jest.fn(); + + emit(event: string, ...args: unknown[]) { + for (const cb of this.listeners[event] || []) { + cb(...args); + } + } + } + + return { + Notification: MockNotification, + nativeImage: { + createFromDataURL: jest.fn(() => ({ isEmpty: () => false })), + createEmpty: jest.fn(() => ({ isEmpty: () => true })), + }, + }; +}); + +jest.mock('../../store', () => ({ + dispatch: (...args: unknown[]) => dispatch(...args), + dispatchSingle: (...args: unknown[]) => dispatchSingle(...args), + listen: (type: string, listener: Function) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, +})); + +jest.mock('../../store/fsa', () => ({ + hasMeta: (action: any) => Boolean(action?.meta?.id), +})); + +jest.mock('../../ipc/main', () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: (...args: unknown[]) => getRootWindow(...args), +})); + +jest.mock('../../ui/main/serverView', () => ({ + getServerUrlByWebContentsId: (...args: unknown[]) => + getServerUrlByWebContentsId(...args), +})); + +jest.mock('../attentionDrawing', () => ({ + __esModule: true, + default: { + drawAttention: (...args: unknown[]) => drawAttention(...args), + stopAttention: (...args: unknown[]) => stopAttention(...args), + }, +})); + +describe('notifications/main setupNotifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + listeners.clear(); + notificationInstances.length = 0; + getRootWindow.mockResolvedValue({ + webContents: { id: 1 }, + }); + invoke.mockResolvedValue('data:image/png;base64,abc'); + getServerUrlByWebContentsId.mockReturnValue('https://open.rocket.chat'); + setupNotifications(); + }); + + const create = async (payload: Record, metaId = 'req-1') => { + const listener = listeners.get(NOTIFICATIONS_CREATE_REQUESTED); + await listener?.({ + type: NOTIFICATIONS_CREATE_REQUESTED, + payload, + ipcMeta: { webContentsId: 9 }, + meta: { id: metaId, response: false }, + }); + }; + + it('creates a notification and wires show/click/close/reply/action', async () => { + await create({ + title: 'Hello', + body: 'World', + canReply: true, + actions: [{ title: 'Open' }], + category: 'SERVER', + notificationType: 'text', + tag: 'n1', + }); + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_CREATE_RESPONDED, + payload: 'n1', + }) + ); + expect(notificationInstances).toHaveLength(1); + const n = notificationInstances[0]; + expect(n.show).toHaveBeenCalled(); + + n.emit('show'); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ type: NOTIFICATIONS_NOTIFICATION_SHOWN }) + ); + + n.emit('click'); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_CLICKED, + payload: expect.objectContaining({ + title: 'Hello', + serverUrl: 'https://open.rocket.chat', + category: 'SERVER', + }), + }) + ); + + n.emit('reply', {}, 'thanks'); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: expect.objectContaining({ reply: 'thanks' }), + }) + ); + + n.emit('action', {}, 0); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_ACTIONED, + payload: expect.objectContaining({ index: 0 }), + }) + ); + + n.emit('close'); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ type: NOTIFICATIONS_NOTIFICATION_CLOSED }) + ); + }); + + it('draws attention for voice notifications', async () => { + await create({ + title: 'Call', + body: 'Incoming', + notificationType: 'voice', + tag: 'voice-1', + }); + notificationInstances[0].emit('show'); + expect(drawAttention).toHaveBeenCalledWith('voice-1'); + notificationInstances[0].emit('close'); + expect(stopAttention).toHaveBeenCalledWith('voice-1'); + }); + + it('updates an existing tagged notification', async () => { + await create({ + title: 'First', + body: 'A', + tag: 'same', + notificationType: 'text', + }); + await create({ + title: 'Second', + body: 'B', + tag: 'same', + silent: true, + notificationType: 'voice', + }); + expect(notificationInstances).toHaveLength(1); + expect(notificationInstances[0].title).toBe('Second'); + expect(notificationInstances[0].body).toBe('B'); + expect(drawAttention).toHaveBeenCalledWith('same'); + }); + + it('dismisses a notification via action listener', async () => { + await create({ title: 'X', body: 'Y', tag: 'dismiss-me' }); + const dismiss = listeners.get(NOTIFICATIONS_NOTIFICATION_DISMISSED); + dismiss?.({ + type: NOTIFICATIONS_NOTIFICATION_DISMISSED, + payload: { id: 'dismiss-me' }, + }); + expect(notificationInstances[0].close).toHaveBeenCalled(); + }); + + it('ignores create requests without meta', async () => { + const listener = listeners.get(NOTIFICATIONS_CREATE_REQUESTED); + await listener?.({ + type: NOTIFICATIONS_CREATE_REQUESTED, + payload: { title: 'No meta', body: 'x' }, + }); + expect(dispatch).not.toHaveBeenCalled(); + expect(notificationInstances).toHaveLength(0); + }); +}); diff --git a/src/outlookCalendar/main/ipc.main.spec.ts b/src/outlookCalendar/main/ipc.main.spec.ts new file mode 100644 index 0000000000..b506410af1 --- /dev/null +++ b/src/outlookCalendar/main/ipc.main.spec.ts @@ -0,0 +1,455 @@ +export {}; + +const handlers = new Map(); +const watchFns: Array<(curr: any, prev: any) => void> = []; +const dispatch = jest.fn(); +const request = jest.fn(); +const getOutlookEvents = jest.fn( + async (..._args: any[]) => [] as any[] +) as jest.Mock; + +const servers = [ + { + url: 'https://open.rocket.chat', + webContentsId: 7, + version: '7.6.0', + outlookCredentials: { + userId: 'u1', + login: 'user@example.com', + password: 'secret', + serverUrl: 'https://exchange.example', + }, + }, +]; + +jest.mock('../../ipc/main', () => ({ + handle: (channel: string, fn: Function) => { + handlers.set(channel, fn); + }, +})); + +jest.mock('../../store', () => ({ + select: jest.fn((selector: any) => + selector({ + servers, + outlookCalendarSyncInterval: 60, + outlookCalendarSyncIntervalOverride: undefined, + allowInsecureOutlookConnections: false, + }) + ), + dispatch: (...args: any[]) => dispatch(...args), + request: (...args: any[]) => request(...args), + listen: jest.fn(), + watch: jest.fn((_sel: any, fn: any) => { + watchFns.push(fn); + return jest.fn(); + }), +})); + +jest.mock('../../app/selectors', () => ({ + selectPersistableValues: (state: any) => state, +})); + +jest.mock('../../ui/main/serverView', () => ({ + getWebContentsByServerUrl: jest.fn(() => ({ + id: 7, + isDestroyed: () => false, + send: jest.fn(), + })), +})); + +jest.mock('../logger', () => ({ + outlookLog: jest.fn(), + outlookError: jest.fn(), + outlookWarn: jest.fn(), + outlookEventDetail: jest.fn(), +})); + +jest.mock('../getOutlookEvents', () => ({ + getOutlookEvents: (...args: any[]) => getOutlookEvents(...args), +})); + +jest.mock('../errorClassification', () => ({ + createClassifiedError: (e: Error) => e, + formatErrorForLogging: (e: Error) => String(e), + generateUserFriendlyMessage: () => 'friendly', +})); + +jest.mock('../../urls', () => ({ + server: (base: string) => ({ + calendarEvents: { + list: `${base}/api/v1/calendar-events.list`, + import: `${base}/api/v1/calendar-events.import`, + update: `${base}/api/v1/calendar-events.update`, + delete: `${base}/api/v1/calendar-events.delete`, + }, + }), +})); + +jest.mock('../../utils', () => ({ + meetsMinimumVersion: () => true, +})); + +const axiosGet = jest.fn(async (..._args: any[]) => ({ + status: 200, + data: { data: [] }, +})) as jest.Mock; +const axiosPost = jest.fn(async (..._args: any[]) => ({ + status: 200, + data: {}, +})) as jest.Mock; +const axiosDelete = jest.fn(async (..._args: any[]) => ({ + status: 200, + data: {}, +})) as jest.Mock; + +jest.mock('axios', () => { + const axios: any = { + get: (...args: any[]) => axiosGet(...args), + post: (...args: any[]) => axiosPost(...args), + delete: (...args: any[]) => axiosDelete(...args), + isAxiosError: (e: any) => !!e?.isAxiosError, + }; + return { __esModule: true, default: axios, ...axios }; +}); + +const encryptString = jest.fn((s: string) => + Buffer.from(`enc:${s}`) +) as jest.Mock; +const decryptString = jest.fn((b: Buffer) => + b.toString().replace(/^enc:/, '') +) as jest.Mock; + +jest.mock('electron', () => ({ + net: { fetch: jest.fn() }, + session: { fromPartition: jest.fn() }, + safeStorage: { + isEncryptionAvailable: jest.fn(() => false), + encryptString: (s: string) => encryptString(s), + decryptString: (b: Buffer) => decryptString(b), + }, + webContents: { + fromId: jest.fn(() => ({ + executeJavaScript: jest.fn(async () => 'token-from-webview'), + })), + }, +})); + +describe('outlookCalendar/ipc', () => { + beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); + watchFns.length = 0; + jest.useFakeTimers(); + getOutlookEvents.mockResolvedValue([]); + axiosGet.mockResolvedValue({ status: 200, data: { data: [] } }); + axiosPost.mockResolvedValue({ status: 200, data: {} }); + axiosDelete.mockResolvedValue({ status: 200, data: {} }); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../ipc').startOutlookCalendarUrlHandler(); + }); + + afterEach(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../ipc').stopOutlookCalendarSync(); + } catch { + // ignore + } + jest.useRealTimers(); + }); + + it('registers outlook calendar IPC handlers', () => { + expect(handlers.has('outlook-calendar/set-user-token')).toBe(true); + expect(handlers.has('outlook-calendar/has-credentials')).toBe(true); + expect(handlers.has('outlook-calendar/clear-credentials')).toBe(true); + expect(handlers.has('outlook-calendar/set-exchange-url')).toBe(true); + expect(handlers.has('outlook-calendar/get-events')).toBe(true); + }); + + it('has-credentials returns false when server missing', async () => { + const result = await handlers.get('outlook-calendar/has-credentials')?.({ + id: 999, + }); + expect(result).toBeFalsy(); + }); + + it('has-credentials returns true for filled credentials', async () => { + const result = await handlers.get('outlook-calendar/has-credentials')?.({ + id: 7, + }); + expect(result).toBe(true); + }); + + it('set-user-token rejects invalid token payloads', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + null, + 'u1' + ); + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'token', + null + ); + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 999 }, + 'token', + 'u1' + ); + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'token', + 'other-user' + ); + + expect(dispatch).not.toHaveBeenCalled(); + expect(getOutlookEvents).not.toHaveBeenCalled(); + }); + + it('set-user-token starts recurring sync and initial debounce', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'user-token', + 'u1' + ); + // flush initial sync debounce + await jest.advanceTimersByTimeAsync(200); + expect(getOutlookEvents).toHaveBeenCalled(); + }); + + it('clear-credentials dispatches empty password credentials', async () => { + await handlers.get('outlook-calendar/clear-credentials')?.({ id: 7 }); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'outlook-calendar/save-credentials', + }) + ); + }); + + it('set-exchange-url saves when url or user changes', async () => { + await handlers.get('outlook-calendar/set-exchange-url')?.( + { id: 7 }, + 'https://new-exchange.example', + 'u2' + ); + expect(dispatch).toHaveBeenCalled(); + }); + + it('get-events rejects without credentials shape', async () => { + // server with empty credentials fields via select override is hard; + // use missing server path + await expect( + handlers.get('outlook-calendar/get-events')?.({ id: 999 }, new Date()) + ).rejects.toThrow('No credentials'); + }); + + it('get-events syncs when token already set via set-user-token', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'user-token', + 'u1' + ); + await jest.advanceTimersByTimeAsync(200); + + getOutlookEvents.mockResolvedValue([ + { + id: 'evt-1', + subject: 'Standup', + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + description: 'daily', + reminderMinutesBeforeStart: 5, + busy: true, + }, + ]); + axiosGet.mockResolvedValue({ + status: 200, + data: { data: [] }, + }); + axiosPost.mockResolvedValue({ status: 200, data: {} }); + + const result = await handlers.get('outlook-calendar/get-events')?.( + { id: 7 }, + new Date() + ); + expect(result).toEqual({ status: 'success' }); + expect(axiosPost).toHaveBeenCalled(); + }); + + it('get-events creates, updates, and deletes events during sync', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'user-token', + 'u1' + ); + await jest.advanceTimersByTimeAsync(200); + + const now = new Date().toISOString(); + getOutlookEvents.mockResolvedValue([ + { + id: 'keep', + subject: 'Updated', + startTime: now, + endTime: now, + description: 'd', + reminderMinutesBeforeStart: 10, + busy: false, + }, + { + id: 'new', + subject: 'New', + startTime: now, + endTime: now, + description: '', + reminderMinutesBeforeStart: 0, + busy: true, + }, + ]); + axiosGet.mockResolvedValue({ + status: 200, + data: { + data: [ + { + _id: 'rc-keep', + externalId: 'keep', + subject: 'Old', + startTime: now, + description: 'old', + }, + { + _id: 'rc-gone', + externalId: 'gone', + subject: 'Gone', + startTime: now, + }, + ], + }, + }); + axiosPost.mockClear(); + axiosPost.mockResolvedValue({ status: 200, data: {} }); + // Deletes are performed via axios.post to the delete endpoint in this + // module — there is no dedicated axios.delete call. + axiosDelete.mockResolvedValue({ status: 200, data: {} }); + + const result = await handlers.get('outlook-calendar/get-events')?.( + { id: 7 }, + new Date() + ); + expect(result).toEqual({ status: 'success' }); + + expect(axiosPost).toHaveBeenCalledWith( + 'https://open.rocket.chat/api/v1/calendar-events.import', + expect.objectContaining({ + externalId: 'new', + subject: 'New', + startTime: now, + description: '', + reminderMinutesBeforeStart: 0, + endTime: now, + busy: true, + }), + expect.anything() + ); + + expect(axiosPost).toHaveBeenCalledWith( + 'https://open.rocket.chat/api/v1/calendar-events.update', + expect.objectContaining({ + eventId: 'rc-keep', + subject: 'Updated', + startTime: now, + description: 'd', + reminderMinutesBeforeStart: 10, + endTime: now, + busy: false, + }), + expect.anything() + ); + + expect(axiosPost).toHaveBeenCalledWith( + 'https://open.rocket.chat/api/v1/calendar-events.delete', + { eventId: 'rc-gone' }, + expect.anything() + ); + }); + + it('get-events fetches token from webContents when missing', async () => { + // Don't call set-user-token; get-events should try webContents path + getOutlookEvents.mockResolvedValue([]); + axiosGet.mockResolvedValue({ status: 200, data: { data: [] } }); + + const result = await handlers.get('outlook-calendar/get-events')?.( + { id: 7 }, + new Date() + ); + expect(result).toEqual({ status: 'success' }); + }); + + it('syncEventsWithRocketChatServer rejects empty token and queues concurrent', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { syncEventsWithRocketChatServer } = require('../ipc'); + const creds = { + userId: 'u1', + login: 'user@example.com', + password: 'secret', + serverUrl: 'https://exchange.example', + }; + + await expect( + syncEventsWithRocketChatServer( + 'https://open.rocket.chat', + creds, + '', + false + ) + ).rejects.toThrow(/Authentication required/); + + getOutlookEvents.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve([]), 50); + }) + ); + axiosGet.mockResolvedValue({ status: 200, data: { data: [] } }); + + const p1 = syncEventsWithRocketChatServer( + 'https://open.rocket.chat', + creds, + 'tok', + false + ); + const p2 = syncEventsWithRocketChatServer( + 'https://open.rocket.chat', + creds, + 'tok', + false + ); + await jest.advanceTimersByTimeAsync(100); + await expect(Promise.all([p1, p2])).resolves.toBeDefined(); + }); + + it('stopOutlookCalendarSync clears state', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { stopOutlookCalendarSync } = require('../ipc'); + expect(() => stopOutlookCalendarSync()).not.toThrow(); + }); + + it('interval watch reschedules when value changes', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'user-token', + 'u1' + ); + await jest.advanceTimersByTimeAsync(200); + getOutlookEvents.mockClear(); + + expect(watchFns.length).toBeGreaterThan(0); + for (const fn of watchFns) { + fn(30, 60); + } + await jest.advanceTimersByTimeAsync(11000); + + expect(getOutlookEvents).toHaveBeenCalled(); + }); +}); diff --git a/src/outlookCalendar/reducers/__tests__/outlookReducers.spec.ts b/src/outlookCalendar/reducers/__tests__/outlookReducers.spec.ts new file mode 100644 index 0000000000..85e6f75858 --- /dev/null +++ b/src/outlookCalendar/reducers/__tests__/outlookReducers.spec.ts @@ -0,0 +1,61 @@ +import { APP_SETTINGS_LOADED } from '../../../app/actions'; +import { SETTINGS_SET_OUTLOOK_CALENDAR_SYNC_INTERVAL_CHANGED } from '../../../ui/actions'; +import { allowInsecureOutlookConnections } from '../allowInsecureOutlookConnections'; +import { outlookCalendarSyncInterval } from '../outlookCalendarSyncInterval'; +import { outlookCalendarSyncIntervalOverride } from '../outlookCalendarSyncIntervalOverride'; + +describe('outlookCalendar reducers', () => { + describe('outlookCalendarSyncInterval', () => { + it('returns default and updates from settings action', () => { + expect(outlookCalendarSyncInterval(undefined, { type: 'x' } as any)).toBe( + 60 + ); + expect( + outlookCalendarSyncInterval(60, { + type: SETTINGS_SET_OUTLOOK_CALENDAR_SYNC_INTERVAL_CHANGED, + payload: 30, + } as any) + ).toBe(30); + expect( + outlookCalendarSyncInterval(60, { + type: APP_SETTINGS_LOADED, + payload: { outlookCalendarSyncInterval: 5 }, + } as any) + ).toBe(5); + }); + }); + + describe('outlookCalendarSyncIntervalOverride', () => { + it('loads override from settings', () => { + expect( + outlookCalendarSyncIntervalOverride(undefined, { type: 'x' } as any) + ).toBeNull(); + expect( + outlookCalendarSyncIntervalOverride(null, { + type: APP_SETTINGS_LOADED, + payload: { outlookCalendarSyncIntervalOverride: 20 }, + } as any) + ).toBe(20); + expect( + outlookCalendarSyncIntervalOverride(20, { + type: APP_SETTINGS_LOADED, + payload: { outlookCalendarSyncIntervalOverride: null }, + } as any) + ).toBeNull(); + }); + }); + + describe('allowInsecureOutlookConnections', () => { + it('loads from settings', () => { + expect( + allowInsecureOutlookConnections(undefined, { type: 'x' } as any) + ).toBe(false); + expect( + allowInsecureOutlookConnections(false, { + type: APP_SETTINGS_LOADED, + payload: { allowInsecureOutlookConnections: true }, + } as any) + ).toBe(true); + }); + }); +}); diff --git a/src/screenSharing/__tests__/resolveStandaloneOriginWindow.main.spec.ts b/src/screenSharing/__tests__/resolveStandaloneOriginWindow.main.spec.ts new file mode 100644 index 0000000000..cc260f424c --- /dev/null +++ b/src/screenSharing/__tests__/resolveStandaloneOriginWindow.main.spec.ts @@ -0,0 +1,69 @@ +import { BrowserWindow, webContents as electronWebContents } from 'electron'; + +import { resolveStandaloneOriginWindow } from '../serverViewScreenSharing'; + +jest.mock('electron', () => ({ + BrowserWindow: { + fromWebContents: jest.fn(), + }, + webContents: { + fromFrame: jest.fn(), + }, +})); + +jest.mock('../../ipc/main', () => ({ handle: jest.fn() })); +jest.mock('../../navigation/main', () => ({ + isProtocolAllowed: jest.fn(), +})); +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(), +})); +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: jest.fn(), +})); +jest.mock('../ScreenSharingRequestTracker', () => ({ + ScreenSharingRequestTracker: jest.fn().mockImplementation(() => ({ + createRequest: jest.fn(), + })), +})); +jest.mock('../desktopCapturerCache', () => ({ + prewarmDesktopCapturerCache: jest.fn(), +})); +jest.mock('../popoutPickerRequest', () => ({ + requestViaPickerWindow: jest.fn(), +})); +jest.mock('../screenRecordingPermission', () => ({ + checkScreenRecordingPermission: jest.fn(), +})); + +describe('resolveStandaloneOriginWindow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when frame is missing', () => { + expect(resolveStandaloneOriginWindow(null)).toBeNull(); + expect(resolveStandaloneOriginWindow(undefined)).toBeNull(); + }); + + it('returns null when frame has no webContents', () => { + (electronWebContents.fromFrame as jest.Mock).mockReturnValue(null); + expect(resolveStandaloneOriginWindow({} as any)).toBeNull(); + }); + + it('returns null for guest webview contents (hostWebContents set)', () => { + (electronWebContents.fromFrame as jest.Mock).mockReturnValue({ + hostWebContents: {}, + }); + expect(resolveStandaloneOriginWindow({} as any)).toBeNull(); + }); + + it('returns BrowserWindow for standalone origin contents', () => { + const wc = { hostWebContents: undefined }; + const win = { id: 1 }; + (electronWebContents.fromFrame as jest.Mock).mockReturnValue(wc); + (BrowserWindow.fromWebContents as jest.Mock).mockReturnValue(win); + expect(resolveStandaloneOriginWindow({} as any)).toBe(win); + expect(BrowserWindow.fromWebContents).toHaveBeenCalledWith(wc); + }); +}); diff --git a/src/screenSharing/__tests__/screenSharePicker.spec.tsx b/src/screenSharing/__tests__/screenSharePicker.spec.tsx new file mode 100644 index 0000000000..9d8fcb2ef3 --- /dev/null +++ b/src/screenSharing/__tests__/screenSharePicker.spec.tsx @@ -0,0 +1,221 @@ +import '@testing-library/jest-dom'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; + +import { ScreenSharePicker } from '../screenSharePicker'; + +const invoke = jest.fn(); +const send = jest.fn(); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: any[]) => invoke(...args), + send: (...args: any[]) => send(...args), + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../ui/components/Dialog', () => ({ + Dialog: ({ isVisible, onClose, children }: any) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +jest.mock('@rocket.chat/fuselage', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + const passthrough = + (tag = 'div') => + ({ children, onClick, ...props }: any) => + React.createElement(tag, { onClick, ...props }, children); + const Tabs = ({ children }: any) =>
{children}
; + Tabs.Item = ({ children, onClick, selected }: any) => ( + + ); + return { + Box: passthrough('div'), + Button: ({ children, onClick, disabled, ...rest }: any) => ( + + ), + Callout: ({ children, title }: any) => ( +
+ {title} + {children} +
+ ), + Label: passthrough('label'), + Tabs, + Scrollable: passthrough('div'), + PaletteStyleTag: () => null, + }; +}); + +const sources = [ + { + id: 'screen:0:0', + name: 'Entire Screen', + thumbnail: { + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,aaa', + }, + }, + { + id: 'window:1:0', + name: 'Chrome', + thumbnail: { + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,bbb', + }, + }, +]; + +describe('ScreenSharePicker', () => { + beforeEach(() => { + jest.clearAllMocks(); + invoke.mockImplementation(async (channel: string) => { + if (channel === 'desktop-capturer-get-sources') return sources; + if (channel.includes('permission')) return true; + return undefined; + }); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('mounts, shows sources, selects, and shares', async () => { + let setVisible: ((v: boolean) => void) | undefined; + render( + { + setVisible = fn; + }} + /> + ); + + await act(async () => { + setVisible?.(true); + }); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'desktop-capturer-get-sources', + expect.any(Array) + ); + }); + + fireEvent.click(screen.getByText('screenSharing.applicationWindow')); + fireEvent.click(screen.getByText('screenSharing.entireScreen')); + + const screenTile = await screen.findByText('Entire Screen'); + fireEvent.click(screenTile); + + const share = screen.getByRole('button', { name: 'screenSharing.share' }); + expect(share).toBeEnabled(); + fireEvent.click(share); + expect(send).toHaveBeenCalledWith( + 'video-call-window/screen-sharing-source-responded', + 'screen:0:0' + ); + + // Always close to exercise cancel path cleanup + await act(async () => { + setVisible?.(false); + }); + }); + + it('sends null on dialog close without selection', async () => { + let setVisible: ((v: boolean) => void) | undefined; + render( + { + setVisible = fn; + }} + responseChannel='custom-response' + /> + ); + await act(async () => { + setVisible?.(true); + }); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + fireEvent.click(screen.getByText('dialog-close')); + expect(send).toHaveBeenCalledWith('custom-response', null); + }); + + it('shows permission denied callout when permission false', async () => { + invoke.mockImplementation(async (channel: string) => { + if (channel.includes('permission')) return false; + if (channel === 'desktop-capturer-get-sources') return sources; + return undefined; + }); + let setVisible: ((v: boolean) => void) | undefined; + render( + { + setVisible = fn; + }} + /> + ); + await act(async () => { + setVisible?.(true); + }); + await waitFor(() => { + expect( + screen.getByText('screenSharing.permissionDenied') + ).toBeInTheDocument(); + }); + await act(async () => { + setVisible?.(false); + }); + }); + + it('handles fetchSources failure without crashing', async () => { + invoke.mockImplementation(async (channel: string) => { + if (channel.includes('permission')) return true; + if (channel === 'desktop-capturer-get-sources') { + throw new Error('enum failed'); + } + return undefined; + }); + let setVisible: ((v: boolean) => void) | undefined; + render( + { + setVisible = fn; + }} + /> + ); + await act(async () => { + setVisible?.(true); + }); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + expect(screen.getByTestId('dialog')).toBeInTheDocument(); + expect(send).not.toHaveBeenCalled(); + + await act(async () => { + setVisible?.(false); + }); + expect(send).toHaveBeenCalledWith( + 'video-call-window/screen-sharing-source-responded', + null + ); + }); +}); diff --git a/src/servers/common.spec.ts b/src/servers/__tests__/common.spec.ts similarity index 98% rename from src/servers/common.spec.ts rename to src/servers/__tests__/common.spec.ts index be04a10789..7b6c150eec 100644 --- a/src/servers/common.spec.ts +++ b/src/servers/__tests__/common.spec.ts @@ -1,7 +1,7 @@ import { isServerUrlResolutionResult, ServerUrlResolutionStatus, -} from './common'; +} from '../common'; describe('servers/common', () => { it('returns false for non-array objects', () => { diff --git a/src/servers/__tests__/fetchInfo.spec.ts b/src/servers/__tests__/fetchInfo.spec.ts new file mode 100644 index 0000000000..dd763ef5de --- /dev/null +++ b/src/servers/__tests__/fetchInfo.spec.ts @@ -0,0 +1,80 @@ +import { fetchInfo } from '../renderer'; + +describe('servers/renderer fetchInfo', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('returns resolved url and version from api/info', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + url: 'https://open.rocket.chat/', + }) + .mockResolvedValueOnce({ + ok: true, + url: 'https://open.rocket.chat/api/info', + json: async () => ({ success: true, version: '6.5.0' }), + }) as any; + + const [url, version] = await fetchInfo('https://open.rocket.chat'); + expect(version).toBe('6.5.0'); + expect(url).toBe('https://open.rocket.chat/'); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('sends basic auth when credentials are in the URL', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + url: 'https://user:pass@open.rocket.chat/', + }) + .mockResolvedValueOnce({ + ok: true, + url: 'https://user:pass@open.rocket.chat/api/info', + json: async () => ({ success: true, version: '6.0.0' }), + }) as any; + + await fetchInfo('https://user:pass@open.rocket.chat'); + const firstCall = (global.fetch as jest.Mock).mock.calls[0]; + const { headers }: { headers: Headers } = firstCall[1]; + expect(headers.get('Authorization')).toMatch(/^Basic /); + }); + + it('throws when home response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }) as any; + await expect(fetchInfo('https://missing.example')).rejects.toThrow( + 'Not Found' + ); + }); + + it('throws when api/info response is not ok', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ ok: true, url: 'https://open.rocket.chat/' }) + .mockResolvedValueOnce({ ok: false, statusText: 'Server Error' }) as any; + await expect(fetchInfo('https://open.rocket.chat')).rejects.toThrow( + 'Server Error' + ); + }); + + it('throws when api/info success is false', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ ok: true, url: 'https://open.rocket.chat/' }) + .mockResolvedValueOnce({ + ok: true, + url: 'https://open.rocket.chat/api/info', + json: async () => ({ success: false }), + }) as any; + await expect(fetchInfo('https://open.rocket.chat')).rejects.toThrow(); + }); +}); diff --git a/src/servers/main.spec.ts b/src/servers/main.spec.ts index ae492e31f4..9f2f5e4a43 100644 --- a/src/servers/main.spec.ts +++ b/src/servers/main.spec.ts @@ -1,55 +1,60 @@ -import { ServerUrlResolutionStatus } from './common'; -import { convertToURL, resolveServerUrl } from './main'; - -describe('convertToUrl', () => { - it.each([ - ['localhost', 'https://localhost/'], - ['localhost:3000', 'https://localhost:3000/'], - ['https://localhost', 'https://localhost/'], - ['http://localhost', 'http://localhost/'], - ['https://localhost/', 'https://localhost/'], - ['http://localhost/', 'http://localhost/'], - ['https://localhost/subdir', 'https://localhost/subdir/'], - ['http://localhost:80', 'http://localhost/'], - ['https://localhost:443', 'https://localhost/'], - ])('transforms %s into URL(%s)', (input, expected) => { - const result = convertToURL(input); - - expect(result.href).toBe(expected); - }); -}); +import { convertToURL } from './main'; -jest.mock('../ui/main/rootWindow', () => ({ - __esModule: true, - getRootWindow: jest.fn(() => ({ webContents: null })), +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp'), + }, +})); + +jest.mock('../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat', version: '4.0.0' }, })); jest.mock('../ipc/main', () => ({ - __esModule: true, - invoke: jest.fn(async (_webContents, channel, ...args) => { - if (channel === 'servers/fetch-info') { - return [args[0], '3.8.0']; - } - - return null; - }), + invoke: jest.fn(), +})); + +jest.mock('../store', () => ({ + select: jest.fn(), + dispatch: jest.fn(), + listen: jest.fn(() => jest.fn()), + watch: jest.fn(() => jest.fn()), +})); + +jest.mock('../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(async () => ({ + webContents: {}, + })), +})); + +jest.mock('../ui/main/serverView', () => ({ + getWebContentsByServerUrl: jest.fn(), })); -describe('resolveServerUrl', () => { - it.each([ - ['localhost', 'https://localhost/'], - ['localhost:3000', 'https://localhost:3000/'], - ['https://localhost', 'https://localhost/'], - ['http://localhost', 'http://localhost/'], - ['https://localhost/', 'https://localhost/'], - ['http://localhost/', 'http://localhost/'], - ['https://localhost/subdir', 'https://localhost/subdir/'], - ['http://localhost:80', 'http://localhost/'], - ['https://localhost:443', 'https://localhost/'], - ])('resolves %s as %s', async (input, expected) => { - const [serverUrl, status, error] = await resolveServerUrl(input); - expect(serverUrl).toBe(expected); - expect(status).toBe(ServerUrlResolutionStatus.OK); - expect(error).toBe(undefined); +describe('servers/main convertToURL', () => { + it('parses absolute http and https urls', () => { + expect(convertToURL('https://open.rocket.chat').href).toBe( + 'https://open.rocket.chat/' + ); + expect(convertToURL('http://localhost:3000').href).toBe( + 'http://localhost:3000/' + ); + }); + + it('prefixes bare hostnames with https', () => { + expect(convertToURL('open.rocket.chat').protocol).toBe('https:'); + expect(convertToURL('open.rocket.chat').hostname).toBe('open.rocket.chat'); + }); + + it('preserves host and path for credentialed urls', () => { + const url = convertToURL('https://user:pass@open.rocket.chat/path'); + expect(url.hostname).toBe('open.rocket.chat'); + expect(url.pathname).toBe('/path/'); + }); + + it('ensures trailing slash on pathname', () => { + expect( + convertToURL('https://open.rocket.chat/channel/general').pathname + ).toBe('/channel/general/'); }); }); diff --git a/src/servers/main/preloadCoverage.main.spec.ts b/src/servers/main/preloadCoverage.main.spec.ts new file mode 100644 index 0000000000..8f96864151 --- /dev/null +++ b/src/servers/main/preloadCoverage.main.spec.ts @@ -0,0 +1,732 @@ +/** + * Exercise preload modules from the main/node Jest project so Istanbul + * coverage counts them under `yarn test:coverage` (renderer preload specs are + * skipped when --coverage is set due to EvalError). + */ +/* eslint-disable @typescript-eslint/no-var-requires -- modules are required + inline per-test, after jest.resetModules(), rather than statically imported */ +import { NOTIFICATIONS_NOTIFICATION_CLICKED } from '../../notifications/actions'; +import { + WEBVIEW_UNREAD_CHANGED, + WEBVIEW_SERVER_VERSION_UPDATED, + WEBVIEW_SERVER_UNIQUE_ID_UPDATED, + WEBVIEW_TITLE_CHANGED, + WEBVIEW_GIT_COMMIT_HASH_CHECK, + WEBVIEW_FORCE_RELOAD_WITH_CACHE_CLEAR, + WEBVIEW_USER_LOGGED_IN, + SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, + WEBVIEW_FOCUS_REQUESTED, +} from '../../ui/actions'; + +const dispatch = jest.fn(); +const request = jest.fn(); +const listen = jest.fn(() => jest.fn()); +const watch = jest.fn(() => jest.fn()); +const select = jest.fn((sel: any) => { + try { + return sel({ + e2ePdfPreviewSizeLimit: 10, + servers: [{ url: 'https://open.rocket.chat' }], + isInternalVideoChatWindowEnabled: true, + navigationLayout: 'sideBar', + }); + } catch { + return undefined; + } +}); + +jest.mock('../../store', () => ({ + dispatch: (...args: any[]) => (dispatch as any)(...args), + request: (...args: any[]) => (request as any)(...args), + listen: (...args: any[]) => (listen as any)(...args), + select: (...args: any[]) => (select as any)(...args), + safeSelect: (sel: any) => { + try { + return select(sel); + } catch { + return undefined; + } + }, + watch: (...args: any[]) => (watch as any)(...args), +})); + +jest.mock('../preload/urls', () => ({ + getServerUrl: jest.fn(() => 'https://open.rocket.chat'), + getAbsoluteUrl: jest.fn((p: string) => + p?.startsWith('http') ? p : `https://open.rocket.chat${p || ''}` + ), +})); + +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: jest.fn(), +})); + +jest.mock('../../ipc/renderer', () => ({ + invoke: jest.fn(async () => 'active'), + invokeWithRetry: jest.fn(async () => ({ language: 'en' })), +})); + +const ipcInvoke = jest.fn(async (..._args: any[]) => undefined) as jest.Mock; +const ipcSend = jest.fn(); +const ipcSendSync = jest.fn((..._args: any[]) => 'jitsi'); +const ipcOn = jest.fn(); +const ipcOnce = jest.fn(); +const ipcRemoveListener = jest.fn(); +const ipcRemoveAllListeners = jest.fn(); +const exposeInMainWorld = jest.fn(); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: any[]) => (ipcInvoke as any)(...args), + send: (...args: any[]) => (ipcSend as any)(...args), + sendSync: (...args: any[]) => (ipcSendSync as any)(...args), + on: (...args: any[]) => (ipcOn as any)(...args), + once: (...args: any[]) => (ipcOnce as any)(...args), + removeListener: (...args: any[]) => (ipcRemoveListener as any)(...args), + removeAllListeners: (...args: any[]) => + (ipcRemoveAllListeners as any)(...args), + }, + contextBridge: { + exposeInMainWorld: (...args: any[]) => (exposeInMainWorld as any)(...args), + }, + webFrame: { setZoomFactor: jest.fn() }, + clipboard: { + writeText: jest.fn(), + readText: jest.fn(() => 'clip'), + }, + nativeImage: { + createFromDataURL: jest.fn(() => ({})), + createFromPath: jest.fn(() => ({})), + }, +})); + +const installDomGlobals = (): void => { + const styleEl = { + id: '', + style: {} as Record, + classList: { add: jest.fn() }, + remove: jest.fn(), + innerHTML: '', + }; + const body = { + append: jest.fn(), + appendChild: jest.fn(), + removeChild: jest.fn(), + }; + const head = { + append: jest.fn(), + appendChild: jest.fn(), + }; + const canvasCtx = { + clearRect: jest.fn(), + drawImage: jest.fn(), + }; + const canvas = { + width: 0, + height: 0, + getContext: jest.fn(() => canvasCtx), + toDataURL: jest.fn(() => 'data:image/png;base64,abc'), + }; + const imageListeners: Record = {}; + const image = { + src: '', + addEventListener: jest.fn((event: string, cb: Function) => { + imageListeners[event] = imageListeners[event] || []; + imageListeners[event].push(cb); + }), + }; + + (global as any).document = { + body, + head: { + ...head, + appendChild: jest.fn((el: any) => { + // Resolve script loads immediately so loadJitsiScript does not hang + if (el && typeof el.onload === 'function') { + queueMicrotask(() => el.onload()); + } + return el; + }), + }, + readyState: 'complete', + createElement: jest.fn((tag: string) => { + if (tag === 'canvas') return canvas; + if (tag === 'img') return image; + if (tag === 'style') return { ...styleEl, style: {} }; + if (tag === 'script') { + return { + src: '', + async: false, + onload: null as null | Function, + onerror: null as null | Function, + }; + } + if (tag === 'div') { + return { + style: {} as Record, + classList: { add: jest.fn(), contains: jest.fn(() => false) }, + remove: jest.fn(), + closest: jest.fn(() => null), + querySelector: jest.fn(() => null), + }; + } + return { style: {}, classList: { add: jest.fn() }, remove: jest.fn() }; + }), + createElementNS: jest.fn(), + getElementById: jest.fn(() => null), + querySelectorAll: jest.fn(() => []), + querySelector: jest.fn(() => null), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + (global as any).window = { + location: { + origin: 'https://open.rocket.chat', + href: 'https://open.rocket.chat/home', + hostname: 'open.rocket.chat', + pathname: '/home', + protocol: 'https:', + }, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + getComputedStyle: jest.fn(() => ({ + background: 'rgb(0,0,0)', + color: 'rgb(255,255,255)', + border: '1px solid #000', + })), + top: { postMessage: jest.fn() }, + localStorage: { + getItem: jest.fn((key: string) => { + if (key === 'Meteor.loginToken') return 'token'; + if (key === 'Meteor.userId') return 'user-1'; + return null; + }), + setItem: jest.fn(), + }, + // Pre-install so initializeJitsiApi skips script load path when set + JitsiMeetExternalAPI: function MockJitsi() { + return { + executeCommand: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispose: jest.fn(), + }; + }, + }; + + (global as any).localStorage = (global as any).window.localStorage; + (global as any).Image = function ImageMock(this: any) { + Object.assign(this, image); + return this; + }; + (global as any).MutationObserver = class { + observe = jest.fn(); + + disconnect = jest.fn(); + + constructor(public cb: Function) {} + }; + (global as any).fetch = jest.fn(async () => ({ + ok: true, + json: async () => ({ roles: ['user', 'admin'] }), + })); +}; + +describe('preload modules coverage (node env)', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + installDomGlobals(); + request.mockResolvedValue('notif-id'); + ipcInvoke.mockResolvedValue(undefined); + }); + + it('covers small servers/preload setters', () => { + const SERVER_URL = 'https://open.rocket.chat'; + + const { setBadge } = require('../preload/badge'); + setBadge(3); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_UNREAD_CHANGED, + payload: { url: SERVER_URL, badge: 3 }, + }); + setBadge('•'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_UNREAD_CHANGED, + payload: { url: SERVER_URL, badge: '•' }, + }); + setBadge(undefined); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_UNREAD_CHANGED, + payload: { url: SERVER_URL, badge: undefined }, + }); + + const { writeTextToClipboard } = require('../preload/clipboard'); + writeTextToClipboard('hello'); + const { clipboard } = require('electron'); + expect(clipboard.writeText).toHaveBeenCalledWith('hello'); + + const { setVersion } = require('../preload/version'); + setVersion('6.5.0'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_SERVER_VERSION_UPDATED, + payload: { url: SERVER_URL, version: '6.5.0' }, + }); + + const { setWorkspaceUID } = require('../preload/uniqueID'); + setWorkspaceUID('uid-1'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_SERVER_UNIQUE_ID_UPDATED, + payload: { url: SERVER_URL, uniqueID: 'uid-1' }, + }); + + const { setTitle } = require('../preload/title'); + setTitle('Community'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_TITLE_CHANGED, + payload: { url: SERVER_URL, title: 'Community' }, + }); + // non-string titles are ignored + dispatch.mockClear(); + setTitle(undefined as unknown as string); + expect(dispatch).not.toHaveBeenCalled(); + + const { setGitCommitHash } = require('../preload/gitCommitHash'); + setGitCommitHash('deadbeef'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_GIT_COMMIT_HASH_CHECK, + payload: { url: SERVER_URL, gitCommitHash: 'deadbeef' }, + }); + + const { reloadServer } = require('../preload/reloadServer'); + reloadServer(); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_FORCE_RELOAD_WITH_CACHE_CLEAR, + payload: SERVER_URL, + }); + + const { openInBrowser } = require('../preload/openInBrowser'); + openInBrowser('https://example.com'); + expect(ipcInvoke).toHaveBeenCalledWith( + 'browser/open-url', + 'https://example.com/' + ); + ipcInvoke.mockClear(); + openInBrowser('javascript:alert(1)'); + expect(ipcInvoke).not.toHaveBeenCalled(); + + const { setUserLoggedIn } = require('../preload/userLoggedIn'); + dispatch.mockClear(); + setUserLoggedIn(true); + // userLoggedIn=true also kicks off the async updateUserRoles() REST + // fallback (fire-and-forget), so only the synchronous dispatch is asserted here. + expect(dispatch).toHaveBeenCalledWith({ + type: WEBVIEW_USER_LOGGED_IN, + payload: { url: SERVER_URL, userLoggedIn: true }, + }); + dispatch.mockClear(); + setUserLoggedIn(false); + // userLoggedIn=false also synchronously calls clearUserRoles(), which + // dispatches a second WEBVIEW_USER_ROLES_CHANGED action right after. + expect(dispatch).toHaveBeenNthCalledWith(1, { + type: WEBVIEW_USER_LOGGED_IN, + payload: { url: SERVER_URL, userLoggedIn: false }, + }); + + const { setUserThemeAppearance } = require('../preload/themeAppearance'); + // No-op kept for desktop-api backwards compatibility: must not throw or dispatch. + dispatch.mockClear(); + expect(() => setUserThemeAppearance('dark' as any)).not.toThrow(); + expect(dispatch).not.toHaveBeenCalled(); + + const { + getE2ePdfPreviewSizeLimit, + } = require('../preload/e2ePdfPreviewSizeLimit'); + expect(getE2ePdfPreviewSizeLimit()).toBe(10); + + const { + openDocumentViewer, + supportedDocumentViewerFormats, + } = require('../preload/documentViewer'); + expect(supportedDocumentViewerFormats()).toEqual(['pdf', 'markdown']); + openDocumentViewer('https://open.rocket.chat/file.pdf', 'pdf', { + page: 1, + }); + expect(ipcInvoke).toHaveBeenCalledWith( + 'document-viewer/open-window', + 'https://open.rocket.chat/file.pdf', + 'pdf', + { page: 1 } + ); + }); + + it('covers favicon and sidebar with DOM mocks', () => { + const { setFavicon } = require('../preload/favicon'); + setFavicon('https://open.rocket.chat/favicon.ico'); + setFavicon(null as any); + + const { + setServerVersionToSidebar, + setBackground, + setSidebarCustomTheme, + } = require('../preload/sidebar'); + setServerVersionToSidebar('6.0.0'); + setBackground('https://open.rocket.chat/bg.png'); + setBackground(''); + setServerVersionToSidebar('6.3.0'); + setBackground('https://open.rocket.chat/bg2.png'); + setSidebarCustomTheme('{"color":"#000"}'); + expect(dispatch).toHaveBeenCalled(); + }); + + it('covers userRoles bridge and REST fallback', async () => { + const { + setUserRoles, + updateUserRoles, + clearUserRoles, + } = require('../preload/userRoles'); + + setUserRoles(['admin', 1, 'user'] as any); + await updateUserRoles(); // bridge already provided → skip + clearUserRoles(); + + // After clear, REST path can run + await updateUserRoles(); + setUserRoles('not-array' as any); + expect(dispatch).toHaveBeenCalled(); + }); + + it('covers internal video chat window open paths', () => { + const { + openInternalVideoChatWindow, + getInternalVideoChatWindowEnabled, + } = require('../preload/internalVideoChatWindow'); + + expect(getInternalVideoChatWindowEnabled()).toBe(true); + + openInternalVideoChatWindow('https://meet.example/room', { + providerName: 'jitsi', + }); + openInternalVideoChatWindow('https://meet.google.com/abc', { + providerName: 'googlemeet', + }); + openInternalVideoChatWindow('https://pexip.example/room', { + providerName: 'pexip', + }); + openInternalVideoChatWindow('https://other.example/room', undefined); + openInternalVideoChatWindow('ftp://bad.example/room', undefined); + + // MAS / disabled path falls back to external + const originalMas = (process as any).mas; + try { + (process as any).mas = true; + openInternalVideoChatWindow('https://meet.example/room', { + providerName: 'jitsi', + }); + } finally { + (process as any).mas = originalMas; + } + + expect(ipcInvoke).toHaveBeenCalled(); + }); + + it('covers notifications/preload createNotification paths', async () => { + const { + createNotification, + destroyNotification, + dispatchCustomNotification, + closeCustomNotification, + listenToNotificationsRequests, + } = require('../../notifications/preload'); + + const onEvent = jest.fn(); + const id = await createNotification({ + title: 'Hello', + body: 'World', + icon: '/static/icon.png', + notificationType: 'text', + category: 'SERVER', + onEvent, + }); + expect(id).toBe('notif-id'); + + await createNotification({ + title: 'Voice', + body: 'Call', + icon: 'data:image/png;base64,abc', + notificationType: 'voice', + category: 'DOWNLOADS', + }); + + await createNotification({ + title: 'No icon', + body: 'x', + }); + + await dispatchCustomNotification({ + type: 'text', + payload: { + title: 'Custom', + body: 'Body', + avatar: 'https://cdn.example/a.png', + requireInteraction: true, + }, + }); + + destroyNotification(id); + closeCustomNotification(id); + listenToNotificationsRequests(); + + const clickedHandler = (listen.mock.calls as any[]).find( + ([matcher]) => matcher === NOTIFICATIONS_NOTIFICATION_CLICKED + )?.[1] as Function; + expect(clickedHandler).toBeDefined(); + + dispatch.mockClear(); + clickedHandler({ + payload: { + id, + serverUrl: 'https://open.rocket.chat', + category: 'DOWNLOADS', + }, + }); + expect(dispatch).toHaveBeenCalledWith({ + type: SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, + }); + + dispatch.mockClear(); + clickedHandler({ + payload: { + id, + serverUrl: 'https://open.rocket.chat', + category: 'SERVER', + }, + }); + expect(dispatch).toHaveBeenCalledWith({ + type: WEBVIEW_FOCUS_REQUESTED, + payload: { url: 'https://open.rocket.chat', view: 'server' }, + }); + + expect(request).toHaveBeenCalled(); + }); + + it('covers navigateToRoute buffering and delivery', () => { + const { + onNavigateToRoute, + listenToNavigateToRouteRequests, + } = require('../preload/navigateToRoute'); + + listenToNavigateToRouteRequests(); + listenToNavigateToRouteRequests(); // idempotent + + expect(ipcOn).toHaveBeenCalledWith( + 'navigate-to-route', + expect.any(Function) + ); + const handler = ipcOn.mock.calls.find( + ([ch]) => ch === 'navigate-to-route' + )?.[1] as Function; + + // Path arrives before callback is registered → buffered + handler({}, '/channel/general'); + const cb = jest.fn(); + onNavigateToRoute(cb); + expect(cb).toHaveBeenCalledWith('/channel/general'); + + // Subsequent path delivered immediately + handler({}, '/group/ops'); + expect(cb).toHaveBeenCalledWith('/group/ops'); + }); + + it('covers outlookCalendar preload success, failure, and fire-and-forget paths', async () => { + const outlook = require('../../outlookCalendar/preload'); + ipcInvoke.mockResolvedValueOnce({ status: 'success', events: [] }); + await outlook.getOutlookEvents(new Date('2026-01-01')); + ipcInvoke.mockRejectedValueOnce(new Error('net')); + await expect(outlook.getOutlookEvents(new Date())).rejects.toThrow(); + ipcInvoke.mockResolvedValue(true); + outlook.setOutlookExchangeUrl('https://exchange.example', 'u1'); + await outlook.hasOutlookCredentials(); + ipcInvoke.mockRejectedValueOnce(new Error('fail')); + await expect(outlook.hasOutlookCredentials()).resolves.toBe(false); + outlook.clearOutlookCredentials(); + outlook.setUserToken('tok', 'u1'); + // rejection paths for fire-and-forget + ipcInvoke.mockRejectedValueOnce(new Error('x')); + outlook.setOutlookExchangeUrl('https://exchange.example', 'u1'); + ipcInvoke.mockRejectedValueOnce(new Error('x')); + outlook.clearOutlookCredentials(); + ipcInvoke.mockRejectedValueOnce(new Error('x')); + outlook.setUserToken('tok', 'u1'); + }); + + describe('userPresence preload', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('covers setUserPresenceDetection and power-monitor handlers', () => { + const { + setUserPresenceDetection, + } = require('../../userPresence/preload'); + jest.useFakeTimers(); + setUserPresenceDetection({ + isAutoAwayEnabled: true, + idleThreshold: 60, + setUserOnline: jest.fn(), + }); + setUserPresenceDetection({ + isAutoAwayEnabled: false, + idleThreshold: null, + setUserOnline: jest.fn(), + }); + // fire power-monitor listen handlers + for (const call of listen.mock.calls as any[]) { + const matcher = call[0]; + const handler = call[1] as Function | undefined; + try { + if (typeof matcher === 'function' && handler) { + handler({ type: 'SYSTEM_SUSPENDING' }); + } + } catch { + // ignore + } + } + jest.runOnlyPendingTimers(); + }); + }); + + it('covers listenToMessageBoxEvents registering a DOM listener', () => { + const { listenToMessageBoxEvents } = require('../../ui/preload/messageBox'); + listenToMessageBoxEvents(); + expect(document.addEventListener).toHaveBeenCalled(); + }); + + it('covers handleTrafficLightsSpacing on darwin and non-darwin platforms', () => { + const { handleTrafficLightsSpacing } = require('../../ui/preload/sidebar'); + const originalPlatform = process.platform; + try { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + handleTrafficLightsSpacing(); + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + handleTrafficLightsSpacing(); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + } + }); + + it('covers listenToScreenSharingRequests registering a window listener', () => { + const { + listenToScreenSharingRequests, + } = require('../../screenSharing/preload'); + listenToScreenSharingRequests(); + expect(window.addEventListener).toHaveBeenCalledWith( + 'get-sourceId', + expect.any(Function) + ); + }); + + it('covers jitsi preload and video-call preload index', async () => { + const jitsi = require('../../jitsi/preload'); + expect(jitsi.desktopCapturer).toBeDefined(); + expect(jitsi.JitsiMeetElectron).toBeDefined(); + + ipcInvoke.mockResolvedValueOnce([ + { + id: 's1', + name: 'Screen', + display_id: '1', + thumbnail: { toDataURL: () => 'data:thumb' }, + appIcon: { toDataURL: () => 'data:icon' }, + }, + ]); + await jitsi.JitsiMeetElectron.obtainDesktopStreams(jest.fn(), jest.fn(), { + types: ['screen'], + }); + ipcInvoke.mockRejectedValueOnce(new Error('denied')); + await jitsi.JitsiMeetElectron.obtainDesktopStreams(jest.fn(), jest.fn(), { + types: ['window'], + }); + + jest.resetModules(); + installDomGlobals(); + ipcSendSync.mockReturnValue('jitsi'); + // Auto-resolve screen-share once listener so requestScreenSharing does not hang + ipcOnce.mockImplementation((channel: string, handler: Function) => { + if (channel === 'video-call-window/screen-sharing-source-responded') { + queueMicrotask(() => handler({}, 'source-1')); + } + }); + require('../../videoCallWindow/preload/index'); + const api = exposeInMainWorld.mock.calls.find( + ([name]) => name === 'videoCallWindow' + )?.[1]; + expect(api).toBeDefined(); + api.openInMainWindow('/channel/general'); + api.openInMainWindow('https://evil.example'); + api.openInMainWindow('//host'); + api.close(); + ipcInvoke.mockResolvedValue(undefined); + await api.requestScreenSharing(); + await api.getAuthCredentials(); + }); + + it('covers jitsiBridge initialize and helpers', async () => { + ipcSendSync.mockReturnValue('jitsi'); + require('../../videoCallWindow/preload/jitsiBridge'); + const b = (window as any).jitsiBridge; + expect(b).toBeTruthy(); + await b.initializeJitsiApi({ domain: '', roomName: '' }); + await b.initializeJitsiApi({ + domain: 'meet.jit.si', + roomName: 'RoomName', + }); + await b.initializeJitsiApi({ + domain: 'meet.jit.si', + roomName: 'RoomName', + }); + expect(b.isInitialized()).toBe(true); + expect(b.getCurrentDomain()).toBe('meet.jit.si'); + expect(b.getCurrentRoomName()).toBe('RoomName'); + await b.startScreenSharing(); + await b.getJitsiVersion(); + + const obtainer = (window as any).JitsiMeetScreenObtainer; + if (obtainer?.openDesktopPicker) { + const success = jest.fn(); + const error = jest.fn(); + obtainer.openDesktopPicker({}, success, error); + const onHandler = (ipcOn.mock.calls as any[]).find( + ([ch]) => ch === 'video-call-window/screen-sharing-source-responded' + )?.[1]; + onHandler?.({}, 'screen:0:0'); + obtainer.openDesktopPicker({}, success, error); + obtainer.openDesktopPicker({}, success, error); + const onHandler2 = (ipcOn.mock.calls as any[]) + .filter( + ([ch]) => ch === 'video-call-window/screen-sharing-source-responded' + ) + .pop()?.[1]; + onHandler2?.({}, null); + } + b.endCall(); + b.dispose(); + expect(window.addEventListener).toHaveBeenCalled(); + }); + + it('skips jitsiBridge when provider is not jitsi', () => { + ipcSendSync.mockReturnValue('pexip'); + const mod = require('../../videoCallWindow/preload/jitsiBridge'); + expect(mod.default).toBeNull(); + }); +}); diff --git a/src/servers/main/resolveServerUrl.main.spec.ts b/src/servers/main/resolveServerUrl.main.spec.ts new file mode 100644 index 0000000000..99a938d652 --- /dev/null +++ b/src/servers/main/resolveServerUrl.main.spec.ts @@ -0,0 +1,88 @@ +import { ServerUrlResolutionStatus } from '../common'; +import { convertToURL, resolveServerUrl } from '../main'; + +jest.mock('electron', () => ({ + app: { getPath: jest.fn(() => '/tmp'), getAppPath: jest.fn(() => '/app') }, +})); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat', version: '4.0.0' }, +})); + +const invoke = jest.fn(); +jest.mock('../../ipc/main', () => ({ + invoke: (...args: any[]) => invoke(...args), +})); + +jest.mock('../../store', () => ({ + select: jest.fn(), + dispatch: jest.fn(), + listen: jest.fn(() => jest.fn()), + watch: jest.fn(() => jest.fn()), +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(async () => ({ webContents: { id: 1 } })), +})); + +jest.mock('../../ui/main/serverView', () => ({ + getWebContentsByServerUrl: jest.fn(), +})); + +describe('resolveServerUrl', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns INVALID_URL for garbage input', async () => { + const [input, status, error] = await resolveServerUrl('://bad'); + expect(status).toBe(ServerUrlResolutionStatus.INVALID_URL); + expect(error).toBeTruthy(); + expect(input).toBe('://bad'); + }); + + it('returns OK for compatible server version', async () => { + invoke.mockResolvedValue(['https://open.rocket.chat/', '6.5.0']); + const [url, status] = await resolveServerUrl('https://open.rocket.chat'); + expect(status).toBe(ServerUrlResolutionStatus.OK); + expect(url).toContain('open.rocket.chat'); + }); + + it('returns INVALID for incompatible server version', async () => { + invoke.mockResolvedValue(['https://open.rocket.chat/', '1.0.0']); + const [, status, error] = await resolveServerUrl( + 'https://open.rocket.chat' + ); + expect(status).toBe(ServerUrlResolutionStatus.INVALID); + expect(String(error?.message || error)).toMatch(/incompatible/i); + }); + + it('returns INVALID when fetch fails for absolute urls', async () => { + invoke.mockRejectedValue(new Error('network')); + const [, status] = await resolveServerUrl('https://down.example'); + expect(status).toBe(ServerUrlResolutionStatus.INVALID); + }); + + it('returns TIMEOUT when fetch aborts', async () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + invoke.mockRejectedValue(err); + const [, status] = await resolveServerUrl('https://slow.example'); + expect(status).toBe(ServerUrlResolutionStatus.TIMEOUT); + }); + + it('convertToURL is used for bare hostnames', () => { + expect(convertToURL('chat.example').hostname).toBe('chat.example'); + }); + + it('retries as a rocket.chat subdomain for bare-word input and returns INVALID on failure', async () => { + invoke.mockRejectedValue(new Error('network')); + const [, status] = await resolveServerUrl('myworkspace'); + expect(status).toBe(ServerUrlResolutionStatus.INVALID); + expect(invoke).toHaveBeenCalledWith( + expect.anything(), + 'servers/fetch-info', + 'https://myworkspace.rocket.chat/' + ); + }); +}); diff --git a/src/servers/main/setupServers.main.spec.ts b/src/servers/main/setupServers.main.spec.ts new file mode 100644 index 0000000000..0b0c2ad9c1 --- /dev/null +++ b/src/servers/main/setupServers.main.spec.ts @@ -0,0 +1,202 @@ +import { + WEBVIEW_GIT_COMMIT_HASH_CHECK, + WEBVIEW_GIT_COMMIT_HASH_CHANGED, +} from '../../ui/actions'; +import { + SERVER_URL_RESOLUTION_REQUESTED, + SERVER_URL_RESOLVED, +} from '../actions'; + +const listenHandlers = new Map(); +const dispatch = jest.fn(); +const select = jest.fn(); +const invoke = jest.fn(); +const getWebContentsByServerUrl = jest.fn(); + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + existsSync: jest.fn(() => false), + promises: { + readFile: jest.fn(async () => { + throw new Error('missing'); + }), + unlink: jest.fn(async () => undefined), + }, + }; +}); + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp/user'), + getAppPath: jest.fn(() => '/app'), + }, +})); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat', version: '4.0.0' }, +})); + +jest.mock('../../ipc/main', () => ({ + invoke: (...args: any[]) => invoke(...args), +})); + +jest.mock('../../store', () => ({ + select: (...args: any[]) => select(...args), + dispatch: (...args: any[]) => dispatch(...args), + listen: (typeOrPredicate: any, handler?: Function) => { + if (handler) { + listenHandlers.set(typeOrPredicate, handler); + } else { + listenHandlers.set('predicate', typeOrPredicate); + } + return jest.fn(); + }, + watch: jest.fn(() => jest.fn()), +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(async () => ({ webContents: { id: 1 } })), +})); + +jest.mock('../../ui/main/serverView', () => ({ + getWebContentsByServerUrl: (...args: any[]) => + getWebContentsByServerUrl(...args), +})); + +describe('setupServers', () => { + beforeEach(() => { + jest.clearAllMocks(); + listenHandlers.clear(); + select.mockImplementation((sel: any) => + sel({ + servers: [ + { + url: 'https://open.rocket.chat', + title: 'Community', + gitCommitHash: 'abc', + }, + ], + currentView: { url: 'https://open.rocket.chat' }, + }) + ); + invoke.mockResolvedValue(['https://open.rocket.chat/', '6.5.0']); + getWebContentsByServerUrl.mockReturnValue({ + session: { + clearStorageData: jest.fn(async () => undefined), + clearCache: jest.fn(async () => undefined), + }, + reload: jest.fn(), + }); + jest.resetModules(); + }); + + it('registers listeners and loads hosts from localStorage string', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({ + 'rocket.chat.hosts': JSON.stringify('https://extra.rocket.chat'), + 'rocket.chat.currentHost': 'https://extra.rocket.chat', + }); + + expect(listenHandlers.size).toBeGreaterThan(0); + expect(dispatch).toHaveBeenCalled(); + }); + + it('loads hosts from localStorage array JSON', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({ + 'rocket.chat.hosts': JSON.stringify( + JSON.stringify(['https://a.example/', 'https://b.example/']) + ), + }); + expect(dispatch).toHaveBeenCalled(); + }); + + it('loads app servers when map empty', async () => { + select.mockImplementation((sel: any) => + sel({ + servers: [], + currentView: 'downloads', + }) + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const freshFs = require('fs'); + (freshFs.promises.readFile as jest.Mock).mockResolvedValueOnce( + JSON.stringify({ Community: 'https://open.rocket.chat' }) + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({}); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + servers: [{ url: 'https://open.rocket.chat', title: 'Community' }], + }), + }) + ); + }); + + it('handles SERVER_URL_RESOLUTION_REQUESTED with meta', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({}); + + const handler = listenHandlers.get(SERVER_URL_RESOLUTION_REQUESTED); + expect(handler).toBeDefined(); + + await handler?.({ + type: SERVER_URL_RESOLUTION_REQUESTED, + payload: 'https://open.rocket.chat', + meta: { id: '1', response: false }, + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: SERVER_URL_RESOLVED, + payload: ['https://open.rocket.chat/', 'ok'], + meta: { response: true, id: '1' }, + }); + }); + + it('handles git commit hash change by clearing guest storage', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({}); + + const handler = listenHandlers.get(WEBVIEW_GIT_COMMIT_HASH_CHECK); + expect(handler).toBeDefined(); + + const session = getWebContentsByServerUrl(); + await handler?.({ + type: WEBVIEW_GIT_COMMIT_HASH_CHECK, + payload: { + url: 'https://open.rocket.chat', + gitCommitHash: 'def', + }, + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: WEBVIEW_GIT_COMMIT_HASH_CHANGED, + payload: { url: 'https://open.rocket.chat', gitCommitHash: 'def' }, + }); + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://open.rocket.chat' + ); + expect(session.session.clearStorageData).toHaveBeenCalledWith({ + storages: ['indexdb'], + }); + expect(session.session.clearCache).toHaveBeenCalled(); + expect(session.reload).toHaveBeenCalled(); + }); + + it('ignores malformed hosts JSON', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({ + 'rocket.chat.hosts': '{not-json', + }); + expect(dispatch).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/components/App.spec.tsx b/src/ui/components/App.spec.tsx new file mode 100644 index 0000000000..699b2c33d7 --- /dev/null +++ b/src/ui/components/App.spec.tsx @@ -0,0 +1,32 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { createStore } from 'redux'; + +import { App } from './App'; + +jest.mock('react-i18next', () => ({ + I18nextProvider: ({ children }: any) => children, + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +jest.mock('i18next', () => ({ + __esModule: true, + default: { t: (k: string) => k, language: 'en' }, +})); + +jest.mock('./Shell', () => ({ + Shell: () =>
shell
, +})); + +jest.mock('./utils/ErrorCatcher', () => ({ + ErrorCatcher: ({ children }: any) => <>{children}, +})); + +describe('App', () => { + it('wraps Shell with store provider', () => { + const store = createStore(() => ({})); + render(); + expect(screen.getByTestId('shell')).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/CertificatesManager/CertificatesManager.spec.tsx b/src/ui/components/CertificatesManager/CertificatesManager.spec.tsx new file mode 100644 index 0000000000..5286805f3a --- /dev/null +++ b/src/ui/components/CertificatesManager/CertificatesManager.spec.tsx @@ -0,0 +1,73 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import ActionButton from './ActionButton'; +import CertificateItem from './CertificateItem'; +import { CertificatesManager } from './CertificatesManager'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const invokeMock = jest.fn(); +jest.mock('../../../ipc/renderer', () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +const makeStore = (partial: Record) => { + const reducer = (state = partial) => state; + return createStore(reducer as any); +}; + +describe('CertificatesManager', () => { + beforeEach(() => { + invokeMock.mockClear(); + }); + + it('renders trusted and not-trusted certificate tables', () => { + const store = makeStore({ + trustedCertificates: { 'https://trusted.example': 'pem' }, + notTrustedCertificates: { 'https://blocked.example': 'pem' }, + }); + + render( + + + + ); + + expect( + screen.getByText('certificatesManager.trustedCertificates') + ).toBeInTheDocument(); + expect( + screen.getByText('certificatesManager.notTrustedCertificates') + ).toBeInTheDocument(); + expect(screen.getByText('https://trusted.example')).toBeInTheDocument(); + expect(screen.getByText('https://blocked.example')).toBeInTheDocument(); + }); + + it('CertificateItem invokes remove channel on action click', () => { + render( + + + + +
+ ); + + fireEvent.click( + screen.getByRole('button', { name: 'certificatesManager.item.remove' }) + ); + expect(invokeMock).toHaveBeenCalledWith( + 'certificatesManager/remove', + 'https://remove.me' + ); + }); + + it('ActionButton renders children', () => { + render(Go); + expect(screen.getByRole('button', { name: 'Go' })).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/FailureImage.spec.tsx b/src/ui/components/FailureImage.spec.tsx new file mode 100644 index 0000000000..2c65338592 --- /dev/null +++ b/src/ui/components/FailureImage.spec.tsx @@ -0,0 +1,23 @@ +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; + +import { FailureImage } from './FailureImage'; + +describe('FailureImage', () => { + it('renders an svg with default colors', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute('viewBox', '0 0 1366 768'); + }); + + it('accepts custom style and color overrides', () => { + const { container } = render( + + ); + const svg = container.querySelector('svg'); + expect(svg).toHaveStyle({ opacity: '0.5' }); + const strokedPath = container.querySelector('path[stroke]'); + expect(strokedPath).toHaveAttribute('stroke', '#000000'); + }); +}); diff --git a/src/ui/components/SelectClientCertificateDialog/index.spec.tsx b/src/ui/components/SelectClientCertificateDialog/index.spec.tsx new file mode 100644 index 0000000000..484a73e553 --- /dev/null +++ b/src/ui/components/SelectClientCertificateDialog/index.spec.tsx @@ -0,0 +1,121 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { SelectClientCertificateDialog } from '.'; +import { + CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED, + SELECT_CLIENT_CERTIFICATE_DIALOG_CERTIFICATE_SELECTED, + SELECT_CLIENT_CERTIFICATE_DIALOG_DISMISSED, +} from '../../../navigation/actions'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../Dialog', () => ({ + Dialog: ({ + children, + isVisible, + onClose, + }: { + children: React.ReactNode; + isVisible?: boolean; + onClose?: () => void; + }) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +const listeners = new Map void>(); + +jest.mock('../../../store', () => ({ + listen: (type: string, listener: (action: any) => void) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, +})); + +jest.mock('../../../store/fsa', () => ({ + isRequest: (action: any) => Boolean(action?.meta?.id), +})); + +const cert = { + subjectName: 'Alice', + issuerName: 'CA', + fingerprint: 'fp-1', + validStart: 1_700_000_000, + validExpiry: 1_800_000_000, +}; + +const makeStore = (partial: Record) => { + const reducer = (state = partial) => state; + return createStore(reducer as any); +}; + +describe('SelectClientCertificateDialog', () => { + beforeEach(() => { + listeners.clear(); + }); + + it('renders certificates when dialog is open', () => { + const store = makeStore({ + openDialog: 'select-client-certificate', + clientCertificates: [cert], + }); + render( + + + + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('CA')).toBeInTheDocument(); + }); + + it('dispatches select and dismiss actions', () => { + const store = makeStore({ + openDialog: 'select-client-certificate', + clientCertificates: [cert], + }); + const spy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + + act(() => { + listeners.get(CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED)?.({ + type: CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED, + meta: { id: 'req-1' }, + }); + }); + + fireEvent.click( + screen.getByRole('button', { + name: 'dialog.selectClientCertificate.select', + }) + ); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: SELECT_CLIENT_CERTIFICATE_DIALOG_CERTIFICATE_SELECTED, + payload: 'fp-1', + }) + ); + + fireEvent.click(screen.getByText('dismiss')); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: SELECT_CLIENT_CERTIFICATE_DIALOG_DISMISSED, + }) + ); + }); +}); diff --git a/src/ui/components/ServerInfoContent.spec.tsx b/src/ui/components/ServerInfoContent.spec.tsx new file mode 100644 index 0000000000..268df6d95e --- /dev/null +++ b/src/ui/components/ServerInfoContent.spec.tsx @@ -0,0 +1,66 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import ServerInfoContent from './ServerInfoContent'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts ? `${key}:${JSON.stringify(opts)}` : key, + i18n: { language: 'en' }, + }), +})); + +jest.mock('../../servers/supportedVersions/main', () => ({ + isServerVersionSupported: jest.fn(async () => ({ + supported: true, + })), + getExpirationMessageTranslated: jest.fn(() => undefined), +})); + +describe('ServerInfoContent', () => { + it('renders url and version labels', () => { + const { unmount } = render( + + ); + expect(screen.getByText('serverInfo.title')).toBeInTheDocument(); + expect(screen.getByText('https://open.rocket.chat')).toBeInTheDocument(); + expect(screen.getByText('6.5.0')).toBeInTheDocument(); + unmount(); + }); + + it('hides title in modal mode and shows exchange url', () => { + const { unmount } = render( + + ); + expect(screen.queryByText('serverInfo.title')).not.toBeInTheDocument(); + expect(screen.getByText('https://exchange.example')).toBeInTheDocument(); + unmount(); + }); + + it('shows loading and error fetch states', () => { + const { rerender, unmount } = render( + + ); + expect(screen.getByText(/serverInfo\.status\.loading/)).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText(/serverInfo\.status\.error/)).toBeInTheDocument(); + unmount(); + }); +}); diff --git a/src/ui/components/ServerInfoModal/index.spec.tsx b/src/ui/components/ServerInfoModal/index.spec.tsx new file mode 100644 index 0000000000..f407cedbbc --- /dev/null +++ b/src/ui/components/ServerInfoModal/index.spec.tsx @@ -0,0 +1,77 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { ServerInfoModal } from '.'; +import { CLOSE_SERVER_INFO_MODAL } from '../../actions'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../ServerInfoContent', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../Dialog', () => ({ + Dialog: ({ + children, + isVisible, + onClose, + }: { + children: React.ReactNode; + isVisible?: boolean; + onClose?: () => void; + }) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +const makeStore = (partial: Record) => + createStore((s = partial) => s as any); + +describe('ServerInfoModal', () => { + it('returns null without server data', () => { + const store = makeStore({ + dialogs: { serverInfoModal: { isOpen: true, serverData: null } }, + }); + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders content and dispatches close', () => { + const store = makeStore({ + dialogs: { + serverInfoModal: { + isOpen: true, + serverData: { + url: 'https://open.rocket.chat', + version: '6.0.0', + }, + }, + }, + }); + const spy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByTestId('server-info-content')).toBeInTheDocument(); + fireEvent.click(screen.getByText('close')); + expect(spy).toHaveBeenCalledWith({ type: CLOSE_SERVER_INFO_MODAL }); + }); +}); diff --git a/src/ui/components/ServersView/DocumentViewer.spec.tsx b/src/ui/components/ServersView/DocumentViewer.spec.tsx new file mode 100644 index 0000000000..7be25cae99 --- /dev/null +++ b/src/ui/components/ServersView/DocumentViewer.spec.tsx @@ -0,0 +1,55 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import DocumentViewer from './DocumentViewer'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('./MarkdownContent', () => ({ + __esModule: true, + default: ({ url }: { url: string }) => ( +
{url}
+ ), +})); + +jest.mock('./PdfContent', () => ({ + __esModule: true, + default: ({ url }: { url: string }) =>
{url}
, +})); + +describe('DocumentViewer', () => { + it('renders markdown content for markdown format', () => { + const close = jest.fn(); + render( + + ); + expect( + screen.getByText('documentViewer.title.markdown') + ).toBeInTheDocument(); + expect(screen.getByTestId('markdown')).toHaveTextContent('file:///doc.md'); + }); + + it('renders pdf content by default and closes on back', () => { + const close = jest.fn(); + render( + + ); + expect(screen.getByText('documentViewer.title.pdf')).toBeInTheDocument(); + expect(screen.getByTestId('pdf')).toHaveTextContent('file:///doc.pdf'); + fireEvent.click( + screen.getByRole('button', { name: 'documentViewer.back' }) + ); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/components/ServersView/ErrorView.spec.tsx b/src/ui/components/ServersView/ErrorView.spec.tsx new file mode 100644 index 0000000000..4fb64b35f3 --- /dev/null +++ b/src/ui/components/ServersView/ErrorView.spec.tsx @@ -0,0 +1,55 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +import ErrorView from './ErrorView'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe('ErrorView', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('renders nothing when not failed', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows reload button with countdown when failed', () => { + render(); + expect(screen.getByText('loadingError.announcement')).toBeInTheDocument(); + expect(screen.getByText('loadingError.title')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /loadingError\.reload/ }) + ).toBeInTheDocument(); + }); + + it('calls onReload when reload button is clicked', () => { + const onReload = jest.fn(); + render(); + fireEvent.click( + screen.getByRole('button', { name: /loadingError\.reload/ }) + ); + expect(onReload).toHaveBeenCalledTimes(1); + }); + + it('auto-reloads when countdown reaches zero', () => { + const onReload = jest.fn(); + render(); + + act(() => { + jest.advanceTimersByTime(60_000); + }); + + expect(onReload).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/components/ServersView/ErrorView.tsx b/src/ui/components/ServersView/ErrorView.tsx index 8bb59793c9..576e86e9be 100644 --- a/src/ui/components/ServersView/ErrorView.tsx +++ b/src/ui/components/ServersView/ErrorView.tsx @@ -56,55 +56,54 @@ const ErrorView = ({ isFailed, onReload }: ErrorViewProps) => { return ( <> - {isFailed || - (isReloading && ( - - - - - - - {t('loadingError.announcement')} + {(isFailed || isReloading) && ( + + + + + + + {t('loadingError.announcement')} - {t('loadingError.title')} - - - + {t('loadingError.title')} + + + - - {isReloading && ( - - - - )} + + {isReloading && ( + + + + )} - {!isReloading && ( - - - - )} - + {!isReloading && ( + + + + )} - ))} + + )} ); }; diff --git a/src/ui/components/ServersView/MarkdownContent.spec.tsx b/src/ui/components/ServersView/MarkdownContent.spec.tsx new file mode 100644 index 0000000000..88e5a2a523 --- /dev/null +++ b/src/ui/components/ServersView/MarkdownContent.spec.tsx @@ -0,0 +1,97 @@ +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; + +import MarkdownContent from './MarkdownContent'; + +const invoke = jest.fn(); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => invoke(...args), + }, + shell: { + openExternal: jest.fn(), + }, +})); + +jest.mock('dompurify', () => ({ + __esModule: true, + default: { + sanitize: (html: string) => html, + }, +})); + +jest.mock('highlight.js', () => ({ + __esModule: true, + default: { + getLanguage: () => true, + highlight: () => ({ value: 'code' }), + }, +})); + +jest.mock('marked', () => { + class Marked { + use() { + return this; + } + + setOptions() { + return this; + } + + parse(text: string) { + return `

${text}

`; + } + } + return { Marked }; +}); + +jest.mock('marked-highlight', () => ({ + markedHighlight: () => ({}), +})); + +describe('MarkdownContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders fetched markdown content', async () => { + invoke.mockResolvedValue('Hello'); + const { unmount } = render( + + ); + + await waitFor( + () => { + expect(screen.getByText('Hello')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + expect(invoke).toHaveBeenCalledWith( + 'document-viewer/fetch-content', + 'https://example.com/doc.md', + 'https://example.com' + ); + unmount(); + }); + + it('shows error message when fetch fails', async () => { + invoke.mockRejectedValue(new Error('network down')); + const { unmount } = render( + + ); + await waitFor( + () => { + expect(screen.getByText('network down')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + unmount(); + }); +}); diff --git a/src/ui/components/ServersView/PdfContent.spec.tsx b/src/ui/components/ServersView/PdfContent.spec.tsx new file mode 100644 index 0000000000..98cc7d2af7 --- /dev/null +++ b/src/ui/components/ServersView/PdfContent.spec.tsx @@ -0,0 +1,45 @@ +import '@testing-library/jest-dom'; +import { act, render } from '@testing-library/react'; + +import PdfContent from './PdfContent'; + +const dispatch = jest.fn(); + +jest.mock('../../../store', () => ({ + dispatch: (...args: any[]) => dispatch(...args), +})); + +describe('PdfContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders webview for a pdf url after delay', () => { + const { container } = render( + + ); + act(() => { + jest.advanceTimersByTime(150); + }); + const webview = container.querySelector('webview'); + expect(webview).not.toBeNull(); + expect(webview?.getAttribute('src')).toBe('file:///doc.pdf'); + }); + + it('clears document when url becomes empty', () => { + const { container, rerender } = render( + + ); + act(() => { + jest.advanceTimersByTime(150); + }); + rerender(); + // The webview element itself stays mounted; only its src is cleared. + expect(container.querySelector('webview')?.getAttribute('src')).toBeFalsy(); + }); +}); diff --git a/src/ui/components/ServersView/ServerPane.spec.tsx b/src/ui/components/ServersView/ServerPane.spec.tsx new file mode 100644 index 0000000000..ffa3e5fdf5 --- /dev/null +++ b/src/ui/components/ServersView/ServerPane.spec.tsx @@ -0,0 +1,103 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { LOADING_ERROR_VIEW_RELOAD_SERVER_CLICKED } from '../../actions'; +import { ServerPane } from './ServerPane'; + +jest.mock('electron', () => ({ + ipcRenderer: { + on: jest.fn(), + removeListener: jest.fn(), + send: jest.fn(), + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('./ErrorView', () => ({ + __esModule: true, + default: ({ isFailed, onReload }: any) => + isFailed ? ( + + ) : null, +})); + +jest.mock('./UnsupportedServer', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('./styles', () => ({ + DocumentViewerWrapper: ({ children }: any) =>
{children}
, + StyledWebView: 'div', + Wrapper: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})); + +const makeStore = () => createStore((s = {}) => s as any); + +describe('ServerPane', () => { + it('renders selected server pane shell', () => { + render( + + + + ); + expect(screen.getByTestId('server-pane')).toBeInTheDocument(); + }); + + it('shows error view when failed and reloads', () => { + const store = makeStore(); + const spy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + fireEvent.click(screen.getByText('error-reload')); + expect(spy).toHaveBeenCalledWith({ + type: LOADING_ERROR_VIEW_RELOAD_SERVER_CLICKED, + payload: { url: 'https://open.rocket.chat' }, + }); + }); + + it('shows unsupported server when not supported', () => { + render( + + + + ); + expect(screen.getByTestId('unsupported')).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/SettingsView/SettingsView.spec.tsx b/src/ui/components/SettingsView/SettingsView.spec.tsx new file mode 100644 index 0000000000..69edbbfcf6 --- /dev/null +++ b/src/ui/components/SettingsView/SettingsView.spec.tsx @@ -0,0 +1,80 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { SettingsView } from './SettingsView'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('./GeneralTab', () => ({ + GeneralTab: () =>
, +})); +jest.mock('./CertificatesTab', () => ({ + CertificatesTab: () =>
, +})); +jest.mock('./VoiceVideoTab', () => ({ + VoiceVideoTab: () =>
, +})); +jest.mock('./DeveloperTab', () => ({ + DeveloperTab: () =>
, +})); + +const makeStore = (partial: Record) => { + const reducer = (state = partial) => state; + return createStore(reducer as any); +}; + +describe('SettingsView', () => { + it('is hidden when currentView is not settings', () => { + const store = makeStore({ + currentView: 'downloads', + isDeveloperModeEnabled: false, + }); + const { container } = render( + + + + ); + expect(container.firstChild).toHaveStyle({ display: 'none' }); + }); + + it('shows general tab by default and switches tabs', () => { + const store = makeStore({ + currentView: 'settings', + isDeveloperModeEnabled: true, + }); + render( + + + + ); + + expect(screen.getByText('settings.title')).toBeInTheDocument(); + expect(screen.getByTestId('general-tab')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('settings.certificates')); + expect(screen.getByTestId('certificates-tab')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('settings.voiceVideo')); + expect(screen.getByTestId('voice-video-tab')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('settings.developer')); + expect(screen.getByTestId('developer-tab')).toBeInTheDocument(); + }); + + it('hides developer tab when developer mode is off', () => { + const store = makeStore({ + currentView: 'settings', + isDeveloperModeEnabled: false, + }); + render( + + + + ); + expect(screen.queryByText('settings.developer')).not.toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/SettingsView/features/ClearPermittedScreenCaptureServers.spec.tsx b/src/ui/components/SettingsView/features/ClearPermittedScreenCaptureServers.spec.tsx new file mode 100644 index 0000000000..d0dafdc818 --- /dev/null +++ b/src/ui/components/SettingsView/features/ClearPermittedScreenCaptureServers.spec.tsx @@ -0,0 +1,48 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { SETTINGS_CLEAR_PERMITTED_SCREEN_CAPTURE_PERMISSIONS } from '../../../actions'; +import { ClearPermittedScreenCaptureServers } from './ClearPermittedScreenCaptureServers'; + +const dispatchMock = jest.fn(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../store', () => ({ + dispatch: (...args: unknown[]) => dispatchMock(...args), +})); + +describe('ClearPermittedScreenCaptureServers', () => { + beforeEach(() => { + dispatchMock.mockClear(); + }); + + it('renders the clear action button', () => { + render(); + expect( + screen.getByRole('button', { + name: 'settings.options.clearPermittedScreenCaptureServers.title', + }) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'settings.options.clearPermittedScreenCaptureServers.description' + ) + ).toBeInTheDocument(); + }); + + it('dispatches clear permissions action on click', () => { + render(); + fireEvent.click( + screen.getByRole('button', { + name: 'settings.options.clearPermittedScreenCaptureServers.title', + }) + ); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: SETTINGS_CLEAR_PERMITTED_SCREEN_CAPTURE_PERMISSIONS, + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/MenuBar.spec.tsx b/src/ui/components/SettingsView/features/MenuBar.spec.tsx new file mode 100644 index 0000000000..6de9dc5803 --- /dev/null +++ b/src/ui/components/SettingsView/features/MenuBar.spec.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED } from '../../../actions'; +import { MenuBar } from './MenuBar'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type PartialState = { + isMenuBarEnabled: boolean; + navigationLayout: 'sidebar' | 'tabs'; +}; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const renderMenuBar = (partial: PartialState) => { + const store = makeStore(partial); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + return { dispatchSpy }; +}; + +describe('MenuBar', () => { + it('renders checked and enabled when sidebar layout allows toggle-off', () => { + renderMenuBar({ isMenuBarEnabled: true, navigationLayout: 'sidebar' }); + const toggle = screen.getByRole('checkbox'); + expect(toggle).toBeChecked(); + expect(toggle).not.toBeDisabled(); + }); + + it('keeps the toggle enabled for tabs layout', () => { + renderMenuBar({ isMenuBarEnabled: true, navigationLayout: 'tabs' }); + expect(screen.getByRole('checkbox')).not.toBeDisabled(); + }); + + it('dispatches SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED on toggle', () => { + const { dispatchSpy } = renderMenuBar({ + isMenuBarEnabled: false, + navigationLayout: 'sidebar', + }); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED, + payload: true, + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/MinimizeOnClose.spec.tsx b/src/ui/components/SettingsView/features/MinimizeOnClose.spec.tsx new file mode 100644 index 0000000000..a494850c1c --- /dev/null +++ b/src/ui/components/SettingsView/features/MinimizeOnClose.spec.tsx @@ -0,0 +1,67 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED } from '../../../actions'; +import { MinimizeOnClose } from './MinimizeOnClose'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type PartialState = { + isMinimizeOnCloseEnabled: boolean; + isTrayIconEnabled: boolean; +}; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const renderMinimizeOnClose = (partial: PartialState) => { + const store = makeStore(partial); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + return { dispatchSpy }; +}; + +describe('MinimizeOnClose', () => { + it('renders enabled when tray icon is off', () => { + renderMinimizeOnClose({ + isMinimizeOnCloseEnabled: false, + isTrayIconEnabled: false, + }); + expect(screen.getByRole('checkbox')).not.toBeDisabled(); + }); + + it('disables toggle and shows hint when tray icon is enabled', () => { + renderMinimizeOnClose({ + isMinimizeOnCloseEnabled: true, + isTrayIconEnabled: true, + }); + expect(screen.getByRole('checkbox')).toBeDisabled(); + expect( + screen.getByText('settings.options.minimizeOnClose.disabledHint') + ).toBeInTheDocument(); + }); + + it('dispatches SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED on toggle', () => { + const { dispatchSpy } = renderMinimizeOnClose({ + isMinimizeOnCloseEnabled: false, + isTrayIconEnabled: false, + }); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED, + payload: true, + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/ToggleField.spec.tsx b/src/ui/components/SettingsView/features/ToggleField.spec.tsx new file mode 100644 index 0000000000..19257827c8 --- /dev/null +++ b/src/ui/components/SettingsView/features/ToggleField.spec.tsx @@ -0,0 +1,72 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { ToggleField } from './ToggleField'; + +describe('ToggleField', () => { + it('renders label, description, and checked toggle', () => { + render( + + ); + + expect(screen.getByText('Label text')).toBeInTheDocument(); + expect(screen.getByText('Description text')).toBeInTheDocument(); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('renders optional hint and children', () => { + render( + + Child content + + ); + + expect(screen.getByText('Hint text')).toBeInTheDocument(); + expect(screen.getByText('Child content')).toBeInTheDocument(); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('invokes onChange when toggled', () => { + const onChange = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('checkbox')); + expect(onChange).toHaveBeenCalled(); + }); + + it('respects disabled prop', () => { + render( + + ); + + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); +}); diff --git a/src/ui/components/SettingsView/features/moreSettings.spec.tsx b/src/ui/components/SettingsView/features/moreSettings.spec.tsx new file mode 100644 index 0000000000..36c7538366 --- /dev/null +++ b/src/ui/components/SettingsView/features/moreSettings.spec.tsx @@ -0,0 +1,244 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { Key } from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { APP_ALLOWED_NTLM_CREDENTIALS_DOMAINS_SET } from '../../../../app/actions'; +import { + SETTINGS_SET_E2E_PDF_PREVIEW_SIZE_LIMIT_CHANGED, + SETTINGS_SET_IS_VIDEO_CALL_SCREEN_CAPTURE_FALLBACK_ENABLED_CHANGED, + SETTINGS_SET_OUTLOOK_CALENDAR_SYNC_INTERVAL_CHANGED, + SETTINGS_NTLM_CREDENTIALS_CHANGED, + SETTINGS_SELECTED_BROWSER_CHANGED, +} from '../../../actions'; +import { AvailableBrowsers } from './AvailableBrowsers'; +import { E2ePdfPreviewSizeLimit } from './E2ePdfPreviewSizeLimit'; +import { NTLMCredentials } from './NTLMCredentials'; +import { OutlookCalendarSyncInterval } from './OutlookCalendarSyncInterval'; +import { ScreenCaptureFallback } from './ScreenCaptureFallback'; +import { ThemeAppearance } from './ThemeAppearance'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Fuselage Select is a custom ARIA dropdown — mock it to a native onChange(e.target.value)} + > + {options.map(([val, label]) => ( + + ))} + + ), + }; +}); + +const makeStore = (partial: Record) => { + const reducer = (state = partial) => state; + return createStore(reducer as any); +}; + +const renderWith = (ui: React.ReactElement, state: Record) => { + const store = makeStore(state); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + render({ui}); + return { dispatchSpy }; +}; + +describe('more settings features', () => { + describe('ScreenCaptureFallback', () => { + it('dispatches toggle and disables when forced', () => { + const { dispatchSpy } = renderWith(, { + isVideoCallScreenCaptureFallbackEnabled: false, + screenCaptureFallbackForced: false, + }); + fireEvent.click(screen.getByRole('checkbox')); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_IS_VIDEO_CALL_SCREEN_CAPTURE_FALLBACK_ENABLED_CHANGED, + payload: true, + }); + }); + + it('is disabled and shows forced description when forced', () => { + renderWith(, { + isVideoCallScreenCaptureFallbackEnabled: false, + screenCaptureFallbackForced: true, + }); + expect(screen.getByRole('checkbox')).toBeDisabled(); + expect( + screen.getByText( + 'settings.options.videoCallScreenCaptureFallback.forcedDescription' + ) + ).toBeInTheDocument(); + }); + }); + + describe('E2ePdfPreviewSizeLimit', () => { + it('clamps and dispatches on change', () => { + const { dispatchSpy } = renderWith(, { + e2ePdfPreviewSizeLimit: 10, + }); + const input = screen.getByRole('spinbutton'); + fireEvent.change(input, { target: { value: '999' } }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_E2E_PDF_PREVIEW_SIZE_LIMIT_CHANGED, + payload: 500, + }); + }); + + it('ignores non-numeric input', () => { + const { dispatchSpy } = renderWith(, { + e2ePdfPreviewSizeLimit: 10, + }); + fireEvent.change(screen.getByRole('spinbutton'), { + target: { value: 'abc' }, + }); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('OutlookCalendarSyncInterval', () => { + it('returns null when overridden', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('dispatches clamped interval', () => { + const { dispatchSpy } = renderWith(, { + outlookCalendarSyncIntervalOverride: null, + outlookCalendarSyncInterval: 15, + }); + fireEvent.change(screen.getByRole('spinbutton'), { + target: { value: '0' }, + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_OUTLOOK_CALENDAR_SYNC_INTERVAL_CHANGED, + payload: 1, + }); + }); + }); + + describe('NTLMCredentials', () => { + it('toggles and updates domains on blur', () => { + const { dispatchSpy } = renderWith(, { + isNTLMCredentialsEnabled: true, + allowedNTLMCredentialsDomains: 'old.com', + }); + fireEvent.click(screen.getByRole('checkbox')); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_NTLM_CREDENTIALS_CHANGED, + payload: false, + }); + + const input = screen.getByPlaceholderText( + '*example.com, *foobar.com, *baz' + ); + fireEvent.blur(input, { target: { value: 'new.com' } }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: APP_ALLOWED_NTLM_CREDENTIALS_DOMAINS_SET, + payload: 'new.com', + }); + }); + }); + + describe('AvailableBrowsers', () => { + it('renders loading placeholder when no browsers', () => { + renderWith(, { + availableBrowsers: [], + selectedBrowser: null, + }); + expect( + screen.getByText('settings.options.availableBrowsers.title') + ).toBeInTheDocument(); + expect( + screen.getByText('settings.options.availableBrowsers.description') + ).toBeInTheDocument(); + expect(screen.getByTestId('available-browsers-select')).toBeDisabled(); + }); + + it('enables select when browsers are available', () => { + renderWith(, { + availableBrowsers: ['Chrome', 'Firefox'], + selectedBrowser: null, + }); + expect( + screen.getByTestId('available-browsers-select') + ).not.toBeDisabled(); + expect( + screen.getByText('settings.options.availableBrowsers.title') + ).toBeInTheDocument(); + }); + + it('dispatches SETTINGS_SELECTED_BROWSER_CHANGED when a browser is selected', () => { + const { dispatchSpy } = renderWith(, { + availableBrowsers: ['Chrome', 'Firefox'], + selectedBrowser: null, + }); + const select = screen.getByTestId('available-browsers-select'); + fireEvent.change(select, { target: { value: 'Firefox' } }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SELECTED_BROWSER_CHANGED, + payload: 'Firefox', + }); + }); + + it('dispatches null payload when system default is selected', () => { + const { dispatchSpy } = renderWith(, { + availableBrowsers: ['Chrome', 'Firefox'], + selectedBrowser: 'Chrome', + }); + const select = screen.getByTestId('available-browsers-select'); + fireEvent.change(select, { target: { value: 'system' } }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SELECTED_BROWSER_CHANGED, + payload: null, + }); + }); + }); + + describe('ThemeAppearance', () => { + it('renders theme preference field', () => { + renderWith(, { userThemePreference: 'auto' }); + expect( + screen.getByText('settings.options.themeAppearance.title') + ).toBeInTheDocument(); + expect( + screen.getByText('settings.options.themeAppearance.description') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/settingsToggles.spec.tsx b/src/ui/components/SettingsView/features/settingsToggles.spec.tsx new file mode 100644 index 0000000000..8c76257606 --- /dev/null +++ b/src/ui/components/SettingsView/features/settingsToggles.spec.tsx @@ -0,0 +1,152 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ComponentType } from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { + SETTINGS_SET_DEBUG_LOGGING_CHANGED, + SETTINGS_SET_DETAILED_EVENTS_LOGGING_CHANGED, + SETTINGS_SET_FLASHFRAME_OPT_IN_CHANGED, + SETTINGS_SET_HARDWARE_ACCELERATION_OPT_IN_CHANGED, + SETTINGS_SET_INTERNALVIDEOCHATWINDOW_OPT_IN_CHANGED, + SETTINGS_SET_IS_TRANSPARENT_WINDOW_ENABLED_CHANGED, + SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED, + SETTINGS_SET_IS_VIDEO_CALL_WINDOW_PERSISTENCE_ENABLED_CHANGED, + SETTINGS_SET_REPORT_OPT_IN_CHANGED, + SETTINGS_SET_VERBOSE_OUTLOOK_LOGGING_CHANGED, +} from '../../../actions'; +import { DebugLogging } from './DebugLogging'; +import { DetailedEventsLogging } from './DetailedEventsLogging'; +import { FlashFrame } from './FlashFrame'; +import { HardwareAcceleration } from './HardwareAcceleration'; +import { InternalVideoChatWindow } from './InternalVideoChatWindow'; +import { ReportErrors } from './ReportErrors'; +import { TransparentWindow } from './TransparentWindow'; +import { TrayIcon } from './TrayIcon'; +import { VerboseOutlookLogging } from './VerboseOutlookLogging'; +import { VideoCallWindowPersistence } from './VideoCallWindowPersistence'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type ToggleCase = { + name: string; + component: ComponentType; + stateKey: string; + actionType: string; + extraState?: Record; +}; + +const makeStore = (partial: Record) => { + const reducer = (state: Record = partial) => state; + return createStore(reducer as any); +}; + +const renderWithState = ( + component: ComponentType, + state: Record +) => { + const Component = component; + const store = makeStore(state); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const view = render( + + + + ); + return { store, dispatchSpy, ...view }; +}; + +const simpleToggles: ToggleCase[] = [ + { + name: 'DebugLogging', + component: DebugLogging, + stateKey: 'isDebugLoggingEnabled', + actionType: SETTINGS_SET_DEBUG_LOGGING_CHANGED, + }, + { + name: 'DetailedEventsLogging', + component: DetailedEventsLogging, + stateKey: 'isDetailedEventsLoggingEnabled', + actionType: SETTINGS_SET_DETAILED_EVENTS_LOGGING_CHANGED, + }, + { + name: 'HardwareAcceleration', + component: HardwareAcceleration, + stateKey: 'isHardwareAccelerationEnabled', + actionType: SETTINGS_SET_HARDWARE_ACCELERATION_OPT_IN_CHANGED, + }, + { + name: 'ReportErrors', + component: ReportErrors, + stateKey: 'isReportEnabled', + actionType: SETTINGS_SET_REPORT_OPT_IN_CHANGED, + }, + { + name: 'TrayIcon', + component: TrayIcon, + stateKey: 'isTrayIconEnabled', + actionType: SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED, + }, + { + name: 'TransparentWindow', + component: TransparentWindow, + stateKey: 'isTransparentWindowEnabled', + actionType: SETTINGS_SET_IS_TRANSPARENT_WINDOW_ENABLED_CHANGED, + }, + { + name: 'VerboseOutlookLogging', + component: VerboseOutlookLogging, + stateKey: 'isVerboseOutlookLoggingEnabled', + actionType: SETTINGS_SET_VERBOSE_OUTLOOK_LOGGING_CHANGED, + }, + { + name: 'VideoCallWindowPersistence', + component: VideoCallWindowPersistence, + stateKey: 'isVideoCallWindowPersistenceEnabled', + actionType: SETTINGS_SET_IS_VIDEO_CALL_WINDOW_PERSISTENCE_ENABLED_CHANGED, + }, + { + name: 'InternalVideoChatWindow', + component: InternalVideoChatWindow, + stateKey: 'isInternalVideoChatWindowEnabled', + actionType: SETTINGS_SET_INTERNALVIDEOCHATWINDOW_OPT_IN_CHANGED, + }, + { + name: 'FlashFrame', + component: FlashFrame, + stateKey: 'isFlashFrameEnabled', + actionType: SETTINGS_SET_FLASHFRAME_OPT_IN_CHANGED, + }, +]; + +describe.each(simpleToggles)( + '$name', + ({ component, stateKey, actionType, extraState = {} }) => { + it('renders unchecked when setting is false', () => { + renderWithState(component, { [stateKey]: false, ...extraState }); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('renders checked when setting is true', () => { + renderWithState(component, { [stateKey]: true, ...extraState }); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('dispatches change action on toggle', () => { + const { dispatchSpy } = renderWithState(component, { + [stateKey]: false, + ...extraState, + }); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: actionType, + payload: true, + }); + }); + } +); diff --git a/src/ui/components/SettingsView/tabs.spec.tsx b/src/ui/components/SettingsView/tabs.spec.tsx new file mode 100644 index 0000000000..e421081ea3 --- /dev/null +++ b/src/ui/components/SettingsView/tabs.spec.tsx @@ -0,0 +1,125 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import { CertificatesTab } from './CertificatesTab'; +import { DeveloperTab } from './DeveloperTab'; +import { GeneralTab } from './GeneralTab'; +import { VoiceVideoTab } from './VoiceVideoTab'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../CertificatesManager', () => ({ + CertificatesManager: () =>
, +})); + +jest.mock('./features/DebugLogging', () => ({ + DebugLogging: () =>
, +})); +jest.mock('./features/DetailedEventsLogging', () => ({ + DetailedEventsLogging: () =>
, +})); +jest.mock('./features/VerboseOutlookLogging', () => ({ + VerboseOutlookLogging: () =>
, +})); + +jest.mock('./features/ThemeAppearance', () => ({ + ThemeAppearance: () =>
, +})); +jest.mock('./features/NavigationLayout', () => ({ + NavigationLayout: () =>
, +})); +jest.mock('./features/TrayIcon', () => ({ + TrayIcon: () =>
, +})); +jest.mock('./features/FlashFrame', () => ({ + FlashFrame: () =>
, +})); +jest.mock('./features/DownloadsPercentage', () => ({ + DownloadsPercentage: () =>
, +})); +jest.mock('./features/AvailableBrowsers', () => ({ + AvailableBrowsers: () =>
, +})); +jest.mock('./features/OutlookCalendarSyncInterval', () => ({ + OutlookCalendarSyncInterval: () =>
, +})); +jest.mock('./features/HardwareAcceleration', () => ({ + HardwareAcceleration: () =>
, +})); +jest.mock('./features/E2ePdfPreviewSizeLimit', () => ({ + E2ePdfPreviewSizeLimit: () =>
, +})); +jest.mock('./features/ReportErrors', () => ({ + ReportErrors: () =>
, +})); +jest.mock('./features/TransparentWindow', () => ({ + TransparentWindow: () =>
, +})); +jest.mock('./features/MinimizeOnClose', () => ({ + MinimizeOnClose: () =>
, +})); +jest.mock('./features/MenuBar', () => ({ + MenuBar: () =>
, +})); +jest.mock('./features/NTLMCredentials', () => ({ + NTLMCredentials: () =>
, +})); + +jest.mock('./features/Telephony', () => ({ + Telephony: () =>
, +})); +jest.mock('./features/TelephonyGlobalShortcut', () => ({ + TelephonyGlobalShortcut: () =>
, +})); +jest.mock('./features/TelephonyServer', () => ({ + TelephonyServer: () =>
, +})); +jest.mock('./features/InternalVideoChatWindow', () => ({ + InternalVideoChatWindow: () =>
, +})); +jest.mock('./features/VideoCallWindowPersistence', () => ({ + VideoCallWindowPersistence: () =>
, +})); +jest.mock('./features/ScreenCaptureFallback', () => ({ + ScreenCaptureFallback: () =>
, +})); +jest.mock('./features/ClearPermittedScreenCaptureServers', () => ({ + ClearPermittedScreenCaptureServers: () =>
, +})); + +describe('Settings tabs', () => { + it('CertificatesTab renders certificates manager', () => { + render(); + expect(screen.getByTestId('certificates-manager')).toBeInTheDocument(); + }); + + it('DeveloperTab renders logging section features', () => { + render(); + expect(screen.getByText('settings.sections.logging')).toBeInTheDocument(); + expect(screen.getByTestId('debug-logging')).toBeInTheDocument(); + expect(screen.getByTestId('verbose-outlook')).toBeInTheDocument(); + expect(screen.getByTestId('detailed-events')).toBeInTheDocument(); + }); + + it('GeneralTab mounts core settings groups', () => { + render(); + expect(screen.getByTestId('nav-layout')).toBeInTheDocument(); + expect(screen.getByTestId('tray')).toBeInTheDocument(); + expect(screen.getByTestId('flash')).toBeInTheDocument(); + expect(screen.getByTestId('browsers')).toBeInTheDocument(); + expect(screen.getByTestId('hw')).toBeInTheDocument(); + expect(screen.getByTestId('report')).toBeInTheDocument(); + }); + + it('VoiceVideoTab mounts telephony and video sections', () => { + render(); + expect(screen.getByText('settings.sections.telephony')).toBeInTheDocument(); + expect( + screen.getByText('settings.sections.videoCalls') + ).toBeInTheDocument(); + expect(screen.getByTestId('telephony')).toBeInTheDocument(); + expect(screen.getByTestId('internal-video')).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/TopBar/index.spec.tsx b/src/ui/components/TopBar/index.spec.tsx new file mode 100644 index 0000000000..74e7b51678 --- /dev/null +++ b/src/ui/components/TopBar/index.spec.tsx @@ -0,0 +1,58 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { TopBar } from './index'; + +const renderTopBar = (state: any) => { + const store = createStore(() => state); + return render( + + + + ); +}; + +describe('TopBar', () => { + it('renders main window title', () => { + renderTopBar({ + mainWindowTitle: 'Rocket.Chat Desktop', + isTransparentWindowEnabled: false, + }); + expect(screen.getByText('Rocket.Chat Desktop')).toBeInTheDocument(); + }); + + it('renders with transparent window enabled', () => { + renderTopBar({ + mainWindowTitle: 'Title', + isTransparentWindowEnabled: true, + }); + expect(screen.getByText('Title')).toBeInTheDocument(); + }); + + it('omits the tint background on darwin when transparent window is enabled', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + + try { + renderTopBar({ + mainWindowTitle: 'Title', + isTransparentWindowEnabled: true, + }); + const title = screen.getByText('Title'); + const sidebar = title.closest('.rcx-sidebar--main') as HTMLElement; + expect(getComputedStyle(sidebar).backgroundColor).toBe( + 'rgba(0, 0, 0, 0)' + ); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + } + }); +}); diff --git a/src/ui/components/utils/ErrorCatcher.spec.tsx b/src/ui/components/utils/ErrorCatcher.spec.tsx new file mode 100644 index 0000000000..43170dc749 --- /dev/null +++ b/src/ui/components/utils/ErrorCatcher.spec.tsx @@ -0,0 +1,26 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import { ErrorCatcher } from './ErrorCatcher'; + +const dispatch = jest.fn(); + +jest.mock('../../../store', () => ({ + dispatch: (...args: any[]) => dispatch(...args), +})); + +describe('ErrorCatcher', () => { + it('renders children when no error', () => { + render( + +
ok
+
+ ); + expect(screen.getByText('ok')).toBeInTheDocument(); + }); + + it('renders null children safely', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); +}); diff --git a/src/ui/components/utils/TooltipProvider.spec.tsx b/src/ui/components/utils/TooltipProvider.spec.tsx new file mode 100644 index 0000000000..b419106454 --- /dev/null +++ b/src/ui/components/utils/TooltipProvider.spec.tsx @@ -0,0 +1,90 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { useContext, useState } from 'react'; + +import { TooltipContext } from './TooltipContext'; +import TooltipProvider from './TooltipProvider'; + +jest.mock('@rocket.chat/fuselage-hooks', () => { + return { + useDebouncedState: (initial: unknown) => { + const [state, setState] = useState(initial); + const set = Object.assign( + (value: unknown) => { + setState(value); + }, + { flush: () => undefined } + ); + return [state, set]; + }, + useMediaQuery: () => true, + }; +}); + +jest.mock('./TooltipPortal', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock('./TooltipComponent', () => ({ + TooltipComponent: ({ title }: { title: React.ReactNode }) => ( +
{title}
+ ), +})); + +const Probe = () => { + const ctx = useContext(TooltipContext); + return ( + + ); +}; + +describe('TooltipProvider', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('opens tooltip content via context', () => { + render( + + + + ); + fireEvent.click(screen.getByText('hover-me')); + expect(screen.getByTestId('tooltip-portal')).toBeInTheDocument(); + expect(screen.getByText('Hello tip')).toBeInTheDocument(); + }); + + it('closes tooltip via context close', () => { + render( + + + + ); + fireEvent.click(screen.getByText('hover-me')); + expect(screen.getByText('Hello tip')).toBeInTheDocument(); + + fireEvent.doubleClick(screen.getByText('hover-me')); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(screen.queryByTestId('tooltip-portal')).not.toBeInTheDocument(); + expect(screen.queryByText('Hello tip')).not.toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/utils/createAnchor.renderer.spec.ts b/src/ui/components/utils/createAnchor.renderer.spec.ts new file mode 100644 index 0000000000..cddfa4b5ae --- /dev/null +++ b/src/ui/components/utils/createAnchor.renderer.spec.ts @@ -0,0 +1,29 @@ +import { createAnchor } from './createAnchor'; +import { deleteAnchor } from './deleteAnchor'; + +describe('createAnchor / deleteAnchor', () => { + afterEach(() => { + const el = document.getElementById('test-anchor'); + if (el) { + try { + deleteAnchor(el); + } catch { + el.remove(); + } + } + }); + + it('creates an element with id and reuses existing matching tag', () => { + const a = createAnchor('test-anchor', 'div'); + expect(a.id).toBe('test-anchor'); + expect(a.tagName.toLowerCase()).toBe('div'); + const again = createAnchor('test-anchor', 'div'); + expect(again).toBe(a); + }); + + it('deleteAnchor removes registered element', () => { + const a = createAnchor('test-anchor', 'div'); + deleteAnchor(a); + expect(document.getElementById('test-anchor')).toBeNull(); + }); +}); diff --git a/src/ui/components/utils/getServerDomId.spec.ts b/src/ui/components/utils/getServerDomId.spec.ts new file mode 100644 index 0000000000..a20485f53f --- /dev/null +++ b/src/ui/components/utils/getServerDomId.spec.ts @@ -0,0 +1,22 @@ +import { getServerPanelId, getServerTabId } from './getServerDomId'; + +describe('getServerDomId', () => { + it('builds stable tab and panel ids for the same url', () => { + const url = 'https://open.rocket.chat'; + const tabId = getServerTabId(url); + const panelId = getServerPanelId(url); + + expect(tabId).toMatch(/^workspace-tab-/); + expect(panelId).toMatch(/^workspace-panel-/); + expect(tabId).toBe(getServerTabId(url)); + expect(panelId).toBe(getServerPanelId(url)); + }); + + it('sanitizes non-alphanumeric characters and differentiates urls', () => { + const a = getServerTabId('https://a.example/path'); + const b = getServerTabId('https://b.example/path'); + + expect(a).not.toBe(b); + expect(a).toMatch(/^workspace-tab-[A-Za-z0-9-]+$/); + }); +}); diff --git a/src/ui/components/utils/getServerInitials.spec.ts b/src/ui/components/utils/getServerInitials.spec.ts new file mode 100644 index 0000000000..fb3a371cf8 --- /dev/null +++ b/src/ui/components/utils/getServerInitials.spec.ts @@ -0,0 +1,24 @@ +import { getServerInitials } from './getServerInitials'; + +describe('getServerInitials', () => { + it('returns undefined when title is undefined', () => { + expect( + getServerInitials(undefined, 'https://chat.example') + ).toBeUndefined(); + }); + + it('uses hostname when title contains the full URL', () => { + expect( + getServerInitials('https://open.rocket.chat', 'https://open.rocket.chat') + ).toBe('OR'); + }); + + it('takes up to two alphanumeric initials from the title', () => { + expect(getServerInitials('Acme Corp', 'https://chat.example')).toBe('AC'); + expect(getServerInitials('OnlyOne', 'https://chat.example')).toBe('O'); + }); + + it('ignores non-alphanumeric separators', () => { + expect(getServerInitials('Foo-Bar_Baz', 'https://chat.example')).toBe('FB'); + }); +}); diff --git a/src/ui/main/__tests__/rootWindowGeometry.main.spec.ts b/src/ui/main/__tests__/rootWindowGeometry.main.spec.ts new file mode 100644 index 0000000000..98e8d87db6 --- /dev/null +++ b/src/ui/main/__tests__/rootWindowGeometry.main.spec.ts @@ -0,0 +1,156 @@ +import { screen } from 'electron'; + +import { + applyRootWindowState, + isInsideSomeScreen, + normalizeNumber, +} from '../rootWindow'; + +jest.mock('electron', () => ({ + app: { + quit: jest.fn(), + addListener: jest.fn(), + name: 'Test', + }, + BrowserWindow: jest.fn(), + nativeImage: { + createEmpty: jest.fn(), + createFromPath: jest.fn(), + }, + nativeTheme: { + shouldUseDarkColors: false, + on: jest.fn(), + }, + screen: { + getPrimaryDisplay: jest.fn(() => ({ + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + workAreaSize: { width: 1920, height: 1080 }, + })), + getAllDisplays: jest.fn(() => [ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + { bounds: { x: 1920, y: 0, width: 1920, height: 1080 } }, + ]), + }, +})); + +const select = jest.fn(); +jest.mock('../../../store', () => ({ + select: (...args: unknown[]) => select(...args), + watch: jest.fn(() => jest.fn()), + listen: jest.fn(() => jest.fn()), + dispatchLocal: jest.fn(), + dispatch: jest.fn(), +})); + +jest.mock('../../../app/main/dev', () => ({ + setupRootWindowReload: jest.fn(), +})); + +jest.mock('../icons', () => ({ + getTrayIconPath: jest.fn(), + getAppIconPath: jest.fn(), +})); + +describe('rootWindow geometry helpers', () => { + describe('normalizeNumber', () => { + it('returns finite values unchanged', () => { + expect(normalizeNumber(12)).toBe(12); + expect(normalizeNumber(-3.5)).toBe(-3.5); + }); + + it('maps undefined, 0 and NaN to 0', () => { + expect(normalizeNumber(undefined)).toBe(0); + expect(normalizeNumber(0)).toBe(0); + expect(normalizeNumber(Number.NaN)).toBe(0); + }); + }); + + describe('isInsideSomeScreen', () => { + it('returns true when the rectangle overlaps primary display', () => { + expect( + isInsideSomeScreen({ x: 100, y: 100, width: 800, height: 600 }) + ).toBe(true); + }); + + it('returns true when the rectangle sits on a secondary display', () => { + expect( + isInsideSomeScreen({ x: 2000, y: 50, width: 400, height: 300 }) + ).toBe(true); + }); + + it('returns false when the rectangle is completely off all displays', () => { + expect( + isInsideSomeScreen({ x: -5000, y: -5000, width: 100, height: 100 }) + ).toBe(false); + }); + }); + + describe('applyRootWindowState', () => { + const browserWindow = { + setBounds: jest.fn(), + setMinimumSize: jest.fn(), + maximize: jest.fn(), + unmaximize: jest.fn(), + minimize: jest.fn(), + restore: jest.fn(), + setFullScreen: jest.fn(), + show: jest.fn(), + showInactive: jest.fn(), + // applyRootWindowState early-returns when already visible + isVisible: jest.fn(() => false), + isMinimized: jest.fn(() => false), + isMaximized: jest.fn(() => false), + isFullScreen: jest.fn(() => false), + focus: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + browserWindow.isVisible.mockReturnValue(false); + }); + + it('centers on primary display when saved bounds are off-screen', () => { + select.mockImplementation((selector: any) => + selector({ + rootWindowState: { + focused: true, + visible: false, + maximized: false, + minimized: false, + fullscreen: false, + normal: true, + bounds: { x: -9999, y: -9999, width: 800, height: 600 }, + }, + isTrayIconEnabled: true, + }) + ); + + applyRootWindowState(browserWindow as any); + expect(screen.getPrimaryDisplay).toHaveBeenCalled(); + expect(browserWindow.setBounds).toHaveBeenCalled(); + const bounds = browserWindow.setBounds.mock.calls[0][0]; + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(0); + }); + + it('skips layout when window is already visible', () => { + browserWindow.isVisible.mockReturnValue(true); + select.mockImplementation((selector: any) => + selector({ + rootWindowState: { + focused: true, + visible: true, + maximized: false, + minimized: false, + fullscreen: false, + normal: true, + bounds: { x: 10, y: 10, width: 800, height: 600 }, + }, + isTrayIconEnabled: true, + }) + ); + applyRootWindowState(browserWindow as any); + expect(browserWindow.setBounds).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/ui/main/dock.main.spec.ts b/src/ui/main/dock.main.spec.ts new file mode 100644 index 0000000000..06c7c45855 --- /dev/null +++ b/src/ui/main/dock.main.spec.ts @@ -0,0 +1,112 @@ +export {}; + +const select = jest.fn(); +const watchCallbacks = new Map(); + +jest.mock('electron', () => ({ + app: { + dock: { + setBadge: jest.fn(), + bounce: jest.fn(), + }, + }, +})); + +jest.mock('../../store', () => { + class Service { + protected watch(selector: unknown, cb: Function) { + watchCallbacks.set(selector, cb); + } + + protected initialize(): void {} + + setUp() { + this.initialize(); + } + } + return { + Service, + select: (...args: unknown[]) => select(...args), + }; +}); + +describe('ui/main/dock', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + jest.resetModules(); + }); + + it('exports a service instance', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dock = require('./dock').default; + expect(dock).toBeDefined(); + expect(typeof dock.setUp).toBe('function'); + }); + + it('no-ops initialize on non-darwin platforms', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + watchCallbacks.clear(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dock = require('./dock').default; + expect(() => dock.setUp()).not.toThrow(); + expect(watchCallbacks.size).toBe(0); + }); + + it('registers badge and bounce watches on darwin and drives dock APIs', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + watchCallbacks.clear(); + select.mockImplementation((selector: any) => + selector({ isFlashFrameEnabled: true }) + ); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { app } = require('electron'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dock = require('./dock').default; + dock.setUp(); + + expect(watchCallbacks.size).toBe(2); + + const [badgeTextCallback, badgeCountCallback] = [ + ...watchCallbacks.values(), + ]; + + badgeTextCallback('3'); + expect(app.dock.setBadge).toHaveBeenCalledWith('3'); + + badgeCountCallback(3, 0); + expect(app.dock.bounce).toHaveBeenCalled(); + }); + + it('does not bounce the dock when flash frame is disabled', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + watchCallbacks.clear(); + select.mockImplementation((selector: any) => + selector({ isFlashFrameEnabled: false }) + ); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { app } = require('electron'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dock = require('./dock').default; + dock.setUp(); + + const [, badgeCountCallback] = [...watchCallbacks.values()]; + badgeCountCallback(3, 0); + expect(app.dock.bounce).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/main/menuBar.spec.ts b/src/ui/main/menuBar.main.spec.ts similarity index 61% rename from src/ui/main/menuBar.spec.ts rename to src/ui/main/menuBar.main.spec.ts index 87b96f5698..f455d12a2a 100644 --- a/src/ui/main/menuBar.spec.ts +++ b/src/ui/main/menuBar.main.spec.ts @@ -2,6 +2,7 @@ import type { MenuItemConstructorOptions } from 'electron'; import { DOWNLOADS_SIMULATION_REQUESTED } from '../../downloads/actions'; import type { Server } from '../../servers/common'; +import { dispatch } from '../../store'; import type { RootState } from '../../store/rootReducer'; import { UPDATES_CHECK_FOR_UPDATES_REQUESTED, @@ -15,28 +16,36 @@ import { selectMenuBarTemplateAsJson, selectServerSwitcherMenuTemplate, } from './menuBar'; +import { getRootWindow } from './rootWindow'; jest.mock('electron', () => ({ app: { name: 'Rocket.Chat', + quit: jest.fn(), commandLine: { hasSwitch: jest.fn(() => false) }, getPath: jest.fn(() => ''), + showAboutPanel: jest.fn(), }, shell: { showItemInFolder: jest.fn(), + openExternal: jest.fn(), }, BrowserWindow: { getAllWindows: jest.fn(() => []), getFocusedWindow: jest.fn(() => null), }, Menu: { - buildFromTemplate: jest.fn(), + buildFromTemplate: jest.fn((template) => ({ + popup: jest.fn(), + template, + })), setApplicationMenu: jest.fn(), }, })); jest.mock('i18next', () => ({ - t: (key: string) => key, + t: (key: string, opts?: { appName?: string }) => + opts?.appName ? `${key}:${opts.appName}` : key, })); jest.mock('../../app/main/app', () => ({ @@ -52,32 +61,60 @@ jest.mock('../../videoCallWindow/ipc', () => ({ })); jest.mock('./dialogs', () => ({ - askForAppDataReset: jest.fn(), + askForAppDataReset: jest.fn().mockResolvedValue(false), })); +const mockBrowserWindow = { + isVisible: jest.fn(() => true), + showInactive: jest.fn(), + show: jest.fn(), + focus: jest.fn(), + hide: jest.fn(), + minimize: jest.fn(), + maximize: jest.fn(), + unmaximize: jest.fn(), + close: jest.fn(), + isFullScreen: jest.fn(() => false), + setFullScreen: jest.fn(), + getNormalBounds: jest.fn(() => ({ x: 0, y: 0, width: 1000, height: 600 })), + getBounds: jest.fn(() => ({ x: 0, y: 0, width: 1000, height: 600 })), + webContents: { + openDevTools: jest.fn(), + toggleDevTools: jest.fn(), + reload: jest.fn(), + reloadIgnoringCache: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + zoomIn: jest.fn(), + zoomOut: jest.fn(), + setZoomLevel: jest.fn(), + getZoomLevel: jest.fn(() => 0), + }, + setMenu: jest.fn(), + setMenuBarVisibility: jest.fn(), + autoHideMenuBar: false, +}; + jest.mock('./rootWindow', () => ({ - getRootWindow: jest.fn(), + getRootWindow: jest.fn(async () => mockBrowserWindow), })); jest.mock('./serverView', () => ({ - getWebContentsByServerUrl: jest.fn(), + getWebContentsByServerUrl: jest.fn(() => ({ + reload: jest.fn(), + reloadIgnoringCache: jest.fn(), + openDevTools: jest.fn(), + })), })); -jest.mock('../../store', () => ({ - dispatch: jest.fn(), - select: jest.fn(), - Service: class Service { - protected initialize(): void {} - - setUp(): void { - this.initialize(); - } - }, -})); - -const createServer = (url: string, title: string): Server => ({ +const createServer = ( + url: string, + title: string, + extras: Partial = {} +): Server => ({ url, title, + ...extras, }); const createState = (overrides: Partial = {}): RootState => @@ -88,7 +125,7 @@ const createState = (overrides: Partial = {}): RootState => isMenuBarEnabled: true, isAddNewServersEnabled: true, isShowWindowOnUnreadChangedEnabled: false, - isDeveloperModeEnabled: false, + isDeveloperModeEnabled: true, isVideoCallDevtoolsAutoOpenEnabled: false, navigationLayout: 'tabs', rootWindowState: { @@ -103,6 +140,41 @@ const createState = (overrides: Partial = {}): RootState => ...overrides, }) as RootState; +jest.mock('../../store', () => ({ + dispatch: jest.fn(), + select: jest.fn((selector: (state: any) => unknown) => + selector({ + servers: [], + currentView: 'downloads', + isTrayIconEnabled: true, + isMenuBarEnabled: true, + isAddNewServersEnabled: true, + isShowWindowOnUnreadChangedEnabled: false, + isDeveloperModeEnabled: true, + isVideoCallDevtoolsAutoOpenEnabled: false, + navigationLayout: 'tabs', + rootWindowState: { + focused: true, + visible: true, + maximized: false, + minimized: false, + fullscreen: false, + normal: true, + bounds: { x: undefined, y: undefined, width: 1000, height: 600 }, + }, + }) + ), + Service: class Service { + protected initialize(): void {} + + setUp(): void { + this.initialize(); + } + }, + watch: jest.fn(), + listen: jest.fn(), +})); + const findMenu = ( template: MenuItemConstructorOptions[], id: string @@ -114,7 +186,30 @@ const findMenu = ( return menu; }; +const collectClickableItems = ( + items: MenuItemConstructorOptions[] | undefined, + acc: MenuItemConstructorOptions[] = [] +): MenuItemConstructorOptions[] => { + if (!items) return acc; + for (const item of items) { + if (typeof item.click === 'function') { + acc.push(item); + } + if (Array.isArray(item.submenu)) { + collectClickableItems(item.submenu, acc); + } + } + return acc; +}; + describe('ui/main/menuBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getRootWindow as jest.Mock).mockResolvedValue(mockBrowserWindow); + mockBrowserWindow.isVisible.mockReturnValue(true); + mockBrowserWindow.isFullScreen.mockReturnValue(false); + }); + describe('selectMenuBarTemplateAsJson', () => { it('differs between a 1-server state and a 2-server state', () => { const oneServerState = createState({ @@ -265,6 +360,31 @@ describe('ui/main/menuBar', () => { await expectNext('sidebar', 'hidden'); await expectNext('hidden', 'tabs'); }); + + it('reflects developer mode and tray toggles', () => { + const state = createState({ + isDeveloperModeEnabled: true, + isTrayIconEnabled: false, + isShowWindowOnUnreadChangedEnabled: true, + isMenuBarEnabled: false, + }); + const template = selectMenuBarTemplate(state); + const viewMenu = findMenu( + template as MenuItemConstructorOptions[], + 'viewMenu' + ); + const submenu = viewMenu.submenu as MenuItemConstructorOptions[]; + const ids = submenu.map((item) => item.id).filter(Boolean); + + // Platform-dependent items may be omitted; assert those that exist. + const tray = submenu.find((item) => item.id === 'showTrayIcon'); + if (tray) expect(tray.checked).toBe(false); + const menuBar = submenu.find((item) => item.id === 'showMenuBar'); + if (menuBar) expect(menuBar.checked).toBe(false); + const unread = submenu.find((item) => item.id === 'showOnUnreadMessage'); + if (unread) expect(unread.checked).toBe(true); + expect(ids.length).toBeGreaterThan(3); + }); }); describe('server switcher menu', () => { @@ -449,4 +569,140 @@ describe('ui/main/menuBar', () => { expect(template[removeIndex - 1]?.type).toBe('separator'); }); }); + + describe('full template coverage', () => { + it('builds app/edit/view/window/help menus for multi-server developer state', () => { + const state = createState({ + servers: [ + createServer('https://one.example', 'One', { badge: 3 }), + createServer('https://two.example', 'Two', { failed: true }), + ], + currentView: { url: 'https://one.example' }, + isAddNewServersEnabled: true, + isDeveloperModeEnabled: true, + isVideoCallDevtoolsAutoOpenEnabled: true, + rootWindowState: { + focused: true, + visible: true, + maximized: true, + minimized: false, + fullscreen: false, + normal: false, + bounds: { x: 0, y: 0, width: 1200, height: 800 }, + }, + }); + + const template = selectMenuBarTemplate( + state + ) as MenuItemConstructorOptions[]; + expect(template.map((item) => item.id)).toEqual( + expect.arrayContaining([ + 'appMenu', + 'editMenu', + 'viewMenu', + 'windowMenu', + 'helpMenu', + ]) + ); + + const clickables = collectClickableItems(template); + expect(clickables.length).toBeGreaterThan(15); + }); + + it('invokes click handlers without throwing for common menu actions', async () => { + const state = createState({ + servers: [ + createServer('https://one.example', 'One'), + createServer('https://two.example', 'Two'), + ], + currentView: { url: 'https://one.example' }, + isAddNewServersEnabled: true, + isDeveloperModeEnabled: true, + }); + + const template = selectMenuBarTemplate( + state + ) as MenuItemConstructorOptions[]; + const clickables = collectClickableItems(template); + + const errors: Array<{ id: unknown; error: unknown }> = []; + for (const item of clickables) { + try { + // Handlers must run sequentially to avoid overlapping shared mock + // state (e.g. mockBrowserWindow.isVisible toggling mid-iteration). + // eslint-disable-next-line no-await-in-loop + await Promise.resolve( + item.click?.({} as any, mockBrowserWindow as any, {} as any) + ); + } catch (error) { + // All handlers are backed by fully mocked dependencies + // (getRootWindow, dispatch, shell, app, BrowserWindow, + // openExternal, relaunchApp, askForAppDataReset, + // getWebContentsByServerUrl); none are expected to throw here. + errors.push({ id: item.id, error }); + } + } + + expect(errors).toEqual([]); + expect(dispatch).toHaveBeenCalled(); + expect(getRootWindow).toHaveBeenCalled(); + }); + + it('builds selectAppMenuPopupTemplate and runs its click handlers', async () => { + const state = createState({ + servers: [createServer('https://one.example', 'One')], + isAddNewServersEnabled: true, + }); + const template = selectAppMenuPopupTemplate(state); + expect(template.find((item) => item.id === 'settings')).toBeDefined(); + expect(template.find((item) => item.id === 'downloads')).toBeDefined(); + expect( + template.find((item) => item.id === 'checkForUpdates') + ).toBeDefined(); + + const clickables = collectClickableItems(template); + const errors: Array<{ id: unknown; error: unknown }> = []; + for (const item of clickables) { + try { + // Handlers must run sequentially to avoid overlapping shared mock + // state (see note in the previous test). + // eslint-disable-next-line no-await-in-loop + await Promise.resolve( + item.click?.({} as any, mockBrowserWindow as any, {} as any) + ); + } catch (error) { + // All handlers are backed by fully mocked dependencies; none are + // expected to throw here (see note in the previous test). + errors.push({ id: item.id, error }); + } + } + + expect(errors).toEqual([]); + expect(dispatch).toHaveBeenCalled(); + }); + + it('shows inactive root window before focusing when hidden', async () => { + // Use windowMenu's 'settings' item: it carries the same + // show-if-hidden-then-focus behavior as the darwin-only 'about'/ + // 'preferences' items, but unlike those, it is registered on every + // platform — selectMenuBarTemplate is memoized per createSelector, so a + // test-local process.platform override can't force recomputation of a + // platform-gated item already cached under the runner's real platform. + mockBrowserWindow.isVisible.mockReturnValue(false); + const state = createState({ + isAddNewServersEnabled: true, + }); + const template = selectMenuBarTemplate( + state + ) as MenuItemConstructorOptions[]; + const windowMenu = findMenu(template, 'windowMenu'); + const settings = ( + windowMenu.submenu as MenuItemConstructorOptions[] + ).find((item) => item.id === 'settings'); + expect(settings).toBeDefined(); + await settings?.click?.({} as any, mockBrowserWindow as any, {} as any); + expect(mockBrowserWindow.showInactive).toHaveBeenCalled(); + expect(mockBrowserWindow.focus).toHaveBeenCalled(); + }); + }); }); diff --git a/src/ui/main/touchBar.main.spec.ts b/src/ui/main/touchBar.main.spec.ts new file mode 100644 index 0000000000..62cf9e5368 --- /dev/null +++ b/src/ui/main/touchBar.main.spec.ts @@ -0,0 +1,116 @@ +export {}; + +const select = jest.fn(); +const dispatch = jest.fn(); +const watchCallbacks: Function[] = []; +const getRootWindow = jest.fn(); +const touchBarCtor = jest.fn(() => ({})); +const scrubberCtor = jest.fn((opts) => ({ ...opts, items: [] })); +const popoverCtor = jest.fn((opts) => ({ ...opts })); +const segmentedCtor = jest.fn((opts) => ({ + ...opts, + segments: (opts.segments || []).map((s: any) => ({ ...s })), +})); +const spacerCtor = jest.fn(() => ({})); + +jest.mock('electron', () => ({ + app: { + getAppPath: jest.fn(() => '/app'), + }, + nativeImage: { + createFromPath: jest.fn(() => ({})), + createFromDataURL: jest.fn(() => ({})), + createEmpty: jest.fn(() => ({})), + }, + TouchBar: Object.assign(touchBarCtor, { + TouchBarScrubber: scrubberCtor, + TouchBarPopover: popoverCtor, + TouchBarSegmentedControl: segmentedCtor, + TouchBarSpacer: spacerCtor, + }), +})); + +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +jest.mock('../../store', () => { + class Service { + protected watch(_sel: unknown, cb: Function) { + watchCallbacks.push(cb); + } + + protected initialize(): void {} + + setUp() { + this.initialize(); + } + } + return { + Service, + select: (...args: unknown[]) => select(...args), + dispatch: (...args: unknown[]) => dispatch(...args), + }; +}); + +jest.mock('./rootWindow', () => ({ + getRootWindow: (...args: unknown[]) => getRootWindow(...args), +})); + +describe('ui/main/touchBar', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + jest.clearAllMocks(); + watchCallbacks.length = 0; + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + getRootWindow.mockResolvedValue({ + isVisible: () => true, + showInactive: jest.fn(), + focus: jest.fn(), + setTouchBar: jest.fn(), + }); + select.mockImplementation((selector: any) => + selector({ + servers: [{ url: 'https://a.example', title: 'A', favicon: null }], + currentView: { url: 'https://a.example' }, + isMessageBoxFocused: true, + }) + ); + jest.resetModules(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('no-ops on non-darwin platforms', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./touchBar').default; + service.setUp(); + expect(watchCallbacks).toHaveLength(0); + }); + + it('initializes touch bar service and registers state watches on darwin', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./touchBar').default; + // Override setUp to call initialize if Service mock setUp is empty + // The real Service subclass implements initialize; our mock setUp calls it. + service.setUp(); + expect(scrubberCtor).toHaveBeenCalled(); + expect(popoverCtor).toHaveBeenCalled(); + expect(segmentedCtor).toHaveBeenCalled(); + expect(touchBarCtor).toHaveBeenCalled(); + expect(watchCallbacks.length).toBe(3); + }); +}); diff --git a/src/ui/main/trayIcon.main.spec.ts b/src/ui/main/trayIcon.main.spec.ts new file mode 100644 index 0000000000..fe676bd61c --- /dev/null +++ b/src/ui/main/trayIcon.main.spec.ts @@ -0,0 +1,124 @@ +export {}; + +const select = jest.fn(); +const dispatch = jest.fn(); +const watchCallbacks = new Map(); +const getRootWindow = jest.fn(); +const getTrayIconPath = jest.fn((..._args: any[]) => '/icon.png'); +const getAppIconPath = jest.fn((..._args: any[]) => '/app.png'); + +const trayMethods = { + addListener: jest.fn(), + setImage: jest.fn(), + setTitle: jest.fn(), + setToolTip: jest.fn(), + setContextMenu: jest.fn(), + displayBalloon: jest.fn(), + destroy: jest.fn(), + popUpContextMenu: jest.fn(), +}; + +jest.mock('electron', () => ({ + app: { + name: 'Rocket.Chat', + quit: jest.fn(), + }, + Menu: { + buildFromTemplate: jest.fn((template) => template), + }, + nativeImage: { + createEmpty: jest.fn(() => ({})), + createFromPath: jest.fn(() => ({})), + }, + Tray: jest.fn(() => trayMethods), +})); + +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +jest.mock('../../store', () => { + class Service { + protected watch(selector: unknown, cb: Function) { + watchCallbacks.set(selector, cb); + // immediately invoke with a default to mirror store watch first-fire + try { + cb(true); + } catch { + // ignore + } + } + + protected initialize(): void {} + + setUp() { + this.initialize(); + } + + protected destroy(): void {} + } + return { + Service, + select: (...args: unknown[]) => select(...args), + dispatch: (...args: unknown[]) => dispatch(...args), + watch: jest.fn((selector: unknown, cb: Function) => { + watchCallbacks.set(selector, cb); + return jest.fn(); + }), + }; +}); + +jest.mock('../selectors', () => ({ + selectGlobalBadge: (state: any) => state.globalBadge, +})); + +jest.mock('./icons', () => ({ + getTrayIconPath: (...args: any[]) => getTrayIconPath(...args), + getAppIconPath: (...args: any[]) => getAppIconPath(...args), +})); + +jest.mock('./rootWindow', () => ({ + getRootWindow: () => getRootWindow(), +})); + +describe('ui/main/trayIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + watchCallbacks.clear(); + getRootWindow.mockResolvedValue({ + isVisible: () => true, + show: jest.fn(), + hide: jest.fn(), + }); + select.mockImplementation((selector: any) => { + if (typeof selector === 'function') { + return selector({ + rootWindowState: { visible: true }, + hasHideOnTrayNotificationShown: false, + globalBadge: 3, + }); + } + return undefined; + }); + jest.resetModules(); + }); + + it('exports a tray icon service', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./trayIcon').default; + expect(service).toBeDefined(); + expect(typeof service.setUp).toBe('function'); + }); + + it('creates tray when enabled via initialize watch', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Tray } = require('electron'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./trayIcon').default; + service.setUp(); + // Allow manageTrayIcon promise to resolve + await new Promise((r) => setTimeout(r, 20)); + expect(Tray).toHaveBeenCalled(); + expect(trayMethods.setImage).toHaveBeenCalled(); + }); +}); diff --git a/src/updates/main/setupUpdates.main.spec.ts b/src/updates/main/setupUpdates.main.spec.ts new file mode 100644 index 0000000000..c864a88dea --- /dev/null +++ b/src/updates/main/setupUpdates.main.spec.ts @@ -0,0 +1,168 @@ +import fs from 'fs'; + +import { ABOUT_DIALOG_UPDATE_CHANNEL_CHANGED } from '../../ui/actions'; +// eslint-disable-next-line import/order +import { + UPDATE_SKIPPED, + UPDATES_CHECK_FOR_UPDATES_REQUESTED, + UPDATES_INSTALL_REQUESTED, + UPDATES_SKIP_REQUESTED, +} from '../actions'; + +const listeners = new Map(); +const select = jest.fn(); +const dispatch = jest.fn(); +const autoUpdater = { + logger: null as unknown, + autoDownload: false, + allowPrerelease: false, + channel: 'latest', + checkForUpdates: jest.fn(async () => undefined), + checkForUpdatesAndNotify: jest.fn(async () => undefined), + quitAndInstall: jest.fn(), + downloadUpdate: jest.fn(async () => undefined), + on: jest.fn(), + once: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + updateConfigPath: '', +}; + +jest.mock('fs', () => ({ + promises: { + readFile: jest.fn(async () => '{}'), + }, +})); + +jest.mock('electron', () => ({ + app: { + getAppPath: jest.fn(() => '/app'), + getPath: jest.fn(() => '/userData'), + isPackaged: true, + }, + BrowserWindow: { + getAllWindows: jest.fn(() => []), + getFocusedWindow: jest.fn(() => null), + }, + autoUpdater: { + on: jest.fn(), + }, +})); + +jest.mock('electron-updater', () => ({ + autoUpdater, +})); + +jest.mock('../../store', () => ({ + select: (...args: unknown[]) => select(...args), + dispatch: (...args: unknown[]) => dispatch(...args), + listen: (type: string, fn: Function) => { + listeners.set(type, fn); + return () => listeners.delete(type); + }, +})); + +jest.mock('../../ui/main/dialogs', () => ({ + askUpdateInstall: jest.fn(async () => 0), + AskUpdateInstallResponse: { INSTALL_UPDATE_AND_RESTART: 0 }, + warnAboutInstallUpdateLater: jest.fn(), + warnAboutUpdateDownload: jest.fn(), + warnAboutUpdateSkipped: jest.fn(), +})); + +// Must stay below the `autoUpdater` const and jest.mock('electron-updater', ...) +// above: importing '../main' pulls in electron-updater, whose mock factory +// closes over `autoUpdater` — hoisting this import breaks that initialization order. +// eslint-disable-next-line import/first +import { setupUpdates } from '../main'; + +describe('updates/setupUpdates', () => { + // `isUpdatingAllowed` is computed by the real loadConfiguration() selector + // straight from process.platform/process.mas/process.windowsStore — never + // from the mocked store state — so it varies by CI runner OS unless pinned + // here. Force the win32-without-windowsStore branch deterministically. + const originalPlatform = process.platform; + const originalWindowsStore = process.windowsStore; + + beforeEach(() => { + jest.clearAllMocks(); + listeners.clear(); + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + Object.defineProperty(process, 'windowsStore', { + value: false, + configurable: true, + }); + select.mockImplementation((selector: any) => + selector({ + isUpdatingEnabled: true, + doCheckForUpdatesOnStartup: false, + skippedUpdateVersion: null, + isReportEnabled: true, + isFlashFrameEnabled: true, + isHardwareAccelerationEnabled: true, + isInternalVideoChatWindowEnabled: true, + isVideoCallScreenCaptureFallbackEnabled: false, + updateChannel: 'latest', + isEachUpdatesSettingConfigurable: true, + isUpdatingAllowed: true, + newUpdateVersion: null, + }) + ); + (fs.promises.readFile as jest.Mock).mockResolvedValue('{}'); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + Object.defineProperty(process, 'windowsStore', { + value: originalWindowsStore, + configurable: true, + }); + }); + + it('wires autoUpdater and action listeners', async () => { + await setupUpdates(); + // electron-updater may use on() and/or addListener() + expect( + (autoUpdater.on as jest.Mock).mock.calls.length + + (autoUpdater.addListener as jest.Mock).mock.calls.length + ).toBeGreaterThan(0); + expect(listeners.has(UPDATES_CHECK_FOR_UPDATES_REQUESTED)).toBe(true); + expect(listeners.has(UPDATES_SKIP_REQUESTED)).toBe(true); + expect(listeners.has(UPDATES_INSTALL_REQUESTED)).toBe(true); + expect(listeners.has(ABOUT_DIALOG_UPDATE_CHANNEL_CHANGED)).toBe(true); + }); + + it('checks for updates when requested', async () => { + await setupUpdates(); + await listeners.get(UPDATES_CHECK_FOR_UPDATES_REQUESTED)?.({ + type: UPDATES_CHECK_FOR_UPDATES_REQUESTED, + }); + expect(autoUpdater.checkForUpdates).toHaveBeenCalled(); + }); + + it('dispatches UPDATE_SKIPPED when skip dialog action fires', async () => { + await setupUpdates(); + await listeners.get(UPDATES_SKIP_REQUESTED)?.({ + type: UPDATES_SKIP_REQUESTED, + payload: '9.9.9', + }); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: UPDATE_SKIPPED, + payload: '9.9.9', + }) + ); + }); + + it('loads update.json configuration files', async () => { + await setupUpdates(); + expect(fs.promises.readFile).toHaveBeenCalled(); + }); +}); diff --git a/src/userPresence/main/setup.main.spec.ts b/src/userPresence/main/setup.main.spec.ts new file mode 100644 index 0000000000..b004b510be --- /dev/null +++ b/src/userPresence/main/setup.main.spec.ts @@ -0,0 +1,51 @@ +export {}; + +const handlers = new Map(); +const dispatch = jest.fn(); +const powerListeners = new Map(); + +jest.mock('../../ipc/main', () => ({ + handle: (channel: string, fn: Function) => { + handlers.set(channel, fn); + }, +})); + +jest.mock('../../store', () => ({ + dispatch: (...args: any[]) => dispatch(...args), +})); + +jest.mock('electron', () => ({ + powerMonitor: { + addListener: (event: string, fn: Function) => { + powerListeners.set(event, fn); + }, + getSystemIdleState: jest.fn(() => 'active'), + }, +})); + +describe('userPresence/main setupPowerMonitor', () => { + beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); + powerListeners.clear(); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../main').setupPowerMonitor(); + }); + + it('registers power monitor listeners and idle-state handler', async () => { + expect(powerListeners.has('suspend')).toBe(true); + expect(powerListeners.has('lock-screen')).toBe(true); + expect(handlers.has('power-monitor/get-system-idle-state')).toBe(true); + + powerListeners.get('suspend')?.(); + powerListeners.get('lock-screen')?.(); + expect(dispatch).toHaveBeenCalledTimes(2); + + const state = await handlers.get('power-monitor/get-system-idle-state')?.( + {}, + 60 + ); + expect(state).toBe('active'); + }); +}); diff --git a/src/videoCallWindow/__tests__/screenSharePickerMount.spec.ts b/src/videoCallWindow/__tests__/screenSharePickerMount.spec.ts new file mode 100644 index 0000000000..692a000390 --- /dev/null +++ b/src/videoCallWindow/__tests__/screenSharePickerMount.spec.ts @@ -0,0 +1,63 @@ +const render = jest.fn(); +const createRoot = jest.fn((_el?: any) => ({ + render, + unmount: jest.fn(), +})) as jest.Mock; + +jest.mock('react-dom/client', () => ({ + createRoot: (el: any) => (createRoot as any)(el), +})); + +jest.mock('react-i18next', () => ({ + I18nextProvider: ({ children }: any) => children, +})); + +jest.mock('i18next', () => ({ + __esModule: true, + default: { t: (k: string) => k }, +})); + +jest.mock('../../screenSharing/screenSharePicker', () => ({ + ScreenSharePicker: () => null, +})); + +describe('screenSharePickerMount', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + document.body.innerHTML = ''; + }); + + it('mounts when container exists and is idempotent', () => { + const root = document.createElement('div'); + root.id = 'screen-picker-root'; + document.body.appendChild(root); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('../screenSharePickerMount'); + mod.mount(); + mod.mount(); + expect(createRoot).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalled(); + }); + + it('logs error when container missing', () => { + const err = jest.spyOn(console, 'error').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('../screenSharePickerMount'); + mod.mount(); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); + + it('show mounts when not yet mounted', () => { + const root = document.createElement('div'); + root.id = 'screen-picker-root'; + document.body.appendChild(root); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('../screenSharePickerMount'); + mod.show(); + expect(createRoot).toHaveBeenCalled(); + }); +}); diff --git a/src/videoCallWindow/__tests__/validateVideoCallUrl.spec.ts b/src/videoCallWindow/__tests__/validateVideoCallUrl.spec.ts new file mode 100644 index 0000000000..bef4b41345 --- /dev/null +++ b/src/videoCallWindow/__tests__/validateVideoCallUrl.spec.ts @@ -0,0 +1,25 @@ +import { validateVideoCallUrl } from '../validateVideoCallUrl'; + +describe('validateVideoCallUrl', () => { + it('accepts http and https urls', () => { + expect(validateVideoCallUrl('https://meet.example/room')).toBe( + 'https://meet.example/room' + ); + expect(validateVideoCallUrl('http://localhost:8080/call')).toContain( + 'http://localhost:8080/call' + ); + }); + + it('rejects non-http(s) protocols', () => { + expect(() => validateVideoCallUrl('file:///etc/passwd')).toThrow( + /Invalid URL protocol/ + ); + expect(() => validateVideoCallUrl('javascript:alert(1)')).toThrow( + /Invalid URL protocol/ + ); + }); + + it('rejects malformed urls', () => { + expect(() => validateVideoCallUrl('not a url')).toThrow(/Invalid URL/); + }); +}); diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts index 2845258c52..d05e56a112 100644 --- a/src/videoCallWindow/main/ipc.main.spec.ts +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -317,9 +317,10 @@ const loadModule = async () => { const open = async ( openWindow: (...a: any[]) => any, callerWc: WebContents, - url = 'https://meet.example/room' + url = 'https://meet.example/room', + options?: unknown ) => { - const p = openWindow(callerWc, url, undefined); + const p = openWindow(callerWc, url, options); await flushPromises(); await flushPromises(); await p; @@ -849,6 +850,32 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { }); }); + // ------------------------------------------------------------------------- + // get-provider-sync: 'video-call-window/get-provider-sync' (ipcMain.on) + // ------------------------------------------------------------------------- + it('writes the current provider name to event.returnValue', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + const electron = (await import('electron')) as any; + const call = electron.ipcMain.on.mock.calls.find( + ([channel]: [string]) => channel === 'video-call-window/get-provider-sync' + ); + expect(call).toBeDefined(); + const listener = call[1] as (event: { returnValue: unknown }) => void; + + const eventBeforeOpen = { returnValue: undefined as unknown }; + listener(eventBeforeOpen); + expect(eventBeforeOpen.returnValue).toBeNull(); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room', { + providerName: 'jitsi', + }); + + const eventAfterOpen = { returnValue: undefined as unknown }; + listener(eventAfterOpen); + expect(eventAfterOpen.returnValue).toBe('jitsi'); + }); + // ------------------------------------------------------------------------- // same-conference reopen: focus the existing window instead of recreating // ------------------------------------------------------------------------- diff --git a/src/videoCallWindow/validateVideoCallUrl.ts b/src/videoCallWindow/validateVideoCallUrl.ts new file mode 100644 index 0000000000..804d616b92 --- /dev/null +++ b/src/videoCallWindow/validateVideoCallUrl.ts @@ -0,0 +1,19 @@ +export const validateVideoCallUrl = (url: string): string => { + try { + const parsedUrl = new URL(url); + + const allowedProtocols = ['http:', 'https:']; + if (!allowedProtocols.includes(parsedUrl.protocol)) { + throw new Error( + `Invalid URL protocol: ${parsedUrl.protocol}. Only http: and https: are allowed.` + ); + } + + return parsedUrl.href; + } catch (error) { + if (error instanceof TypeError) { + throw new Error(`Invalid URL format: ${url}`); + } + throw error; + } +}; diff --git a/src/videoCallWindow/video-call-window.ts b/src/videoCallWindow/video-call-window.ts index 6b24484452..b890db7946 100644 --- a/src/videoCallWindow/video-call-window.ts +++ b/src/videoCallWindow/video-call-window.ts @@ -12,6 +12,7 @@ import { InternalPickerProvider, } from '../screenSharing/screenPicker'; import type { ScreenSharePickerModuleType } from './screenSharePickerMount'; +import { validateVideoCallUrl } from './validateVideoCallUrl'; const MAX_INIT_ATTEMPTS = 10; const MAX_RECOVERY_ATTEMPTS = 3; @@ -596,26 +597,6 @@ const setupWebviewEventHandlers = (webview: HTMLElement): void => { webviewElement.addEventListener('crashed', handleCrashed); }; -const validateVideoCallUrl = (url: string): string => { - try { - const parsedUrl = new URL(url); - - const allowedProtocols = ['http:', 'https:']; - if (!allowedProtocols.includes(parsedUrl.protocol)) { - throw new Error( - `Invalid URL protocol: ${parsedUrl.protocol}. Only http: and https: are allowed.` - ); - } - - return parsedUrl.href; - } catch (error) { - if (error instanceof TypeError) { - throw new Error(`Invalid URL format: ${url}`); - } - throw error; - } -}; - const createWebview = (url: string, partition?: string | null): void => { const container = document.getElementById('webview-container'); if (!container) {