mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
add app control server for launcher-to-app attachment
- Launcher detects a running app via control socket and attaches without spawning a new process - Own-lifecycle app launches now pass --background --managed-playback; borrowed apps skip --background - Separate plain subtitle websocket (tokens: []) from annotation websocket - Default pauseVideoOnHover to true; update docs and config.example.jsonc - Setup: remove plugin readiness card, add Open SubMiner Settings button
This commit is contained in:
@@ -206,3 +206,65 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: 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>;
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
|
||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, false);
|
||||
assert.equal(
|
||||
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
|
||||
?.autoStart,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
cleanupPlaybackSession,
|
||||
launchAppCommandDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
isRunningAppControlServerAvailable,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
state,
|
||||
@@ -146,6 +147,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
waitForUnixSocketReady,
|
||||
startOverlay,
|
||||
launchAppCommandDetached,
|
||||
isAppControlServerAvailable: isRunningAppControlServerAvailable,
|
||||
log,
|
||||
cleanupPlaybackSession,
|
||||
getMpvProc: () => state.mpvProc,
|
||||
@@ -164,6 +166,7 @@ type PlaybackCommandDeps = {
|
||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||
startOverlay: typeof startOverlay;
|
||||
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||
isAppControlServerAvailable?: (logLevel: Args['logLevel']) => Promise<boolean>;
|
||||
log: typeof log;
|
||||
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||
getMpvProc: () => typeof state.mpvProc;
|
||||
@@ -213,8 +216,19 @@ export async function runPlaybackCommandWithDeps(
|
||||
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
||||
}
|
||||
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldLauncherAttachRunningApp =
|
||||
pluginAutoStartEnabled &&
|
||||
!args.startOverlay &&
|
||||
!args.autoStartOverlay &&
|
||||
!isAppOwnedYoutubeFlow &&
|
||||
((await deps.isAppControlServerAvailable?.(args.logLevel)) ?? false);
|
||||
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
|
||||
? { ...pluginRuntimeConfig, autoStart: false }
|
||||
: pluginRuntimeConfig;
|
||||
|
||||
const shouldPauseUntilOverlayReady =
|
||||
pluginRuntimeConfig.autoStart &&
|
||||
effectivePluginRuntimeConfig.autoStart &&
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||
pluginRuntimeConfig.autoStartPauseUntilReady;
|
||||
|
||||
@@ -238,16 +252,19 @@ export async function runPlaybackCommandWithDeps(
|
||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
runtimePluginConfig: {
|
||||
...pluginRuntimeConfig,
|
||||
...effectivePluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
|
||||
const shouldStartOverlay =
|
||||
args.startOverlay ||
|
||||
args.autoStartOverlay ||
|
||||
isAppOwnedYoutubeFlow ||
|
||||
shouldLauncherAttachRunningApp;
|
||||
if (shouldStartOverlay) {
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||
@@ -258,14 +275,17 @@ export async function runPlaybackCommandWithDeps(
|
||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||
);
|
||||
}
|
||||
await deps.startOverlay(
|
||||
appPath,
|
||||
args,
|
||||
mpvSocketPath,
|
||||
isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: [],
|
||||
);
|
||||
const extraAppArgs = isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: shouldLauncherAttachRunningApp
|
||||
? [
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay
|
||||
? '--show-visible-overlay'
|
||||
: '--hide-visible-overlay',
|
||||
...(pluginRuntimeConfig.texthookerEnabled ? ['--texthooker'] : []),
|
||||
]
|
||||
: [];
|
||||
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs);
|
||||
} else if (pluginAutoStartEnabled) {
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
|
||||
@@ -655,6 +655,48 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay starts launcher-owned playback in background managed mode', async () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||
'if [ "$1" = "--app-ping" ]; then exit 1; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
fs.writeFileSync(socketPath, '');
|
||||
const originalCreateConnection = net.createConnection;
|
||||
try {
|
||||
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'), 10);
|
||||
return socket;
|
||||
}) as typeof net.createConnection;
|
||||
|
||||
await startOverlay(appPath, makeArgs(), socketPath);
|
||||
|
||||
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
||||
assert.match(invocationText, /--background/);
|
||||
assert.match(invocationText, /--managed-playback/);
|
||||
assert.equal(state.overlayManagedByLauncher, true);
|
||||
assert.equal(state.appPath, appPath);
|
||||
} finally {
|
||||
net.createConnection = originalCreateConnection;
|
||||
state.overlayProc = null;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
@@ -686,6 +728,7 @@ test('startOverlay borrows an already-running background app instead of owning i
|
||||
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
||||
assert.match(invocationText, /--app-ping/);
|
||||
assert.match(invocationText, /--start/);
|
||||
assert.doesNotMatch(invocationText, /--background/);
|
||||
assert.equal(state.overlayManagedByLauncher, false);
|
||||
assert.equal(state.appPath, '');
|
||||
} finally {
|
||||
@@ -697,6 +740,89 @@ test('startOverlay borrows an already-running background app instead of owning i
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay attaches through the running app control socket without spawning another app command', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const controlSocketPath = path.join(dir, 'control.sock');
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
const receivedControlArgv: string[][] = [];
|
||||
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const mpvServer = net.createServer((socket) => socket.end());
|
||||
const controlServer = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
const line = buffer.split(/\r?\n/, 1)[0];
|
||||
if (!line) return;
|
||||
const payload = JSON.parse(line) as { argv?: unknown };
|
||||
if (Array.isArray(payload.argv)) {
|
||||
receivedControlArgv.push(
|
||||
payload.argv.filter((value): value is string => typeof value === 'string'),
|
||||
);
|
||||
}
|
||||
socket.end(JSON.stringify({ ok: true }) + '\n');
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mpvServer.once('error', reject);
|
||||
mpvServer.listen(socketPath, resolve);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
controlServer.once('error', reject);
|
||||
controlServer.listen(controlSocketPath, resolve);
|
||||
});
|
||||
|
||||
await startOverlay(appPath, makeArgs(), socketPath);
|
||||
|
||||
const invocationText = fs.existsSync(appInvocationsPath)
|
||||
? fs.readFileSync(appInvocationsPath, 'utf8')
|
||||
: '';
|
||||
assert.equal(invocationText, '');
|
||||
assert.equal(receivedControlArgv.length, 1);
|
||||
assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [
|
||||
'--start',
|
||||
'--managed-playback',
|
||||
'--backend',
|
||||
'x11',
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--log-level',
|
||||
]);
|
||||
assert.equal(state.overlayManagedByLauncher, false);
|
||||
assert.equal(state.appPath, '');
|
||||
} finally {
|
||||
if (originalControlSocket === undefined) {
|
||||
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
} else {
|
||||
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
|
||||
}
|
||||
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
|
||||
state.overlayProc = null;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
|
||||
+77
-4
@@ -4,6 +4,11 @@ import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
||||
import {
|
||||
isAppControlServerAvailable as checkAppControlServerAvailable,
|
||||
sendAppControlCommand,
|
||||
} from '../src/shared/app-control-client.js';
|
||||
import { getDefaultConfigDir } from '../src/shared/setup-state.js';
|
||||
import {
|
||||
detectInstalledMpvPlugin,
|
||||
type InstalledMpvPluginDetection,
|
||||
@@ -1004,19 +1009,70 @@ export async function startOverlay(
|
||||
): Promise<void> {
|
||||
const backend = detectBackend(args.backend);
|
||||
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
||||
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
|
||||
const alreadyManagedByLauncher = state.overlayManagedByLauncher && state.appPath === appPath;
|
||||
|
||||
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
|
||||
const overlayArgs = [
|
||||
'--start',
|
||||
'--managed-playback',
|
||||
'--backend',
|
||||
backend,
|
||||
'--socket',
|
||||
socketPath,
|
||||
...extraAppArgs,
|
||||
];
|
||||
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
||||
|
||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
||||
const controlResult = await sendAppControlCommand(overlayArgs, {
|
||||
configDir: getLauncherConfigDir(),
|
||||
});
|
||||
if (controlResult.ok) {
|
||||
log('debug', args.logLevel, 'Attached to running SubMiner app via control socket');
|
||||
if (alreadyManagedByLauncher) {
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
} else {
|
||||
clearOverlayManagedByLauncher();
|
||||
state.overlayProc = null;
|
||||
}
|
||||
|
||||
const socketReady = await waitForUnixSocketReady(
|
||||
socketPath,
|
||||
OVERLAY_START_SOCKET_READY_TIMEOUT_MS,
|
||||
);
|
||||
if (!socketReady) {
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
'Overlay start continuing before mpv socket readiness was confirmed',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (controlResult.unavailable !== true) {
|
||||
log(
|
||||
'warn',
|
||||
args.logLevel,
|
||||
`Running SubMiner app control command failed: ${controlResult.error ?? 'unknown error'}`,
|
||||
);
|
||||
if (!alreadyManagedByLauncher) {
|
||||
clearOverlayManagedByLauncher();
|
||||
state.overlayProc = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
|
||||
const borrowingExistingApp = appAlreadyRunning && !alreadyManagedByLauncher;
|
||||
const spawnOverlayArgs = [...overlayArgs];
|
||||
if (!borrowingExistingApp) spawnOverlayArgs.unshift('--background');
|
||||
|
||||
const target = resolveAppSpawnTarget(appPath, spawnOverlayArgs);
|
||||
state.overlayProc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(state.overlayProc);
|
||||
if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) {
|
||||
if (borrowingExistingApp) {
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
@@ -1045,6 +1101,23 @@ export async function startOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
function getLauncherConfigDir(): string {
|
||||
return getDefaultConfigDir({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function isRunningAppControlServerAvailable(logLevel: LogLevel): Promise<boolean> {
|
||||
const available = await checkAppControlServerAvailable({
|
||||
configDir: getLauncherConfigDir(),
|
||||
});
|
||||
if (available) {
|
||||
log('debug', logLevel, 'Running SubMiner app control socket detected');
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
export function markOverlayManagedByLauncher(appPath?: string): void {
|
||||
if (appPath) {
|
||||
state.appPath = appPath;
|
||||
|
||||
+118
-29
@@ -238,6 +238,84 @@ async function waitForJsonLines(
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs = 1500): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(filePath)) return;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
async function startFakeControlServer(
|
||||
smokeCase: SmokeCase,
|
||||
): Promise<{ socketPath: string; logPath: string; stop: () => Promise<void> }> {
|
||||
const socketPath = path.join(smokeCase.socketDir, 'app-control.sock');
|
||||
const logPath = path.join(smokeCase.artifactsDir, 'fake-control.log');
|
||||
const readyPath = path.join(smokeCase.artifactsDir, 'fake-control.ready');
|
||||
const scriptPath = path.join(smokeCase.artifactsDir, 'fake-control-server.js');
|
||||
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
`const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const path = require('node:path');
|
||||
|
||||
const socketPath = ${JSON.stringify(socketPath)};
|
||||
const logPath = ${JSON.stringify(logPath)};
|
||||
const readyPath = ${JSON.stringify(readyPath)};
|
||||
try { fs.rmSync(socketPath, { force: true }); } catch {}
|
||||
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
const line = buffer.split(/\\r?\\n/, 1)[0];
|
||||
if (!line) return;
|
||||
fs.appendFileSync(logPath, line + '\\n');
|
||||
socket.end(JSON.stringify({ ok: true }) + '\\n');
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(socketPath, () => {
|
||||
fs.writeFileSync(readyPath, 'ready');
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
server.close(() => {
|
||||
try { fs.rmSync(socketPath, { force: true }); } catch {}
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
);
|
||||
|
||||
const proc = spawn(process.execPath, [scriptPath], { stdio: 'ignore' });
|
||||
await waitForFile(readyPath);
|
||||
|
||||
return {
|
||||
socketPath,
|
||||
logPath,
|
||||
stop: async () => {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
||||
proc.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 1000);
|
||||
proc.once('close', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('launcher smoke fixture seeds completed setup state', () => {
|
||||
const smokeCase = createSmokeCase('setup-state');
|
||||
try {
|
||||
@@ -295,7 +373,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
||||
});
|
||||
|
||||
test(
|
||||
'launcher start-overlay run forwards socket/backend and keeps background app alive after mpv exits',
|
||||
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
||||
@@ -330,7 +408,9 @@ test(
|
||||
|
||||
const appStartArgs = appStartEntries[0]?.argv;
|
||||
assert.equal(Array.isArray(appStartArgs), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--background'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--start'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--managed-playback'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--backend'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('x11'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--socket'), true);
|
||||
@@ -351,44 +431,53 @@ test(
|
||||
);
|
||||
|
||||
test(
|
||||
'launcher start-overlay borrows a running background app and does not stop it after mpv exits',
|
||||
'launcher start-overlay attaches to a running background app without spawning another app command',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||
const controlServer = await startFakeControlServer(smokeCase);
|
||||
const env = {
|
||||
...makeTestEnv(smokeCase),
|
||||
SUBMINER_FAKE_APP_RUNNING: '1',
|
||||
SUBMINER_APP_CONTROL_SOCKET: controlServer.socketPath,
|
||||
};
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
|
||||
env,
|
||||
'overlay-borrow-background',
|
||||
);
|
||||
try {
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
|
||||
env,
|
||||
'overlay-borrow-background',
|
||||
);
|
||||
|
||||
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
|
||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||
await waitForJsonLines(appStartPath, 1);
|
||||
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
|
||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||
await waitForJsonLines(controlServer.logPath, 1);
|
||||
|
||||
const appEntries = readJsonLines(appLogPath);
|
||||
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);
|
||||
const appEntries = readJsonLines(appLogPath);
|
||||
const appStartEntries = readJsonLines(appStartPath);
|
||||
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.ok(
|
||||
appEntries.some(
|
||||
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
|
||||
),
|
||||
);
|
||||
assert.equal(appStartEntries.length, 1);
|
||||
assert.equal(appStopEntries.length, 0);
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(appEntries.length, 0);
|
||||
assert.equal(appStartEntries.length, 0);
|
||||
assert.equal(appStopEntries.length, 0);
|
||||
assert.equal(controlEntries.length, 1);
|
||||
const controlArgs = controlEntries[0]?.argv;
|
||||
assert.equal(Array.isArray(controlArgs), true);
|
||||
assert.equal((controlArgs as string[]).includes('--background'), false);
|
||||
assert.equal((controlArgs as string[]).includes('--start'), true);
|
||||
assert.equal((controlArgs as string[]).includes('--managed-playback'), true);
|
||||
} finally {
|
||||
await controlServer.stop();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user