From be2730edfa7ca72cb7aa886fa156bb2e0c65d7ec Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 11:12:35 +0200 Subject: [PATCH] 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 --- .../vlc-session.service.lifecycle.spec.ts | 281 ++++++++++++++++++ .../app/events/vlc-session.service.spec.ts | 246 +++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts create mode 100644 apps/electron-backend/src/app/events/vlc-session.service.spec.ts diff --git a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts new file mode 100644 index 000000000..b78363f61 --- /dev/null +++ b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts @@ -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 { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (spawnMock.mock.calls.length >= count) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Expected ${count} player spawn calls`); +} + +function mockStoreValues(values: Record): 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 { + 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'); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts new file mode 100644 index 000000000..1c80160ea --- /dev/null +++ b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts @@ -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 { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (spawnMock.mock.calls.length >= count) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Expected ${count} player spawn calls`); +} + +function mockStoreValues(values: Record): 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(); + }); + }); +});