aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-27 02:15:13 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-27 02:37:38 +0100
commit3d9ee27d8d813101114cb15c448f2307a72eebb3 (patch)
treebacfa8fd77b6b2e8a7d4ead365fb5c4b3d956df9 /packages
parentrefactor: Improve error handling in preload (diff)
downloadsophie-3d9ee27d8d813101114cb15c448f2307a72eebb3.tar.gz
sophie-3d9ee27d8d813101114cb15c448f2307a72eebb3.tar.zst
sophie-3d9ee27d8d813101114cb15c448f2307a72eebb3.zip
test: Add preload unit tests
Diffstat (limited to 'packages')
-rw-r--r--packages/preload/package.json3
-rw-r--r--packages/preload/src/contextBridge/SophieRendererImpl.ts48
-rw-r--r--packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts211
-rw-r--r--packages/preload/src/index.ts3
-rw-r--r--packages/preload/tsconfig.json5
-rw-r--r--packages/renderer/src/stores/RendererStore.ts8
-rw-r--r--packages/shared/src/contextBridge/SophieRenderer.ts2
7 files changed, 246 insertions, 34 deletions
diff --git a/packages/preload/package.json b/packages/preload/package.json
index bd2b412..a4bd75f 100644
--- a/packages/preload/package.json
+++ b/packages/preload/package.json
@@ -17,6 +17,9 @@
17 "mobx-state-tree": "^5.1.0" 17 "mobx-state-tree": "^5.1.0"
18 }, 18 },
19 "devDependencies": { 19 "devDependencies": {
20 "@types/jest": "^27.0.3",
21 "jest": "^27.4.5",
22 "jest-mock": "^27.4.2",
20 "rimraf": "^3.0.2", 23 "rimraf": "^3.0.2",
21 "typescript": "^4.5.4", 24 "typescript": "^4.5.4",
22 "vite": "^2.7.6" 25 "vite": "^2.7.6"
diff --git a/packages/preload/src/contextBridge/SophieRendererImpl.ts b/packages/preload/src/contextBridge/SophieRendererImpl.ts
index 153fdd1..61b01e9 100644
--- a/packages/preload/src/contextBridge/SophieRendererImpl.ts
+++ b/packages/preload/src/contextBridge/SophieRendererImpl.ts
@@ -30,52 +30,46 @@ import {
30import { RendererToMainIpcService } from '../services/RendererToMainIpcService'; 30import { RendererToMainIpcService } from '../services/RendererToMainIpcService';
31 31
32class SophieRendererImpl implements SophieRenderer { 32class SophieRendererImpl implements SophieRenderer {
33 readonly #ipcService: RendererToMainIpcService; 33 readonly #ipcService = new RendererToMainIpcService();
34 34
35 #onSharedStoreChangeCalled = false; 35 #onSharedStoreChangeCalled: boolean = false;
36 36
37 #listener: SharedStoreListener | null = null; 37 #listener: SharedStoreListener | null = null;
38 38
39 constructor(ipcService: RendererToMainIpcService) { 39 constructor() {
40 this.#ipcService = ipcService; 40 this.#ipcService.onSharedStorePatch((patch) => {
41 ipcService.onSharedStorePatch((patch) => {
42 try { 41 try {
43 // `mobx-state-tree` will validate the patch, so we can safely cast here. 42 // `mobx-state-tree` will validate the patch, so we can safely cast here.
44 this.#listener?.onPatch(patch as IJsonPatch); 43 this.#listener?.onPatch(patch as IJsonPatch);
45 } catch (err) { 44 } catch (err) {
46 console.log('Shared store listener onPatch failed', err); 45 console.error('Shared store listener onPatch failed', err);
46 this.#listener = null;
47 } 47 }
48 }); 48 });
49 } 49 }
50 50
51 async #setSharedStoreChangeListener(listener: SharedStoreListener): Promise<void> { 51 async onSharedStoreChange(listener: SharedStoreListener): Promise<void> {
52 let snapshot: unknown; 52 if (this.#onSharedStoreChangeCalled) {
53 throw new Error('Shared store change listener was already set');
54 }
55 this.#onSharedStoreChangeCalled = true;
56 let success = false;
57 let snapshot: unknown | null = null;
53 try { 58 try {
54 snapshot = await this.#ipcService.getSharedStoreSnapshot(); 59 snapshot = await this.#ipcService.getSharedStoreSnapshot();
60 success = true;
55 } catch (err) { 61 } catch (err) {
56 console.error('Failed to get initial shared store snapshot', err); 62 console.error('Failed to get initial shared store snapshot', err);
57 return;
58 } 63 }
59 if (sharedStore.is(snapshot)) { 64 if (success) {
60 try { 65 if (sharedStore.is(snapshot)) {
61 listener.onSnapshot(snapshot); 66 listener.onSnapshot(snapshot);
62 } catch (err) { 67 this.#listener = listener;
63 console.error('Shared store listener onSnapshot failed', err);
64 return; 68 return;
65 } 69 }
66 this.#listener = listener; 70 console.error('Got invalid initial shared store snapshot', snapshot);
67 return;
68 } 71 }
69 console.error('Got invalid initial shared store snapshot', snapshot); 72 throw new Error('Failed to connect to shared store');
70 }
71
72 onSharedStoreChange(listener: SharedStoreListener): void {
73 if (this.#onSharedStoreChangeCalled) {
74 throw new Error('Shared store change listener was already set');
75 }
76 this.#setSharedStoreChangeListener(listener).catch((err) => {
77 console.log('Failed to set shared store change listener', err);
78 });
79 } 73 }
80 74
81 dispatchAction(actionToDispatch: Action): void { 75 dispatchAction(actionToDispatch: Action): void {
@@ -93,8 +87,8 @@ class SophieRendererImpl implements SophieRenderer {
93 } 87 }
94} 88}
95 89
96export function createSophieRenderer(ipcService: RendererToMainIpcService): SophieRenderer { 90export function createSophieRenderer(): SophieRenderer {
97 const impl = new SophieRendererImpl(ipcService); 91 const impl = new SophieRendererImpl();
98 return { 92 return {
99 onSharedStoreChange: impl.onSharedStoreChange.bind(impl), 93 onSharedStoreChange: impl.onSharedStoreChange.bind(impl),
100 dispatchAction: impl.dispatchAction.bind(impl), 94 dispatchAction: impl.dispatchAction.bind(impl),
diff --git a/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts b/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts
new file mode 100644
index 0000000..c0e5ec2
--- /dev/null
+++ b/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts
@@ -0,0 +1,211 @@
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 { mocked } from 'jest-mock';
22import type { IJsonPatch } from 'mobx-state-tree';
23import type { Action, SharedStoreSnapshotIn, SophieRenderer } from '@sophie/shared';
24
25import { RendererToMainIpcService } from '../../services/RendererToMainIpcService';
26import { createSophieRenderer } from '../SophieRendererImpl';
27
28jest.mock('../../services/RendererToMainIpcService');
29
30const snapshot: SharedStoreSnapshotIn = {
31 shouldUseDarkColors: true,
32};
33
34const invalidSnapshot = {
35 shouldUseDarkColors: -1,
36} as unknown as SharedStoreSnapshotIn;
37
38const patch: IJsonPatch = {
39 op: 'replace',
40 path: 'foo',
41 value: 'bar',
42};
43
44const action: Action = {
45 action: 'set-theme-source',
46 themeSource: 'dark',
47};
48
49const invalidAction = {
50 action: 'not-a-valid-action',
51} as unknown as Action;
52
53describe('constructor', () => {
54 it('registers a shared store patch listener', () => {
55 createSophieRenderer();
56 expect(RendererToMainIpcService).toHaveBeenCalledTimes(1);
57 const service = mocked(RendererToMainIpcService).mock.instances[0];
58 expect(service.onSharedStorePatch).toHaveBeenCalledTimes(1);
59 });
60});
61
62describe('instance', () => {
63 let sut: SophieRenderer;
64 let service: RendererToMainIpcService;
65 let onSharedStorePatch: (patch: unknown) => void;
66 let listener = {
67 onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}),
68 onPatch: jest.fn((_patch: IJsonPatch) => {}),
69 };
70
71 beforeEach(() => {
72 sut = createSophieRenderer();
73 service = mocked(RendererToMainIpcService).mock.instances[0];
74 onSharedStorePatch = mocked(service.onSharedStorePatch).mock.calls[0][0];
75 });
76
77 describe('onSharedStoreChange', () => {
78 it('requests a snapshot from the service', async () => {
79 mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot);
80 await sut.onSharedStoreChange(listener);
81 expect(service.getSharedStoreSnapshot).toBeCalledTimes(1);
82 });
83
84 it('passes the snapshot to the listener', async () => {
85 mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot);
86 await sut.onSharedStoreChange(listener);
87 expect(listener.onSnapshot).toBeCalledWith(snapshot);
88 });
89
90 it('catches service errors without exposing them', async () => {
91 mocked(service.getSharedStoreSnapshot).mockRejectedValue(new Error('s3cr3t'));
92 await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty(
93 'message',
94 expect.stringMatching(/s3cr3t/),
95 );
96 expect(listener.onSnapshot).toBeCalledTimes(0);
97 });
98
99 it('does not pass on invalid snapshots', async () => {
100 mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(invalidSnapshot);
101 await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error);
102 expect(listener.onSnapshot).toBeCalledTimes(0);
103 });
104 });
105
106 describe('dispatchAction', () => {
107 it('dispatched valid actions', () => {
108 sut.dispatchAction(action);
109 expect(service.dispatchAction).toBeCalledWith(action);
110 });
111
112 it('does not dispatch invalid actions', () => {
113 expect(() => sut.dispatchAction(invalidAction)).toThrowError();
114 expect(service.dispatchAction).toBeCalledTimes(0);
115 });
116 });
117
118 describe('when no listener is registered', () => {
119 it('discards the received patch', () => {
120 onSharedStorePatch(patch);
121 });
122 });
123
124 function itRefusesToRegisterAnotherListener() {
125 it('refuses to register another listener', async () => {
126 await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error);
127 });
128 }
129
130 function itDoesNotPassPatchesToTheListener() {
131 it('does not pass patches to the listener', () => {
132 onSharedStorePatch(patch);
133 expect(listener.onPatch).toBeCalledTimes(0);
134 });
135 }
136
137 describe('when a listener registered successfully', () => {
138 beforeEach(async () => {
139 mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot);
140 await sut.onSharedStoreChange(listener);
141 });
142
143 it('passes patches to the listener', () => {
144 onSharedStorePatch(patch);
145 expect(listener.onPatch).toBeCalledWith(patch);
146 });
147
148 it('catches listener errors', () => {
149 mocked(listener.onPatch).mockImplementation(() => { throw new Error(); });
150 onSharedStorePatch(patch);
151 });
152
153 itRefusesToRegisterAnotherListener();
154
155 describe('that later threw an error', () => {
156 beforeEach(() => {
157 mocked(listener.onPatch).mockImplementation(() => { throw new Error(); });
158 onSharedStorePatch(patch);
159 listener.onPatch.mockRestore();
160 });
161
162 itDoesNotPassPatchesToTheListener();
163 });
164 });
165
166 describe('when a listener failed to register due to service error', () => {
167 beforeEach(async () => {
168 mocked(service.getSharedStoreSnapshot).mockRejectedValue(new Error());
169 try {
170 await sut.onSharedStoreChange(listener);
171 } catch {
172 // Ignore error.
173 }
174 });
175
176 itRefusesToRegisterAnotherListener();
177
178 itDoesNotPassPatchesToTheListener();
179 });
180
181 describe('when a listener failed to register due to an invalid snapshot', () => {
182 beforeEach(async () => {
183 mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(invalidSnapshot);
184 try {
185 await sut.onSharedStoreChange(listener);
186 } catch {
187 // Ignore error.
188 }
189 });
190
191 itRefusesToRegisterAnotherListener();
192
193 itDoesNotPassPatchesToTheListener();
194 });
195
196 describe('when a listener failed to register due to listener error', () => {
197 beforeEach(async () => {
198 mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot);
199 mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); });
200 try {
201 await sut.onSharedStoreChange(listener);
202 } catch {
203 // Ignore error.
204 }
205 });
206
207 itRefusesToRegisterAnotherListener();
208
209 itDoesNotPassPatchesToTheListener();
210 });
211});
diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts
index 9336433..ef466b4 100644
--- a/packages/preload/src/index.ts
+++ b/packages/preload/src/index.ts
@@ -21,8 +21,7 @@
21import { contextBridge } from 'electron'; 21import { contextBridge } from 'electron';
22 22
23import { createSophieRenderer } from './contextBridge/SophieRendererImpl'; 23import { createSophieRenderer } from './contextBridge/SophieRendererImpl';
24import { RendererToMainIpcService } from './services/RendererToMainIpcService';
25 24
26const sophieRenderer = createSophieRenderer(new RendererToMainIpcService()); 25const sophieRenderer = createSophieRenderer();
27 26
28contextBridge.exposeInMainWorld('sophieRenderer', sophieRenderer); 27contextBridge.exposeInMainWorld('sophieRenderer', sophieRenderer);
diff --git a/packages/preload/tsconfig.json b/packages/preload/tsconfig.json
index 66726e3..2e0b10f 100644
--- a/packages/preload/tsconfig.json
+++ b/packages/preload/tsconfig.json
@@ -4,7 +4,10 @@
4 "composite": true, 4 "composite": true,
5 "declarationDir": "dist-types", 5 "declarationDir": "dist-types",
6 "emitDeclarationOnly": true, 6 "emitDeclarationOnly": true,
7 "rootDir": "src" 7 "rootDir": "src",
8 "libs": [
9 "@types/jest"
10 ]
8 }, 11 },
9 "references": [ 12 "references": [
10 { 13 {
diff --git a/packages/renderer/src/stores/RendererStore.ts b/packages/renderer/src/stores/RendererStore.ts
index 3de82ac..12f6786 100644
--- a/packages/renderer/src/stores/RendererStore.ts
+++ b/packages/renderer/src/stores/RendererStore.ts
@@ -36,19 +36,19 @@ import { getEnv, RendererEnv } from './RendererEnv';
36export const rendererStore = types.model('RendererStore', { 36export const rendererStore = types.model('RendererStore', {
37 shared: types.optional(sharedStore, {}), 37 shared: types.optional(sharedStore, {}),
38}).actions((self) => ({ 38}).actions((self) => ({
39 setBrowserViewBounds(browserViewBounds: BrowserViewBounds) { 39 setBrowserViewBounds(browserViewBounds: BrowserViewBounds): void {
40 getEnv(self).dispatchMainAction({ 40 getEnv(self).dispatchMainAction({
41 action: 'set-browser-view-bounds', 41 action: 'set-browser-view-bounds',
42 browserViewBounds, 42 browserViewBounds,
43 }); 43 });
44 }, 44 },
45 setThemeSource(themeSource: ThemeSource) { 45 setThemeSource(themeSource: ThemeSource): void {
46 getEnv(self).dispatchMainAction({ 46 getEnv(self).dispatchMainAction({
47 action: 'set-theme-source', 47 action: 'set-theme-source',
48 themeSource, 48 themeSource,
49 }); 49 });
50 }, 50 },
51 toggleDarkMode() { 51 toggleDarkMode(): void {
52 if (self.shared.shouldUseDarkColors) { 52 if (self.shared.shouldUseDarkColors) {
53 this.setThemeSource('light'); 53 this.setThemeSource('light');
54 } else { 54 } else {
@@ -80,6 +80,8 @@ export function createAndConnectRendererStore(ipc: SophieRenderer): RendererStor
80 onPatch(patch) { 80 onPatch(patch) {
81 applyPatch(store.shared, patch); 81 applyPatch(store.shared, patch);
82 }, 82 },
83 }).catch((err) => {
84 console.error('Failed to connect to shared store', err);
83 }); 85 });
84 86
85 return store; 87 return store;
diff --git a/packages/shared/src/contextBridge/SophieRenderer.ts b/packages/shared/src/contextBridge/SophieRenderer.ts
index a471250..fc43b6e 100644
--- a/packages/shared/src/contextBridge/SophieRenderer.ts
+++ b/packages/shared/src/contextBridge/SophieRenderer.ts
@@ -23,7 +23,7 @@ import { SharedStoreListener } from '../stores/SharedStore';
23import { Action } from '../schemas'; 23import { Action } from '../schemas';
24 24
25export interface SophieRenderer { 25export interface SophieRenderer {
26 onSharedStoreChange(listener: SharedStoreListener): void; 26 onSharedStoreChange(listener: SharedStoreListener): Promise<void>;
27 27
28 dispatchAction(action: Action): void; 28 dispatchAction(action: Action): void;
29} 29}