aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-05-28 21:06:37 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-05-29 22:39:49 +0200
commita310fc4dde89c6b09a1da4c67dcefb87f2864935 (patch)
tree30d6b66472f08d6e6de80ba38888d6818ad2fa2a
parentrefactor: use undefined constant (diff)
downloadsophie-a310fc4dde89c6b09a1da4c67dcefb87f2864935.tar.gz
sophie-a310fc4dde89c6b09a1da4c67dcefb87f2864935.tar.zst
sophie-a310fc4dde89c6b09a1da4c67dcefb87f2864935.zip
test(main): ConfigFile integration test
Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r--jest.config.js6
-rw-r--r--jest.integ.config.cjs6
-rw-r--r--packages/main/src/infrastructure/config/impl/ConfigFile.ts44
-rw-r--r--packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts293
-rw-r--r--packages/main/src/infrastructure/electron/impl/__tests__/electron.integ.test.ts9
-rw-r--r--packages/main/src/reactions/__tests__/synchronizeConfig.test.ts2
6 files changed, 332 insertions, 28 deletions
diff --git a/jest.config.js b/jest.config.js
index be24800..340c6dc 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,7 +1,11 @@
1/** @type {import('@jest/types').Config.InitialOptions} */ 1/** @type {import('@jest/types').Config.InitialOptions} */
2export default { 2export default {
3 projects: ['<rootDir>/packages/*/jest.config.js'], 3 projects: ['<rootDir>/packages/*/jest.config.js'],
4 collectCoverageFrom: ['**/*.{ts,tsx}', '!**/__fixtures__/**/*'], 4 collectCoverageFrom: [
5 '**/*.{ts,tsx}',
6 '**/*.{test,spec}.{ts,tsx}',
7 '!**/__fixtures__/**/*',
8 ],
5 /** @type {'v8'} */ 9 /** @type {'v8'} */
6 coverageProvider: 'v8', 10 coverageProvider: 'v8',
7 /** @type {['cobertura', 'text']} */ 11 /** @type {['cobertura', 'text']} */
diff --git a/jest.integ.config.cjs b/jest.integ.config.cjs
index b9411bc..4ba5cba 100644
--- a/jest.integ.config.cjs
+++ b/jest.integ.config.cjs
@@ -1,7 +1,11 @@
1/** @type {import('@jest/types').Config.InitialOptions} */ 1/** @type {import('@jest/types').Config.InitialOptions} */
2module.exports = { 2module.exports = {
3 projects: ['<rootDir>/packages/*/jest.integ.config.cjs'], 3 projects: ['<rootDir>/packages/*/jest.integ.config.cjs'],
4 collectCoverageFrom: ['**/*.{ts,tsx}', '!**/__fixtures__/**/*'], 4 collectCoverageFrom: [
5 '**/*.{ts,tsx}',
6 '!**/*.{test,spec}.{ts,tsx}',
7 '!**/__fixtures__/**/*',
8 ],
5 /** @type {'v8'} */ 9 /** @type {'v8'} */
6 coverageProvider: 'v8', 10 coverageProvider: 'v8',
7 /** @type {['cobertura', 'text']} */ 11 /** @type {['cobertura', 'text']} */
diff --git a/packages/main/src/infrastructure/config/impl/ConfigFile.ts b/packages/main/src/infrastructure/config/impl/ConfigFile.ts
index 684a827..4f0d4f0 100644
--- a/packages/main/src/infrastructure/config/impl/ConfigFile.ts
+++ b/packages/main/src/infrastructure/config/impl/ConfigFile.ts
@@ -32,6 +32,8 @@ import type { ReadConfigResult } from '../ConfigRepository.js';
32 32
33const log = getLogger('ConfigFile'); 33const log = getLogger('ConfigFile');
34 34
35export const CONFIG_FILE_NAME = 'settings.json';
36
35export default class ConfigFile implements ConfigRepository { 37export default class ConfigFile implements ConfigRepository {
36 private readonly configFilePath: string; 38 private readonly configFilePath: string;
37 39
@@ -41,7 +43,7 @@ export default class ConfigFile implements ConfigRepository {
41 43
42 constructor( 44 constructor(
43 private readonly userDataDir: string, 45 private readonly userDataDir: string,
44 private readonly configFileName = 'settings.json', 46 private readonly configFileName = CONFIG_FILE_NAME,
45 ) { 47 ) {
46 this.configFilePath = path.join(userDataDir, configFileName); 48 this.configFilePath = path.join(userDataDir, configFileName);
47 } 49 }
@@ -65,6 +67,9 @@ export default class ConfigFile implements ConfigRepository {
65 } 67 }
66 68
67 async writeConfig(contents: string): Promise<void> { 69 async writeConfig(contents: string): Promise<void> {
70 if (this.writingConfig) {
71 throw new Error('writeConfig cannot be called reentrantly');
72 }
68 this.writingConfig = true; 73 this.writingConfig = true;
69 try { 74 try {
70 await writeFile(this.configFilePath, contents, 'utf8'); 75 await writeFile(this.configFilePath, contents, 'utf8');
@@ -95,7 +100,11 @@ export default class ConfigFile implements ConfigRepository {
95 ); 100 );
96 return; 101 return;
97 } 102 }
98 throw error; 103 log.error(
104 'Unexpected error while listening for config file changes',
105 error,
106 );
107 return;
99 } 108 }
100 if ( 109 if (
101 !this.writingConfig && 110 !this.writingConfig &&
@@ -111,20 +120,23 @@ export default class ConfigFile implements ConfigRepository {
111 } 120 }
112 }, throttleMs); 121 }, throttleMs);
113 122
114 const watcher = watch(this.userDataDir, { 123 const watcher = watch(
115 persistent: false, 124 this.userDataDir,
116 }); 125 {
117 126 persistent: false,
118 watcher.on('change', (eventType, filename) => { 127 recursive: false,
119 if ( 128 },
120 eventType === 'change' && 129 (_eventType, filename) => {
121 (filename === this.configFileName || filename === null) 130 if (filename === this.configFileName || filename === null) {
122 ) { 131 configChanged()?.catch((err) => {
123 configChanged()?.catch((err) => { 132 log.error(
124 log.error('Unhandled error while listening for config changes', err); 133 'Unhandled error while listening for config changes',
125 }); 134 err,
126 } 135 );
127 }); 136 });
137 }
138 },
139 );
128 140
129 return () => { 141 return () => {
130 log.trace('Removing watcher for', this.configFilePath); 142 log.trace('Removing watcher for', this.configFilePath);
diff --git a/packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts b/packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts
new file mode 100644
index 0000000..b61e85a
--- /dev/null
+++ b/packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts
@@ -0,0 +1,293 @@
1/*
2 * Copyright (C) 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 {
22 chmod,
23 mkdir,
24 mkdtemp,
25 readFile,
26 rename,
27 rm,
28 stat,
29 writeFile,
30} from 'node:fs/promises';
31import { tmpdir } from 'node:os';
32import path from 'node:path';
33
34import { jest } from '@jest/globals';
35import { mocked } from 'jest-mock';
36
37import Disposer from '../../../../utils/Disposer.js';
38import ConfigFile, { CONFIG_FILE_NAME } from '../ConfigFile.js';
39
40const THROTTLE_MS = 1000;
41
42let filesystemDelay = 100;
43let realSetTimeout: typeof setTimeout;
44let userDataDir: string | undefined;
45let configFilePath: string;
46let repository: ConfigFile;
47
48/**
49 * Wait for a short (real-time) delay to let node be notified of file changes.
50 *
51 * Will also run all pending microtasks to completion.
52 *
53 * Use the `SOPHIE_INTEG_TEST_DELAY` environmental variable to customize the delay
54 * (e.g., quicker test execution on faster computers or longer wait time in CI).
55 * The default delay is 100 ms, but as little as 10 ms might be enough, depending on your system.
56 *
57 * @returns A promise that resolves in a short while.
58 */
59function catchUpWithFilesystem(): Promise<void> {
60 return new Promise((resolve, reject) => {
61 realSetTimeout(() => {
62 try {
63 jest.runAllTicks();
64 } catch (error) {
65 reject(error);
66 return;
67 }
68 resolve();
69 }, filesystemDelay);
70 });
71}
72
73async function catchUpWithFilesystemAndTimers(): Promise<void> {
74 await catchUpWithFilesystem();
75 jest.runAllTimers();
76 await catchUpWithFilesystem();
77}
78
79beforeAll(() => {
80 const delayEnv = process.env.SOPHIE_INTEG_TEST_DELAY;
81 if (delayEnv !== undefined) {
82 filesystemDelay = Number.parseInt(delayEnv, 10);
83 }
84 jest.useRealTimers();
85 // Save the real implementation of `setTimeout` before we mock it.
86 realSetTimeout = setTimeout;
87 jest.useFakeTimers();
88});
89
90beforeEach(async () => {
91 try {
92 userDataDir = await mkdtemp(path.join(tmpdir(), 'sophie-configFile-'));
93 configFilePath = path.join(userDataDir, CONFIG_FILE_NAME);
94 repository = new ConfigFile(userDataDir);
95 } catch (error) {
96 userDataDir = undefined;
97 throw error;
98 }
99});
100
101afterEach(async () => {
102 jest.clearAllTimers();
103 if (userDataDir === undefined) {
104 return;
105 }
106 if (!userDataDir.startsWith(tmpdir())) {
107 throw new Error(
108 `Refusing to delete directory ${userDataDir} outside of tmp directory`,
109 );
110 }
111 await rm(userDataDir, {
112 force: true,
113 recursive: true,
114 });
115 userDataDir = undefined;
116});
117
118describe('readConfig', () => {
119 test('returns false when the config file is not found', async () => {
120 const result = await repository.readConfig();
121 expect(result.found).toBe(false);
122 });
123
124 test('reads the contents of the config file if found', async () => {
125 await writeFile(configFilePath, 'Hello World!', 'utf8');
126 const result = await repository.readConfig();
127 expect(result.found).toBe(true);
128 expect(result).toHaveProperty('contents', 'Hello World!');
129 });
130
131 test('throws an error if the config file cannot be read', async () => {
132 await mkdir(configFilePath);
133 await expect(repository.readConfig()).rejects.toThrow();
134 });
135});
136
137describe('writeConfig', () => {
138 test('writes the config file', async () => {
139 await repository.writeConfig('Hi Mars!');
140 const contents = await readFile(configFilePath, 'utf8');
141 expect(contents).toBe('Hi Mars!');
142 });
143
144 test('overwrites the config file', async () => {
145 await writeFile(configFilePath, 'Hello World!', 'utf8');
146 await repository.writeConfig('Hi Mars!');
147 const contents = await readFile(configFilePath, 'utf8');
148 expect(contents).toBe('Hi Mars!');
149 });
150
151 test('throws an error if the config file cannot be written', async () => {
152 await mkdir(configFilePath);
153 await expect(repository.writeConfig('Hi Mars!')).rejects.toThrow();
154 });
155
156 test('throws an error when called reentrantly', async () => {
157 const promise = repository.writeConfig('Hello World!');
158 try {
159 await expect(repository.writeConfig('Hi Mars!')).rejects.toThrow();
160 } finally {
161 await promise;
162 }
163 });
164});
165
166describe('watchConfig', () => {
167 let callback: () => Promise<void>;
168 let watcher: Disposer | undefined;
169
170 beforeEach(() => {
171 callback = jest.fn(() => Promise.resolve());
172 });
173
174 afterEach(() => {
175 if (watcher !== undefined) {
176 // Make sure we dispose the watcher.
177 watcher();
178 }
179 });
180
181 describe('when the config file does not exist', () => {
182 beforeEach(() => {
183 watcher = repository.watchConfig(callback, THROTTLE_MS);
184 });
185
186 test('notifies when the config file is created externally', async () => {
187 await writeFile(configFilePath, 'Hello World!', 'utf8');
188 await catchUpWithFilesystem();
189 expect(callback).toHaveBeenCalled();
190 });
191
192 test('does not notify when the config file is created by the repository', async () => {
193 await repository.writeConfig('Hello World!');
194 await catchUpWithFilesystemAndTimers();
195 expect(callback).not.toHaveBeenCalled();
196 });
197 });
198
199 describe('when the config file already exists', () => {
200 beforeEach(async () => {
201 await writeFile(configFilePath, 'Hello World!', 'utf8');
202 watcher = repository.watchConfig(callback, THROTTLE_MS);
203 });
204
205 test('notifies when the config file is updated externally', async () => {
206 await writeFile(configFilePath, 'Hi Mars!', 'utf8');
207 await catchUpWithFilesystem();
208 expect(callback).toHaveBeenCalled();
209 });
210
211 test('does not notify when the config file is created by the repository', async () => {
212 await repository.writeConfig('Hi Mars!');
213 await catchUpWithFilesystemAndTimers();
214 expect(callback).not.toHaveBeenCalled();
215 });
216
217 test('throttles notifications of external changes to the config file', async () => {
218 await writeFile(configFilePath, 'Hi Mars!', 'utf8');
219 await catchUpWithFilesystem();
220 expect(callback).toHaveBeenCalledTimes(1);
221 mocked(callback).mockClear();
222
223 jest.advanceTimersByTime(100);
224 await writeFile(configFilePath, 'Howdy Venus!', 'utf8');
225 await catchUpWithFilesystem();
226
227 jest.advanceTimersByTime(100);
228 await catchUpWithFilesystem();
229 expect(callback).not.toHaveBeenCalled();
230
231 jest.advanceTimersByTime(THROTTLE_MS);
232 await catchUpWithFilesystem();
233 expect(callback).toHaveBeenCalledTimes(1);
234 });
235
236 test('handles the config file being deleted', async () => {
237 await writeFile(configFilePath, 'Hi Mars!', 'utf8');
238 // Clear the mock if we inadverently set off `callback`.
239 mocked(callback).mockClear();
240 await rm(configFilePath);
241 await catchUpWithFilesystemAndTimers();
242 expect(callback).not.toHaveBeenCalled();
243
244 await writeFile(configFilePath, 'Hello World!', 'utf8');
245 await catchUpWithFilesystem();
246 expect(callback).toHaveBeenCalled();
247 });
248
249 test('handles the config file being renamed', async () => {
250 const renamedPath = `${configFilePath}.renamed`;
251
252 await writeFile(configFilePath, 'Hi Mars!', 'utf8');
253 // Clear the mock if we inadverently set off `callback`.
254 mocked(callback).mockClear();
255 await rename(configFilePath, renamedPath);
256 await catchUpWithFilesystemAndTimers();
257 expect(callback).not.toHaveBeenCalled();
258
259 await writeFile(renamedPath, 'Hello World!', 'utf8');
260 await catchUpWithFilesystemAndTimers();
261 expect(callback).not.toHaveBeenCalled();
262
263 await writeFile(configFilePath, 'Hello World!', 'utf8');
264 await catchUpWithFilesystem();
265 expect(callback).toHaveBeenCalled();
266 });
267
268 test('handles other filesystem errors', async () => {
269 await writeFile(configFilePath, 'Hi Mars!', 'utf8');
270 // Clear the mock if we inadverently set off `callback`.
271 mocked(callback).mockClear();
272 const { mode } = await stat(userDataDir!);
273 // Remove permission to force a filesystem error.
274 // eslint-disable-next-line no-bitwise -- Compute reduced permissions.
275 await chmod(userDataDir!, mode & 0o666);
276 try {
277 await catchUpWithFilesystemAndTimers();
278 expect(callback).not.toHaveBeenCalled();
279 } finally {
280 await chmod(userDataDir!, mode);
281 }
282 });
283
284 test('handles callback errors', async () => {
285 mocked(callback).mockRejectedValue(new Error('Test error'));
286 await writeFile(configFilePath, 'Hi Mars!', 'utf8');
287 await catchUpWithFilesystem();
288 // This test would fail an unhandled promise rejection error if `ConfigFile`
289 // did not handle the rejection properly.
290 expect(callback).toHaveBeenCalled();
291 });
292 });
293});
diff --git a/packages/main/src/infrastructure/electron/impl/__tests__/electron.integ.test.ts b/packages/main/src/infrastructure/electron/impl/__tests__/electron.integ.test.ts
deleted file mode 100644
index 75b12ac..0000000
--- a/packages/main/src/infrastructure/electron/impl/__tests__/electron.integ.test.ts
+++ /dev/null
@@ -1,9 +0,0 @@
1import { BrowserWindow } from 'electron';
2
3import '../ElectronMainWindow.js';
4
5test('create a BrowserWindow', async () => {
6 const w = new BrowserWindow();
7 await expect(w.loadURL('https://example.org')).resolves.toBeUndefined();
8 w.close();
9});
diff --git a/packages/main/src/reactions/__tests__/synchronizeConfig.test.ts b/packages/main/src/reactions/__tests__/synchronizeConfig.test.ts
index d4d8abf..ae3b59f 100644
--- a/packages/main/src/reactions/__tests__/synchronizeConfig.test.ts
+++ b/packages/main/src/reactions/__tests__/synchronizeConfig.test.ts
@@ -43,7 +43,7 @@ beforeEach(() => {
43 store = SharedStore.create(); 43 store = SharedStore.create();
44}); 44});
45 45
46describe('when synchronizeializing', () => { 46describe('when initializing', () => {
47 describe('when there is no config file', () => { 47 describe('when there is no config file', () => {
48 beforeEach(() => { 48 beforeEach(() => {
49 mocked(repository.readConfig).mockResolvedValueOnce({ 49 mocked(repository.readConfig).mockResolvedValueOnce({