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 { 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[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((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\)\./); }); test('stats command aborts pending response wait when app exits before startup response', async () => { const context = createContext(); context.args.stats = true; let aborted = false; await assert.rejects(async () => { await runStatsCommand(context, { createTempDir: () => '/tmp/subminer-stats-test', joinPath: (...parts) => parts.join('/'), runAppCommandAttached: async () => 2, waitForStatsResponse: async (_responsePath, signal) => await new Promise((resolve) => { signal?.addEventListener( 'abort', () => { aborted = true; resolve({ ok: false, error: 'aborted' }); }, { once: true }, ); }), removeDir: () => {}, }); }, /Stats app exited before startup response \(status 2\)\./); assert.equal(aborted, true); }); test('stats cleanup command aborts pending response wait when app exits before startup response', async () => { const context = createContext(); context.args.stats = true; context.args.statsCleanup = true; context.args.statsCleanupVocab = true; let aborted = false; await assert.rejects(async () => { await runStatsCommand(context, { createTempDir: () => '/tmp/subminer-stats-test', joinPath: (...parts) => parts.join('/'), runAppCommandAttached: async () => 2, waitForStatsResponse: async (_responsePath, signal) => await new Promise((resolve) => { signal?.addEventListener( 'abort', () => { aborted = true; resolve({ ok: false, error: 'aborted' }); }, { once: true }, ); }), removeDir: () => {}, }); }, /Stats app exited before startup response \(status 2\)\./); assert.equal(aborted, true); });