diff options
Diffstat (limited to 'packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts')
-rw-r--r-- | packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts | 258 |
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 | |||
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('../createSophieRenderer'); | ||
44 | |||
45 | const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent; | ||
46 | |||
47 | const snapshot: SharedStoreSnapshotIn = { | ||
48 | shouldUseDarkColors: true, | ||
49 | }; | ||
50 | |||
51 | const invalidSnapshot = { | ||
52 | shouldUseDarkColors: -1, | ||
53 | } as unknown as SharedStoreSnapshotIn; | ||
54 | |||
55 | const patch: IJsonPatch = { | ||
56 | op: 'replace', | ||
57 | path: 'foo', | ||
58 | value: 'bar', | ||
59 | }; | ||
60 | |||
61 | const action: Action = { | ||
62 | action: 'set-theme-source', | ||
63 | themeSource: 'dark', | ||
64 | }; | ||
65 | |||
66 | const invalidAction = { | ||
67 | action: 'not-a-valid-action', | ||
68 | } as unknown as Action; | ||
69 | |||
70 | beforeAll(() => { | ||
71 | log.disableAll(); | ||
72 | }); | ||
73 | |||
74 | describe('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 | |||
84 | describe('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 | }); | ||