/* * 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 { jest } from '@jest/globals'; import { Action, MainToRendererIpcMessage, RendererToMainIpcMessage, SharedStoreSnapshotIn, SophieRenderer, } from '@sophie/shared'; import { mocked } from 'jest-mock'; import log from 'loglevel'; import type { IJsonPatch } from 'mobx-state-tree'; jest.unstable_mockModule('electron', () => ({ ipcRenderer: { invoke: jest.fn(), on: jest.fn(), send: jest.fn(), }, })); const { ipcRenderer } = await import('electron'); const { default: createSophieRenderer } = await import('../createSophieRenderer'); const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent; 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; beforeAll(() => { log.disableAll(); }); describe('createSophieRenderer', () => { it('registers a shared store patch listener', () => { createSophieRenderer(false); expect(ipcRenderer.on).toHaveBeenCalledWith( MainToRendererIpcMessage.SharedStorePatch, expect.anything(), ); }); }); describe('SharedStoreConnector', () => { let sut: SophieRenderer; let onSharedStorePatch: (eventArg: Electron.IpcRendererEvent, patchArg: unknown) => void; const listener = { // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars onPatch: jest.fn((_patch: IJsonPatch) => {}), }; beforeEach(() => { sut = createSophieRenderer(false); [, onSharedStorePatch] = mocked(ipcRenderer.on).mock.calls.find( ([channel]) => channel === MainToRendererIpcMessage.SharedStorePatch, )!; }); describe('onSharedStoreChange', () => { it('should request a snapshot from the main process', async () => { mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); await sut.onSharedStoreChange(listener); expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); expect(listener.onSnapshot).toBeCalledWith(snapshot); }); it('should catch IPC errors without exposing them', async () => { mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t')); await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( 'message', expect.stringMatching(/s3cr3t/), ); expect(listener.onSnapshot).not.toBeCalled(); }); it('should not pass on invalid snapshots', async () => { mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); expect(listener.onSnapshot).not.toBeCalled(); }); }); describe('dispatchAction', () => { it('should dispatch valid actions', () => { sut.dispatchAction(action); expect(ipcRenderer.send).toBeCalledWith(RendererToMainIpcMessage.DispatchAction, action); }); it('should not dispatch invalid actions', () => { expect(() => sut.dispatchAction(invalidAction)).toThrowError(); expect(ipcRenderer.send).not.toBeCalled(); }); }); describe('when no listener is registered', () => { it('should discard the received patch without any error', () => { onSharedStorePatch(event, patch); }); }); function itRefusesToRegisterAnotherListener(): void { it('should refuse to register another listener', async () => { await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); }); } function itDoesNotPassPatchesToTheListener( name = 'should not pass patches to the listener', ): void { it(name, () => { onSharedStorePatch(event, patch); expect(listener.onPatch).not.toBeCalled(); }); } describe('when a listener registered successfully', () => { beforeEach(async () => { mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); await sut.onSharedStoreChange(listener); }); it('should pass patches to the listener', () => { onSharedStorePatch(event, patch); expect(listener.onPatch).toBeCalledWith(patch); }); it('should catch listener errors', () => { mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); onSharedStorePatch(event, patch); }); itRefusesToRegisterAnotherListener(); describe('after the listener threw in onPatch', () => { beforeEach(() => { mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); onSharedStorePatch(event, patch); listener.onPatch.mockRestore(); }); itDoesNotPassPatchesToTheListener('should not pass on patches any more'); }); }); describe('when a listener failed to register due to IPC error', () => { beforeEach(async () => { mocked(ipcRenderer.invoke).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(ipcRenderer.invoke).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(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); try { await sut.onSharedStoreChange(listener); } catch { // Ignore error. } }); itRefusesToRegisterAnotherListener(); itDoesNotPassPatchesToTheListener(); }); describe('when it is allowed to replace listeners', () => { const snapshot2 = { shouldUseDarkColors: false, }; const listener2 = { // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => { }), // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars onPatch: jest.fn((_patch: IJsonPatch) => { }), }; it('should fetch a second snapshot', async () => { mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); await sut.onSharedStoreChange(listener2); expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); expect(listener2.onSnapshot).toBeCalledWith(snapshot2); }); it('should pass the second snapshot to the new listener', async () => { mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); await sut.onSharedStoreChange(listener2); onSharedStorePatch(event, patch); expect(listener2.onPatch).toBeCalledWith(patch); }); }); });