aboutsummaryrefslogtreecommitdiffstats
path: root/packages/preload/src/contextBridge/__tests__/createSophieRenderer.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/preload/src/contextBridge/__tests__/createSophieRenderer.test.ts')
-rw-r--r--packages/preload/src/contextBridge/__tests__/createSophieRenderer.test.ts258
1 files changed, 258 insertions, 0 deletions
diff --git a/packages/preload/src/contextBridge/__tests__/createSophieRenderer.test.ts b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.test.ts
new file mode 100644
index 0000000..b6af137
--- /dev/null
+++ b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.test.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(
44 '../createSophieRenderer.js'
45);
46
47const event: Electron.IpcRendererEvent =
48 undefined as unknown as Electron.IpcRendererEvent;
49
50const snapshot: SharedStoreSnapshotIn = {
51 shouldUseDarkColors: true,
52};
53
54const patch: IJsonPatch[] = [
55 {
56 op: 'replace',
57 path: 'foo',
58 value: 'bar',
59 },
60];
61
62const action: Action = {
63 action: 'set-theme-source',
64 themeSource: 'dark',
65};
66
67const invalidAction = {
68 action: 'not-a-valid-action',
69} as unknown as Action;
70
71beforeAll(() => {
72 log.disableAll();
73});
74
75describe('createSophieRenderer', () => {
76 test('registers a shared store patch listener', () => {
77 createSophieRenderer(false);
78 expect(ipcRenderer.on).toHaveBeenCalledWith(
79 MainToRendererIpcMessage.SharedStorePatch,
80 expect.anything(),
81 );
82 });
83});
84
85describe('SharedStoreConnector', () => {
86 let sut: SophieRenderer;
87 let onSharedStorePatch: (
88 eventArg: Electron.IpcRendererEvent,
89 patchArg: unknown,
90 ) => void;
91 const listener = {
92 // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
93 onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}),
94 // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
95 onPatch: jest.fn((_patch: IJsonPatch[]) => {}),
96 };
97
98 beforeEach(() => {
99 sut = createSophieRenderer(false);
100 [, onSharedStorePatch] = mocked(ipcRenderer.on).mock.calls.find(
101 ([channel]) => channel === MainToRendererIpcMessage.SharedStorePatch,
102 )!;
103 });
104
105 describe('onSharedStoreChange', () => {
106 test('requests a snapshot from the main process', async () => {
107 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot);
108 await sut.onSharedStoreChange(listener);
109 expect(ipcRenderer.invoke).toHaveBeenCalledWith(
110 RendererToMainIpcMessage.GetSharedStoreSnapshot,
111 );
112 expect(listener.onSnapshot).toHaveBeenCalledWith(snapshot);
113 });
114
115 test('catches IPC errors without exposing them', async () => {
116 mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t'));
117 await expect(
118 sut.onSharedStoreChange(listener),
119 ).rejects.not.toHaveProperty('message', expect.stringMatching(/s3cr3t/));
120 expect(listener.onSnapshot).not.toHaveBeenCalled();
121 });
122 });
123
124 describe('dispatchAction', () => {
125 test('dispatches valid actions', () => {
126 sut.dispatchAction(action);
127 expect(ipcRenderer.send).toHaveBeenCalledWith(
128 RendererToMainIpcMessage.DispatchAction,
129 action,
130 );
131 });
132
133 test('does not dispatch invalid actions', () => {
134 expect(() => sut.dispatchAction(invalidAction)).toThrow();
135 expect(ipcRenderer.send).not.toHaveBeenCalled();
136 });
137 });
138
139 describe('when no listener is registered', () => {
140 test('discards the received patch without any error', () => {
141 expect(() => onSharedStorePatch(event, patch)).not.toThrow();
142 });
143 });
144
145 function testRefusesToRegisterAnotherListener(): void {
146 test('refuses to register another listener', async () => {
147 await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(
148 Error,
149 );
150 });
151 }
152
153 function testDoesNotPassPatchesToTheListener(
154 name = 'does not pass patches to the listener',
155 ): void {
156 // eslint-disable-next-line jest/valid-title -- Title is a string parameter.
157 test(name, () => {
158 onSharedStorePatch(event, patch);
159 expect(listener.onPatch).not.toHaveBeenCalled();
160 });
161 }
162
163 describe('when a listener registered successfully', () => {
164 beforeEach(async () => {
165 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot);
166 await sut.onSharedStoreChange(listener);
167 });
168
169 test('passes patches to the listener', () => {
170 onSharedStorePatch(event, patch);
171 expect(listener.onPatch).toHaveBeenCalledWith(patch);
172 });
173
174 test('catches listener errors', () => {
175 mocked(listener.onPatch).mockImplementation(() => {
176 throw new Error('listener error');
177 });
178 expect(() => onSharedStorePatch(event, patch)).not.toThrow();
179 });
180
181 testRefusesToRegisterAnotherListener();
182
183 describe('after the listener threw in onPatch', () => {
184 beforeEach(() => {
185 mocked(listener.onPatch).mockImplementation(() => {
186 throw new Error('listener error');
187 });
188 onSharedStorePatch(event, patch);
189 listener.onPatch.mockRestore();
190 });
191
192 testDoesNotPassPatchesToTheListener('does not pass on patches any more');
193 });
194 });
195
196 describe('when a listener failed to register due to IPC error', () => {
197 beforeEach(async () => {
198 mocked(ipcRenderer.invoke).mockRejectedValue(
199 new Error('ipcRenderer error'),
200 );
201 try {
202 await sut.onSharedStoreChange(listener);
203 } catch {
204 // Ignore error.
205 }
206 });
207
208 testRefusesToRegisterAnotherListener();
209
210 testDoesNotPassPatchesToTheListener();
211 });
212
213 describe('when a listener failed to register due to listener error', () => {
214 beforeEach(async () => {
215 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot);
216 mocked(listener.onSnapshot).mockImplementation(() => {
217 throw new Error('listener error');
218 });
219 try {
220 await sut.onSharedStoreChange(listener);
221 } catch {
222 // Ignore error.
223 }
224 });
225
226 testRefusesToRegisterAnotherListener();
227
228 testDoesNotPassPatchesToTheListener();
229 });
230
231 describe('when it is allowed to replace listeners', () => {
232 const snapshot2 = {
233 shouldUseDarkColors: false,
234 };
235 const listener2 = {
236 // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
237 onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}),
238 // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
239 onPatch: jest.fn((_patch: IJsonPatch[]) => {}),
240 };
241
242 test('fetches a second snapshot', async () => {
243 mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2);
244 await sut.onSharedStoreChange(listener2);
245 expect(ipcRenderer.invoke).toHaveBeenCalledWith(
246 RendererToMainIpcMessage.GetSharedStoreSnapshot,
247 );
248 expect(listener2.onSnapshot).toHaveBeenCalledWith(snapshot2);
249 });
250
251 test('passes 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).toHaveBeenCalledWith(patch);
256 });
257 });
258});