mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
1232 lines
42 KiB
TypeScript
1232 lines
42 KiB
TypeScript
jest.mock('electron', () => ({
|
|
ipcMain: {
|
|
handle: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('child_process', () => ({
|
|
spawn: jest.fn(),
|
|
}));
|
|
|
|
// Passthrough by default (the VLC specs bind a real ephemeral port via
|
|
// createServer); individual tests override createConnection to capture the
|
|
// JSON IPC traffic of a reused mpv instance.
|
|
jest.mock('net', () => ({
|
|
...jest.requireActual('net'),
|
|
createConnection: jest.fn((...args: unknown[]) =>
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
jest.requireActual('net').createConnection(...(args as [any]))
|
|
),
|
|
}));
|
|
|
|
jest.mock('../app', () => ({
|
|
__esModule: true,
|
|
default: {
|
|
mainWindow: null,
|
|
},
|
|
}));
|
|
|
|
jest.mock('../services/store.service', () => ({
|
|
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
|
|
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
|
|
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
|
|
VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS',
|
|
VLC_PLAYER_PATH: 'VLC_PLAYER_PATH',
|
|
VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE',
|
|
store: {
|
|
get: jest.fn(),
|
|
set: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
import { spawn, type ChildProcess } from 'child_process';
|
|
import { EventEmitter } from 'events';
|
|
import { createConnection } from 'net';
|
|
import {
|
|
MPV_PLAYER_PATH,
|
|
MPV_REUSE_INSTANCE,
|
|
VLC_PLAYER_PATH,
|
|
VLC_REUSE_INSTANCE,
|
|
store,
|
|
} from '../services/store.service';
|
|
import { externalPlayerSessions } from './external-player-runtime';
|
|
import { externalPlayerProcessTeardownGate } from './external-player-process';
|
|
import {
|
|
openMpvPlayer,
|
|
setMpvReuseInstance,
|
|
shutdownMpvSession,
|
|
} from './mpv-session.service';
|
|
import {
|
|
openVlcPlayer,
|
|
setVlcReuseInstance,
|
|
shutdownVlcSession,
|
|
} from './vlc-session.service';
|
|
|
|
function createMockChildProcess(): ChildProcess {
|
|
return Object.assign(new EventEmitter(), {
|
|
exitCode: null,
|
|
killed: false,
|
|
kill: jest.fn(() => true),
|
|
signalCode: null,
|
|
stderr: null,
|
|
stdout: null,
|
|
unref: jest.fn(),
|
|
}) as unknown as ChildProcess;
|
|
}
|
|
|
|
async function waitForSpawnCallCount(count: number): Promise<void> {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
if ((spawn as unknown as jest.Mock).mock.calls.length >= count) {
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
}
|
|
|
|
throw new Error(`Expected ${count} player spawn calls`);
|
|
}
|
|
|
|
function mockStoreValues(values: Record<string, unknown>): void {
|
|
(store.get as unknown as jest.Mock).mockImplementation(
|
|
(key: string, fallback?: unknown) =>
|
|
key in values ? values[key] : fallback
|
|
);
|
|
}
|
|
|
|
describe('external player shutdown on app quit', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('kills the stored reusable MPV process on shutdown', async () => {
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
|
|
await openMpvPlayer({
|
|
title: 'Reusable MPV stream',
|
|
url: 'https://example.com/live.m3u8',
|
|
});
|
|
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
|
|
shutdownMpvSession();
|
|
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
|
|
// The stored process reference is cleared, so a second shutdown
|
|
// must not attempt another kill.
|
|
shutdownMpvSession();
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('blocks a launch until MPV exits after reuse is disabled', async () => {
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'Reusable MPV stream',
|
|
url: 'https://example.com/live.m3u8',
|
|
});
|
|
|
|
setMpvReuseInstance(false);
|
|
|
|
await expect(
|
|
openMpvPlayer({
|
|
title: 'Replacement MPV stream',
|
|
url: 'https://example.com/replacement.m3u8',
|
|
})
|
|
).rejects.toThrow('previous external player is still shutting down');
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
});
|
|
|
|
it('settles a fresh MPV launch when Stop wins before startup confirmation', async () => {
|
|
jest.useFakeTimers();
|
|
try {
|
|
const proc = createMockChildProcess();
|
|
(proc.kill as jest.Mock).mockImplementation(() => {
|
|
Object.defineProperty(proc, 'killed', { value: true });
|
|
setImmediate(() => {
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
});
|
|
return true;
|
|
});
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: false,
|
|
});
|
|
|
|
const opening = openMpvPlayer({
|
|
title: 'Stopped stream',
|
|
url: 'https://example.com/stopped.m3u8',
|
|
});
|
|
const sessionId =
|
|
externalPlayerSessions.getActiveSessionId() as string;
|
|
const closing = externalPlayerSessions.closeSession(sessionId);
|
|
|
|
await jest.runAllTimersAsync();
|
|
await expect(closing).resolves.toMatchObject({ status: 'closed' });
|
|
|
|
let launchSettled = false;
|
|
void opening.then(() => {
|
|
launchSettled = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(launchSettled).toBe(true);
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('settles a fresh MPV launch when bounded Stop cannot confirm exit', async () => {
|
|
jest.useFakeTimers();
|
|
const proc = createMockChildProcess();
|
|
try {
|
|
(proc.kill as jest.Mock).mockImplementation(() => {
|
|
Object.defineProperty(proc, 'killed', {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
return true;
|
|
});
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: false,
|
|
});
|
|
|
|
const opening = openMpvPlayer({
|
|
title: 'Unresponsive stream',
|
|
url: 'https://example.com/unresponsive.m3u8',
|
|
});
|
|
let launchSettled = false;
|
|
void opening.then(
|
|
() => {
|
|
launchSettled = true;
|
|
},
|
|
() => {
|
|
launchSettled = true;
|
|
}
|
|
);
|
|
const sessionId =
|
|
externalPlayerSessions.getActiveSessionId() as string;
|
|
const closing = externalPlayerSessions.closeSession(sessionId);
|
|
const closeRejection = expect(closing).rejects.toThrow(
|
|
'External player process did not exit'
|
|
);
|
|
|
|
await jest.advanceTimersByTimeAsync(5_000);
|
|
await closeRejection;
|
|
await Promise.resolve();
|
|
|
|
expect(launchSettled).toBe(true);
|
|
expect(externalPlayerSessions.getSession(sessionId)).toMatchObject({
|
|
status: 'error',
|
|
canClose: true,
|
|
});
|
|
} finally {
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('does not let a stale reusable MPV closer stop the remapped session', async () => {
|
|
const proc = createMockChildProcess();
|
|
(proc.kill as jest.Mock).mockImplementation(() => {
|
|
Object.defineProperty(proc, 'exitCode', {
|
|
value: 0,
|
|
configurable: true,
|
|
});
|
|
proc.emit('exit', 0);
|
|
return true;
|
|
});
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
const previous = await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('connect'));
|
|
return socket;
|
|
});
|
|
const current = await openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
(proc.kill as jest.Mock).mockClear();
|
|
|
|
await expect(
|
|
externalPlayerSessions.closeSession(previous.id)
|
|
).resolves.toMatchObject({ status: 'closed' });
|
|
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
expect(externalPlayerSessions.getSession(current.id)).toMatchObject({
|
|
status: 'opened',
|
|
canClose: true,
|
|
});
|
|
});
|
|
|
|
it('does not send a stale quit after a closed reused session is replaced', async () => {
|
|
shutdownMpvSession();
|
|
const firstProc = createMockChildProcess();
|
|
const nextProc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock)
|
|
.mockReturnValueOnce(firstProc)
|
|
.mockReturnValueOnce(nextProc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
const written: string[] = [];
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn((chunk: string) => written.push(chunk)),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('connect'));
|
|
return socket;
|
|
});
|
|
const reused = await openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
|
|
Object.defineProperty(firstProc, 'exitCode', { value: 0 });
|
|
firstProc.emit('exit', 0);
|
|
const current = await openMpvPlayer({
|
|
title: 'Third stream',
|
|
url: 'https://example.com/three.m3u8',
|
|
});
|
|
written.length = 0;
|
|
|
|
await expect(
|
|
externalPlayerSessions.closeSession(reused.id)
|
|
).resolves.toMatchObject({ status: 'closed' });
|
|
|
|
expect(written).toEqual([]);
|
|
expect(nextProc.kill).not.toHaveBeenCalled();
|
|
expect(externalPlayerSessions.getSession(current.id)).toMatchObject({
|
|
status: 'opened',
|
|
canClose: true,
|
|
});
|
|
|
|
shutdownMpvSession();
|
|
});
|
|
|
|
it('escapes commas in http header fields passed to mpv', async () => {
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: false,
|
|
});
|
|
|
|
await openMpvPlayer({
|
|
title: 'Stalker live stream',
|
|
url: 'https://portal.example/ch/1234',
|
|
headers: {
|
|
'X-User-Agent':
|
|
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
|
},
|
|
});
|
|
|
|
const args = (spawn as unknown as jest.Mock).mock
|
|
.calls[0][1] as string[];
|
|
const headerArg = args.find((arg) =>
|
|
arg.startsWith('--http-header-fields=')
|
|
);
|
|
|
|
// mpv parses the option as a comma-separated list; the comma inside
|
|
// the MAG user agent must arrive escaped or the header is truncated
|
|
// and Stalker portals reject the stream with HTTP 400.
|
|
expect(headerArg).toContain('(KHTML\\, like Gecko) MAG250');
|
|
expect(headerArg).not.toContain('(KHTML, like Gecko)');
|
|
});
|
|
|
|
it('escapes commas in http header fields on the reused-instance IPC path', async () => {
|
|
// Reset any reusable instance a previous test may have left behind so
|
|
// the first launch below spawns rather than reuses.
|
|
shutdownMpvSession();
|
|
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
|
|
// First launch spawns the reusable instance and records its IPC socket.
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://portal.example/ch/1',
|
|
});
|
|
|
|
// Capture the JSON IPC traffic of the second, reused launch.
|
|
const written: string[] = [];
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn((chunk: string) => written.push(chunk)),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('connect'));
|
|
return socket;
|
|
});
|
|
|
|
await openMpvPlayer({
|
|
title: 'Stalker live stream',
|
|
url: 'https://portal.example/ch/1234',
|
|
headers: {
|
|
'X-User-Agent':
|
|
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
|
},
|
|
});
|
|
|
|
const commands = written.map(
|
|
(chunk) =>
|
|
JSON.parse(chunk.trim()).command as Array<string | number>
|
|
);
|
|
const setHeaderFields = commands.find(
|
|
(command) =>
|
|
command[0] === 'set_property' &&
|
|
command[1] === 'http-header-fields'
|
|
);
|
|
|
|
// The value must survive the JSON IPC transport with the mpv escape
|
|
// intact: mpv re-parses the property as a comma-separated stringlist
|
|
// on its side, so an unescaped comma truncates the MAG user agent.
|
|
expect(setHeaderFields).toBeDefined();
|
|
expect(setHeaderFields?.[2]).toContain('(KHTML\\, like Gecko) MAG250');
|
|
expect(setHeaderFields?.[2]).not.toContain('(KHTML, like Gecko)');
|
|
|
|
shutdownMpvSession();
|
|
});
|
|
|
|
it('bounds a reused MPV close when its IPC socket never connects', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('connect'));
|
|
return socket;
|
|
});
|
|
const reused = await openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() =>
|
|
Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
})
|
|
);
|
|
jest.useFakeTimers();
|
|
try {
|
|
const closing = externalPlayerSessions.closeSession(reused.id);
|
|
|
|
await jest.advanceTimersByTimeAsync(2_000);
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
await expect(closing).resolves.toMatchObject({
|
|
id: reused.id,
|
|
status: 'closed',
|
|
});
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('allows Stop to retry a reused MPV teardown after confirmation times out', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('connect'));
|
|
return socket;
|
|
});
|
|
const reused = await openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('error', new Error('quit failed')));
|
|
return socket;
|
|
});
|
|
|
|
jest.useFakeTimers();
|
|
try {
|
|
const firstClose = externalPlayerSessions.closeSession(reused.id);
|
|
const firstRejection = expect(firstClose).rejects.toThrow(
|
|
'External player process did not exit'
|
|
);
|
|
await jest.advanceTimersByTimeAsync(5_000);
|
|
await firstRejection;
|
|
const killsAfterFirstAttempt = (proc.kill as jest.Mock).mock.calls
|
|
.length;
|
|
|
|
const retry = externalPlayerSessions.closeSession(reused.id);
|
|
await jest.advanceTimersByTimeAsync(0);
|
|
expect((proc.kill as jest.Mock).mock.calls.length).toBeGreaterThan(
|
|
killsAfterFirstAttempt
|
|
);
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
|
|
await expect(retry).resolves.toMatchObject({ status: 'closed' });
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('guards reused MPV teardown while its protocol quit is pending', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('connect'));
|
|
return socket;
|
|
});
|
|
const session = await openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
|
|
const sockets: EventEmitter[] = [];
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
sockets.push(socket);
|
|
return socket;
|
|
});
|
|
const closing = externalPlayerSessions.closeSession(session.id);
|
|
|
|
try {
|
|
expect(() =>
|
|
externalPlayerProcessTeardownGate.assertLaunchAllowed()
|
|
).toThrow('previous external player is still shutting down');
|
|
} finally {
|
|
while (sockets.length === 0) {
|
|
await Promise.resolve();
|
|
}
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
sockets[0].emit('connect');
|
|
proc.emit('exit', 0);
|
|
await closing;
|
|
}
|
|
});
|
|
|
|
it('does not dispatch reused MPV content after Stop interrupts header setup', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
|
|
const sockets: Array<
|
|
EventEmitter & { write: jest.Mock; end: jest.Mock }
|
|
> = [];
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
sockets.push(socket);
|
|
if (sockets.length > 2) {
|
|
setImmediate(() => socket.emit('connect'));
|
|
}
|
|
return socket;
|
|
});
|
|
|
|
const opening = openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
userAgent: 'IPTVnator test agent',
|
|
});
|
|
while (sockets.length < 1) await Promise.resolve();
|
|
const replacementId =
|
|
externalPlayerSessions.getActiveSessionId() as string;
|
|
const closing = externalPlayerSessions.closeSession(replacementId);
|
|
while (sockets.length < 2) await Promise.resolve();
|
|
|
|
sockets[0].emit('connect');
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
sockets[1].emit('connect');
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
await expect(closing).resolves.toMatchObject({ status: 'closed' });
|
|
await expect(opening).resolves.toMatchObject({ status: 'closed' });
|
|
const commands = sockets.flatMap((socket) =>
|
|
socket.write.mock.calls.map(([request]) =>
|
|
JSON.parse(String(request)).command[0]
|
|
)
|
|
);
|
|
expect(commands).not.toContain('loadfile');
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not spawn a replacement when Stop interrupts reused MPV seek', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
|
|
const sockets: EventEmitter[] = [];
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
sockets.push(socket);
|
|
if (sockets.length === 1 || sockets.length === 3) {
|
|
setImmediate(() => socket.emit('connect'));
|
|
}
|
|
return socket;
|
|
});
|
|
|
|
const opening = openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
startTime: 120,
|
|
});
|
|
while (sockets.length < 2) {
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
const replacementId =
|
|
externalPlayerSessions.getActiveSessionId() as string;
|
|
const closing = externalPlayerSessions.closeSession(replacementId);
|
|
while (sockets.length < 3) {
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
sockets[1].emit('error', new Error('connection closed'));
|
|
|
|
await expect(closing).resolves.toMatchObject({
|
|
id: replacementId,
|
|
status: 'closed',
|
|
});
|
|
await expect(opening).resolves.toMatchObject({
|
|
id: replacementId,
|
|
status: 'closed',
|
|
});
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not spawn fresh when Stop arrives during failed MPV reuse teardown', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() =>
|
|
socket.emit('error', new Error('reuse command failed'))
|
|
);
|
|
return socket;
|
|
});
|
|
|
|
const opening = openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
const replacementId =
|
|
externalPlayerSessions.getActiveSessionId() as string;
|
|
for (
|
|
let attempt = 0;
|
|
attempt < 20 && !(proc.kill as jest.Mock).mock.calls.length;
|
|
attempt += 1
|
|
) {
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
|
|
const closing = externalPlayerSessions.closeSession(replacementId);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
|
|
await expect(closing).resolves.toMatchObject({ status: 'closed' });
|
|
await expect(opening).resolves.toMatchObject({
|
|
id: replacementId,
|
|
status: 'closed',
|
|
});
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('restores the globally displaced session when reusable MPV teardown is unconfirmed', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
const displaced = externalPlayerSessions.beginSession({
|
|
player: 'vlc',
|
|
title: 'Current VLC stream',
|
|
streamUrl: 'https://example.com/current-vlc.m3u8',
|
|
});
|
|
externalPlayerSessions.attachCloser(displaced.id, jest.fn());
|
|
externalPlayerSessions.markOpened(displaced.id);
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() =>
|
|
socket.emit('error', new Error('reuse command failed'))
|
|
);
|
|
return socket;
|
|
});
|
|
|
|
jest.useFakeTimers();
|
|
try {
|
|
const opening = openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
const rejection = expect(opening).rejects.toThrow(
|
|
'External player process did not exit'
|
|
);
|
|
|
|
for (
|
|
let attempt = 0;
|
|
attempt < 4 && !(proc.kill as jest.Mock).mock.calls.length;
|
|
attempt += 1
|
|
) {
|
|
await jest.runOnlyPendingTimersAsync();
|
|
}
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
await jest.advanceTimersByTimeAsync(5_000);
|
|
await rejection;
|
|
|
|
expect(externalPlayerSessions.getActiveSessionId()).toBe(
|
|
displaced.id
|
|
);
|
|
expect(
|
|
externalPlayerSessions.getSession(displaced.id)
|
|
).toMatchObject({
|
|
status: 'opened',
|
|
canClose: true,
|
|
});
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
jest.useRealTimers();
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
}
|
|
});
|
|
|
|
it('keeps an orphaned reusable MPV teardown failure closable', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
const previous = await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
externalPlayerSessions.markClosed(previous.id);
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() =>
|
|
socket.emit('error', new Error('reuse command failed'))
|
|
);
|
|
return socket;
|
|
});
|
|
|
|
jest.useFakeTimers();
|
|
try {
|
|
const opening = openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
const replacementId =
|
|
externalPlayerSessions.getActiveSessionId() as string;
|
|
const rejection = expect(opening).rejects.toThrow(
|
|
'External player process did not exit'
|
|
);
|
|
|
|
for (
|
|
let attempt = 0;
|
|
attempt < 4 && !(proc.kill as jest.Mock).mock.calls.length;
|
|
attempt += 1
|
|
) {
|
|
await jest.runOnlyPendingTimersAsync();
|
|
}
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
await jest.advanceTimersByTimeAsync(5_000);
|
|
await rejection;
|
|
|
|
expect(
|
|
externalPlayerSessions.getSession(replacementId)
|
|
).toMatchObject({
|
|
title: 'Second stream',
|
|
status: 'error',
|
|
canClose: true,
|
|
});
|
|
} finally {
|
|
jest.useRealTimers();
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
}
|
|
});
|
|
|
|
it('keeps replacement ownership when reused MPV content changed before teardown failed', async () => {
|
|
shutdownMpvSession();
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
const previous = await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
let connectionCount = 0;
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
connectionCount += 1;
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => {
|
|
if (connectionCount === 1) {
|
|
socket.emit('connect');
|
|
} else {
|
|
socket.emit('error', new Error('seek failed'));
|
|
}
|
|
});
|
|
return socket;
|
|
});
|
|
|
|
jest.useFakeTimers();
|
|
try {
|
|
const opening = openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
startTime: 120,
|
|
});
|
|
const replacementId = externalPlayerSessions.getActiveSessionId();
|
|
const rejection = expect(opening).rejects.toThrow(
|
|
'External player process did not exit'
|
|
);
|
|
|
|
for (
|
|
let attempt = 0;
|
|
attempt < 4 && !(proc.kill as jest.Mock).mock.calls.length;
|
|
attempt += 1
|
|
) {
|
|
await jest.runOnlyPendingTimersAsync();
|
|
}
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
await jest.advanceTimersByTimeAsync(5_000);
|
|
await rejection;
|
|
|
|
expect(replacementId).not.toBe(previous.id);
|
|
expect(externalPlayerSessions.getActiveSessionId()).toBe(
|
|
replacementId
|
|
);
|
|
expect(
|
|
externalPlayerSessions.getSession(replacementId as string)
|
|
).toMatchObject({
|
|
title: 'Second stream',
|
|
status: 'error',
|
|
canClose: true,
|
|
});
|
|
|
|
Object.defineProperty(proc, 'killed', { value: true });
|
|
await expect(
|
|
openMpvPlayer({
|
|
title: 'Third stream',
|
|
url: 'https://example.com/three.m3u8',
|
|
})
|
|
).rejects.toThrow(
|
|
'previous external player is still shutting down'
|
|
);
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
jest.useRealTimers();
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
}
|
|
});
|
|
|
|
it('waits for the stale reusable MPV process to exit before spawning fresh', async () => {
|
|
shutdownMpvSession();
|
|
const staleProc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValueOnce(staleProc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('error', new Error('stale socket')));
|
|
return socket;
|
|
});
|
|
const freshProc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValueOnce(freshProc);
|
|
|
|
const secondLaunch = openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
expect(staleProc.kill).toHaveBeenCalledTimes(1);
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
|
|
Object.defineProperty(staleProc, 'exitCode', { value: 0 });
|
|
staleProc.emit('exit', 0);
|
|
await waitForSpawnCallCount(2);
|
|
await secondLaunch;
|
|
|
|
expect(spawn).toHaveBeenCalledTimes(2);
|
|
shutdownMpvSession();
|
|
});
|
|
|
|
it('rechecks the process-wide teardown gate before fallback MPV spawn', async () => {
|
|
shutdownMpvSession();
|
|
const staleProc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(staleProc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => socket.emit('error', new Error('stale socket')));
|
|
return socket;
|
|
});
|
|
|
|
const opening = openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(staleProc.kill).toHaveBeenCalledTimes(1);
|
|
|
|
const blocker = createMockChildProcess();
|
|
const blockerTeardown =
|
|
externalPlayerProcessTeardownGate.terminate(blocker);
|
|
try {
|
|
Object.defineProperty(staleProc, 'exitCode', { value: 0 });
|
|
staleProc.emit('exit', 0);
|
|
|
|
await expect(opening).rejects.toThrow(
|
|
'previous external player is still shutting down'
|
|
);
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
Object.defineProperty(blocker, 'exitCode', { value: 0 });
|
|
blocker.emit('exit', 0);
|
|
await blockerTeardown;
|
|
}
|
|
});
|
|
|
|
it('keeps the fallback MPV session live after partial reuse failure', async () => {
|
|
shutdownMpvSession();
|
|
const reusedProc = createMockChildProcess();
|
|
(reusedProc.kill as jest.Mock).mockImplementation(() => {
|
|
setImmediate(() => {
|
|
Object.defineProperty(reusedProc, 'exitCode', { value: 0 });
|
|
reusedProc.emit('exit', 0);
|
|
});
|
|
return true;
|
|
});
|
|
const freshProc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock)
|
|
.mockReturnValueOnce(reusedProc)
|
|
.mockReturnValueOnce(freshProc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: true,
|
|
});
|
|
await openMpvPlayer({
|
|
title: 'First stream',
|
|
url: 'https://example.com/one.m3u8',
|
|
});
|
|
let connectionCount = 0;
|
|
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
|
connectionCount += 1;
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
write: jest.fn(),
|
|
end: jest.fn(),
|
|
destroy: jest.fn(),
|
|
});
|
|
setImmediate(() => {
|
|
if (connectionCount === 1) {
|
|
socket.emit('connect');
|
|
} else {
|
|
socket.emit('error', new Error('seek failed'));
|
|
}
|
|
});
|
|
return socket;
|
|
});
|
|
|
|
const session = await openMpvPlayer({
|
|
title: 'Second stream',
|
|
url: 'https://example.com/two.m3u8',
|
|
startTime: 120,
|
|
});
|
|
|
|
expect(spawn).toHaveBeenCalledTimes(2);
|
|
expect(session.status).toBe('opened');
|
|
expect(externalPlayerSessions.getSession(session.id)?.status).toBe(
|
|
'opened'
|
|
);
|
|
shutdownMpvSession();
|
|
});
|
|
|
|
it('does not track non-reusable MPV processes for shutdown', async () => {
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: false,
|
|
});
|
|
|
|
await openMpvPlayer({
|
|
title: 'Detached MPV stream',
|
|
url: 'https://example.com/live.m3u8',
|
|
});
|
|
|
|
expect(proc.unref).toHaveBeenCalled();
|
|
|
|
shutdownMpvSession();
|
|
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('waits for a detached MPV process to exit before closing its session', async () => {
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
|
|
[MPV_REUSE_INSTANCE]: false,
|
|
});
|
|
const session = await openMpvPlayer({
|
|
title: 'Detached MPV stream',
|
|
url: 'https://example.com/live.m3u8',
|
|
});
|
|
|
|
let closeSettled = false;
|
|
const closePromise = externalPlayerSessions
|
|
.closeSession(session.id)
|
|
.then((closed) => {
|
|
closeSettled = true;
|
|
return closed;
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
expect(closeSettled).toBe(false);
|
|
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
|
|
await expect(closePromise).resolves.toMatchObject({
|
|
id: session.id,
|
|
status: 'closed',
|
|
});
|
|
});
|
|
|
|
it('kills the stored reusable VLC process on shutdown', async () => {
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
|
|
[VLC_REUSE_INSTANCE]: true,
|
|
});
|
|
|
|
const openPromise = openVlcPlayer({
|
|
title: 'Reusable VLC stream',
|
|
url: 'https://example.com/live.m3u8',
|
|
});
|
|
|
|
await waitForSpawnCallCount(1);
|
|
proc.emit('spawn');
|
|
await openPromise;
|
|
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
|
|
shutdownVlcSession();
|
|
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
|
|
shutdownVlcSession();
|
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('blocks a launch until VLC exits after reuse is disabled', async () => {
|
|
const proc = createMockChildProcess();
|
|
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
|
mockStoreValues({
|
|
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
|
|
[VLC_REUSE_INSTANCE]: true,
|
|
});
|
|
const openPromise = openVlcPlayer({
|
|
title: 'Reusable VLC stream',
|
|
url: 'https://example.com/live.m3u8',
|
|
});
|
|
await waitForSpawnCallCount(1);
|
|
proc.emit('spawn');
|
|
await openPromise;
|
|
|
|
setVlcReuseInstance(false);
|
|
|
|
await expect(
|
|
openVlcPlayer({
|
|
title: 'Replacement VLC stream',
|
|
url: 'https://example.com/replacement.m3u8',
|
|
})
|
|
).rejects.toThrow('previous external player is still shutting down');
|
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
|
|
Object.defineProperty(proc, 'exitCode', { value: 0 });
|
|
proc.emit('exit', 0);
|
|
});
|
|
});
|