Files
ToolsRtmpPush/tests/RtmpPush.test.ts

433 lines
15 KiB
TypeScript

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');
});
});
});