refactor(main): modularize runtime and harden anilist setup flow

This commit is contained in:
2026-02-19 16:04:59 -08:00
parent 58f28b7b55
commit 162be118e1
73 changed files with 4413 additions and 1251 deletions

View File

@@ -197,8 +197,10 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
return { deps, calls, osd };
}
test('handleCliCommand ignores --start for second-instance without actions', () => {
const { deps, calls } = createDeps();
test('handleCliCommand ignores --start for second-instance when overlay runtime is already initialized', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
});
const args = makeArgs({ start: true });
handleCliCommand(args, 'second-instance', deps);
@@ -210,6 +212,23 @@ test('handleCliCommand ignores --start for second-instance without actions', ()
);
});
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
const { deps, calls } = createDeps();
const args = makeArgs({ start: true });
handleCliCommand(args, 'second-instance', deps);
assert.equal(
calls.some((value) => value === 'log:Ignoring --start because SubMiner is already running.'),
false,
);
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
assert.equal(
calls.some((value) => value.includes('connectMpvClient')),
true,
);
});
test('handleCliCommand runs texthooker flow with browser open', () => {
const { deps, calls } = createDeps();
const args = makeArgs({ texthooker: true });
@@ -239,6 +258,7 @@ test('handleCliCommand applies socket path and connects on start', () => {
handleCliCommand(makeArgs({ start: true, socketPath: '/tmp/custom.sock' }), 'initial', deps);
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('setMpvSocketPath:/tmp/custom.sock'));
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/custom.sock'));
assert.ok(calls.includes('connectMpvClient'));
@@ -304,6 +324,16 @@ test('handleCliCommand still runs non-start actions on second-instance', () => {
);
});
test('handleCliCommand connects MPV for toggle on second-instance', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps);
assert.ok(calls.includes('toggleVisibleOverlay'));
assert.equal(
calls.some((value) => value === 'connectMpvClient'),
true,
);
});
test('handleCliCommand handles visibility and utility command dispatches', () => {
const cases: Array<{
args: Partial<CliArgs>;

View File

@@ -275,7 +275,11 @@ export function handleCliCommand(
args.jellyfinRemoteAnnounce ||
args.texthooker ||
args.help;
const ignoreStartOnly = source === 'second-instance' && args.start && !hasNonStartAction;
const ignoreStartOnly =
source === 'second-instance' &&
args.start &&
!hasNonStartAction &&
deps.isOverlayRuntimeInitialized();
if (ignoreStartOnly) {
deps.log('Ignoring --start because SubMiner is already running.');
return;
@@ -283,9 +287,11 @@ export function handleCliCommand(
const shouldStart =
args.start ||
(source === 'initial' &&
(args.toggle || args.toggleVisibleOverlay || args.toggleInvisibleOverlay));
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
if (args.socketPath !== undefined) {
deps.setMpvSocketPath(args.socketPath);
@@ -306,7 +312,7 @@ export function handleCliCommand(
return;
}
if (needsOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
if (shouldInitializeOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}

View File

@@ -52,6 +52,7 @@ test('config hot reload runtime debounces rapid watch events', () => {
onHotReloadApplied: () => {},
onRestartRequired: () => {},
onInvalidConfig: () => {},
onValidationWarnings: () => {},
};
const runtime = createConfigHotReloadRuntime(deps);
@@ -103,9 +104,59 @@ test('config hot reload runtime reports invalid config and skips apply', () => {
onInvalidConfig: (message) => {
invalidMessages.push(message);
},
onValidationWarnings: () => {
throw new Error('Validation warnings should not trigger for invalid config.');
},
});
runtime.start();
assert.equal(watchedChangeCallback, null);
assert.equal(invalidMessages.length, 1);
});
test('config hot reload runtime reports validation warnings from reload', () => {
let watchedChangeCallback: (() => void) | null = null;
const warningCalls: Array<{ path: string; count: number }> = [];
const runtime = createConfigHotReloadRuntime({
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => ({
ok: true,
config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [
{
path: 'ankiConnect.openRouter',
message: 'Deprecated key; use ankiConnect.ai instead.',
value: { enabled: true },
fallback: {},
},
],
path: '/tmp/config.jsonc',
}),
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
callback();
return 1 as unknown as NodeJS.Timeout;
},
clearTimeout: () => {},
debounceMs: 0,
onHotReloadApplied: () => {},
onRestartRequired: () => {},
onInvalidConfig: () => {},
onValidationWarnings: (path, warnings) => {
warningCalls.push({ path, count: warnings.length });
},
});
runtime.start();
assert.equal(warningCalls.length, 0);
if (!watchedChangeCallback) {
throw new Error('Expected watch callback to be registered.');
}
const trigger = watchedChangeCallback as () => void;
trigger();
assert.deepEqual(warningCalls, [{ path: '/tmp/config.jsonc', count: 1 }]);
});

View File

@@ -1,4 +1,5 @@
import { type ReloadConfigStrictResult } from '../../config';
import type { ConfigValidationWarning } from '../../types';
import type { ResolvedConfig } from '../../types';
export interface ConfigHotReloadDiff {
@@ -16,6 +17,7 @@ export interface ConfigHotReloadRuntimeDeps {
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
onRestartRequired: (fields: string[]) => void;
onInvalidConfig: (message: string) => void;
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
}
export interface ConfigHotReloadRuntime {
@@ -107,6 +109,10 @@ export function createConfigHotReloadRuntime(
watchPath(result.path);
}
if (result.warnings.length > 0) {
deps.onValidationWarnings(result.path, result.warnings);
}
const diff = classifyDiff(prev, result.config);
if (diff.hotReloadFields.length > 0) {
deps.onHotReloadApplied(diff, result.config);