import {describe, it, expect, beforeEach, afterEach, mock} from 'bun:test'; import {RtmpPush} from '../src/RtmpPush.js'; // Mock the child_process module const mockProcess = { stdout: {on: mock(() => {})}, stderr: {on: mock(() => {})}, on: mock(() => {}), kill: mock(() => {}), killed: false }; const mockSpawn = mock(() => mockProcess); mock.module('node:child_process', () => ({ spawn: mockSpawn })); 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); // Reset mock calls mockSpawn.mockClear(); mockProcess.stdout.on.mockClear(); mockProcess.stderr.on.mockClear(); mockProcess.on.mockClear(); mockProcess.kill.mockClear(); }); 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(''); }); }); 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('start() method', () => { it('should start RTMP push process with correct parameters', () => { const streamKey = 'test-stream-123'; const capabilities = ['h264', 'aac']; const result = rtmpPush.start(streamKey, capabilities); expect(result).toBe(rtmpPush); expect(rtmpPush.isRunning()).toBe(true); }); it('should construct correct ffmpeg command', () => { const streamKey = 'test-stream'; const capabilities = ['h264', 'aac']; rtmpPush.start(streamKey, capabilities); expect(mockSpawn).toHaveBeenCalledWith( 'ffmpeg', expect.arrayContaining([ '-re', '-hide_banner', '-stream_loop', '-1', '-y', '-flags', 'low_delay', '-fflags', '+nobuffer+flush_packets', '-i', mockMediaSourceUri, '-c:a', 'copy', '-c:v', 'copy', '-flush_packets', '1', '-copyts', '-f', 'flv' ]) ); }); it('should construct correct ingest URI', () => { const streamKey = 'test-stream'; const capabilities = ['h264', 'aac']; rtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); }); it('should handle empty capabilities array', () => { const streamKey = 'test-stream'; const capabilities: string[] = []; rtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=;tags=`; expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); }); it('should handle single capability', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; rtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=h264;tags=`; expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); }); it('should set up process event handlers', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; rtmpPush.start(streamKey, capabilities); expect(mockProcess.stdout.on).toHaveBeenCalledWith('data', expect.any(Function)); expect(mockProcess.stderr.on).toHaveBeenCalledWith('data', expect.any(Function)); expect(mockProcess.on).toHaveBeenCalledWith('close', expect.any(Function)); expect(mockProcess.on).toHaveBeenCalledWith('error', expect.any(Function)); }); it('should be chainable', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; const result = rtmpPush.start(streamKey, capabilities); expect(result).toBe(rtmpPush); }); }); describe('stop() method', () => { it('should stop running process', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; rtmpPush.start(streamKey, capabilities); expect(rtmpPush.isRunning()).toBe(true); const result = rtmpPush.stop(); expect(result).toBe(rtmpPush); expect(rtmpPush.isRunning()).toBe(false); }); 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); }); }); describe('isRunning() method', () => { it('should return false when no process is started', () => { expect(rtmpPush.isRunning()).toBe(false); }); it('should return true when process is running', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; rtmpPush.start(streamKey, capabilities); expect(rtmpPush.isRunning()).toBe(true); }); it('should return false after process is stopped', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; rtmpPush.start(streamKey, capabilities); expect(rtmpPush.isRunning()).toBe(true); rtmpPush.stop(); expect(rtmpPush.isRunning()).toBe(false); }); }); describe('Process lifecycle', () => { it('should handle process close event', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; rtmpPush.start(streamKey, capabilities); // Simulate process close const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close')?.[1]; if (closeHandler && typeof closeHandler === 'function') { closeHandler(0); } expect(rtmpPush.isRunning()).toBe(false); }); it('should handle process error event', () => { const streamKey = 'test-stream'; const capabilities = ['h264']; rtmpPush.start(streamKey, capabilities); // Simulate process error const errorHandler = mockProcess.on.mock.calls.find(call => call[0] === 'error')?.[1]; if (errorHandler && typeof errorHandler === 'function') { // Call the error handler with a mock error errorHandler(new Error('Process error')); } // The error handler should set the process to null expect(rtmpPush.isRunning()).toBe(false); }); }); describe('Edge cases', () => { it('should handle special characters in stream key', () => { const streamKey = 'test-stream-with-special-chars!@#$%^&*()'; const capabilities = ['h264']; rtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); }); it('should handle special characters in capabilities', () => { const streamKey = 'test-stream'; const capabilities = ['h.264', 'aac-lc', 'stereo_audio']; rtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); }); 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); const streamKey = 'test-stream'; const capabilities = ['h264']; longRtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${longRtmpUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); }); }); });