mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
test(player): cover the VLC session service
Mirror the MPV session spec patterns for VLC: enqueue-command building and RC response parsing, launch argv construction, instance reuse over the RC socket, exit-code handling, and the retry-without-RC fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1761fdffb8
commit
be2730edfa
2 files changed
+527
No files matched your search
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Instance reuse, process exit, and spawn-error coverage for the VLC session
|
||||
* service. Pure helpers and launch-argument construction live in
|
||||
* `vlc-session.service.spec.ts`.
|
||||
*/
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('net', () => ({
|
||||
createConnection: jest.fn(),
|
||||
createServer: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../app', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
mainWindow: null,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
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, createServer } from 'net';
|
||||
import {
|
||||
VLC_PLAYER_PATH,
|
||||
VLC_REUSE_INSTANCE,
|
||||
store,
|
||||
} from '../services/store.service';
|
||||
import { externalPlayerSessions } from './external-player-runtime';
|
||||
import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service';
|
||||
|
||||
const spawnMock = spawn as unknown as jest.Mock;
|
||||
const streamUrl = 'https://example.com/stream.m3u8';
|
||||
const rcWrites: string[] = [];
|
||||
|
||||
function createMockChildProcess(): ChildProcess {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
killed: false,
|
||||
kill: jest.fn(() => true),
|
||||
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 (spawnMock.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
|
||||
);
|
||||
}
|
||||
|
||||
function installRcSocketMock(behavior: 'ack' | 'error'): void {
|
||||
(createConnection as unknown as jest.Mock).mockImplementation(() => {
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
destroyed: false,
|
||||
write: jest.fn((data: string) => {
|
||||
rcWrites.push(data);
|
||||
setImmediate(() => socket.emit('data', Buffer.from('> ')));
|
||||
return true;
|
||||
}),
|
||||
destroy: jest.fn(() => {
|
||||
socket.destroyed = true;
|
||||
}),
|
||||
});
|
||||
setImmediate(() => {
|
||||
if (behavior === 'error') {
|
||||
socket.emit('error', new Error('rc connect failed'));
|
||||
} else {
|
||||
socket.emit('connect');
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
});
|
||||
}
|
||||
|
||||
async function openTrackedVlcInstance(proc: ChildProcess): Promise<void> {
|
||||
spawnMock.mockReturnValueOnce(proc);
|
||||
const openPromise = openVlcPlayer({ title: 'First', url: streamUrl });
|
||||
await waitForSpawnCallCount(1);
|
||||
proc.emit('spawn');
|
||||
await openPromise;
|
||||
}
|
||||
|
||||
describe('vlc-session.service process lifecycle', () => {
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
rcWrites.length = 0;
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
(createServer as unknown as jest.Mock).mockImplementation(() => ({
|
||||
unref: jest.fn(),
|
||||
on: jest.fn(),
|
||||
listen: (_port: number, _host: string, cb: () => void) => cb(),
|
||||
address: () => ({ port: 43210 }),
|
||||
close: (cb?: () => void) => cb?.(),
|
||||
}));
|
||||
mockStoreValues({
|
||||
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
|
||||
[VLC_REUSE_INSTANCE]: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Drop any process tracked for reuse so tests stay isolated.
|
||||
shutdownVlcSession();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('instance reuse', () => {
|
||||
beforeEach(() => {
|
||||
mockStoreValues({
|
||||
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
|
||||
[VLC_REUSE_INSTANCE]: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the tracked VLC instance through RC commands', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
await openTrackedVlcInstance(proc);
|
||||
installRcSocketMock('ack');
|
||||
|
||||
const session = await openVlcPlayer({
|
||||
title: 'Second',
|
||||
url: 'https://example.com/two.m3u8',
|
||||
referer: 'https://ref.example',
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(rcWrites).toEqual([
|
||||
'clear\n',
|
||||
'add https://example.com/two.m3u8 ' +
|
||||
':http-referrer=https://ref.example :meta-title=Second\n',
|
||||
]);
|
||||
expect(session.status).toBe('opened');
|
||||
});
|
||||
|
||||
it('kills the stale instance and spawns fresh when RC reuse fails', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
await openTrackedVlcInstance(proc);
|
||||
installRcSocketMock('error');
|
||||
|
||||
const freshProc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(freshProc);
|
||||
const openPromise = openVlcPlayer({
|
||||
title: 'Second',
|
||||
url: 'https://example.com/two.m3u8',
|
||||
});
|
||||
await waitForSpawnCallCount(2);
|
||||
freshProc.emit('spawn');
|
||||
const session = await openPromise;
|
||||
|
||||
expect(proc.kill).toHaveBeenCalled();
|
||||
expect(session.status).toBe('opened');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process exit handling', () => {
|
||||
it('marks the session closed on a clean exit', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(proc);
|
||||
const openPromise = openVlcPlayer({ title: 'S', url: streamUrl });
|
||||
await waitForSpawnCallCount(1);
|
||||
proc.emit('spawn');
|
||||
const session = await openPromise;
|
||||
|
||||
proc.emit('exit', 0);
|
||||
expect(externalPlayerSessions.getSession(session.id)?.status).toBe(
|
||||
'closed'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the session as errored on an unexpected exit code', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(proc);
|
||||
const openPromise = openVlcPlayer({ title: 'S', url: streamUrl });
|
||||
await waitForSpawnCallCount(1);
|
||||
proc.emit('spawn');
|
||||
const session = await openPromise;
|
||||
|
||||
proc.emit('exit', 2);
|
||||
const updated = externalPlayerSessions.getSession(session.id);
|
||||
expect(updated?.status).toBe('error');
|
||||
expect(updated?.error).toContain('exit code: 2');
|
||||
});
|
||||
|
||||
it('retries without the RC interface when VLC exits with code 1', async () => {
|
||||
mockStoreValues({
|
||||
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
|
||||
[VLC_REUSE_INSTANCE]: true,
|
||||
});
|
||||
const proc = createMockChildProcess();
|
||||
await openTrackedVlcInstance(proc);
|
||||
|
||||
const retryProc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(retryProc);
|
||||
proc.emit('exit', 1);
|
||||
await waitForSpawnCallCount(2);
|
||||
|
||||
const retryArgs = spawnMock.mock.calls[1][1] as string[];
|
||||
expect(retryArgs.join(' ')).not.toContain('--extraintf');
|
||||
expect(retryArgs.join(' ')).not.toContain('--rc-host');
|
||||
// Retry processes are never tracked for reuse.
|
||||
expect(spawnMock.mock.calls[1][2]).toMatchObject({
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
expect(retryProc.unref).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawn error handling', () => {
|
||||
it('rejects with an actionable error when VLC fails to start', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(proc);
|
||||
const openPromise = openVlcPlayer({ title: 'S', url: streamUrl });
|
||||
const sessionId = externalPlayerSessions.getActiveSessionId();
|
||||
await waitForSpawnCallCount(1);
|
||||
|
||||
proc.emit('error', new Error('boom'));
|
||||
|
||||
await expect(openPromise).rejects.toThrow(
|
||||
"Failed to start VLC player: boom. Make sure VLC is installed and the path '/usr/bin/vlc' is correct."
|
||||
);
|
||||
expect(
|
||||
externalPlayerSessions.getSession(sessionId as string)?.status
|
||||
).toBe('error');
|
||||
});
|
||||
|
||||
it('retries without the RC interface after a start error', async () => {
|
||||
mockStoreValues({
|
||||
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
|
||||
[VLC_REUSE_INSTANCE]: true,
|
||||
});
|
||||
const proc = createMockChildProcess();
|
||||
const retryProc = createMockChildProcess();
|
||||
spawnMock
|
||||
.mockReturnValueOnce(proc)
|
||||
.mockReturnValueOnce(retryProc);
|
||||
|
||||
const openPromise = openVlcPlayer({ title: 'S', url: streamUrl });
|
||||
await waitForSpawnCallCount(1);
|
||||
proc.emit('error', new Error('rc unsupported'));
|
||||
await waitForSpawnCallCount(2);
|
||||
|
||||
const retryArgs = spawnMock.mock.calls[1][1] as string[];
|
||||
expect(retryArgs.join(' ')).not.toContain('--extraintf');
|
||||
retryProc.emit('spawn');
|
||||
const session = await openPromise;
|
||||
expect(session.status).toBe('opened');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Pure helper and launch-argument coverage for the VLC session service.
|
||||
* Instance reuse, process exit, and spawn-error handling live in
|
||||
* `vlc-session.service.lifecycle.spec.ts`.
|
||||
*/
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('net', () => ({
|
||||
createConnection: jest.fn(),
|
||||
createServer: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../app', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
mainWindow: null,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
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 { createServer } from 'net';
|
||||
import {
|
||||
VLC_PLAYER_PATH,
|
||||
VLC_REUSE_INSTANCE,
|
||||
store,
|
||||
} from '../services/store.service';
|
||||
import {
|
||||
buildVlcEnqueueCommands,
|
||||
openVlcPlayer,
|
||||
parseVlcRcNumericResponse,
|
||||
parseVlcRcPlaybackState,
|
||||
shutdownVlcSession,
|
||||
} from './vlc-session.service';
|
||||
|
||||
const spawnMock = spawn as unknown as jest.Mock;
|
||||
const streamUrl = 'https://example.com/stream.m3u8';
|
||||
|
||||
function createMockChildProcess(): ChildProcess {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
killed: false,
|
||||
kill: jest.fn(() => true),
|
||||
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 (spawnMock.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('vlc-session.service helpers and launch args', () => {
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
(createServer as unknown as jest.Mock).mockImplementation(() => ({
|
||||
unref: jest.fn(),
|
||||
on: jest.fn(),
|
||||
listen: (_port: number, _host: string, cb: () => void) => cb(),
|
||||
address: () => ({ port: 43210 }),
|
||||
close: (cb?: () => void) => cb?.(),
|
||||
}));
|
||||
mockStoreValues({
|
||||
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
|
||||
[VLC_REUSE_INSTANCE]: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Drop any process tracked for reuse so tests stay isolated.
|
||||
shutdownVlcSession();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('buildVlcEnqueueCommands', () => {
|
||||
it('builds clear/add/seek commands with all input options', () => {
|
||||
expect(
|
||||
buildVlcEnqueueCommands({
|
||||
url: 'http://srv/1',
|
||||
title: 'My Title',
|
||||
userAgent: 'UA/1.0',
|
||||
referer: 'https://ref.example',
|
||||
headers: { 'X-A': ' padded ', 'X-Empty': ' ' },
|
||||
startTime: 12.7,
|
||||
})
|
||||
).toEqual([
|
||||
'clear',
|
||||
'add http://srv/1 :http-user-agent=UA/1.0 ' +
|
||||
':http-referrer=https://ref.example ' +
|
||||
':http-header=X-A: padded :meta-title=My Title',
|
||||
'seek 12',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to origin as referrer and omits empty options', () => {
|
||||
expect(
|
||||
buildVlcEnqueueCommands({
|
||||
url: 'http://srv/2',
|
||||
origin: 'https://origin.example',
|
||||
})
|
||||
).toEqual([
|
||||
'clear',
|
||||
'add http://srv/2 :http-referrer=https://origin.example',
|
||||
]);
|
||||
expect(buildVlcEnqueueCommands({ url: 'http://srv/3' })).toEqual([
|
||||
'clear',
|
||||
'add http://srv/3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RC response parsing', () => {
|
||||
it('extracts numeric RC responses', () => {
|
||||
expect(parseVlcRcNumericResponse('status change: > 123')).toBe(
|
||||
'123'
|
||||
);
|
||||
expect(parseVlcRcNumericResponse('> -4.5')).toBe('-4.5');
|
||||
expect(parseVlcRcNumericResponse('no prompt here')).toBe('');
|
||||
});
|
||||
|
||||
it('extracts the playback state', () => {
|
||||
expect(parseVlcRcPlaybackState('( state Stopped )')).toBe(
|
||||
'stopped'
|
||||
);
|
||||
expect(parseVlcRcPlaybackState('garbage')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('openVlcPlayer launch args', () => {
|
||||
it('spawns a detached VLC process with http options and start time', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(proc);
|
||||
|
||||
const openPromise = openVlcPlayer({
|
||||
title: 'My Stream',
|
||||
url: streamUrl,
|
||||
userAgent: 'UA/1.0',
|
||||
referer: 'https://ref.example/page',
|
||||
headers: { 'X-Test': 'yes' },
|
||||
startTime: 90,
|
||||
});
|
||||
await waitForSpawnCallCount(1);
|
||||
proc.emit('spawn');
|
||||
const session = await openPromise;
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'/usr/bin/vlc',
|
||||
[
|
||||
':http-user-agent=UA/1.0',
|
||||
':http-referrer=https://ref.example/page',
|
||||
':http-header=X-Test: yes',
|
||||
'--start-time=90',
|
||||
streamUrl,
|
||||
':meta-title=My Stream',
|
||||
],
|
||||
{ shell: false, detached: true, stdio: 'ignore' }
|
||||
);
|
||||
expect(proc.unref).toHaveBeenCalled();
|
||||
expect(session.status).toBe('opened');
|
||||
});
|
||||
|
||||
it('uses the origin as referrer fallback when no referer is given', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(proc);
|
||||
|
||||
const openPromise = openVlcPlayer({
|
||||
title: 'Origin Stream',
|
||||
url: streamUrl,
|
||||
origin: 'https://origin.example',
|
||||
});
|
||||
await waitForSpawnCallCount(1);
|
||||
proc.emit('spawn');
|
||||
await openPromise;
|
||||
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([
|
||||
':http-referrer=https://origin.example',
|
||||
streamUrl,
|
||||
':meta-title=Origin Stream',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds the RC interface and tracks the process when reuse is enabled', async () => {
|
||||
mockStoreValues({
|
||||
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
|
||||
[VLC_REUSE_INSTANCE]: true,
|
||||
});
|
||||
const proc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(proc);
|
||||
|
||||
const openPromise = openVlcPlayer({
|
||||
title: 'First',
|
||||
url: streamUrl,
|
||||
});
|
||||
await waitForSpawnCallCount(1);
|
||||
proc.emit('spawn');
|
||||
await openPromise;
|
||||
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([
|
||||
'--extraintf=rc',
|
||||
'--rc-host=127.0.0.1:43210',
|
||||
streamUrl,
|
||||
':meta-title=First',
|
||||
]);
|
||||
expect(spawnMock.mock.calls[0][2]).toEqual({
|
||||
shell: false,
|
||||
detached: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(proc.unref).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user