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