Files
SubMiner/launcher/mpv.test.ts
T
sudacode b31ddce627 feat(controller): add hold + press combos to main bindings (#328)
* feat(controller): add hold + press combos to main bindings

Main Alt+C actions can now be learned as combos such as L1 + D-pad right.
A combo fires on each press while its hold button is down, and claims its
press input so token move and popup scroll ignore it until it is released.
A plain action on a hold button fires on release, unless another bound
button was pressed during the hold.

The save path needed two changes for combos to persist. The IPC validator
rebuilt bindings field by field and dropped `hold`, so Alt+C showed a combo
from its local copy until the next reload turned it back into a plain
button. Validator and config resolver now share one parser. Saves also
replace the controller section, since the deep merge in patchRawConfig kept
the old `hold` when a combo was rebound to one button.

Also fixes Learn on the (D-pad) rows, which threw after cancel() cleared
the capture target.

* test(launcher): wait for content in background child test

The test polled for the fake app's output files to exist, but the shell
creates a redirect target before writing to it. A poll landing in that gap
read an empty env file and failed with '' !== '1', as seen once in CI.
Wait until both files have content instead.

* fix(controller): keep a diagonal push from learning as a combo

Learn took the first axis of a diagonal stick push as the hold and the
second as the press, saving a combo that one axis could never trigger. This
reached main Alt+C rows when they moved to combo capture. Axes crossing the
threshold in the same poll as the first input now count as one movement. A
second axis moved in a later poll still records a combo.

* fix(controller): only treat simultaneous axes as one movement

The diagonal push guard also blocked an axis activated in the same poll as a
button, so L1 plus a stick direction could not record the stick until it was
released and moved again. Apply the guard only when the first input is an
axis, so that input pair records as a combo again.
2026-11-05 23:23:54 -08:00

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', async () => {
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
if (process.platform !== 'linux') return;
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');
// The shell creates each file before writing to it, so wait for content, not existence.
const hasContent = (file: string) => fs.existsSync(file) && fs.statSync(file).size > 0;
const deadline = Date.now() + 1000;
while ((!hasContent(argsPath) || !hasContent(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);
},
);
},
);