From 3d9ee27d8d813101114cb15c448f2307a72eebb3 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 27 Dec 2021 02:15:13 +0100 Subject: test: Add preload unit tests --- packages/preload/package.json | 3 + .../src/contextBridge/SophieRendererImpl.ts | 48 ++--- .../__tests__/SophieRendererImpl.spec.ts | 211 +++++++++++++++++++++ packages/preload/src/index.ts | 3 +- packages/preload/tsconfig.json | 5 +- packages/renderer/src/stores/RendererStore.ts | 8 +- .../shared/src/contextBridge/SophieRenderer.ts | 2 +- 7 files changed, 246 insertions(+), 34 deletions(-) create mode 100644 packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts (limited to 'packages') diff --git a/packages/preload/package.json b/packages/preload/package.json index bd2b412..a4bd75f 100644 --- a/packages/preload/package.json +++ b/packages/preload/package.json @@ -17,6 +17,9 @@ "mobx-state-tree": "^5.1.0" }, "devDependencies": { + "@types/jest": "^27.0.3", + "jest": "^27.4.5", + "jest-mock": "^27.4.2", "rimraf": "^3.0.2", "typescript": "^4.5.4", "vite": "^2.7.6" diff --git a/packages/preload/src/contextBridge/SophieRendererImpl.ts b/packages/preload/src/contextBridge/SophieRendererImpl.ts index 153fdd1..61b01e9 100644 --- a/packages/preload/src/contextBridge/SophieRendererImpl.ts +++ b/packages/preload/src/contextBridge/SophieRendererImpl.ts @@ -30,52 +30,46 @@ import { import { RendererToMainIpcService } from '../services/RendererToMainIpcService'; class SophieRendererImpl implements SophieRenderer { - readonly #ipcService: RendererToMainIpcService; + readonly #ipcService = new RendererToMainIpcService(); - #onSharedStoreChangeCalled = false; + #onSharedStoreChangeCalled: boolean = false; #listener: SharedStoreListener | null = null; - constructor(ipcService: RendererToMainIpcService) { - this.#ipcService = ipcService; - ipcService.onSharedStorePatch((patch) => { + constructor() { + this.#ipcService.onSharedStorePatch((patch) => { try { // `mobx-state-tree` will validate the patch, so we can safely cast here. this.#listener?.onPatch(patch as IJsonPatch); } catch (err) { - console.log('Shared store listener onPatch failed', err); + console.error('Shared store listener onPatch failed', err); + this.#listener = null; } }); } - async #setSharedStoreChangeListener(listener: SharedStoreListener): Promise { - let snapshot: unknown; + async onSharedStoreChange(listener: SharedStoreListener): Promise { + if (this.#onSharedStoreChangeCalled) { + throw new Error('Shared store change listener was already set'); + } + this.#onSharedStoreChangeCalled = true; + let success = false; + let snapshot: unknown | null = null; try { snapshot = await this.#ipcService.getSharedStoreSnapshot(); + success = true; } catch (err) { console.error('Failed to get initial shared store snapshot', err); - return; } - if (sharedStore.is(snapshot)) { - try { + if (success) { + if (sharedStore.is(snapshot)) { listener.onSnapshot(snapshot); - } catch (err) { - console.error('Shared store listener onSnapshot failed', err); + this.#listener = listener; return; } - this.#listener = listener; - return; + console.error('Got invalid initial shared store snapshot', snapshot); } - console.error('Got invalid initial shared store snapshot', snapshot); - } - - onSharedStoreChange(listener: SharedStoreListener): void { - if (this.#onSharedStoreChangeCalled) { - throw new Error('Shared store change listener was already set'); - } - this.#setSharedStoreChangeListener(listener).catch((err) => { - console.log('Failed to set shared store change listener', err); - }); + throw new Error('Failed to connect to shared store'); } dispatchAction(actionToDispatch: Action): void { @@ -93,8 +87,8 @@ class SophieRendererImpl implements SophieRenderer { } } -export function createSophieRenderer(ipcService: RendererToMainIpcService): SophieRenderer { - const impl = new SophieRendererImpl(ipcService); +export function createSophieRenderer(): SophieRenderer { + const impl = new SophieRendererImpl(); return { onSharedStoreChange: impl.onSharedStoreChange.bind(impl), dispatchAction: impl.dispatchAction.bind(impl), diff --git a/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts b/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts new file mode 100644 index 0000000..c0e5ec2 --- /dev/null +++ b/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2021-2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mocked } from 'jest-mock'; +import type { IJsonPatch } from 'mobx-state-tree'; +import type { Action, SharedStoreSnapshotIn, SophieRenderer } from '@sophie/shared'; + +import { RendererToMainIpcService } from '../../services/RendererToMainIpcService'; +import { createSophieRenderer } from '../SophieRendererImpl'; + +jest.mock('../../services/RendererToMainIpcService'); + +const snapshot: SharedStoreSnapshotIn = { + shouldUseDarkColors: true, +}; + +const invalidSnapshot = { + shouldUseDarkColors: -1, +} as unknown as SharedStoreSnapshotIn; + +const patch: IJsonPatch = { + op: 'replace', + path: 'foo', + value: 'bar', +}; + +const action: Action = { + action: 'set-theme-source', + themeSource: 'dark', +}; + +const invalidAction = { + action: 'not-a-valid-action', +} as unknown as Action; + +describe('constructor', () => { + it('registers a shared store patch listener', () => { + createSophieRenderer(); + expect(RendererToMainIpcService).toHaveBeenCalledTimes(1); + const service = mocked(RendererToMainIpcService).mock.instances[0]; + expect(service.onSharedStorePatch).toHaveBeenCalledTimes(1); + }); +}); + +describe('instance', () => { + let sut: SophieRenderer; + let service: RendererToMainIpcService; + let onSharedStorePatch: (patch: unknown) => void; + let listener = { + onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), + onPatch: jest.fn((_patch: IJsonPatch) => {}), + }; + + beforeEach(() => { + sut = createSophieRenderer(); + service = mocked(RendererToMainIpcService).mock.instances[0]; + onSharedStorePatch = mocked(service.onSharedStorePatch).mock.calls[0][0]; + }); + + describe('onSharedStoreChange', () => { + it('requests a snapshot from the service', async () => { + mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + expect(service.getSharedStoreSnapshot).toBeCalledTimes(1); + }); + + it('passes the snapshot to the listener', async () => { + mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + expect(listener.onSnapshot).toBeCalledWith(snapshot); + }); + + it('catches service errors without exposing them', async () => { + mocked(service.getSharedStoreSnapshot).mockRejectedValue(new Error('s3cr3t')); + await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( + 'message', + expect.stringMatching(/s3cr3t/), + ); + expect(listener.onSnapshot).toBeCalledTimes(0); + }); + + it('does not pass on invalid snapshots', async () => { + mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(invalidSnapshot); + await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); + expect(listener.onSnapshot).toBeCalledTimes(0); + }); + }); + + describe('dispatchAction', () => { + it('dispatched valid actions', () => { + sut.dispatchAction(action); + expect(service.dispatchAction).toBeCalledWith(action); + }); + + it('does not dispatch invalid actions', () => { + expect(() => sut.dispatchAction(invalidAction)).toThrowError(); + expect(service.dispatchAction).toBeCalledTimes(0); + }); + }); + + describe('when no listener is registered', () => { + it('discards the received patch', () => { + onSharedStorePatch(patch); + }); + }); + + function itRefusesToRegisterAnotherListener() { + it('refuses to register another listener', async () => { + await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); + }); + } + + function itDoesNotPassPatchesToTheListener() { + it('does not pass patches to the listener', () => { + onSharedStorePatch(patch); + expect(listener.onPatch).toBeCalledTimes(0); + }); + } + + describe('when a listener registered successfully', () => { + beforeEach(async () => { + mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + }); + + it('passes patches to the listener', () => { + onSharedStorePatch(patch); + expect(listener.onPatch).toBeCalledWith(patch); + }); + + it('catches listener errors', () => { + mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); + onSharedStorePatch(patch); + }); + + itRefusesToRegisterAnotherListener(); + + describe('that later threw an error', () => { + beforeEach(() => { + mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); + onSharedStorePatch(patch); + listener.onPatch.mockRestore(); + }); + + itDoesNotPassPatchesToTheListener(); + }); + }); + + describe('when a listener failed to register due to service error', () => { + beforeEach(async () => { + mocked(service.getSharedStoreSnapshot).mockRejectedValue(new Error()); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when a listener failed to register due to an invalid snapshot', () => { + beforeEach(async () => { + mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(invalidSnapshot); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when a listener failed to register due to listener error', () => { + beforeEach(async () => { + mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); + mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); +}); diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 9336433..ef466b4 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -21,8 +21,7 @@ import { contextBridge } from 'electron'; import { createSophieRenderer } from './contextBridge/SophieRendererImpl'; -import { RendererToMainIpcService } from './services/RendererToMainIpcService'; -const sophieRenderer = createSophieRenderer(new RendererToMainIpcService()); +const sophieRenderer = createSophieRenderer(); contextBridge.exposeInMainWorld('sophieRenderer', sophieRenderer); diff --git a/packages/preload/tsconfig.json b/packages/preload/tsconfig.json index 66726e3..2e0b10f 100644 --- a/packages/preload/tsconfig.json +++ b/packages/preload/tsconfig.json @@ -4,7 +4,10 @@ "composite": true, "declarationDir": "dist-types", "emitDeclarationOnly": true, - "rootDir": "src" + "rootDir": "src", + "libs": [ + "@types/jest" + ] }, "references": [ { diff --git a/packages/renderer/src/stores/RendererStore.ts b/packages/renderer/src/stores/RendererStore.ts index 3de82ac..12f6786 100644 --- a/packages/renderer/src/stores/RendererStore.ts +++ b/packages/renderer/src/stores/RendererStore.ts @@ -36,19 +36,19 @@ import { getEnv, RendererEnv } from './RendererEnv'; export const rendererStore = types.model('RendererStore', { shared: types.optional(sharedStore, {}), }).actions((self) => ({ - setBrowserViewBounds(browserViewBounds: BrowserViewBounds) { + setBrowserViewBounds(browserViewBounds: BrowserViewBounds): void { getEnv(self).dispatchMainAction({ action: 'set-browser-view-bounds', browserViewBounds, }); }, - setThemeSource(themeSource: ThemeSource) { + setThemeSource(themeSource: ThemeSource): void { getEnv(self).dispatchMainAction({ action: 'set-theme-source', themeSource, }); }, - toggleDarkMode() { + toggleDarkMode(): void { if (self.shared.shouldUseDarkColors) { this.setThemeSource('light'); } else { @@ -80,6 +80,8 @@ export function createAndConnectRendererStore(ipc: SophieRenderer): RendererStor onPatch(patch) { applyPatch(store.shared, patch); }, + }).catch((err) => { + console.error('Failed to connect to shared store', err); }); return store; diff --git a/packages/shared/src/contextBridge/SophieRenderer.ts b/packages/shared/src/contextBridge/SophieRenderer.ts index a471250..fc43b6e 100644 --- a/packages/shared/src/contextBridge/SophieRenderer.ts +++ b/packages/shared/src/contextBridge/SophieRenderer.ts @@ -23,7 +23,7 @@ import { SharedStoreListener } from '../stores/SharedStore'; import { Action } from '../schemas'; export interface SophieRenderer { - onSharedStoreChange(listener: SharedStoreListener): void; + onSharedStoreChange(listener: SharedStoreListener): Promise; dispatchAction(action: Action): void; } -- cgit v1.2.3-54-g00ecf