Files
SubMiner/launcher/commands/playback-command.test.ts
T

306 lines
9.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
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, type startMpv } from '../mpv.js';
import { makeLauncherArgs } from '../test-support/args.js';
import { withEnv } from '../test-support/env.js';
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 {
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',
logLevel: 'info',
useTexthooker: false,
...options.args,
}),
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: makePluginConfig(options.plugin),
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
processAdapter: {
platform: () => 'linux',
onSignal: () => {},
writeStdout: () => {},
exit: (_code: number): never => {
throw new Error('unexpected exit');
},
setExitCode: () => {},
},
};
}
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', () => {
const context = createContext();
const registeredSignals: NodeJS.Signals[] = [];
context.processAdapter.onSignal = (signal) => {
registeredSignals.push(signal);
};
registerCleanup(context);
registerCleanup(context);
assert.deepEqual(registeredSignals, ['SIGINT', 'SIGTERM']);
});
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const harness = makeHarness({
context: createContext({
plugin: {
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
},
}),
});
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 harness = makeHarness();
await harness.run();
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({
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;
killed: boolean;
kill: () => boolean;
};
mpvProc.exitCode = null;
mpvProc.killed = false;
mpvProc.kill = () => true;
let cleanupSawManagedOverlay = true;
const harness = makeHarness({
context,
deps: {
startMpv: async () => {
setTimeout(() => {
mpvProc.exitCode = 0;
mpvProc.emit('exit', 0);
}, 5);
},
startOverlay: async () => {
throw new Error('startOverlay should not run when plugin auto-start is used');
},
cleanupPlaybackSession: async () => {
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
},
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
},
});
try {
await harness.run();
assert.equal(cleanupSawManagedOverlay, false);
} finally {
state.appPath = '';
state.overlayManagedByLauncher = false;
}
});
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
const harness = makeHarness({
context: createContext({
args: { ...FILE_TARGET, useTexthooker: true },
plugin: { texthookerEnabled: true },
}),
deps: { isAppControlServerAvailable: async () => true },
});
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 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'), '{}');
let availabilityConfigDir: string | undefined;
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(harness.overlayCalls[0]?.configDir, expectedConfigDir);
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.overlayLoadingOsd, true);
} finally {
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
}
});
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
const harness = makeHarness({
context: createContext({ args: FILE_TARGET, plugin: { texthookerEnabled: true } }),
deps: { isAppControlServerAvailable: async () => true },
});
await harness.run();
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--show-visible-overlay']);
});
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
const harness = makeHarness({ context: createContext({ args: FILE_TARGET }) });
await harness.run();
assertRanInOrder(harness.events, 'plugin', 'startMpv');
});
test('rofi playback repairs support assets before opening the picker', async () => {
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: () => {},
},
});
await harness.run();
assertRanInOrder(events, 'plugin', 'picker', 'startMpv');
});