Files
SubMiner/launcher/commands/command-modules.test.ts

412 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs } from '../config.js';
import type { ProcessAdapter } from '../process-adapter.js';
import type { LauncherCommandContext } from './context.js';
import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runStatsCommand } from './stats-command.js';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function createContext(overrides: Partial<LauncherCommandContext> = {}): LauncherCommandContext {
const args = parseArgs([], 'subminer', {});
const adapter: ProcessAdapter = {
platform: () => 'linux',
onSignal: () => {},
writeStdout: () => {},
exit: (code) => {
throw new ExitSignal(code);
},
setExitCode: () => {},
};
return {
args,
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
},
appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {},
processAdapter: adapter,
...overrides,
};
}
test('config command writes newline-terminated path via process adapter', () => {
const writes: string[] = [];
const context = createContext();
context.args.configPath = true;
context.processAdapter = {
...context.processAdapter,
writeStdout: (text) => writes.push(text),
};
const handled = runConfigCommand(context, {
existsSync: () => true,
readFileSync: () => '',
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
});
assert.equal(handled, true);
assert.deepEqual(writes, ['/tmp/SubMiner/config.jsonc\n']);
});
test('doctor command exits non-zero for missing hard dependencies', () => {
const context = createContext({ appPath: null });
context.args.doctor = true;
assert.throws(
() =>
runDoctorCommand(context, {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: () => {
throw new Error('unexpected app handoff');
},
}),
(error: unknown) => error instanceof ExitSignal && error.code === 1,
);
});
test('doctor command forwards refresh-known-words to app binary', () => {
const context = createContext();
context.args.doctor = true;
context.args.doctorRefreshKnownWords = true;
const forwarded: string[][] = [];
assert.throws(
() =>
runDoctorCommand(context, {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
throw new ExitSignal(0);
},
}),
(error: unknown) => error instanceof ExitSignal && error.code === 0,
);
assert.deepEqual(forwarded, [['--refresh-known-words']]);
});
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
const context = createContext();
context.args.mpvStatus = true;
await assert.rejects(
async () => {
await runMpvPreAppCommand(context, {
waitForUnixSocketReady: async () => false,
launchMpvIdleDetached: async () => {},
});
},
(error: unknown) => error instanceof ExitSignal && error.code === 1,
);
});
test('dictionary command forwards --dictionary and target path to app binary', () => {
const context = createContext();
context.args.dictionary = true;
context.args.dictionaryTarget = '/tmp/anime';
const forwarded: string[][] = [];
assert.throws(
() =>
runDictionaryCommand(context, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
throw new ExitSignal(0);
},
}),
(error: unknown) => error instanceof ExitSignal && error.code === 0,
);
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
});
test('dictionary command throws if app handoff unexpectedly returns', () => {
const context = createContext();
context.args.dictionary = true;
assert.throws(
() =>
runDictionaryCommand(context, {
runAppCommandWithInherit: () => undefined as never,
}),
/unexpectedly returned/,
);
});
test('stats command launches attached app command with response path', async () => {
const context = createContext();
context.args.stats = true;
context.args.logLevel = 'debug';
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--log-level',
'debug',
],
]);
});
test('stats background command launches attached daemon control command with response path', async () => {
const context = createContext();
context.args.stats = true;
(context.args as typeof context.args & { statsBackground?: boolean }).statsBackground = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
} as Parameters<typeof runStatsCommand>[1]);
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--stats-daemon-start',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
]);
});
test('stats command waits for attached app exit after startup response', async () => {
const context = createContext();
context.args.stats = true;
const forwarded: string[][] = [];
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
const statsCommand = runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return started;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
});
const result = await Promise.race([
statsCommand.then(() => 'resolved'),
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 5)),
]);
assert.equal(result, 'timeout');
const final = await statsCommand;
assert.equal(final, true);
assert.deepEqual(forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
]);
});
test('stats command throws when attached app exits non-zero after startup response', async () => {
const context = createContext();
context.args.stats = true;
await assert.rejects(async () => {
await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return 3;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
});
}, /Stats app exited with status 3\./);
});
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
const context = createContext();
context.args.stats = true;
context.args.statsCleanup = true;
context.args.statsCleanupVocab = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--stats-cleanup',
'--stats-cleanup-vocab',
],
]);
});
test('stats stop command forwards stop flag to the app', async () => {
const context = createContext();
context.args.stats = true;
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--stats-daemon-stop',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
]);
});
test('stats stop command exits on process exit without waiting for startup response', async () => {
const context = createContext();
context.args.stats = true;
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
let waitedForResponse = false;
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async () => 0,
waitForStatsResponse: async () => {
waitedForResponse = true;
return { ok: true };
},
removeDir: () => {},
});
assert.equal(handled, true);
assert.equal(waitedForResponse, false);
});
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
const context = createContext();
context.args.stats = true;
context.args.statsCleanup = true;
context.args.statsCleanupLifetime = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--stats-cleanup',
'--stats-cleanup-lifetime',
],
]);
});
test('stats command throws when stats response reports an error', async () => {
const context = createContext();
context.args.stats = true;
await assert.rejects(async () => {
await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async () => 0,
waitForStatsResponse: async () => ({
ok: false,
error: 'Immersion tracking is disabled in config.',
}),
removeDir: () => {},
});
}, /Immersion tracking is disabled in config\./);
});
test('stats cleanup command fails if attached app exits before startup response', async () => {
const context = createContext();
context.args.stats = true;
context.args.statsCleanup = true;
context.args.statsCleanupVocab = true;
await assert.rejects(async () => {
await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async () => 2,
waitForStatsResponse: async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
return { ok: true, url: 'http://127.0.0.1:5175' };
},
removeDir: () => {},
});
}, /Stats app exited before startup response \(status 2\)\./);
});