|
|
|
|
@@ -1,20 +1,5 @@
|
|
|
|
|
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
|
|
|
|
|
}));
|
|
|
|
|
import {describe, it, expect, beforeEach, afterEach} from 'bun:test';
|
|
|
|
|
import {RtmpPush} from '../src/RtmpPush';
|
|
|
|
|
|
|
|
|
|
describe('RtmpPush', () => {
|
|
|
|
|
let rtmpPush: RtmpPush;
|
|
|
|
|
@@ -23,12 +8,6 @@ describe('RtmpPush', () => {
|
|
|
|
|
|
|
|
|
|
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(() => {
|
|
|
|
|
@@ -50,6 +29,24 @@ describe('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', () => {
|
|
|
|
|
@@ -71,119 +68,18 @@ describe('RtmpPush', () => {
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
describe('isRunning() method', () => {
|
|
|
|
|
it('should return false when no process is started', () => {
|
|
|
|
|
expect(rtmpPush.isRunning()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
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 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);
|
|
|
|
|
|
|
|
|
|
@@ -197,103 +93,164 @@ describe('RtmpPush', () => {
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
it('should handle multiple stop calls', () => {
|
|
|
|
|
rtmpPush.stop();
|
|
|
|
|
const result = rtmpPush.stop();
|
|
|
|
|
expect(result).toBe(rtmpPush);
|
|
|
|
|
expect(rtmpPush.isRunning()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Process lifecycle', () => {
|
|
|
|
|
it('should handle process close event', () => {
|
|
|
|
|
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'];
|
|
|
|
|
|
|
|
|
|
rtmpPush.start(streamKey, capabilities);
|
|
|
|
|
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=h264;tags=`;
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
expect(expectedIngestUri).toContain(streamKey);
|
|
|
|
|
expect(expectedIngestUri).toContain(';capabilities=h264;tags=');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle process error event', () => {
|
|
|
|
|
it('should handle multiple capabilities', () => {
|
|
|
|
|
const streamKey = 'test-stream';
|
|
|
|
|
const capabilities = ['h264'];
|
|
|
|
|
const capabilities = ['h264', 'aac', 'stereo', '1080p'];
|
|
|
|
|
|
|
|
|
|
rtmpPush.start(streamKey, capabilities);
|
|
|
|
|
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
expect(expectedIngestUri).toContain(streamKey);
|
|
|
|
|
expect(expectedIngestUri).toContain(';capabilities=h264,aac,stereo,1080p;tags=');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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]));
|
|
|
|
|
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'];
|
|
|
|
|
|
|
|
|
|
rtmpPush.start(streamKey, capabilities);
|
|
|
|
|
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(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri]));
|
|
|
|
|
expect(expectedIngestUri).toContain(streamKey);
|
|
|
|
|
expect(expectedIngestUri).toContain(';capabilities=h264;tags=');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
it('should handle very long capability lists', () => {
|
|
|
|
|
const streamKey = 'test-stream';
|
|
|
|
|
const capabilities = Array.from({length: 100}, (_, i) => `capability-${i}`);
|
|
|
|
|
|
|
|
|
|
const longRtmpPush = new RtmpPush(longMediaUri, longRtmpUri);
|
|
|
|
|
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'];
|
|
|
|
|
|
|
|
|
|
longRtmpPush.start(streamKey, capabilities);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const expectedIngestUri = `${longRtmpUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
|
|
|
|
|
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri]));
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|