Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions api/src/__test__/store/watch/state-watch.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 28 additions & 0 deletions api/src/__test__/store/watch/state-watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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');
Expand Down
45 changes: 37 additions & 8 deletions api/src/store/watch/state-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,37 @@ const POLLED_STATE_KEYS = [StateFileKey.disks, StateFileKey.shares] as const;
const POLLED_STATE_KEY_SET = new Set<StateFileKey>(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;
}
Expand All @@ -54,9 +70,22 @@ export class StateManager {
return StateManager.instance;
}

public async close(): Promise<void> {
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') {
Expand Down Expand Up @@ -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);
Expand Down
Loading