diff --git a/api/src/__test__/store/watch/state-watch.integration.test.ts b/api/src/__test__/store/watch/state-watch.integration.test.ts new file mode 100644 index 0000000000..02ad6d4b04 --- /dev/null +++ b/api/src/__test__/store/watch/state-watch.integration.test.ts @@ -0,0 +1,108 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StateFileKey } from '@app/store/types.js'; + +const testContext = vi.hoisted(() => ({ + states: '', +})); + +const mockedStore = vi.hoisted(() => ({ + dispatch: vi.fn().mockResolvedValue(undefined), +})); + +const mockedGetters = vi.hoisted(() => ({ + paths: vi.fn(() => ({ + states: testContext.states, + })), +})); + +const mockedLoadSingleStateFile = vi.hoisted(() => + vi.fn((key: StateFileKey) => ({ type: 'emhttp/load-single-state-file', payload: key })) +); + +const mockedLoadRegistrationKey = vi.hoisted(() => + vi.fn(() => ({ type: 'registration/load-registration-key' })) +); + +vi.mock('@app/environment.js', () => ({ + CHOKIDAR_USEPOLLING: false, +})); + +vi.mock('@app/store/index.js', () => ({ + store: mockedStore, + getters: mockedGetters, +})); + +vi.mock('@app/store/modules/emhttp.js', () => ({ + loadSingleStateFile: mockedLoadSingleStateFile, +})); + +vi.mock('@app/store/modules/registration.js', () => ({ + loadRegistrationKey: mockedLoadRegistrationKey, +})); + +vi.mock('@app/core/log.js', () => ({ + emhttpLogger: { + trace: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, +})); + +describe('StateManager integration', () => { + let tempRoot: string; + let statesDirectory: string; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + tempRoot = await mkdtemp(join(tmpdir(), 'state-watch-')); + statesDirectory = join(tempRoot, 'state'); + await mkdir(statesDirectory); + testContext.states = statesDirectory; + + const { StateManager } = await import('@app/store/watch/state-watch.js'); + const { store } = await import('@app/store/index.js'); + + await StateManager.getInstance().ready; + await new Promise((resolve) => setTimeout(resolve, 500)); + vi.mocked(store.dispatch).mockClear(); + }); + + afterEach(async () => { + const { StateManager } = await import('@app/store/watch/state-watch.js'); + + if (StateManager.instance) { + await StateManager.instance.close(); + } + + await rm(tempRoot, { recursive: true, force: true }); + }); + + it('reloads var state when emhttp writes var.ini.new into the state directory', async () => { + const { store } = await import('@app/store/index.js'); + const { loadSingleStateFile } = await import('@app/store/modules/emhttp.js'); + const { loadRegistrationKey } = await import('@app/store/modules/registration.js'); + + await writeFile(join(statesDirectory, 'var.ini.new'), 'NAME="Gamer5"\n'); + + await vi.waitFor(() => { + expect(store.dispatch).toHaveBeenNthCalledWith(1, loadSingleStateFile(StateFileKey.var)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, loadRegistrationKey()); + }); + }); + + it('does not route disks.ini.new through the directory watcher', async () => { + const { store } = await import('@app/store/index.js'); + + await writeFile(join(statesDirectory, 'disks.ini.new'), '[disk1]\nname=disk1\n'); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/__test__/store/watch/state-watch.test.ts b/api/src/__test__/store/watch/state-watch.test.ts index 0f6625733c..80816f7897 100644 --- a/api/src/__test__/store/watch/state-watch.test.ts +++ b/api/src/__test__/store/watch/state-watch.test.ts @@ -157,9 +157,16 @@ describe('StateManager', () => { const ignored = standardWatcher?.options.ignored; expect(ignored).toBeTypeOf('function'); + expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state')).toBe(false); expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state/README.txt')).toBe(true); expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state/devs.ini')).toBe(false); + expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state/var.ini.new')).toBe( + false + ); expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state/disks.ini')).toBe(true); + expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state/disks.ini.new')).toBe( + true + ); }); it('reloads registration key when var.ini is replaced after boot', async () => { @@ -183,6 +190,27 @@ describe('StateManager', () => { expect(store.dispatch).toHaveBeenNthCalledWith(2, loadRegistrationKey()); }); + it('reloads registration key when var.ini.new is observed after boot', async () => { + const { StateManager } = await import('@app/store/watch/state-watch.js'); + const { store } = await import('@app/store/index.js'); + const { loadSingleStateFile } = await import('@app/store/modules/emhttp.js'); + const { loadRegistrationKey } = await import('@app/store/modules/registration.js'); + + await StateManager.getInstance().ready; + vi.mocked(store.dispatch).mockClear(); + + const standardWatcher = watchRegistrations.find( + (registration) => registration.options.usePolling === false + ); + const addHandler = standardWatcher?.handlers.add; + expect(addHandler).toBeDefined(); + + await addHandler?.('/usr/local/emhttp/state/var.ini.new'); + + expect(store.dispatch).toHaveBeenNthCalledWith(1, loadSingleStateFile(StateFileKey.var)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, loadRegistrationKey()); + }); + it('routes polled state files through the polling directory watcher', async () => { const { StateManager } = await import('@app/store/watch/state-watch.js'); const { store } = await import('@app/store/index.js'); diff --git a/api/src/store/watch/state-watch.ts b/api/src/store/watch/state-watch.ts index 1585f6b7eb..87422e63e0 100644 --- a/api/src/store/watch/state-watch.ts +++ b/api/src/store/watch/state-watch.ts @@ -14,21 +14,37 @@ const POLLED_STATE_KEYS = [StateFileKey.disks, StateFileKey.shares] as const; const POLLED_STATE_KEY_SET = new Set(POLLED_STATE_KEYS); const STATE_FILE_NAMES = new Set(Object.values(StateFileKey).map((key) => `${key}.ini`)); -const shouldIgnoreStatePath = (path: string): boolean => { +const getNormalizedStateFileName = (path: string): string | null => { const parsed = parse(path); - const isStateFile = parsed.ext === '.ini' && STATE_FILE_NAMES.has(parsed.base); + if (STATE_FILE_NAMES.has(parsed.base)) { + return parsed.base; + } + + if (!parsed.base.endsWith('.new')) { + return null; + } + + const originalStateFileName = parsed.base.slice(0, -'.new'.length); + return STATE_FILE_NAMES.has(originalStateFileName) ? originalStateFileName : null; +}; + +const shouldIgnoreStatePath = (path: string): boolean => { + const normalizedStateFileName = getNormalizedStateFileName(path); - if (!isStateFile) { + if (!normalizedStateFileName) { return true; } - const stateFileKey = StateFileKey[parsed.name]; + const stateFileKey = StateFileKey[normalizedStateFileName.slice(0, -'.ini'.length)]; return POLLED_STATE_KEY_SET.has(stateFileKey); }; -const chokidarOptionsForStateDirectory = (): ChokidarOptions => ({ +const chokidarOptionsForStateDirectory = (statesPath: string): ChokidarOptions => ({ ignoreInitial: true, ignored: (path, stats) => { + if (path === statesPath) { + return false; + } if (stats?.isDirectory()) { return false; } @@ -54,9 +70,22 @@ export class StateManager { return StateManager.instance; } + public async close(): Promise { + await Promise.all(this.fileWatchers.map(async (watcher) => watcher.close())); + this.fileWatchers.length = 0; + + if (StateManager.instance === this) { + StateManager.instance = null; + } + } + private getStateFileKeyFromPath(path: string): StateFileKey | undefined { - const parsed = parse(path); - return StateFileKey[parsed.name]; + const normalizedStateFileName = getNormalizedStateFileName(path); + if (!normalizedStateFileName) { + return undefined; + } + + return StateFileKey[normalizedStateFileName.slice(0, -'.ini'.length)]; } private async reloadStateFile(stateFile: StateFileKey, reason: 'add' | 'change' | 'startup-sync') { @@ -96,7 +125,7 @@ export class StateManager { const { states } = getters.paths(); emhttpLogger.debug('Setting up watch for path: %s', states); - const directoryWatch = watch(states, chokidarOptionsForStateDirectory()); + const directoryWatch = watch(states, chokidarOptionsForStateDirectory(states)); directoryWatch.on('add', async (path) => this.handleStateFileUpdate(path, 'add')); directoryWatch.on('change', async (path) => this.handleStateFileUpdate(path, 'change')); this.fileWatchers.push(directoryWatch);