import {describe, it, expect, beforeEach, afterEach} from 'bun:test'; import {RtmpPush} from '../src/index.js'; import type {ICommandBuilder, IProcessManager, ILogger, IProcessSpawner} from '../src/index.js'; import {CommandBuilder, ProcessManager, ProcessSpawner, ConsoleLogger} from '../src/index.js'; describe('RtmpPush', () => { let rtmpPush: RtmpPush; const mockMediaSourceUri = 'https://example.com/test-video.ts'; const mockRtmpIngestUri = 'rtmp://ingest.example.com:80/ingest'; beforeEach(() => { rtmpPush = new RtmpPush(mockMediaSourceUri, mockRtmpIngestUri); }); afterEach(() => { // Clean up any running processes if (rtmpPush.isRunning()) { rtmpPush.stop(); } }); describe('Constructor', () => { it('should create instance with correct properties', () => { expect(rtmpPush).toBeInstanceOf(RtmpPush); expect(rtmpPush.mediaSourceUri).toBe(mockMediaSourceUri); expect(rtmpPush.rtmpIngestUri).toBe(mockRtmpIngestUri); }); it('should handle empty strings', () => { const emptyRtmpPush = new RtmpPush('', ''); expect(emptyRtmpPush.mediaSourceUri).toBe(''); expect(emptyRtmpPush.rtmpIngestUri).toBe(''); }); it('should handle special characters in URIs', () => { const specialMediaUri = 'https://example.com/video with spaces & special chars!.ts'; const specialRtmpUri = 'rtmp://ingest.example.com:80/ingest/path with spaces'; const specialRtmpPush = new RtmpPush(specialMediaUri, specialRtmpUri); expect(specialRtmpPush.mediaSourceUri).toBe(specialMediaUri); expect(specialRtmpPush.rtmpIngestUri).toBe(specialRtmpUri); }); it('should handle very long URIs', () => { const longMediaUri = 'https://example.com/' + 'a'.repeat(1000) + '.ts'; const longRtmpUri = 'rtmp://ingest.example.com:80/ingest/' + 'b'.repeat(1000); const longRtmpPush = new RtmpPush(longMediaUri, longRtmpUri); expect(longRtmpPush.mediaSourceUri).toBe(longMediaUri); expect(longRtmpPush.rtmpIngestUri).toBe(longRtmpUri); }); }); describe('Getters', () => { it('should return correct mediaSourceUri', () => { expect(rtmpPush.mediaSourceUri).toBe(mockMediaSourceUri); }); it('should return correct rtmpIngestUri', () => { expect(rtmpPush.rtmpIngestUri).toBe(mockRtmpIngestUri); }); it('should return immutable values', () => { const originalMediaUri = rtmpPush.mediaSourceUri; const originalRtmpUri = rtmpPush.rtmpIngestUri; // These should not change the internal state expect(rtmpPush.mediaSourceUri).toBe(originalMediaUri); expect(rtmpPush.rtmpIngestUri).toBe(originalRtmpUri); }); }); describe('isRunning() method', () => { it('should return false when no process is started', () => { expect(rtmpPush.isRunning()).toBe(false); }); it('should return false for new instances', () => { const newRtmpPush = new RtmpPush('https://example.com/video.ts', 'rtmp://example.com/ingest'); expect(newRtmpPush.isRunning()).toBe(false); }); }); describe('stop() method', () => { it('should handle stopping when no process is running', () => { expect(rtmpPush.isRunning()).toBe(false); const result = rtmpPush.stop(); expect(result).toBe(rtmpPush); expect(rtmpPush.isRunning()).toBe(false); }); it('should be chainable', () => { const result = rtmpPush.stop(); expect(result).toBe(rtmpPush); }); it('should handle multiple stop calls', () => { rtmpPush.stop(); const result = rtmpPush.stop(); expect(result).toBe(rtmpPush); expect(rtmpPush.isRunning()).toBe(false); }); }); describe('URI Construction Logic', () => { it('should construct valid RTMP URIs', () => { const streamKey = 'test-stream-key'; const capabilities = ['h264', 'aac', 'stereo']; const expectedUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; // The URI should be properly formatted expect(expectedUri).toContain(streamKey); expect(expectedUri).toContain(capabilities.join(',')); expect(expectedUri).toContain(';capabilities='); expect(expectedUri).toContain(';tags='); }); it('should handle various stream key formats', () => { const testCases = ['simple-key', 'key_with_underscores', 'key-with-dashes', 'key123', 'key!@#$%^&*()', 'key with spaces', 'key.with.dots']; testCases.forEach(streamKey => { const capabilities = ['h264']; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(expectedIngestUri).toContain(streamKey); expect(expectedIngestUri).toContain(';capabilities=h264;tags='); }); }); it('should handle empty capabilities array', () => { const streamKey = 'test-stream'; const capabilities: string[] = []; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=;tags=`; expect(expectedIngestUri).toContain(streamKey); expect(expectedIngestUri).toContain(';capabilities=;tags='); }); it('should handle single capability', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=h264;tags=`; expect(expectedIngestUri).toContain(streamKey); expect(expectedIngestUri).toContain(';capabilities=h264;tags='); }); it('should handle multiple capabilities', () => { const streamKey = 'test-stream'; const capabilities = ['h264', 'aac', 'stereo', '1080p']; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(expectedIngestUri).toContain(streamKey); expect(expectedIngestUri).toContain(';capabilities=h264,aac,stereo,1080p;tags='); }); it('should handle special characters in stream key', () => { const streamKey = 'test-stream-with-special-chars!@#$%^&*()'; const capabilities = ['h264']; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(expectedIngestUri).toContain(streamKey); expect(expectedIngestUri).toContain(';capabilities=h264;tags='); }); it('should handle special characters in capabilities', () => { const streamKey = 'test-stream'; const capabilities = ['h.264', 'aac-lc', 'stereo_audio']; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(expectedIngestUri).toContain(streamKey); expect(expectedIngestUri).toContain(';capabilities=h.264,aac-lc,stereo_audio;tags='); }); it('should handle very long stream keys', () => { const streamKey = 'a'.repeat(1000); const capabilities = ['h264']; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(expectedIngestUri).toContain(streamKey); expect(expectedIngestUri).toContain(';capabilities=h264;tags='); }); it('should handle very long capability lists', () => { const streamKey = 'test-stream'; const capabilities = Array.from({length: 100}, (_, i) => `capability-${i}`); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(expectedIngestUri).toContain(streamKey); expect(expectedIngestUri).toContain(';capabilities='); expect(expectedIngestUri).toContain(';tags='); expect(expectedIngestUri).toContain('capability-0'); expect(expectedIngestUri).toContain('capability-99'); }); }); describe('Method Chaining', () => { it('should support method chaining', () => { const result = rtmpPush.stop(); expect(result).toBe(rtmpPush); // Should be able to chain multiple calls const chainedResult = rtmpPush.stop().stop().stop(); expect(chainedResult).toBe(rtmpPush); }); }); describe('Instance Independence', () => { it('should maintain separate state for different instances', () => { const rtmpPush1 = new RtmpPush('https://example1.com/video.ts', 'rtmp://example1.com/ingest'); const rtmpPush2 = new RtmpPush('https://example2.com/video.ts', 'rtmp://example2.com/ingest'); expect(rtmpPush1.mediaSourceUri).toBe('https://example1.com/video.ts'); expect(rtmpPush2.mediaSourceUri).toBe('https://example2.com/video.ts'); expect(rtmpPush1.rtmpIngestUri).toBe('rtmp://example1.com/ingest'); expect(rtmpPush2.rtmpIngestUri).toBe('rtmp://example2.com/ingest'); expect(rtmpPush1.isRunning()).toBe(false); expect(rtmpPush2.isRunning()).toBe(false); }); }); describe('start() method basic functionality', () => { it('should be chainable', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; try { const result = rtmpPush.start(streamKey, capabilities); expect(result).toBe(rtmpPush); } catch (error) { // If ffmpeg is not available, that's expected expect(error).toBeInstanceOf(Error); } }); it('should handle invalid parameters gracefully', () => { try { rtmpPush.start('', []); // If it doesn't throw, it should at least not be running expect(rtmpPush.isRunning()).toBe(false); } catch (error) { // Expected to fail expect(error).toBeInstanceOf(Error); } }); it('should throw error when starting an already running stream', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; // Mock process manager that reports as running const mockProcessManager: IProcessManager = { isRunning: () => true, start: () => {}, stop: () => {}, on: () => mockProcessManager, emit: () => false, once: () => mockProcessManager, off: () => mockProcessManager, removeListener: () => mockProcessManager, removeAllListeners: () => mockProcessManager, addListener: () => mockProcessManager, setMaxListeners: () => mockProcessManager, getMaxListeners: () => 10, listeners: () => [], rawListeners: () => [], listenerCount: () => 0, prependListener: () => mockProcessManager, prependOnceListener: () => mockProcessManager, eventNames: () => [] } as IProcessManager; const customRtmpPush = new RtmpPush(mockMediaSourceUri, mockRtmpIngestUri, { processManager: mockProcessManager }); expect(() => { customRtmpPush.start(streamKey, capabilities); }).toThrow('Stream is already running'); }); }); describe('Dependency Injection', () => { it('should accept custom command builder', () => { const mockCommandBuilder: ICommandBuilder = { buildCommand: (mediaUri, rtmpUri, streamKey, capabilities) => { expect(mediaUri).toBe(mockMediaSourceUri); expect(rtmpUri).toBe(mockRtmpIngestUri); expect(streamKey).toBe('test-key'); expect(capabilities).toEqual(['h264']); return ['custom-ffmpeg', '-custom', 'args']; } }; const customRtmpPush = new RtmpPush(mockMediaSourceUri, mockRtmpIngestUri, { commandBuilder: mockCommandBuilder }); expect(customRtmpPush.mediaSourceUri).toBe(mockMediaSourceUri); expect(customRtmpPush.rtmpIngestUri).toBe(mockRtmpIngestUri); }); it('should accept custom process manager', () => { let startCalled = false; const mockProcessManager: IProcessManager = { isRunning: () => false, start: (command, args) => { startCalled = true; expect(command).toBe('ffmpeg'); expect(args).toBeInstanceOf(Array); }, stop: () => {}, on: () => mockProcessManager, emit: () => false, once: () => mockProcessManager, off: () => mockProcessManager, removeListener: () => mockProcessManager, removeAllListeners: () => mockProcessManager, addListener: () => mockProcessManager, setMaxListeners: () => mockProcessManager, getMaxListeners: () => 10, listeners: () => [], rawListeners: () => [], listenerCount: () => 0, prependListener: () => mockProcessManager, prependOnceListener: () => mockProcessManager, eventNames: () => [] } as IProcessManager; const customRtmpPush = new RtmpPush(mockMediaSourceUri, mockRtmpIngestUri, { processManager: mockProcessManager }); try { customRtmpPush.start('test-key', ['h264']); expect(startCalled).toBe(true); } catch (error) { // If ffmpeg is not available, that's expected expect(error).toBeInstanceOf(Error); } }); it('should accept custom ffmpeg command', () => { const customRtmpPush = new RtmpPush(mockMediaSourceUri, mockRtmpIngestUri, { ffmpegCommand: 'custom-ffmpeg-path' }); expect(customRtmpPush.mediaSourceUri).toBe(mockMediaSourceUri); expect(customRtmpPush.rtmpIngestUri).toBe(mockRtmpIngestUri); }); it('should use default implementations when none provided', () => { const defaultRtmpPush = new RtmpPush(mockMediaSourceUri, mockRtmpIngestUri); expect(defaultRtmpPush).toBeInstanceOf(RtmpPush); expect(defaultRtmpPush.mediaSourceUri).toBe(mockMediaSourceUri); expect(defaultRtmpPush.rtmpIngestUri).toBe(mockRtmpIngestUri); }); }); describe('Process Manager Events', () => { it('should provide access to process manager for event handling', () => { const processManager = rtmpPush.getProcessManager(); expect(processManager).toBeDefined(); expect(typeof processManager.on).toBe('function'); expect(typeof processManager.emit).toBe('function'); }); it('should allow listening to process events', () => { const processManager = rtmpPush.getProcessManager(); let dataReceived = false; let errorReceived = false; let closeReceived = false; processManager.on('data', () => { dataReceived = true; }); processManager.on('error', () => { errorReceived = true; }); processManager.on('close', () => { closeReceived = true; }); // Events would be emitted by the actual process, but we can verify the listeners are set up expect(typeof processManager.on).toBe('function'); }); }); describe('CommandBuilder', () => { it('should build correct FFmpeg commands', () => { const builder = new CommandBuilder(); const command = builder.buildCommand( 'https://example.com/video.ts', 'rtmp://ingest.example.com/ingest', 'test-key', ['h264', 'aac'] ); expect(command[0]).toBe('ffmpeg'); expect(command).toContain('-re'); expect(command).toContain('-i'); expect(command).toContain('https://example.com/video.ts'); expect(command[command.length - 1]).toContain('rtmp://ingest.example.com/ingest/test-key'); expect(command[command.length - 1]).toContain('capabilities=h264,aac'); }); it('should use custom ffmpeg command when provided', () => { const builder = new CommandBuilder('custom-ffmpeg'); const command = builder.buildCommand( 'https://example.com/video.ts', 'rtmp://ingest.example.com/ingest', 'test-key', ['h264'] ); expect(command[0]).toBe('custom-ffmpeg'); }); }); });