mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(config): enforce strict startup config parsing
This commit is contained in:
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { ConfigService } from './service';
|
||||
import { ConfigService, ConfigStartupParseError } from './service';
|
||||
import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY } from './definitions';
|
||||
import { generateConfigTemplate } from './template';
|
||||
|
||||
@@ -39,6 +39,24 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
|
||||
});
|
||||
|
||||
test('throws actionable startup parse error for malformed config at construction time', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(configPath, '{"websocket":', 'utf-8');
|
||||
|
||||
assert.throws(
|
||||
() => new ConfigService(dir),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ConfigStartupParseError);
|
||||
assert.equal(error.path, configPath);
|
||||
assert.ok(error.parseError.length > 0);
|
||||
assert.ok(error.message.includes(configPath));
|
||||
assert.ok(error.message.includes(error.parseError));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -18,12 +18,26 @@ export type ReloadConfigStrictResult =
|
||||
path: string;
|
||||
};
|
||||
|
||||
export class ConfigStartupParseError extends Error {
|
||||
readonly path: string;
|
||||
readonly parseError: string;
|
||||
|
||||
constructor(configPath: string, parseError: string) {
|
||||
super(
|
||||
`Failed to parse startup config at ${configPath}: ${parseError}. Fix the config file and restart SubMiner.`,
|
||||
);
|
||||
this.name = 'ConfigStartupParseError';
|
||||
this.path = configPath;
|
||||
this.parseError = parseError;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private readonly configPaths: ConfigPaths;
|
||||
private rawConfig: RawConfig = {};
|
||||
private resolvedConfig: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG);
|
||||
private warnings: ConfigValidationWarning[] = [];
|
||||
private configPathInUse: string;
|
||||
private configPathInUse!: string;
|
||||
|
||||
constructor(configDir: string) {
|
||||
this.configPaths = {
|
||||
@@ -31,8 +45,11 @@ export class ConfigService {
|
||||
configFileJsonc: path.join(configDir, 'config.jsonc'),
|
||||
configFileJson: path.join(configDir, 'config.json'),
|
||||
};
|
||||
this.configPathInUse = this.configPaths.configFileJsonc;
|
||||
this.reloadConfig();
|
||||
const loadResult = loadRawConfigStrict(this.configPaths);
|
||||
if (!loadResult.ok) {
|
||||
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
|
||||
}
|
||||
this.applyResolvedConfig(loadResult.config, loadResult.path);
|
||||
}
|
||||
|
||||
getConfigPath(): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildConfigParseErrorDetails,
|
||||
buildConfigWarningNotificationBody,
|
||||
buildConfigWarningSummary,
|
||||
failStartupFromConfig,
|
||||
@@ -52,6 +53,15 @@ test('buildConfigWarningNotificationBody includes concise warning details', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConfigParseErrorDetails includes path error and restart guidance', () => {
|
||||
const details = buildConfigParseErrorDetails('/tmp/config.jsonc', 'unexpected token at line 1');
|
||||
|
||||
assert.match(details, /Failed to parse config file at:/);
|
||||
assert.match(details, /\/tmp\/config\.jsonc/);
|
||||
assert.match(details, /Error: unexpected token at line 1/);
|
||||
assert.match(details, /Fix the config file and restart SubMiner\./);
|
||||
});
|
||||
|
||||
test('failStartupFromConfig invokes handlers and throws', () => {
|
||||
const calls: string[] = [];
|
||||
const previousExitCode = process.exitCode;
|
||||
|
||||
@@ -61,6 +61,17 @@ export function buildConfigWarningNotificationBody(
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildConfigParseErrorDetails(configPath: string, parseError: string): string {
|
||||
return [
|
||||
'Failed to parse config file at:',
|
||||
configPath,
|
||||
'',
|
||||
`Error: ${parseError}`,
|
||||
'',
|
||||
'Fix the config file and restart SubMiner.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function failStartupFromConfig(
|
||||
title: string,
|
||||
details: string,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCriticalConfigErrorHandler,
|
||||
createReloadConfigHandler,
|
||||
} from './startup-config';
|
||||
import { createCriticalConfigErrorHandler, createReloadConfigHandler } from './startup-config';
|
||||
|
||||
test('createReloadConfigHandler runs success flow with warnings', async () => {
|
||||
const calls: string[] = [];
|
||||
@@ -42,9 +39,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
|
||||
assert.ok(calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')));
|
||||
assert.ok(calls.some((entry) => entry.startsWith('warn:[config] Validation found 1 issue(s)')));
|
||||
assert.ok(
|
||||
calls.some((entry) =>
|
||||
entry.includes('notify:SubMiner:1 config validation issue(s) detected.'),
|
||||
),
|
||||
calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')),
|
||||
);
|
||||
assert.ok(calls.some((entry) => entry.includes('1. ankiConnect.pollingRate: must be >= 50')));
|
||||
assert.ok(calls.includes('hotReload:start'));
|
||||
@@ -79,6 +74,9 @@ test('createReloadConfigHandler fails startup for parse errors', () => {
|
||||
assert.throws(() => reloadConfig(), /Failed to parse config file at:/);
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.ok(calls.some((entry) => entry.startsWith('error:Failed to parse config file at:')));
|
||||
assert.ok(calls.some((entry) => entry.includes('/tmp/config.jsonc')));
|
||||
assert.ok(calls.some((entry) => entry.includes('Error: unexpected token')));
|
||||
assert.ok(calls.some((entry) => entry.includes('Fix the config file and restart SubMiner.')));
|
||||
assert.ok(
|
||||
calls.some((entry) =>
|
||||
entry.startsWith('dialog:SubMiner config parse error:Failed to parse config file at:'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConfigValidationWarning } from '../../types';
|
||||
import {
|
||||
buildConfigParseErrorDetails,
|
||||
buildConfigWarningNotificationBody,
|
||||
buildConfigWarningSummary,
|
||||
failStartupFromConfig,
|
||||
@@ -48,7 +49,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
|
||||
if (!result.ok) {
|
||||
failStartupFromConfig(
|
||||
'SubMiner config parse error',
|
||||
`Failed to parse config file at:\n${result.path}\n\nError: ${result.error}\n\nFix the config file and restart SubMiner.`,
|
||||
buildConfigParseErrorDetails(result.path, result.error),
|
||||
deps.failHandlers,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user