fix(config): improve startup validation and config error reporting

This commit is contained in:
2026-02-19 00:47:01 -08:00
parent 2c2f342854
commit 07cedabfe3
6 changed files with 803 additions and 90 deletions

View File

@@ -149,3 +149,92 @@ test('runAppReadyRuntime does not await background warmups', async () => {
assert.ok(releaseWarmup);
releaseWarmup();
});
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
const capturedErrors: string[][] = [];
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
secondarySub: {},
ankiConnect: {
enabled: true,
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
sentence: ' ',
miscInfo: 'MiscInfo',
translation: '',
},
},
}),
onCriticalConfigErrors: (errors) => {
capturedErrors.push(errors);
},
});
await runAppReadyRuntime(deps);
assert.equal(capturedErrors.length, 1);
assert.deepEqual(capturedErrors[0], [
'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.',
'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.',
]);
assert.ok(calls.includes('reloadConfig'));
assert.equal(calls.includes('createMpvClient'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('startBackgroundWarmups'), false);
});
test('runAppReadyRuntime aggregates multiple critical anki mapping errors', async () => {
const capturedErrors: string[][] = [];
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
secondarySub: {},
ankiConnect: {
enabled: true,
fields: {
audio: ' ',
image: '',
sentence: '\t',
miscInfo: ' ',
translation: '',
},
},
}),
onCriticalConfigErrors: (errors) => {
capturedErrors.push(errors);
},
});
await runAppReadyRuntime(deps);
assert.equal(capturedErrors.length, 1);
assert.equal(capturedErrors[0].length, 5);
assert.ok(
capturedErrors[0].includes(
'ankiConnect.fields.audio must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
capturedErrors[0].includes(
'ankiConnect.fields.image must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
capturedErrors[0].includes(
'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
capturedErrors[0].includes(
'ankiConnect.fields.miscInfo must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
capturedErrors[0].includes(
'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.',
),
);
assert.equal(calls.includes('loadSubtitlePosition'), false);
});

View File

@@ -76,6 +76,16 @@ interface AppReadyConfigLike {
secondarySub?: {
defaultMode?: SecondarySubMode;
};
ankiConnect?: {
enabled?: boolean;
fields?: {
audio?: string;
image?: string;
sentence?: string;
miscInfo?: string;
translation?: string;
};
};
websocket?: {
enabled?: boolean | 'auto';
port?: number;
@@ -113,9 +123,38 @@ export interface AppReadyRuntimeDeps {
initializeOverlayRuntime: () => void;
handleInitialArgs: () => void;
logDebug?: (message: string) => void;
onCriticalConfigErrors?: (errors: string[]) => void;
now?: () => number;
}
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
'audio',
'image',
'sentence',
'miscInfo',
'translation',
] as const;
function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
if (!config.ankiConnect?.enabled) {
return [];
}
const errors: string[] = [];
const fields = config.ankiConnect.fields ?? {};
for (const key of REQUIRED_ANKI_FIELD_MAPPING_KEYS) {
const value = fields[key];
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(
`ankiConnect.fields.${key} must be a non-empty string when ankiConnect is enabled.`,
);
}
}
return errors;
}
export function getInitialInvisibleOverlayVisibility(
config: RuntimeConfigLike,
platform: NodeJS.Platform,
@@ -151,16 +190,25 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const startupStartedAtMs = now();
deps.logDebug?.('App-ready critical path started.');
deps.loadSubtitlePosition();
deps.resolveKeybindings();
deps.createMpvClient();
deps.reloadConfig();
const config = deps.getResolvedConfig();
const criticalConfigErrors = getStartupCriticalConfigErrors(config);
if (criticalConfigErrors.length > 0) {
deps.onCriticalConfigErrors?.(criticalConfigErrors);
deps.logDebug?.(
`App-ready critical path aborted after config validation in ${now() - startupStartedAtMs}ms.`,
);
return;
}
deps.setLogLevel(config.logging?.level ?? 'info', 'config');
for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning);
}
deps.loadSubtitlePosition();
deps.resolveKeybindings();
deps.createMpvClient();
deps.initRuntimeOptionsManager();
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);