Files
SubMiner/launcher/mpv.test.ts
T

1027 lines
34 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { EventEmitter } from 'node:events';
import type { Args } from './types';
import { getAppControlSocketPath } from '../src/shared/app-control';
import { makeLauncherArgs } from './test-support/args.js';
import { withEnv } from './test-support/env.js';
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
import {
buildConfiguredMpvDefaultArgs,
buildRuntimeExtraScriptOptParts,
buildMpvBackendArgs,
buildMpvEnv,
cleanupPlaybackSession,
detectBackend,
launchAppBackgroundDetached,
findAppBinary,
launchAppCommandDetached,
launchTexthookerOnly,
parseMpvArgString,
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
stopOverlay,
startOverlay,
state,
waitForUnixSocketReady,
} from './mpv';
function makeArgs(overrides: Partial<Args> = {}): Args {
return makeLauncherArgs({ backend: 'x11', logLevel: 'error', ...overrides });
}
// ── shared helpers ───────────────────────────────────────────────────────────
interface TempCase {
dir: string;
socketPath: string;
}
/** Runs `run` in a fresh temp dir (with an mpv socket path inside it) and removes it afterwards. */
async function withTempCase<T>(run: (tempCase: TempCase) => T | Promise<T>): Promise<T> {
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests');
fs.mkdirSync(baseDir, { recursive: true });
const dir = fs.mkdtempSync(path.join(baseDir, 'case-'));
try {
return await run({ dir, socketPath: path.join(dir, 'mpv.sock') });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function withPlatform<T>(platform: NodeJS.Platform, callback: () => T): T {
const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
try {
return callback();
} finally {
if (originalDescriptor) {
Object.defineProperty(process, 'platform', originalDescriptor);
}
}
}
/** Writes an executable shell script `name` into `dir` and returns its path. */
function writeFakeApp(dir: string, lines: string[], name = 'fake-subminer.sh'): string {
const appPath = path.join(dir, name);
fs.writeFileSync(appPath, ['#!/bin/sh', ...lines, ''].join('\n'));
fs.chmodSync(appPath, 0o755);
return appPath;
}
/** Fake app that logs every invocation's argv and answers `--app-ping` with `pingExitCode`. */
function writeInvocationRecorder(dir: string, pingExitCode: number) {
const invocationsPath = path.join(dir, 'app-invocations.log');
const appPath = writeFakeApp(dir, [
`printf '%s\\n' "$@" >> ${JSON.stringify(invocationsPath)}`,
`if [ "$1" = "--app-ping" ]; then exit ${pingExitCode}; fi`,
'exit 0',
]);
return {
appPath,
readInvocations: () =>
fs.existsSync(invocationsPath) ? fs.readFileSync(invocationsPath, 'utf8') : '',
};
}
/** Makes every `net.createConnection` look like an mpv socket that connects after `delayMs`. */
async function withConnectableSockets<T>(delayMs: number, run: () => Promise<T>): Promise<T> {
const originalCreateConnection = net.createConnection;
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), delayMs);
return socket;
}) as typeof net.createConnection;
try {
return await run();
} finally {
net.createConnection = originalCreateConnection;
}
}
function resetLauncherState(): void {
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
}
const listen = (server: net.Server, socketPath: string) =>
new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, resolve);
});
const closeServer = (server: net.Server) =>
new Promise<void>((resolve) => server.close(() => resolve()));
/** Fake app control server: records each request's argv and answers with `reply`. */
function createControlServer(reply: { ok: boolean; error?: string } = { ok: true }) {
const receivedArgv: string[][] = [];
const server = net.createServer((socket) => {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex < 0) return;
const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown };
if (Array.isArray(payload.argv)) {
receivedArgv.push(
payload.argv.filter((value): value is string => typeof value === 'string'),
);
}
socket.end(JSON.stringify(reply) + '\n');
});
});
return { server, receivedArgv };
}
// ── app command execution ────────────────────────────────────────────────────
test('runAppCommandCaptureOutput captures status and stdio', () => {
const result = runAppCommandCaptureOutput(process.execPath, [
'-e',
'process.stdout.write("stdout-line"); process.stderr.write("stderr-line");',
]);
assert.equal(result.status, 0);
assert.equal(result.stdout, 'stdout-line');
assert.equal(result.stderr, 'stderr-line');
assert.equal(result.error, undefined);
});
test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', async () => {
await withEnv({ ELECTRON_RUN_AS_NODE: '1' }, () => {
const result = runAppCommandCaptureOutput(process.execPath, [
'-e',
'process.stdout.write(String(process.env.ELECTRON_RUN_AS_NODE ?? ""));',
]);
assert.equal(result.status, 0);
assert.equal(result.stdout, '');
});
});
test(
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
{ skip: process.platform !== 'linux' },
async () => {
await withTempCase(({ dir }) => {
const appPath = writeFakeApp(
dir,
[
'printf "args:%s\\n" "$*"',
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
],
'SubMiner.AppImage',
);
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
assert.equal(result.status, 0);
assert.match(result.stdout, /^args:\n/m);
assert.match(result.stdout, /^argc:2\n/m);
assert.match(result.stdout, /^arg0:--app-ping\n/m);
assert.match(result.stdout, /^arg1:--socket\n/m);
});
},
);
test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', async () => {
await withTempCase(({ dir }) => {
const appPath = writeFakeApp(
dir,
[
'printf "args:%s\\n" "$*"',
'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"',
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
],
'SubMiner.AppImage',
);
const result = withPlatform('linux', () =>
runAppCommandCaptureOutput(appPath, ['--sync-cli', 'sync', '--snapshot', '/tmp/out']),
);
assert.equal(result.status, 0);
assert.match(result.stdout, /^args:-e /m);
assert.match(result.stdout, /^electron-node:1$/m);
assert.match(result.stdout, /^argc:4$/m);
assert.match(result.stdout, /^arg0:--sync-cli$/m);
});
});
// ── args, env, and plugin resolution ─────────────────────────────────────────
test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title',
'',
'--force-media-title',
'',
'--pause',
]);
});
test('detectBackend resolves windows on win32 auto mode', () => {
withPlatform('win32', () => {
assert.equal(detectBackend('auto'), 'windows');
});
});
test('buildMpvEnv forces X11 by dropping Wayland hints when backend resolves to x11', () => {
withPlatform('linux', () => {
const env = buildMpvEnv(makeArgs({ backend: 'x11' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
SWAYSOCK: '/tmp/sway.sock',
});
assert.equal(env.DISPLAY, ':1');
assert.equal(env.WAYLAND_DISPLAY, undefined);
assert.equal(env.XDG_SESSION_TYPE, 'x11');
// The overlay inherits this env and needs hyprctl for its XWayland window.
assert.equal(env.HYPRLAND_INSTANCE_SIGNATURE, 'hypr');
assert.equal(env.SWAYSOCK, undefined);
});
});
test('buildMpvEnv auto mode falls back to X11 when no supported Wayland tracker backend is detected', () => {
withPlatform('linux', () => {
const env = buildMpvEnv(makeArgs({ backend: 'auto' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
XDG_CURRENT_DESKTOP: 'KDE',
XDG_SESSION_DESKTOP: 'plasma',
});
assert.equal(env.DISPLAY, ':1');
assert.equal(env.WAYLAND_DISPLAY, undefined);
assert.equal(env.XDG_SESSION_TYPE, 'x11');
});
});
test('buildMpvEnv preserves native Wayland env for supported Hyprland and Sway auto backends', () => {
withPlatform('linux', () => {
const hyprEnv = buildMpvEnv(makeArgs({ backend: 'auto' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
});
assert.equal(hyprEnv.WAYLAND_DISPLAY, 'wayland-0');
assert.equal(hyprEnv.XDG_SESSION_TYPE, 'wayland');
const swayEnv = buildMpvEnv(makeArgs({ backend: 'auto' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
SWAYSOCK: '/tmp/sway.sock',
});
assert.equal(swayEnv.WAYLAND_DISPLAY, 'wayland-0');
assert.equal(swayEnv.XDG_SESSION_TYPE, 'wayland');
});
});
test('buildMpvBackendArgs pins the X11 window context when backend resolves to x11', () => {
withPlatform('linux', () => {
assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
}),
['--gpu-context=x11vk,x11egl,x11'],
);
});
});
test('buildMpvBackendArgs pins the same X11 window context for unsupported Wayland auto fallback', () => {
withPlatform('linux', () => {
assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
XDG_CURRENT_DESKTOP: 'KDE',
XDG_SESSION_DESKTOP: 'plasma',
}),
['--gpu-context=x11vk,x11egl,x11'],
);
});
});
test('buildMpvBackendArgs keeps supported Hyprland and Sway auto backends unchanged', () => {
withPlatform('linux', () => {
assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
}),
[],
);
assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
SWAYSOCK: '/tmp/sway.sock',
}),
[],
);
});
});
test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured defaults', () => {
withPlatform('linux', () => {
const args = buildConfiguredMpvDefaultArgs(makeArgs({ launchMode: 'maximized' }), {
DISPLAY: ':1',
XDG_SESSION_TYPE: 'x11',
});
assert.equal(args.at(-1), '--window-maximized=yes');
});
});
test('buildConfiguredMpvDefaultArgs passes configured mpv profile before SubMiner defaults', () => {
withPlatform('linux', () => {
assert.deepEqual(
buildConfiguredMpvDefaultArgs(makeArgs({ profile: 'anime,hdr' }), {
DISPLAY: ':1',
XDG_SESSION_TYPE: 'x11',
}).slice(0, 2),
['--profile=anime,hdr', '--sub-auto=fuzzy'],
);
});
});
test('buildConfiguredMpvDefaultArgs disables macOS menu shortcuts so SubMiner bindings reach mpv', () => {
withPlatform('darwin', () => {
assert.equal(
buildConfiguredMpvDefaultArgs(makeArgs()).includes('--macos-menu-shortcuts=no'),
true,
);
});
});
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
const pluginDir = '/opt/SubMiner/plugin/subminer';
assert.equal(
resolveLauncherRuntimePluginPath({
appPath: '/opt/SubMiner/SubMiner.AppImage',
env: { SUBMINER_MPV_PLUGIN_PATH: pluginDir },
existsSync: (candidate) => candidate === path.join(pluginDir, 'main.lua'),
}),
path.join(pluginDir, 'main.lua'),
);
});
test('resolveLauncherRuntimePluginPath finds Linux app-support plugin assets', () => {
const homeDir = '/home/tester';
const expected = path.join(
homeDir,
'.local',
'share',
'SubMiner',
'plugin',
'subminer',
'main.lua',
);
assert.equal(
resolveLauncherRuntimePluginPath({
appPath: '/home/tester/.local/bin/SubMiner.AppImage',
scriptPath: '/home/tester/.local/bin/subminer',
platform: 'linux',
homeDir,
env: {},
existsSync: (candidate) => candidate === expected,
}),
expected,
);
});
test('resolveLauncherRuntimePluginPlan injects bundled plugin when no installed plugin exists', () => {
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
});
assert.equal(plan.scriptPath, '/opt/SubMiner/plugin/subminer/main.lua');
assert.equal(plan.installedPlugin.installed, false);
assert.equal(plan.warningMessage, null);
assert.equal(plan.errorMessage, null);
});
test('resolveLauncherRuntimePluginPlan uses installed plugin instead of bundled injection', () => {
const installedPath = '/home/tester/.config/mpv/scripts/subminer/main.lua';
const versionPath = '/home/tester/.config/mpv/scripts/subminer/version.lua';
const existing = new Set([installedPath, versionPath]);
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
platform: 'linux',
homeDir: '/home/tester',
existsSync: (candidate) => existing.has(candidate),
readFileSync: () => 'return { version = "0.12.0" }',
});
assert.equal(plan.scriptPath, null);
assert.equal(plan.installedPlugin.path, installedPath);
assert.equal(plan.installedPlugin.version, '0.12.0');
assert.match(plan.warningMessage ?? '', /This mpv session will use the installed plugin/);
assert.equal(plan.errorMessage, null);
});
test('resolveLauncherRuntimePluginPlan reports missing bundled plugin when no installed plugin exists', () => {
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: null,
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
});
assert.equal(plan.scriptPath, null);
assert.equal(plan.installedPlugin.installed, false);
assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/);
});
test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate', () => {
assert.deepEqual(
buildRuntimeExtraScriptOptParts('/tmp/video.mkv', 'file', {
startPaused: true,
runtimePluginConfig: {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
}),
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
});
assert.equal(error.code, 1);
assert.match(error.stderr, /Failed to launch texthooker mode/);
});
test('launchTexthookerOnly forwards browser-open request to app command', async () => {
await withTempCase(({ dir }) => {
const argsPath = path.join(dir, 'args.txt');
const openedUrls: string[] = [];
const appPath = writeFakeApp(dir, [`printf '%s\\n' "$@" > "${argsPath}"`, 'exit 0']);
const error = withProcessExitIntercept(() => {
launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), {
openBrowser: (url) => openedUrls.push(url),
});
});
assert.equal(error.code, 0);
assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [
'--texthooker',
'--open-browser',
]);
assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']);
});
});
test('launchAppCommandDetached handles child process spawn errors', async () => {
let uncaughtError: Error | null = null;
const onUncaughtException = (error: Error) => {
uncaughtError = error;
};
process.once('uncaughtException', onUncaughtException);
try {
launchAppCommandDetached(
'/definitely-missing-subminer-binary',
[],
makeArgs({ logLevel: 'warn' }).logLevel,
'test',
);
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(uncaughtError, null);
} finally {
process.removeListener('uncaughtException', onUncaughtException);
}
});
test('launchAppBackgroundDetached starts background child directly', async () => {
await withTempCase(async ({ dir }) => {
const argsPath = path.join(dir, 'args.txt');
const envPath = path.join(dir, 'env.txt');
const appPath = writeFakeApp(dir, [
`printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`,
`printf '%s\\n' "$SUBMINER_BACKGROUND_CHILD" > ${JSON.stringify(envPath)}`,
]);
launchAppBackgroundDetached(appPath, 'info');
const deadline = Date.now() + 1000;
while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background');
assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1');
});
});
test('stopOverlay logs a warning when stop command cannot be spawned', () => {
const originalWrite = process.stdout.write;
const writes: string[] = [];
const overlayProc = {
killed: false,
kill: () => true,
} as unknown as NonNullable<typeof state.overlayProc>;
try {
process.stdout.write = ((chunk: string | Uint8Array) => {
writes.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk));
return true;
}) as typeof process.stdout.write;
state.stopRequested = false;
state.overlayManagedByLauncher = true;
state.appPath = '/definitely-missing-subminer-binary';
state.overlayProc = overlayProc;
stopOverlay(makeArgs({ logLevel: 'warn' }));
assert.ok(writes.some((text) => text.includes('Failed to stop SubMiner overlay')));
} finally {
process.stdout.write = originalWrite;
state.stopRequested = false;
resetLauncherState();
}
});
// ── socket readiness ─────────────────────────────────────────────────────────
test('waitForUnixSocketReady returns false when socket never appears', async () => {
await withTempCase(async ({ socketPath }) => {
assert.equal(await waitForUnixSocketReady(socketPath, 120), false);
});
});
test('waitForUnixSocketReady returns false when path exists but is not socket', async () => {
await withTempCase(async ({ socketPath }) => {
fs.writeFileSync(socketPath, 'not-a-socket');
assert.equal(await waitForUnixSocketReady(socketPath, 200), false);
});
});
test('waitForUnixSocketReady returns true when socket becomes connectable before timeout', async () => {
await withTempCase(async ({ socketPath }) => {
fs.writeFileSync(socketPath, '');
const ready = await withConnectableSockets(25, () => waitForUnixSocketReady(socketPath, 400));
assert.equal(ready, true);
});
});
// ── startOverlay ownership and attach ────────────────────────────────────────
/**
* Runs `startOverlay` against a recording fake app and a connectable mpv socket, and returns
* what the app saw plus the launcher ownership state, captured before state is reset.
*/
async function runStartOverlay(options: { pingExitCode: number; alreadyManaged?: boolean }) {
return withTempCase(async ({ dir, socketPath }) => {
const app = writeInvocationRecorder(dir, options.pingExitCode);
fs.writeFileSync(socketPath, '');
if (options.alreadyManaged) {
state.appPath = app.appPath;
state.overlayManagedByLauncher = true;
}
try {
await withConnectableSockets(10, () => startOverlay(app.appPath, makeArgs(), socketPath));
return {
invocations: app.readInvocations(),
managedByLauncher: state.overlayManagedByLauncher,
ownedAppPath: state.appPath,
appPath: app.appPath,
};
} finally {
resetLauncherState();
}
});
}
test('startOverlay captures app stdout and stderr into app log', async () => {
await withTempCase(async ({ dir, socketPath }) => {
const appLogPath = path.join(dir, 'app.log');
const appPath = writeFakeApp(dir, [
'printf "hello from stdout\\n"',
'printf "hello from stderr\\n" >&2',
'exit 0',
]);
fs.writeFileSync(socketPath, '');
try {
await withEnv({ SUBMINER_APP_LOG: appLogPath }, () =>
withConnectableSockets(10, () => startOverlay(appPath, makeArgs(), socketPath)),
);
const logText = fs.readFileSync(appLogPath, 'utf8');
assert.match(logText, /\[STDOUT\] hello from stdout/);
assert.match(logText, /\[STDERR\] hello from stderr/);
} finally {
resetLauncherState();
}
});
});
test('startOverlay starts launcher-owned playback in background managed mode', async () => {
const result = await runStartOverlay({ pingExitCode: 1 });
assert.match(result.invocations, /--background/);
assert.match(result.invocations, /--managed-playback/);
assert.equal(result.managedByLauncher, true);
assert.equal(result.ownedAppPath, result.appPath);
});
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
const result = await runStartOverlay({ pingExitCode: 0 });
assert.match(result.invocations, /--app-ping/);
assert.match(result.invocations, /--start/);
assert.doesNotMatch(result.invocations, /--background/);
assert.equal(result.managedByLauncher, false);
assert.equal(result.ownedAppPath, '');
});
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
const result = await runStartOverlay({ pingExitCode: 0, alreadyManaged: true });
assert.equal(result.managedByLauncher, true);
assert.equal(result.ownedAppPath, result.appPath);
});
const controlSocketCases = [
{
name: 'startOverlay attaches through the running app control socket without spawning another app command',
// The control socket is found through SUBMINER_APP_CONTROL_SOCKET.
useConfigDir: false,
},
{
name: 'startOverlay uses caller config dir for app control socket discovery',
useConfigDir: true,
},
];
for (const c of controlSocketCases) {
test(c.name, { skip: process.platform === 'win32' }, async () => {
await withTempCase(async ({ dir, socketPath }) => {
const configDir = path.join(dir, 'launcher-config');
fs.mkdirSync(configDir, { recursive: true });
const controlSocketPath = c.useConfigDir
? getAppControlSocketPath({ configDir, platform: 'linux' })
: path.join(dir, 'control.sock');
const app = writeInvocationRecorder(dir, 0);
const mpvServer = net.createServer((socket) => socket.end());
const control = createControlServer();
try {
await listen(mpvServer, socketPath);
await listen(control.server, controlSocketPath);
await withEnv(
{ SUBMINER_APP_CONTROL_SOCKET: c.useConfigDir ? undefined : controlSocketPath },
() =>
c.useConfigDir
? startOverlay(app.appPath, makeArgs(), socketPath, [], configDir)
: startOverlay(app.appPath, makeArgs(), socketPath),
);
assert.equal(app.readInvocations(), '');
assert.equal(control.receivedArgv.length, 1);
assert.deepEqual(control.receivedArgv[0]?.slice(0, 6), [
'--start',
'--managed-playback',
'--backend',
'x11',
'--socket',
socketPath,
]);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
await closeServer(mpvServer);
await closeServer(control.server);
resetLauncherState();
}
});
});
}
test(
'startOverlay falls back to legacy app startup when control command fails',
{ skip: process.platform === 'win32' },
async () => {
await withTempCase(async ({ dir, socketPath }) => {
const controlSocketPath = path.join(dir, 'control.sock');
const app = writeInvocationRecorder(dir, 0);
const control = createControlServer({ ok: false, error: 'boom' });
try {
await listen(control.server, controlSocketPath);
await withEnv({ SUBMINER_APP_CONTROL_SOCKET: controlSocketPath }, () =>
startOverlay(app.appPath, makeArgs(), socketPath),
);
const invocations = app.readInvocations();
assert.match(invocations, /--app-ping/);
assert.match(invocations, /--start/);
} finally {
await closeServer(control.server);
resetLauncherState();
}
});
},
);
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
await withTempCase(async ({ dir }) => {
const app = writeInvocationRecorder(dir, 0);
const calls: string[] = [];
const overlayProc = {
killed: false,
kill: () => {
calls.push('overlay-kill');
return true;
},
} as unknown as NonNullable<typeof state.overlayProc>;
const mpvProc = {
killed: false,
kill: () => {
calls.push('mpv-kill');
return true;
},
} as unknown as NonNullable<typeof state.mpvProc>;
state.stopRequested = false;
state.appPath = app.appPath;
state.overlayManagedByLauncher = true;
state.overlayProc = overlayProc;
state.mpvProc = mpvProc;
try {
await cleanupPlaybackSession(makeArgs());
assert.deepEqual(calls.sort(), ['mpv-kill', 'overlay-kill']);
assert.match(app.readInvocations(), /--stop/);
} finally {
state.mpvProc = null;
state.stopRequested = false;
resetLauncherState();
}
});
});
// ── findAppBinary ────────────────────────────────────────────────────────────
interface FindAppBinaryScenario {
platform: NodeJS.Platform;
home: string;
/** Extra env; SUBMINER_APPIMAGE_PATH / SUBMINER_BINARY_PATH are always cleared first. */
env?: Record<string, string | undefined>;
/** When set, `fs.accessSync` accepts only these paths. */
executables?: string[];
/** When set, `fs.existsSync` / `fs.statSync` see only these paths. */
existing?: string[];
directories?: string[];
/** When set, replaces `fs.realpathSync`. */
realpath?: (filePath: string) => string;
}
/**
* Runs `run` with platform, home dir, env, and (optionally) fs probes faked for
* `findAppBinary`, which reads them from process globals. Everything is restored afterwards.
*/
async function withFindAppBinaryScenario(
scenario: FindAppBinaryScenario,
run: (pathModule: typeof path) => void,
): Promise<void> {
const restores: Array<() => void> = [];
const stub = (target: object, key: string, value: unknown): void => {
const original = Object.getOwnPropertyDescriptor(target, key);
Object.defineProperty(target, key, { value, configurable: true, writable: true });
restores.push(() => {
if (original) Object.defineProperty(target, key, original);
else Reflect.deleteProperty(target, key);
});
};
const missing = (code: string, filePath: string) =>
Object.assign(new Error(`${code}: ${filePath}`), { code });
try {
stub(process, 'platform', scenario.platform);
stub(os, 'homedir', () => scenario.home);
if (scenario.executables) {
const executables = new Set(scenario.executables);
stub(fs, 'accessSync', (filePath: string): void => {
if (!executables.has(filePath)) throw missing('EACCES', filePath);
});
}
if (scenario.existing || scenario.directories) {
const directories = new Set(scenario.directories ?? []);
const files = new Set(scenario.existing ?? []);
stub(
fs,
'existsSync',
(filePath: string) => files.has(filePath) || directories.has(filePath),
);
stub(fs, 'statSync', (filePath: string) => {
if (directories.has(filePath)) return { isDirectory: () => true };
if (files.has(filePath)) return { isDirectory: () => false };
throw missing('ENOENT', filePath);
});
}
if (scenario.realpath) stub(fs, 'realpathSync', scenario.realpath);
await withEnv(
{ SUBMINER_APPIMAGE_PATH: undefined, SUBMINER_BINARY_PATH: undefined, ...scenario.env },
() => run(scenario.platform === 'win32' ? (path.win32 as typeof path) : path),
);
} finally {
for (const restore of restores.reverse()) restore();
}
}
function makeExecutable(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(filePath, 0o755);
}
test(
'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
{ concurrency: false },
async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
try {
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
makeExecutable(appImage);
await withFindAppBinaryScenario({ platform: 'linux', home: baseDir }, (pathModule) => {
assert.equal(findAppBinary('/some/other/path/subminer', pathModule), appImage);
});
} finally {
fs.rmSync(baseDir, { recursive: true, force: true });
}
},
);
test(
'findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist',
{ concurrency: false },
async () => {
await withFindAppBinaryScenario(
{
platform: 'linux',
home: '/home/tester',
executables: ['/opt/SubMiner/SubMiner.AppImage'],
},
(pathModule) => {
assert.equal(
findAppBinary('/some/other/path/subminer', pathModule),
'/opt/SubMiner/SubMiner.AppImage',
);
},
);
},
);
test(
'findAppBinary finds subminer on PATH when AppImage candidates do not exist',
{ concurrency: false },
async () => {
const binDir = '/home/tester/bin';
const wrapperPath = path.join(binDir, 'subminer');
await withFindAppBinaryScenario(
{
platform: 'linux',
home: '/home/tester',
env: { PATH: binDir },
executables: [wrapperPath],
},
(pathModule) => {
// selfPath must differ from wrapperPath so the self-check does not exclude it
assert.equal(findAppBinary('/home/tester/launcher/subminer', pathModule), wrapperPath);
},
);
},
);
test(
'findAppBinary excludes PATH matches that canonicalize to the launcher path',
{ concurrency: false },
async () => {
const binDir = '/home/tester/bin';
const wrapperPath = path.join(binDir, 'subminer');
const canonicalPath = '/home/tester/launch/subminer';
await withFindAppBinaryScenario(
{
platform: 'linux',
home: '/home/tester',
env: { PATH: binDir },
executables: [wrapperPath],
realpath: (filePath) =>
filePath === canonicalPath || filePath === wrapperPath ? canonicalPath : filePath,
},
(pathModule) => {
assert.equal(findAppBinary(canonicalPath, pathModule), null);
},
);
},
);
const windowsHome = 'C:\\Users\\tester';
const windowsLauncherPath = path.win32.join(windowsHome, 'launcher', 'SubMiner.exe');
test(
'findAppBinary resolves Windows install paths when present',
{ concurrency: false },
async () => {
const localAppData = path.win32.join(windowsHome, 'AppData', 'Local');
const appExe = path.win32.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe');
await withFindAppBinaryScenario(
{
platform: 'win32',
home: windowsHome,
env: { LOCALAPPDATA: localAppData },
executables: [appExe],
},
(pathModule) => {
assert.equal(findAppBinary(windowsLauncherPath, pathModule), appExe);
},
);
},
);
test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, async () => {
const binDir = path.win32.join(windowsHome, 'bin');
const wrapperPath = path.win32.join(binDir, 'SubMiner.exe');
await withFindAppBinaryScenario(
{ platform: 'win32', home: windowsHome, env: { PATH: binDir }, executables: [wrapperPath] },
(pathModule) => {
assert.equal(findAppBinary(windowsLauncherPath, pathModule), wrapperPath);
},
);
});
test(
'findAppBinary resolves a Windows install directory to SubMiner.exe',
{ concurrency: false },
async () => {
const installDir = path.win32.join(windowsHome, 'Programs', 'SubMiner');
const appExe = path.win32.join(installDir, 'SubMiner.exe');
await withFindAppBinaryScenario(
{
platform: 'win32',
home: windowsHome,
env: { SUBMINER_BINARY_PATH: installDir },
existing: [appExe],
directories: [installDir],
executables: [appExe],
},
(pathModule) => {
assert.equal(findAppBinary(windowsLauncherPath, pathModule), appExe);
},
);
},
);