Files
ToolsRtmpPush/tests/RtmpPush.test.ts
2025-08-17 15:37:42 -04:00

300 lines
9.1 KiB
TypeScript

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