Files
SubMiner/e2e/harness/mpv.ts
T
sudacode 2a0e931325 refactor(main): drop pass-through main-deps builders (#306)
* refactor(main): drop mining/anki main-deps builders

* refactor(anilist): drop main-deps builders

* refactor(jellyfin): drop main-deps builders

* refactor(overlay): drop overlay and shortcut main-deps builders

* refactor(main): drop ipc, tray, and osd main-deps builders

* refactor(startup): drop startup main-deps builders

* test(scripts): ignore stray local requests in AUR download test

Also apply prettier to launcher/mpv.test.ts so format:check:src passes.

* fix(shortcuts): forward overlay numeric selection hooks through the composer

composeShortcutRuntimes only passed the digit callbacks, so the visible-overlay
selection added in #78 never ran from the multi-copy/mine-sentence shortcuts.

* fix(stats): skip mpv auto-connect when stats starts the immersion tracker

The immersion-startup main-deps builder never forwarded shouldAutoConnectMpv,
so background stats and stats CLI startup always connected to mpv. Drop the
builder and pass the deps straight to the handler.

* build(deps): bump follow-redirects override to 1.16.2

Resolves GHSA-mrwp-f3q8-3g9p (high), which fails the quality-gate
bun audit --audit-level high step.

* test(subtitles): cover the null-tokenization settle path in prefetch test

The mock ignored the value the test resolved, so the null case never ran.

* test(e2e): surface mpv crashes and install current mpv on macOS CI

The harness IPC client never handled socket close, so a crashed mpv left
commands pending until the 120s test timeout with an empty mpv.log. Reject
pending commands on close, write mpv's own log via --log-file, and refresh
Homebrew on macOS CI so the installed mpv build no longer depends on the
runner image.

* test(e2e): render mpv through cocoa-cb OpenGL on macOS

mpv 0.41.0_12 with libplacebo 7.387.0 segfaults in gpu-next's MoltenVK path
on GitHub's paravirtual macOS GPU on the first frame, so the E2E session
never started.

* test(e2e): name mpv-stdio.log in the mpv close diagnostic
2026-10-31 00:50:33 -07:00

131 lines
4.4 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs';
import net from 'node:net';
import type { E2eDisplay } from './display';
import { E2E_SCREEN } from './display';
import { applyEnvDelta } from './process';
import { waitUntil } from './wait';
export type MpvClient = {
/** Sends one mpv JSON IPC command and resolves with its `data` field. */
command: (...args: unknown[]) => Promise<unknown>;
close: () => void;
};
type MpvReply = { request_id?: number; error?: string; data?: unknown };
function videoOutputArgs(display: E2eDisplay): string[] {
// Xvfb has no GPU; the software X11 output always works there.
if (display.kind === 'xvfb') return ['--vo=x11'];
// gpu-next's Vulkan path (libplacebo over MoltenVK) segfaults on the first frame
// on GitHub's paravirtual macOS GPU with libplacebo 7.387. `--vo=libmpv` is mpv's
// cocoa-cb OpenGL output, which avoids it and still opens the window the overlay tracks.
if (process.platform === 'darwin') return ['--vo=libmpv'];
return [];
}
export async function startMpv(options: {
socketPath: string;
mediaPath: string;
subtitlePath: string;
/** mpv's own verbose log (`--log-file`); `--no-terminal` silences its console output. */
logPath: string;
/** Process stdout/stderr, which still catches crash output from mpv's libraries. */
stdioLogPath: string;
display: E2eDisplay;
}): Promise<ChildProcess> {
const log = fs.openSync(options.stdioLogPath, 'w');
const child = spawn(
'mpv',
[
'--no-config',
'--no-terminal',
`--log-file=${options.logPath}`,
'--pause',
'--keep-open=yes',
'--ao=null',
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
...videoOutputArgs(options.display),
`--input-ipc-server=${options.socketPath}`,
`--sub-file=${options.subtitlePath}`,
options.mediaPath,
],
{ env: applyEnvDelta(process.env, options.display.env), stdio: ['ignore', log, log] },
);
fs.closeSync(log);
await Promise.race([
once(child, 'spawn'),
once(child, 'error').then(([error]) => {
throw new Error(`Could not start mpv (${String(error)}). Is it on PATH?`);
}),
]);
return child;
}
function tryConnect(socketPath: string): Promise<net.Socket | null> {
return new Promise((resolve) => {
const socket = net.connect(socketPath);
socket.once('connect', () => resolve(socket));
socket.once('error', () => resolve(null));
});
}
/**
* Connects to mpv's JSON IPC socket. Once the socket closes (mpv exited or
* crashed), pending and later commands reject instead of hanging until the test
* timeout; `describeClose` adds context such as mpv's exit status.
*/
export async function connectMpv(
socketPath: string,
describeClose: () => string = () => 'mpv IPC connection closed',
): Promise<MpvClient> {
const socket = await waitUntil(() => tryConnect(socketPath), {
description: `mpv IPC socket ${socketPath}`,
});
const pending = new Map<number, (reply: MpvReply) => void>();
let nextRequestId = 1;
let buffer = '';
let closedError: Error | null = null;
const failPending = () => {
closedError ??= new Error(describeClose());
for (const settle of pending.values()) settle({ error: closedError.message });
pending.clear();
};
socket.on('close', failPending);
// 'close' always follows 'error'; listening here keeps the error from being thrown.
socket.on('error', () => {});
socket.setEncoding('utf8');
socket.on('data', (chunk: string) => {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
const reply: MpvReply = JSON.parse(line);
if (reply.request_id === undefined) continue;
pending.get(reply.request_id)?.(reply);
pending.delete(reply.request_id);
}
});
return {
command: (...args) =>
new Promise((resolve, reject) => {
if (closedError) {
reject(new Error(`mpv ${JSON.stringify(args)} failed: ${closedError.message}`));
return;
}
const requestId = nextRequestId++;
pending.set(requestId, (reply) => {
if (reply.error === 'success') resolve(reply.data);
else reject(new Error(`mpv ${JSON.stringify(args)} failed: ${reply.error}`));
});
socket.write(`${JSON.stringify({ command: args, request_id: requestId })}\n`);
}),
close: () => socket.destroy(),
};
}