test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-05 00:07:44 -07:00
1 parent 4b6842d034
commit b48083ddd8
368 files changed
+18045 -60331

No files matched your search

+35 -216
View File
@@ -150,74 +150,7 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
);
});
test('doctor command forwards refresh-known-words to app binary', () => {
const context = createContext();
context.args.doctor = true;
context.args.doctorRefreshKnownWords = true;
const forwarded: string[][] = [];
const handled = runDoctorCommand(context, {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--refresh-known-words']]);
});
test('logs command exports logs and writes archive path', () => {
const writes: string[] = [];
const context = createContext();
context.args.logsExport = true;
context.processAdapter = {
...context.processAdapter,
writeStdout: (text) => writes.push(text),
};
const handled = runLogsCommand(context, {
exportLogsArchive: () => ({
zipPath: '/tmp/subminer-logs.zip',
exportedFiles: ['/tmp/app.log'],
mode: 'current-day',
}),
});
assert.equal(handled, true);
assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']);
});
test('logs command ignores unrelated launcher commands', () => {
const context = createContext();
assert.equal(runLogsCommand(context), false);
});
test('app command starts default macOS background app detached from launcher', () => {
const context = createContext();
context.args.appPassthrough = true;
context.args.appArgs = [];
const calls: string[] = [];
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchYoutubeBrowserDetached: () => calls.push('youtube'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
});
test('app command starts default Linux background app detached from launcher', () => {
test('app command starts default background app detached from launcher app detached from launcher', () => {
const context = createContext();
context.args.appPassthrough = true;
context.args.appArgs = [];
@@ -389,49 +322,6 @@ test('dictionary command forwards candidate and selection modes to app binary',
]);
});
test('dictionary command returns after app handoff starts', () => {
const context = createContext();
context.args.dictionary = true;
const handled = runDictionaryCommand(context, {
runAppCommandWithInherit: () => undefined,
});
assert.equal(handled, true);
});
test('update command runs direct Linux release update without launching Electron', async () => {
const context = createContext();
context.args.update = true;
const calls: string[] = [];
const handled = await runUpdateCommand(context, {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'not-found' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Support assets update: skipped',
]);
});
test('stats command launches attached app command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
@@ -449,16 +339,41 @@ test('stats command launches attached app command with response path', async ()
assert.equal(harness.removedPaths.length, 1);
});
test('stats background command launches attached daemon control command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, statsBackground: true });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
const STATS_RESPONSE_ARGS = ['--stats-response-path', '/tmp/subminer-stats-test/response.json'];
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});
const statsForwardingCases = [
{
name: 'stats background command launches the daemon start command',
overrides: { statsBackground: true },
expected: ['--stats-daemon-start', ...STATS_RESPONSE_ARGS],
},
{
name: 'stats stop command forwards the daemon stop flag',
overrides: { statsStop: true },
expected: ['--stats-daemon-stop', ...STATS_RESPONSE_ARGS],
},
{
name: 'stats cleanup command forwards cleanup vocab flags',
overrides: { statsCleanup: true, statsCleanupVocab: true },
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-vocab'],
},
{
name: 'stats cleanup command forwards the lifetime rebuild flag',
overrides: { statsCleanup: true, statsCleanupLifetime: true },
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-lifetime'],
},
];
for (const c of statsForwardingCases) {
test(`${c.name} to the app`, async () => {
const harness = createStatsTestHarness({ stats: true, ...c.overrides });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [c.expected]);
assert.equal(harness.removedPaths.length, 1);
});
}
test('stats command waits for attached app exit after startup response', async () => {
const harness = createStatsTestHarness({ stats: true });
@@ -503,45 +418,6 @@ test('stats command throws when attached app exits non-zero after startup respon
assert.equal(harness.removedPaths.length, 1);
});
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
const harness = createStatsTestHarness({
stats: true,
statsCleanup: true,
statsCleanupVocab: true,
});
const handled = await runStatsCommand(harness.context, {
...harness.commandDeps,
waitForStatsResponse: async () => ({ ok: true }),
});
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--stats-cleanup',
'--stats-cleanup-vocab',
],
]);
assert.equal(harness.removedPaths.length, 1);
});
test('stats stop command forwards stop flag to the app', async () => {
const harness = createStatsTestHarness({ stats: true, statsStop: true });
const handled = await runStatsCommand(harness.context, {
...harness.commandDeps,
waitForStatsResponse: async () => ({ ok: true }),
});
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});
test('stats stop command exits on process exit without waiting for startup response', async () => {
const harness = createStatsTestHarness({ stats: true, statsStop: true });
let waitedForResponse = false;
@@ -563,30 +439,6 @@ test('stats stop command exits on process exit without waiting for startup respo
assert.equal(harness.removedPaths.length, 1);
});
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
const harness = createStatsTestHarness({
stats: true,
statsCleanup: true,
statsCleanupLifetime: true,
});
const handled = await runStatsCommand(harness.context, {
...harness.commandDeps,
waitForStatsResponse: async () => ({ ok: true }),
});
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--stats-cleanup',
'--stats-cleanup-lifetime',
],
]);
assert.equal(harness.removedPaths.length, 1);
});
test('stats command throws when stats response reports an error', async () => {
const harness = createStatsTestHarness({ stats: true });
@@ -692,36 +544,3 @@ test('stats command aborts pending response wait when attached app fails to spaw
assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
});
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
const harness = createStatsTestHarness({
stats: true,
statsCleanup: true,
statsCleanupVocab: true,
});
let aborted = false;
await assert.rejects(async () => {
await runStatsCommand(harness.context, {
...harness.commandDeps,
runAppCommandAttached: async (...args) => {
await harness.runAppCommandAttachedStub(...args);
return 2;
},
waitForStatsResponse: async (_responsePath, signal) =>
await new Promise((resolve) => {
signal?.addEventListener(
'abort',
() => {
aborted = true;
resolve({ ok: false, error: 'aborted' });
},
{ once: true },
);
}),
});
}, /Stats app exited before startup response \(status 2\)\./);
assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
});
+182 -401
View File
@@ -4,81 +4,53 @@ import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
import { state, type startMpv } from '../mpv.js';
import { makeLauncherArgs } from '../test-support/args.js';
import { withEnv } from '../test-support/env.js';
function createContext(): LauncherCommandContext {
type PlaybackDeps = Parameters<typeof runPlaybackCommandWithDeps>[1];
type StartMpvOptions = NonNullable<Parameters<typeof startMpv>[6]>;
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=65Ovd7t8sNw';
function makePluginConfig(
overrides: Partial<LauncherCommandContext['pluginRuntimeConfig']> = {},
): LauncherCommandContext['pluginRuntimeConfig'] {
return {
args: {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
...overrides,
};
}
/** A context for a YouTube URL target with logging at info and CLI texthooker off. */
function createContext(
options: {
args?: Partial<Args>;
plugin?: Partial<LauncherCommandContext['pluginRuntimeConfig']>;
} = {},
): LauncherCommandContext {
return {
args: makeLauncherArgs({
target: YOUTUBE_URL,
targetKind: 'url',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
youtubeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: '',
jellyfinUsername: '',
jellyfinPassword: '',
launchMode: 'normal',
},
logLevel: 'info',
useTexthooker: false,
...options.args,
}),
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
pluginRuntimeConfig: makePluginConfig(options.plugin),
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
processAdapter: {
@@ -93,8 +65,70 @@ function createContext(): LauncherCommandContext {
};
}
const FILE_TARGET = { target: '/tmp/movie.mkv', targetKind: 'file' } as const;
/**
* Playback harness. Deps are inert by default and record what the tests assert on: the
* options mpv was started with, the overlay launches, and an `events` log for the few cases
* where relative order is the behavior.
*/
function makeHarness(
options: {
context?: LauncherCommandContext;
deps?: Partial<PlaybackDeps>;
events?: string[];
} = {},
) {
const context = options.context ?? createContext();
const events = options.events ?? [];
const startMpvOptions: StartMpvOptions[] = [];
const overlayCalls: Array<{ extraAppArgs: string[]; configDir?: string }> = [];
const deps: PlaybackDeps = {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
events.push('plugin');
},
chooseTarget: async () => ({
target: context.args.target,
kind: context.args.targetKind === 'file' ? 'file' : 'url',
}),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (_target, _kind, _args, _socket, _appPath, _subtitles, startOptions) => {
events.push('startMpv');
if (startOptions) startMpvOptions.push(startOptions);
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socket, extraAppArgs = [], configDir) => {
events.push('startOverlay');
overlayCalls.push({ extraAppArgs, configDir });
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
...options.deps,
};
return {
context,
events,
startMpvOptions,
overlayCalls,
run: () => runPlaybackCommandWithDeps(context, deps),
};
}
function assertRanInOrder(events: string[], ...expected: string[]): void {
const positions = expected.map((event) => events.indexOf(event));
assert.ok(
positions.every(
(position, index) => position >= 0 && (index === 0 || position > positions[index - 1]!),
),
`expected ${expected.join(' -> ')} in order, got ${events.join(', ')}`,
);
}
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
const context = createContext();
const registeredSignals: NodeJS.Signals[] = [];
context.processAdapter.onSignal = (signal) => {
@@ -108,121 +142,41 @@ test('playback cleanup signal handlers are registered once across repeated sessi
});
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const calls: string[] = [];
const context = createContext();
context.pluginRuntimeConfig = {
...context.pluginRuntimeConfig,
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
const receivedStartMpvOptions: Record<string, unknown>[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
calls.push(`launch:${appArgs.join(' ')}`);
},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
const harness = makeHarness({
context: createContext({
plugin: {
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
},
}),
});
assert.deepEqual(calls, [
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw',
]);
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
await harness.run();
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--youtube-play', YOUTUBE_URL]);
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
assert.equal(harness.startMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
});
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
const context = createContext();
context.pluginRuntimeConfig = {
...context.pluginRuntimeConfig,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
const receivedStartMpvOptions: Record<string, unknown>[] = [];
const harness = makeHarness();
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
await harness.run();
const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as
| { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean }
| undefined;
const runtimeConfig = harness.startMpvOptions[0]?.runtimePluginConfig;
assert.equal(runtimeConfig?.autoStart, false);
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
});
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
useTexthooker: true,
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
osdMessages: false,
texthookerEnabled: false,
};
const appPath = context.appPath ?? '';
state.appPath = appPath;
const context = createContext({
args: { ...FILE_TARGET, useTexthooker: true },
plugin: { autoStartVisibleOverlay: false, autoStartPauseUntilReady: false },
});
state.appPath = context.appPath ?? '';
state.overlayManagedByLauncher = false;
const mpvProc = new EventEmitter() as EventEmitter & {
exitCode: number | null;
@@ -233,31 +187,27 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
mpvProc.killed = false;
mpvProc.kill = () => true;
let cleanupSawManagedOverlay = true;
try {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
const harness = makeHarness({
context,
deps: {
startMpv: async () => {
setTimeout(() => {
mpvProc.exitCode = 0;
mpvProc.emit('exit', 0);
}, 5);
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {
throw new Error('startOverlay should not run when plugin auto-start is used');
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
},
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
});
},
});
try {
await harness.run();
assert.equal(cleanupSawManagedOverlay, false);
} finally {
@@ -267,258 +217,89 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
});
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
useTexthooker: true,
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
};
const calls: string[] = [];
const receivedStartMpvOptions: Record<string, unknown>[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
calls.push('startMpv');
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
isAppControlServerAvailable: async () => true,
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
isAppControlServerAvailable: () => Promise<boolean>;
const harness = makeHarness({
context: createContext({
args: { ...FILE_TARGET, useTexthooker: true },
plugin: { texthookerEnabled: true },
}),
deps: { isAppControlServerAvailable: async () => true },
});
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
assert.equal(
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
?.autoStart,
false,
);
await harness.run();
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, [
'--show-visible-overlay',
'--texthooker',
]);
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.autoStart, false);
});
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
const context = createContext();
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalAppData = process.env.APPDATA;
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
fs.mkdirSync(expectedConfigDir, { recursive: true });
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
useTexthooker: true,
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
};
let availabilityConfigDir: string | undefined;
let overlayConfigDir: string | undefined;
let overlayLoadingOsd: boolean | undefined;
try {
process.env.XDG_CONFIG_HOME = xdgConfigHome;
process.env.APPDATA = xdgConfigHome;
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
overlayLoadingOsd = (
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
)?.overlayLoadingOsd;
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
overlayConfigDir = configDir;
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
const harness = makeHarness({
context: createContext({
args: { ...FILE_TARGET, useTexthooker: true },
plugin: { texthookerEnabled: true },
}),
deps: {
isAppControlServerAvailable: async (_logLevel, configDir) => {
availabilityConfigDir = configDir;
return true;
},
});
},
});
try {
await withEnv({ XDG_CONFIG_HOME: xdgConfigHome, APPDATA: xdgConfigHome }, () => harness.run());
assert.equal(availabilityConfigDir, expectedConfigDir);
assert.equal(overlayConfigDir, expectedConfigDir);
assert.equal(overlayLoadingOsd, true);
assert.equal(harness.overlayCalls[0]?.configDir, expectedConfigDir);
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.overlayLoadingOsd, true);
} finally {
if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
}
if (originalAppData === undefined) {
delete process.env.APPDATA;
} else {
process.env.APPDATA = originalAppData;
}
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
}
});
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
isAppControlServerAvailable: async () => true,
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
isAppControlServerAvailable: () => Promise<boolean>;
const harness = makeHarness({
context: createContext({ args: FILE_TARGET, plugin: { texthookerEnabled: true } }),
deps: { isAppControlServerAvailable: async () => true },
});
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
await harness.run();
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--show-visible-overlay']);
});
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
const calls: string[] = [];
const harness = makeHarness({ context: createContext({ args: FILE_TARGET }) });
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
await harness.run();
assert.deepEqual(calls, ['plugin', 'startMpv']);
assertRanInOrder(harness.events, 'plugin', 'startMpv');
});
test('rofi playback repairs support assets before opening the picker', async () => {
const context = createContext();
context.args = {
...context.args,
target: '',
targetKind: '',
useRofi: true,
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('assets');
const events: string[] = [];
const harness = makeHarness({
events,
context: createContext({ args: { target: '', targetKind: '', useRofi: true } }),
deps: {
chooseTarget: async () => {
events.push('picker');
return { target: '/tmp/movie.mkv', kind: 'file' };
},
checkPickerDependencies: () => {},
},
chooseTarget: async () => {
calls.push('picker');
return { target: '/tmp/movie.mkv', kind: 'file' };
},
checkPickerDependencies: () => {},
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, ['assets', 'picker', 'startMpv']);
await harness.run();
assertRanInOrder(events, 'plugin', 'picker', 'startMpv');
});
-43
View File
@@ -48,49 +48,6 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
assert.equal(spawned.length, 1);
});
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
const spawned: string[][] = [];
const deps: Partial<SyncCommandDeps> = {
runAppCommand: (_appPath, appArgs) => {
spawned.push(appArgs);
},
};
await runSyncCommand(
makeContext({
syncCliTokens: [
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
],
logLevel: 'debug',
}),
deps,
);
assert.deepEqual(spawned, [
[
'--sync-cli',
'sync',
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
'--log-level',
'debug',
],
]);
});
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
const deps: Partial<SyncCommandDeps> = {
runAppCommand: () => {
-5
View File
@@ -8,7 +8,6 @@ import {
buildPluginRuntimeScriptOptParts,
parsePluginRuntimeConfigFromMainConfig,
} from './config/plugin-runtime-config.js';
import { getDefaultSocketPath } from './types.js';
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
const parsed = parseLauncherYoutubeSubgenConfig({
@@ -207,10 +206,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
);
});
test('getDefaultSocketPath returns Windows named pipe default', () => {
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
});
test('parseLauncherMpvConfig reads configured mpv profile', () => {
assert.deepEqual(
parseLauncherMpvConfig({
-20
View File
@@ -1,20 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
test('launcher root help lists subcommands', () => {
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
encoding: 'utf8',
});
assert.match(output, /Commands:/);
assert.match(output, /jellyfin\|jf/);
assert.match(output, /doctor/);
assert.match(output, /config/);
assert.match(output, /mpv/);
assert.match(output, /logs/);
assert.match(output, /dictionary\|dict/);
assert.match(output, /texthooker/);
assert.match(output, /app\|bin/);
});
+83 -162
View File
@@ -9,6 +9,7 @@ import {
applyRootOptionsToArgs,
createDefaultArgs,
} from './args-normalizer.js';
import type { CliInvocations } from './cli-parser-builder.js';
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
@@ -19,6 +20,48 @@ function withTempDir<T>(fn: (dir: string) => T): T {
}
}
/** An invocations bag with nothing triggered; tests pass only the fields under test. */
function emptyInvocations(overrides: Partial<CliInvocations> = {}): CliInvocations {
return {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
...overrides,
};
}
test('createDefaultArgs normalizes configured language codes', () => {
const parsed = createDefaultArgs({
primarySubLanguages: [' JA ', 'jpn', 'ja'],
@@ -92,53 +135,23 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: {
action: 'play',
play: true,
server: 'https://jf.example',
username: 'alice',
password: 'secret',
logLevel: 'debug',
},
configInvocation: {
action: 'show',
logLevel: 'warn',
},
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
applyInvocationsToArgs(
parsed,
emptyInvocations({
jellyfinInvocation: {
action: 'play',
play: true,
server: 'https://jf.example',
username: 'alice',
password: 'secret',
logLevel: 'debug',
},
configInvocation: {
action: 'show',
logLevel: 'warn',
},
}),
);
assert.equal(parsed.jellyfin, false);
assert.equal(parsed.jellyfinPlay, true);
@@ -155,45 +168,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: {
logLevel: undefined,
},
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
applyInvocationsToArgs(
parsed,
emptyInvocations({
settingsInvocation: {
logLevel: undefined,
},
}),
);
assert.equal(parsed.settings, true);
assert.equal(parsed.configPath, false);
@@ -203,45 +185,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
const parsed = createDefaultArgs({});
const error = withProcessExitIntercept(() => {
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: {
action: undefined,
},
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
applyInvocationsToArgs(
parsed,
emptyInvocations({
configInvocation: {
action: undefined,
},
}),
);
});
assert.equal(error.code, 1);
@@ -251,43 +202,13 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: true,
texthookerLogLevel: null,
texthookerOpenBrowser: true,
});
applyInvocationsToArgs(
parsed,
emptyInvocations({
texthookerTriggered: true,
texthookerOpenBrowser: true,
}),
);
assert.equal(parsed.texthookerOnly, true);
assert.equal(parsed.texthookerOpenBrowser, true);
+297 -120
View File
@@ -1,128 +1,305 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Args } from './types.js';
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
buildForwardedJellyfinAppArgs,
buildRootSearchGroups,
classifyJellyfinChildSelection,
deriveJellyfinTokenStorePath,
hasStoredJellyfinSession,
parseEpisodePathFromDisplay,
parseJellyfinErrorFromAppOutput,
parseJellyfinItemsFromAppOutput,
parseJellyfinLibrariesFromAppOutput,
parseJellyfinPreviewAuthResponse,
readUtf8FileAppendedSince,
runJellyfinPlayMenuWithDeps,
shouldRetryWithStartForNoRunningInstance,
} from './jellyfin.js';
import { makeLauncherArgs } from './test-support/args.js';
import { withEnv } from './test-support/env.js';
function createArgs(): Args {
return {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: '',
targetKind: '',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
youtubeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: 'https://jellyfin.example.test',
jellyfinUsername: '',
jellyfinPassword: '',
launchMode: 'normal',
};
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-test-'));
try {
return fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
const calls: string[] = [];
class HandoffComplete extends Error {}
try {
await assert.rejects(
() =>
runJellyfinPlayMenuWithDeps(
'/tmp/SubMiner.AppImage',
createArgs(),
'/tmp/subminer',
'/tmp/subminer.sock',
{
loadLauncherJellyfinConfig: () => ({}),
findRofiTheme: () => null,
resolveJellyfinSelection: async () => 'item-123',
resolveJellyfinSelectionViaApp: async () => {
throw new Error('unexpected app-based selection');
},
hasStoredJellyfinSession: () => true,
requestJellyfinPreviewAuthFromApp: async () => null,
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
pathExists: () => false,
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveLauncherRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
runAppCommandWithInheritLogged: () => {
calls.push('handoff');
throw new Error('stop after handoff');
},
log: () => {},
},
),
/stop after handoff/,
);
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
} finally {
if (originalAccessToken === undefined) {
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
} else {
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
}
if (originalUserId === undefined) {
delete process.env.SUBMINER_JELLYFIN_USER_ID;
} else {
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
}
}
/**
* Runs the play menu with fake deps and a direct env session. The app handoff never returns in
* production, so the fake throws `HandoffComplete` after recording its args into `handoff`.
*/
async function runPlayMenu(options: {
socketReady?: boolean;
overrides?: Partial<PlayMenuDeps>;
args?: Parameters<typeof makeLauncherArgs>[0];
}) {
const launches: string[] = [];
const handoff: { appArgs: string[] | null } = { appArgs: null };
const deps: PlayMenuDeps = {
loadLauncherJellyfinConfig: () => ({}),
findRofiTheme: () => null,
resolveJellyfinSelection: async () => 'item-123',
resolveJellyfinSelectionViaApp: async () => {
throw new Error('unexpected app-based selection');
},
hasStoredJellyfinSession: () => true,
requestJellyfinPreviewAuthFromApp: async () => null,
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
pathExists: () => options.socketReady ?? false,
ensureRuntimePluginReady: async () => {
launches.push('plugin');
},
waitForUnixSocketReady: async () => true,
launchMpvIdleDetached: async () => {
launches.push('launch');
},
resolveLauncherRuntimePluginPath: () => '/tmp/plugin/main.lua',
runAppCommandWithInheritLogged: (_appPath, appArgs) => {
handoff.appArgs = appArgs;
throw new HandoffComplete();
},
log: () => {},
...options.overrides,
};
await withEnv(
{ SUBMINER_JELLYFIN_ACCESS_TOKEN: 'token', SUBMINER_JELLYFIN_USER_ID: 'user' },
() =>
assert.rejects(
() =>
runJellyfinPlayMenuWithDeps(
'/tmp/SubMiner.AppImage',
makeLauncherArgs({ jellyfinServer: 'https://jellyfin.example.test', ...options.args }),
'/tmp/subminer',
'/tmp/subminer.sock',
deps,
),
HandoffComplete,
),
);
return { launches, appArgs: handoff.appArgs };
}
test('Jellyfin playback launches the idle mpv with the runtime plugin, then hands the item to the app', async () => {
const { launches, appArgs } = await runPlayMenu({});
assert.deepEqual(launches, ['plugin', 'launch']);
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
});
test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv', async () => {
const { launches, appArgs } = await runPlayMenu({ socketReady: true });
assert.deepEqual(launches, []);
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
});
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
const parsed = parseJellyfinLibrariesFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
`);
assert.deepEqual(parsed, [
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
{ id: 'lib2', name: 'Movies', kind: 'movies' },
]);
});
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
const parsed = parseJellyfinItemsFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
`);
assert.deepEqual(parsed, [
{
id: 'item-10',
name: 'Solo Leveling S01E10',
type: 'Episode',
display: 'Solo Leveling S01E10',
},
{
id: 'movie-1',
name: 'Movie [Alt]',
type: 'Movie',
display: 'Movie [Alt]',
},
]);
});
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
const forwarded = buildForwardedJellyfinAppArgs(
{
jellyfinServer: 'https://jf.example.test/',
passwordStore: 'gnome-libsecret',
logLevel: 'info',
} as never,
['--jellyfin-libraries'],
);
assert.deepEqual(forwarded, [
'--jellyfin-libraries',
'--jellyfin-server',
'https://jf.example.test',
'--password-store',
'gnome-libsecret',
'--log-level',
'info',
]);
});
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
`);
assert.equal(
parsed,
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
});
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
`);
assert.equal(
parsed,
'[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
);
});
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
const parsed = parseJellyfinPreviewAuthResponse(
JSON.stringify({
serverUrl: 'http://pve-main:8096/',
accessToken: 'token-123',
userId: 'user-1',
}),
);
assert.deepEqual(parsed, {
serverUrl: 'http://pve-main:8096',
accessToken: 'token-123',
userId: 'user-1',
});
});
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
assert.equal(
parseJellyfinPreviewAuthResponse(
JSON.stringify({
serverUrl: 'http://pve-main:8096',
accessToken: '',
userId: 'user-1',
}),
),
null,
);
});
test('hasStoredJellyfinSession checks token-store existence', () => {
const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
const tokenPath = deriveJellyfinTokenStorePath(configPath);
const exists = (candidate: string): boolean => candidate === tokenPath;
assert.equal(hasStoredJellyfinSession(configPath, exists), true);
assert.equal(
hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists),
false,
);
});
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
assert.equal(
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
true,
);
assert.equal(
shouldRetryWithStartForNoRunningInstance(
'Missing Jellyfin session. Run --jellyfin-login first.',
),
false,
);
});
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
withTempDir((root) => {
const logPath = path.join(root, 'SubMiner.log');
const prefix = '[subminer] こんにちは\n';
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
const byteOffset = Buffer.byteLength(prefix, 'utf8');
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
});
});
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
assert.deepEqual(
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
{
seriesName: 'KONOSUBA',
seasonNumber: 1,
},
);
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
seriesName: 'Frieren',
seasonNumber: 2,
});
});
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
});
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
const groups = buildRootSearchGroups([
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
]);
assert.deepEqual(groups, [
{
id: 'series-1',
name: 'The Eminence in Shadow',
type: 'Series',
display: 'The Eminence in Shadow (Series)',
},
{
id: 'movie-1',
name: 'Spirited Away',
type: 'Movie',
display: 'Spirited Away (Movie)',
},
]);
});
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
assert.deepEqual(next, {
kind: 'container',
id: 'season-2',
});
});
+325 -894
View File
File diff suppressed because it is too large. Load diff
+523 -964
View File
File diff suppressed because it is too large. Load diff
-14
View File
@@ -30,12 +30,6 @@ test('parseArgs captures mpv args string', () => {
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
});
test('parseArgs appends CLI mpv profile to configured mpv profile', () => {
const parsed = parseArgs(['--profile', 'hdr'], 'subminer', {}, { profile: 'anime' });
assert.equal(parsed.profile, 'anime,hdr');
});
test('parseArgs maps root settings window option', () => {
const parsed = parseArgs(['--settings'], 'subminer', {});
@@ -224,14 +218,6 @@ test('parseArgs maps stats cleanup to vocab mode by default', () => {
assert.equal(parsed.statsCleanupVocab, true);
});
test('parseArgs maps explicit stats cleanup vocab flag', () => {
const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
assert.equal(parsed.stats, true);
assert.equal(parsed.statsCleanup, true);
assert.equal(parsed.statsCleanupVocab, true);
});
test('parseArgs maps lifetime stats cleanup flag', () => {
const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {});
+144 -258
View File
@@ -8,282 +8,168 @@ import {
installManagedPluginAssetsViaApp,
} from './runtime-plugin-preflight';
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
const calls: string[] = [];
type EnsureOptions = Parameters<typeof ensureLinuxRuntimePluginAvailable>[0];
await ensureLinuxRuntimePluginAvailable({
platform: 'darwin',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: () => {
calls.push('log');
},
});
assert.deepEqual(calls, []);
});
test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => {
calls.push('detect');
return true;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
isManagedThumbnailerAvailable: () => {
calls.push('thumbnailer');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
});
test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
isManagedThumbnailerAvailable: () => {
calls.push('thumbnailer');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
const calls: string[] = [];
let themeAvailable = false;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
isManagedThemeAvailable: () => {
calls.push('theme');
return themeAvailable;
},
isManagedThumbnailerAvailable: () => {
calls.push('thumbnailer');
return true;
},
installManagedPluginAssets: async () => {
calls.push('install');
themeAvailable = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'detect',
'resolve',
'theme',
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
'resolve',
'theme',
'thumbnailer',
]);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => {
const calls: string[] = [];
let thumbnailerAvailable = false;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => true,
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
installManagedPluginAssets: async () => {
calls.push('install');
thumbnailerAvailable = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (_level, _configured, message) => {
calls.push(message);
},
});
assert.deepEqual(calls, [
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
'install',
'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
]);
});
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
const calls: string[] = [];
let thumbnailerAvailable = false;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => true,
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
installManagedPluginAssets: async () => {
calls.push('install');
thumbnailerAvailable = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: () => {},
});
assert.deepEqual(calls, ['install']);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
const calls: string[] = [];
let resolveCount = 0;
await ensureLinuxRuntimePluginAvailable({
/**
* Linux preflight harness: by default every managed asset resolves, so tests override only
* the probes that should report something missing. Counts installs and collects log messages.
*/
function makeEnsureHarness(overrides: Partial<EnsureOptions> = {}) {
const state = { installs: 0, logs: [] as string[] };
const options: EnsureOptions = {
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => {
resolveCount += 1;
calls.push(`resolve:${resolveCount}`);
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
},
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => true,
installManagedPluginAssets: async () => {
calls.push('install');
state.installs += 1;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
log: (_level, _configured, message) => {
state.logs.push(message);
},
...overrides,
};
return { state, run: () => ensureLinuxRuntimePluginAvailable(options) };
}
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
const unexpected = () => {
throw new Error('probed on a non-Linux platform');
};
const harness = makeEnsureHarness({
platform: 'darwin',
detectInstalledPlugin: unexpected,
resolveRuntimePluginPath: unexpected,
isManagedThemeAvailable: unexpected,
isManagedThumbnailerAvailable: unexpected,
});
await harness.run();
assert.equal(harness.state.installs, 0);
assert.deepEqual(harness.state.logs, []);
});
const skipInstallCases: Array<{ name: string; overrides: Partial<EnsureOptions> }> = [
{
name: 'plugin, theme, and thumbnailer exist',
overrides: { detectInstalledPlugin: () => true, resolveRuntimePluginPath: () => null },
},
{ name: 'all managed assets resolve', overrides: {} },
];
for (const c of skipInstallCases) {
test(`ensureLinuxRuntimePluginAvailable skips install when ${c.name}`, async () => {
const harness = makeEnsureHarness(c.overrides);
await harness.run();
assert.equal(harness.state.installs, 0);
assert.deepEqual(harness.state.logs, []);
});
}
const installWhenMissingCases: Array<{ name: string; missing: 'theme' | 'thumbnailer' }> = [
{ name: 'rofi theme is missing', missing: 'theme' },
{ name: 'thumbnailer is missing', missing: 'thumbnailer' },
];
for (const c of installWhenMissingCases) {
test(`ensureLinuxRuntimePluginAvailable installs managed assets when ${c.name}`, async () => {
let available = false;
const probe = {
[c.missing === 'theme' ? 'isManagedThemeAvailable' : 'isManagedThumbnailerAvailable']: () =>
available,
};
const harness = makeEnsureHarness({
...probe,
installManagedPluginAssets: async () => {
harness.state.installs += 1;
available = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
});
await harness.run();
assert.equal(harness.state.installs, 1);
assert.equal(harness.state.logs.length, 2);
assert.match(harness.state.logs[0]!, /support assets missing; installing/);
assert.match(
harness.state.logs[1]!,
/installed: plugin=\/tmp\/plugin\/main\.lua theme=.*subminer\.rasi thumbnailer=.*\.thumbnailer/,
);
});
}
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
let thumbnailerAvailable = false;
const harness = makeEnsureHarness({
detectInstalledPlugin: () => true,
// An installed plugin means the bundled path is never needed, even after the install.
resolveRuntimePluginPath: () => null,
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
installManagedPluginAssets: async () => {
harness.state.installs += 1;
thumbnailerAvailable = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
});
assert.deepEqual(calls, [
'resolve:1',
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
'resolve:2',
]);
await harness.run();
assert.equal(harness.state.installs, 1);
});
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: false,
status: 'failed',
error: 'copy failed',
}),
log: () => {},
}),
/copy failed/,
);
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
let installed = false;
const harness = makeEnsureHarness({
resolveRuntimePluginPath: () => (installed ? '/tmp/plugin/main.lua' : null),
installManagedPluginAssets: async () => {
harness.state.installs += 1;
installed = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
});
await harness.run();
assert.equal(harness.state.installs, 1);
});
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
log: () => {},
const failureCases: Array<{ name: string; overrides: Partial<EnsureOptions>; error: RegExp }> = [
{
name: 'install result is not ok',
overrides: {
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: false,
status: 'failed',
error: 'copy failed',
}),
/managed runtime plugin assets could not be installed/i,
);
});
},
error: /copy failed/,
},
{
name: 'runtime path remains unresolved after install',
overrides: { resolveRuntimePluginPath: () => null },
error: /managed runtime plugin assets could not be installed/i,
},
{
name: 'thumbnailer remains missing after install',
overrides: { detectInstalledPlugin: () => true, isManagedThumbnailerAvailable: () => false },
error: /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
},
];
test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => true,
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => false,
installManagedPluginAssets: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
log: () => {},
}),
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
);
});
for (const c of failureCases) {
test(`ensureLinuxRuntimePluginAvailable fails when ${c.name}`, async () => {
await assert.rejects(() => makeEnsureHarness(c.overrides).run(), c.error);
});
}
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
+105 -211
View File
@@ -3,104 +3,90 @@ import assert from 'node:assert/strict';
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
import type { SetupState } from '../src/shared/setup-state';
const commandLineSetupDefaults = {
bunInstallStatus: 'unknown',
launcherInstallStatus: 'unknown',
launcherInstallPath: null,
} satisfies Pick<SetupState, 'bunInstallStatus' | 'launcherInstallStatus' | 'launcherInstallPath'>;
/** An in-progress setup state; tests override only the fields that matter. */
function makeState(overrides: Partial<SetupState> = {}): SetupState {
return {
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
bunInstallStatus: 'unknown',
launcherInstallStatus: 'unknown',
launcherInstallPath: null,
...overrides,
};
}
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
const sequence: Array<SetupState | null> = [
null,
{
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
},
{
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'skipped',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'skipped',
...commandLineSetupDefaults,
},
];
function completedState(overrides: Partial<SetupState> = {}): SetupState {
return makeState({
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1,
...overrides,
});
}
const result = await waitForSetupCompletion({
readSetupState: () => sequence.shift() ?? null,
const cancelledState = () => makeState({ status: 'cancelled' });
/** Deterministic clock: every `now()` advances 100ms and `sleep` returns immediately. */
function fakeClock() {
let value = 0;
return {
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
now: () => (value += 100),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
};
}
assert.equal(result, 'completed');
});
/** Reader that serves `states` in order, then repeats the last one; counts its reads. */
function sequenceReader(states: Array<SetupState | null>) {
const reader = {
reads: 0,
read: (): SetupState | null => states[Math.min(reader.reads++, states.length - 1)] ?? null,
};
return reader;
}
for (const c of [
{ name: 'completed', last: completedState(), expected: 'completed' },
{ name: 'cancelled', last: cancelledState(), expected: 'cancelled' },
] as const) {
test(`waitForSetupCompletion resolves ${c.name} state after polling through earlier states`, async () => {
const reader = sequenceReader([null, makeState(), c.last]);
const result = await waitForSetupCompletion({
readSetupState: reader.read,
...fakeClock(),
});
assert.equal(result, c.expected);
assert.equal(reader.reads, 3);
});
}
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
const calls: string[] = [];
let reads = 0;
const reader = sequenceReader([
null,
makeState(),
completedState({ pluginInstallStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv' }),
]);
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
if (reads === 1) return null;
if (reads === 2) {
return {
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
return {
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'installed',
...commandLineSetupDefaults,
};
},
readSetupState: reader.read,
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
...fakeClock(),
});
assert.equal(ready, true);
@@ -116,10 +102,7 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5_000,
pollIntervalMs: 100,
...fakeClock(),
});
assert.equal(ready, true);
@@ -129,76 +112,47 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
const calls: string[] = [];
let legacyPluginInstalled = true;
let reads = 0;
// The completion timestamp moves forward once the setup app has finished.
const reader = sequenceReader([
completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
completedState({
completedAt: '2026-05-12T14:40:00.000Z',
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
}),
]);
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
return {
version: 4,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
completionSource: 'user',
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
readSetupState: reader.read,
hasLegacyMpvPlugin: () => legacyPluginInstalled,
launchSetupApp: () => {
calls.push('launch');
legacyPluginInstalled = false;
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
...fakeClock(),
});
assert.equal(ready, true);
assert.deepEqual(calls, ['launch']);
assert.equal(reads >= 3, true);
assert.equal(reader.reads >= 3, true);
});
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
const calls: string[] = [];
let reads = 0;
const reader = sequenceReader([
completedState({ lastSeenYomitanDictionaryCount: 2 }),
completedState({ lastSeenYomitanDictionaryCount: 2 }),
completedState({ completedAt: '2026-05-12T14:30:00.000Z', lastSeenYomitanDictionaryCount: 2 }),
]);
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
return {
version: 4,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 2,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
readSetupState: reader.read,
hasLegacyMpvPlugin: () => true,
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
...fakeClock(),
});
assert.equal(ready, true);
@@ -207,90 +161,30 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 4,
status: 'cancelled',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
}),
readSetupState: cancelledState,
launchSetupApp: () => undefined,
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
...fakeClock(),
});
assert.equal(result, false);
});
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
let reads = 0;
const reader = sequenceReader([
cancelledState(),
cancelledState(),
makeState(),
completedState({
completionSource: 'legacy_auto_detected',
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
}),
]);
const result = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
if (reads <= 2) {
return {
version: 4,
status: 'cancelled',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
if (reads === 3) {
return {
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
return {
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'legacy_auto_detected',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
readSetupState: reader.read,
launchSetupApp: () => undefined,
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
...fakeClock(),
});
assert.equal(result, true);
+51 -67
View File
@@ -9,7 +9,6 @@ import {
createDefaultSetupState,
getDefaultConfigDir,
getSetupStatePath,
readSetupState,
writeSetupState,
} from '../src/shared/setup-state.js';
@@ -407,65 +406,65 @@ setInterval(() => {}, 1000);
};
}
test('launcher smoke fixture seeds completed setup state', () => {
const smokeCase = createSmokeCase('setup-state');
/**
* Some sandboxes forbid binding unix sockets (EPERM). The fake mpv needs one, so these tests
* cannot pass there; probe once and skip them instead of weakening every assertion.
*/
function unixSocketsDenied(): boolean {
if (process.platform === 'win32') return false;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sock-probe-'));
try {
const configDir = getDefaultConfigDir({
xdgConfigHome: smokeCase.xdgConfigHome,
homeDir: smokeCase.homeDir,
});
const statePath = getSetupStatePath(configDir);
assert.equal(readSetupState(statePath)?.status, 'completed');
const probe = spawnSync(
process.execPath,
[
'-e',
`require('node:net').createServer().on('error', (e) => { process.stderr.write(String(e)); process.exit(1); }).listen(${JSON.stringify(path.join(dir, 's.sock'))}, function () { this.close(() => process.exit(0)); });`,
],
{ encoding: 'utf8', timeout: 5000 },
);
return probe.status !== 0 && /eperm|operation not permitted/i.test(probe.stderr);
} finally {
fs.rmSync(smokeCase.root, { recursive: true, force: true });
fs.rmSync(smokeCase.socketDir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
});
}
test('launcher mpv status returns ready when socket is connectable', async () => {
await withSmokeCase('mpv-status', async (smokeCase) => {
const env = makeTestEnv(smokeCase);
const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
env,
stdio: 'ignore',
});
const skipWithoutUnixSockets = unixSocketsDenied() ? 'sandbox denies unix socket binding' : false;
try {
await waitForSocketReady(smokeCase.socketPath);
const result = runLauncher(
smokeCase,
['mpv', 'status', '--log-level', 'debug'],
test(
'launcher mpv status returns ready when socket is connectable',
{ skip: skipWithoutUnixSockets },
async () => {
await withSmokeCase('mpv-status', async (smokeCase) => {
const env = makeTestEnv(smokeCase);
const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
env,
'mpv-status',
);
const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const fakeMpvError = fakeMpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
stdio: 'ignore',
});
if (unixSocketDenied) {
assert.equal(result.status, 1);
assert.match(result.stdout, /socket not ready/i);
} else {
try {
await waitForSocketReady(smokeCase.socketPath);
const result = runLauncher(
smokeCase,
['mpv', 'status', '--log-level', 'debug'],
env,
'mpv-status',
);
assert.equal(result.status, 0);
assert.match(result.stdout, /socket ready/i);
} finally {
if (fakeMpv.exitCode === null) {
await new Promise<void>((resolve) => {
fakeMpv.once('close', () => resolve());
});
}
}
} finally {
if (fakeMpv.exitCode === null) {
await new Promise<void>((resolve) => {
fakeMpv.once('close', () => resolve());
});
}
}
});
});
});
},
);
test(
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
async () => {
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
const env = makeTestEnv(smokeCase);
@@ -484,13 +483,8 @@ test(
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(result.status, 0);
assert.match(result.stdout, /Starting SubMiner overlay/i);
assert.equal(appStartEntries.length, 1);
@@ -523,7 +517,7 @@ test(
test(
'launcher start-overlay attaches to a running background app without spawning another app start command',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
const controlServer = await startFakeControlServer(smokeCase);
@@ -550,13 +544,8 @@ test(
const appStopEntries = readJsonLines(appStopPath);
const controlEntries = readJsonLines(controlServer.logPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(result.status, 0);
if (process.platform === 'linux') {
assert.equal(appEntries.length > 0, true);
assert.equal(
@@ -583,7 +572,7 @@ test(
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
async () => {
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
fs.writeFileSync(
@@ -607,14 +596,9 @@ test(
);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
const mpvFirstArgs = mpvEntries[0]?.argv;
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(result.status, 0);
assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
assert.match(
+7
View File
@@ -0,0 +1,7 @@
import { parseArgs } from '../config.js';
import type { Args } from '../types.js';
/** Default launcher `Args` (as if run with no CLI input), with `overrides` applied. */
export function makeLauncherArgs(overrides: Partial<Args> = {}): Args {
return { ...parseArgs([], 'subminer', {}), ...overrides };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Runs `run` with the given process env values (`undefined` deletes a key) and restores the
* originals afterwards, even if `run` throws or rejects.
*/
export async function withEnv<T>(
overrides: Record<string, string | undefined>,
run: () => T | Promise<T>,
): Promise<T> {
const originals = Object.fromEntries(
Object.keys(overrides).map((key) => [key, process.env[key]]),
);
const apply = (values: Record<string, string | undefined>): void => {
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
};
apply(overrides);
try {
return await run();
} finally {
apply(originals);
}
}
@@ -5,7 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { Database as BunDatabase } from 'bun:sqlite';
import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js';
import { createImmersionDbFixture, insertFixtureSession } from './immersion-db-fixture.js';
import { createImmersionDbFixture } from './immersion-db-fixture.js';
type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null };
@@ -108,65 +108,3 @@ test('fixture schema stays aligned with production sync-touched tables and index
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('fixture leaves lexical rollups pending when their table is absent', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-rollup-state-'));
const fixturePath = path.join(dir, 'fixture.sqlite');
try {
createImmersionDbFixture(fixturePath);
const db = new BunDatabase(fixturePath, { readonly: true });
try {
const state = db
.query<{ state_value: string }>(
`SELECT state_value FROM imm_rollup_state
WHERE state_key = 'lexical_daily_rollups_version'`,
)
.get();
const rollupTable = db
.query<{ name: string }>(
`SELECT name FROM sqlite_schema
WHERE type = 'table' AND name = 'imm_lexical_daily_rollups'`,
)
.get();
assert.equal(state?.state_value, '0');
assert.equal(rollupTable, null);
} finally {
db.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('fixture session inserts enforce foreign keys', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-'));
const fixturePath = path.join(dir, 'fixture.sqlite');
try {
createImmersionDbFixture(fixturePath);
const db = new BunDatabase(fixturePath, { readwrite: true });
try {
db.run(`
CREATE TRIGGER remove_fixture_video
BEFORE INSERT ON imm_sessions
BEGIN
DELETE FROM imm_videos WHERE video_id = NEW.video_id;
END
`);
} finally {
db.close();
}
assert.throws(
() =>
insertFixtureSession(fixturePath, {
uuid: 'foreign-key-check',
videoKey: 'foreign-key-check',
startedAtMs: Date.UTC(2026, 6, 9),
}),
/FOREIGN KEY constraint failed/,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});