/* * Copyright (C) 2022 Kristóf Marussy * * This file is part of Sophie. * * Sophie is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * SPDX-License-Identifier: AGPL-3.0-only */ import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, utimes, writeFile, } from 'node:fs/promises'; import { platform, tmpdir, userInfo } from 'node:os'; import path from 'node:path'; import { jest } from '@jest/globals'; import { testIf } from '@sophie/test-utils'; import { mocked } from 'jest-mock'; import Disposer from '../../../../utils/Disposer.js'; import ConfigFile, { CONFIG_FILE_NAME } from '../ConfigFile.js'; const CONFIG_CHANGE_DEBOUNCE_MS = 10; let filesystemDelay = 100; let realSetTimeout: typeof setTimeout; let userDataDir: string | undefined; let configFilePath: string; let repository: ConfigFile; async function realDelay(ms: number): Promise { return new Promise((resolve) => { realSetTimeout(() => resolve(), ms); }); } /** * Wait for a short (real-time) delay to let node be notified of file changes. * * Use the `SOPHIE_INTEG_TEST_DELAY` environmental variable to customize the delay * (e.g., quicker test execution on faster computers or longer wait time in CI). * The default delay is 100 ms, but as little as 10 ms might be enough, depending on your system. * * @param ms The time that should elapse after noticing the filesystem change. * @returns A promise that resolves in a `filesystemDelay` ms. */ async function catchUpWithFilesystem( ms = CONFIG_CHANGE_DEBOUNCE_MS, ): Promise { await realDelay(filesystemDelay); jest.advanceTimersByTime(ms); // Some extra real time is needed after advancing the timer to make sure the callback runs. await realDelay(Math.max(filesystemDelay / 10, 1)); } beforeAll(() => { const delayEnv = process.env.SOPHIE_INTEG_TEST_DELAY; if (delayEnv !== undefined) { filesystemDelay = Number.parseInt(delayEnv, 10); } jest.useRealTimers(); // Save the real implementation of `setTimeout` before we mock it. realSetTimeout = setTimeout; jest.useFakeTimers(); }); beforeEach(async () => { try { userDataDir = await mkdtemp(path.join(tmpdir(), 'sophie-configFile-')); configFilePath = path.join(userDataDir, CONFIG_FILE_NAME); repository = new ConfigFile( userDataDir, CONFIG_FILE_NAME, CONFIG_CHANGE_DEBOUNCE_MS, ); } catch (error) { userDataDir = undefined; throw error; } }); afterEach(async () => { jest.clearAllTimers(); if (userDataDir === undefined) { return; } if (!userDataDir.startsWith(tmpdir())) { throw new Error( `Refusing to delete directory ${userDataDir} outside of tmp directory`, ); } await rm(userDataDir, { force: true, recursive: true, }); userDataDir = undefined; }); describe('readConfig', () => { test('returns false when the config file is not found', async () => { const result = await repository.readConfig(); expect(result.found).toBe(false); }); test('reads the contents of the config file if found', async () => { await writeFile(configFilePath, 'Hello World!', 'utf8'); const result = await repository.readConfig(); expect(result.found).toBe(true); expect(result).toHaveProperty('contents', 'Hello World!'); }); test('throws an error if the config file cannot be read', async () => { await mkdir(configFilePath); await expect(repository.readConfig()).rejects.toThrow(); }); }); describe('writeConfig', () => { test('writes the config file', async () => { await repository.writeConfig('Hi Mars!'); const contents = await readFile(configFilePath, 'utf8'); expect(contents).toBe('Hi Mars!'); }); test('overwrites the config file', async () => { await writeFile(configFilePath, 'Hello World!', 'utf8'); await repository.writeConfig('Hi Mars!'); const contents = await readFile(configFilePath, 'utf8'); expect(contents).toBe('Hi Mars!'); }); test('throws an error if the config file cannot be written', async () => { await mkdir(configFilePath); await expect(repository.writeConfig('Hi Mars!')).rejects.toThrow(); }); test('throws an error when called reentrantly', async () => { const promise = repository.writeConfig('Hello World!'); try { await expect(repository.writeConfig('Hi Mars!')).rejects.toThrow(); } finally { await promise; } }); }); describe('watchConfig', () => { let callback: () => Promise; let watcher: Disposer | undefined; beforeEach(() => { callback = jest.fn(() => Promise.resolve()); }); afterEach(() => { if (watcher !== undefined) { // Make sure we dispose the watcher. watcher(); } }); describe('when the config file does not exist', () => { beforeEach(() => { watcher = repository.watchConfig(callback); }); test('notifies when the config file is created externally', async () => { await writeFile(configFilePath, 'Hello World!', 'utf8'); await catchUpWithFilesystem(); expect(callback).toHaveBeenCalled(); }); test('does not notify when the config file is created by the repository', async () => { await repository.writeConfig('Hello World!'); await catchUpWithFilesystem(); expect(callback).not.toHaveBeenCalled(); }); }); describe('when the config file already exists', () => { beforeEach(async () => { await writeFile(configFilePath, 'Hello World!', 'utf8'); watcher = repository.watchConfig(callback); }); test('notifies when the config file is updated externally', async () => { await writeFile(configFilePath, 'Hi Mars!', 'utf8'); await catchUpWithFilesystem(); expect(callback).toHaveBeenCalled(); }); test('does not notify when the config file is created by the repository', async () => { await repository.writeConfig('Hi Mars!'); await catchUpWithFilesystem(); expect(callback).not.toHaveBeenCalled(); }); test('debounces changes to the config file', async () => { await writeFile(configFilePath, 'Hi Mars!', 'utf8'); await catchUpWithFilesystem(5); expect(callback).not.toHaveBeenCalled(); await writeFile(configFilePath, 'Hi Mars!', 'utf8'); await catchUpWithFilesystem(); expect(callback).toHaveBeenCalledTimes(1); }); test('handles the config file being deleted', async () => { await rm(configFilePath); await catchUpWithFilesystem(); expect(callback).not.toHaveBeenCalled(); await writeFile(configFilePath, 'Hello World!', 'utf8'); await catchUpWithFilesystem(); expect(callback).toHaveBeenCalled(); }); test('handles the config file being renamed', async () => { const renamedPath = `${configFilePath}.renamed`; await rename(configFilePath, renamedPath); await catchUpWithFilesystem(); expect(callback).not.toHaveBeenCalled(); await writeFile(renamedPath, 'Hello World!', 'utf8'); await catchUpWithFilesystem(); expect(callback).not.toHaveBeenCalled(); await writeFile(configFilePath, 'Hello World!', 'utf8'); await catchUpWithFilesystem(); expect(callback).toHaveBeenCalled(); }); // We can only cause a filesystem error by changing permissions if we run on a POSIX-like // system and we aren't root (i.e., not in CI). testIf(platform() !== 'win32' && userInfo().uid !== 0)( 'handles other filesystem errors', async () => { const { mode } = await stat(userDataDir!); await writeFile(configFilePath, 'Hi Mars!', 'utf8'); // Remove permission to force a filesystem error. // eslint-disable-next-line no-bitwise -- Compute reduced permissions. await chmod(userDataDir!, mode & 0o666); try { await catchUpWithFilesystem(); expect(callback).not.toHaveBeenCalled(); } finally { await chmod(userDataDir!, mode); } }, ); test('does not notify when the modification date is prior to the last write', async () => { await repository.writeConfig('Hello World!'); const date = new Date(Date.now() - 3_600_000); await utimes(configFilePath, date, date); await catchUpWithFilesystem(); expect(callback).not.toHaveBeenCalled(); }); test('handles callback errors', async () => { mocked(callback).mockRejectedValue(new Error('Test error')); await writeFile(configFilePath, 'Hi Mars!', 'utf8'); await catchUpWithFilesystem(); // This test would fail an unhandled promise rejection error if `ConfigFile` // did not handle the rejection properly. expect(callback).toHaveBeenCalled(); }); }); });