diff options
Diffstat (limited to 'packages/preload/src/contextBridge/__tests__/createSophieRenderer.test.ts')
-rw-r--r-- | packages/preload/src/contextBridge/__tests__/createSophieRenderer.test.ts | 258 |
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 | |||
21 | import { jest } from '@jest/globals'; | ||
22 | import { | ||
23 | Action, | ||
24 | MainToRendererIpcMessage, | ||
25 | RendererToMainIpcMessage, | ||
26 | SharedStoreSnapshotIn, | ||
27 | SophieRenderer, | ||
28 | } from '@sophie/shared'; | ||
29 | import { mocked } from 'jest-mock'; | ||
30 | import log from 'loglevel'; | ||
31 | import type { IJsonPatch } from 'mobx-state-tree'; | ||
32 | |||
33 | jest.unstable_mockModule('electron', () => ({ | ||
34 | ipcRenderer: { | ||
35 | invoke: jest.fn(), | ||
36 | on: jest.fn(), | ||
37 | send: jest.fn(), | ||
38 | }, | ||
39 | })); | ||
40 | |||
41 | const { ipcRenderer } = await import('electron'); | ||
42 | |||
43 | const { default: createSophieRenderer } = await import( | ||
44 | '../createSophieRenderer.js' | ||
45 | ); | ||
46 | |||
47 | const event: Electron.IpcRendererEvent = | ||
48 | undefined as unknown as Electron.IpcRendererEvent; | ||
49 | |||
50 | const snapshot: SharedStoreSnapshotIn = { | ||
51 | shouldUseDarkColors: true, | ||
52 | }; | ||
53 | |||
54 | const patch: IJsonPatch[] = [ | ||
55 | { | ||
56 | op: 'replace', | ||
57 | path: 'foo', | ||
58 | value: 'bar', | ||
59 | }, | ||
60 | ]; | ||
61 | |||
62 | const action: Action = { | ||
63 | action: 'set-theme-source', | ||
64 | themeSource: 'dark', | ||
65 | }; | ||
66 | |||
67 | const invalidAction = { | ||
68 | action: 'not-a-valid-action', | ||
69 | } as unknown as Action; | ||
70 | |||
71 | beforeAll(() => { | ||
72 | log.disableAll(); | ||
73 | }); | ||
74 | |||
75 | describe('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 | |||
85 | describe('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 | }); | ||