aboutsummaryrefslogtreecommitdiffstats
path: root/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts')
-rw-r--r--packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts258
1 files changed, 258 insertions, 0 deletions
diff --git a/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts
new file mode 100644
index 0000000..a38dbac
--- /dev/null
+++ b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts
@@ -0,0 +1,258 @@
1/*
2 * Copyright (C) 2021-2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { jest } from '@jest/globals';
22import {
23 Action,
24 MainToRendererIpcMessage,
25 RendererToMainIpcMessage,
26 SharedStoreSnapshotIn,
27 SophieRenderer,
28} from '@sophie/shared';
29import { mocked } from 'jest-mock';
30import log from 'loglevel';
31import type { IJsonPatch } from 'mobx-state-tree';
32
33jest.unstable_mockModule('electron', () => ({
34 ipcRenderer: {
35 invoke: jest.fn(),
36 on: jest.fn(),
37 send: jest.fn(),
38 },
39}));
40
41const { ipcRenderer } = await import('electron');
42
43const { default: createSophieRenderer } = await import('../createSophieRenderer');
44
45const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent;
46
47const snapshot: SharedStoreSnapshotIn = {
48 shouldUseDarkColors: true,
49};
50
51const invalidSnapshot = {
52 shouldUseDarkColors: -1,
53} as unknown as SharedStoreSnapshotIn;
54
55const patch: IJsonPatch = {
56 op: 'replace',
57 path: 'foo',
58 value: 'bar',
59};
60
61const action: Action = {
62 action: 'set-theme-source',
63 themeSource: 'dark',
64};
65
66const invalidAction = {
67 action: 'not-a-valid-action',
68} as unknown as Action;
69
70beforeAll(() => {
71 log.disableAll();
72});
73
74describe('createSophieRenderer', () => {
75 it('registers a shared store patch listener', () => {
76 createSophieRenderer(false);
77 expect(ipcRenderer.on).toHaveBeenCalledWith(
78 MainToRendererIpcMessage.SharedStorePatch,
79 expect.anything(),
80 );
81 });
82});
83
84describe('SharedStoreConnector', () => {
85 let sut: SophieRenderer;
86 let onSharedStorePatch: (eventArg: Electron.IpcRendererEvent, patchArg: unknown) => void;
87 const listener = {
88 // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
89 onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}),
90 // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
91 onPatch: jest.fn((_patch: IJsonPatch) => {}),
92 };
93
94 beforeEach(() => {
95 sut = createSophieRenderer(false);
96 [, onSharedStorePatch] = mocked(ipcRenderer.on).mock.calls.find(
97 ([channel]) => channel === MainToRendererIpcMessage.SharedStorePatch,
98 )!;
99 });
100
101 describe('onSharedStoreChange', () => {
102 it('should request a snapshot from the main process', async () => {
103 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot);
104 await sut.onSharedStoreChange(listener);
105 expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot);
106 expect(listener.onSnapshot).toBeCalledWith(snapshot);
107 });
108
109 it('should catch IPC errors without exposing them', async () => {
110 mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t'));
111 await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty(
112 'message',
113 expect.stringMatching(/s3cr3t/),
114 );
115 expect(listener.onSnapshot).not.toBeCalled();
116 });
117
118 it('should not pass on invalid snapshots', async () => {
119 mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot);
120 await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error);
121 expect(listener.onSnapshot).not.toBeCalled();
122 });
123 });
124
125 describe('dispatchAction', () => {
126 it('should dispatch valid actions', () => {
127 sut.dispatchAction(action);
128 expect(ipcRenderer.send).toBeCalledWith(RendererToMainIpcMessage.DispatchAction, action);
129 });
130
131 it('should not dispatch invalid actions', () => {
132 expect(() => sut.dispatchAction(invalidAction)).toThrowError();
133 expect(ipcRenderer.send).not.toBeCalled();
134 });
135 });
136
137 describe('when no listener is registered', () => {
138 it('should discard the received patch without any error', () => {
139 onSharedStorePatch(event, patch);
140 });
141 });
142
143 function itRefusesToRegisterAnotherListener(): void {
144 it('should refuse to register another listener', async () => {
145 await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error);
146 });
147 }
148
149 function itDoesNotPassPatchesToTheListener(
150 name = 'should not pass patches to the listener',
151 ): void {
152 it(name, () => {
153 onSharedStorePatch(event, patch);
154 expect(listener.onPatch).not.toBeCalled();
155 });
156 }
157
158 describe('when a listener registered successfully', () => {
159 beforeEach(async () => {
160 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot);
161 await sut.onSharedStoreChange(listener);
162 });
163
164 it('should pass patches to the listener', () => {
165 onSharedStorePatch(event, patch);
166 expect(listener.onPatch).toBeCalledWith(patch);
167 });
168
169 it('should catch listener errors', () => {
170 mocked(listener.onPatch).mockImplementation(() => { throw new Error(); });
171 onSharedStorePatch(event, patch);
172 });
173
174 itRefusesToRegisterAnotherListener();
175
176 describe('after the listener threw in onPatch', () => {
177 beforeEach(() => {
178 mocked(listener.onPatch).mockImplementation(() => { throw new Error(); });
179 onSharedStorePatch(event, patch);
180 listener.onPatch.mockRestore();
181 });
182
183 itDoesNotPassPatchesToTheListener('should not pass on patches any more');
184 });
185 });
186
187 describe('when a listener failed to register due to IPC error', () => {
188 beforeEach(async () => {
189 mocked(ipcRenderer.invoke).mockRejectedValue(new Error());
190 try {
191 await sut.onSharedStoreChange(listener);
192 } catch {
193 // Ignore error.
194 }
195 });
196
197 itRefusesToRegisterAnotherListener();
198
199 itDoesNotPassPatchesToTheListener();
200 });
201
202 describe('when a listener failed to register due to an invalid snapshot', () => {
203 beforeEach(async () => {
204 mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot);
205 try {
206 await sut.onSharedStoreChange(listener);
207 } catch {
208 // Ignore error.
209 }
210 });
211
212 itRefusesToRegisterAnotherListener();
213
214 itDoesNotPassPatchesToTheListener();
215 });
216
217 describe('when a listener failed to register due to listener error', () => {
218 beforeEach(async () => {
219 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot);
220 mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); });
221 try {
222 await sut.onSharedStoreChange(listener);
223 } catch {
224 // Ignore error.
225 }
226 });
227
228 itRefusesToRegisterAnotherListener();
229
230 itDoesNotPassPatchesToTheListener();
231 });
232
233 describe('when it is allowed to replace listeners', () => {
234 const snapshot2 = {
235 shouldUseDarkColors: false,
236 };
237 const listener2 = {
238 // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
239 onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => { }),
240 // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
241 onPatch: jest.fn((_patch: IJsonPatch) => { }),
242 };
243
244 it('should fetch a second snapshot', async () => {
245 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2);
246 await sut.onSharedStoreChange(listener2);
247 expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot);
248 expect(listener2.onSnapshot).toBeCalledWith(snapshot2);
249 });
250
251 it('should pass the second snapshot to the new listener', async () => {
252 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2);
253 await sut.onSharedStoreChange(listener2);
254 onSharedStorePatch(event, patch);
255 expect(listener2.onPatch).toBeCalledWith(patch);
256 });
257 });
258});