diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index d33ad03..5d3d1d8 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -1,5 +1,5 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; -import { sep } from 'node:path'; +import { resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import AudioManager from '../src/audio-manager'; @@ -59,11 +59,14 @@ const connectionOptions: VoiceConnectionOptions = { adapterCreator: jest.fn() as unknown as VoiceConnectionOptions['adapterCreator'], }; -const source: AudioSource = { +const liveStreamSource: AudioSource = { type: 'url', - url: 'https://example.com/audio.mp3', + url: 'https://synradiode.stream.laut.fm/synradiode', }; +const fileSourcePath = 'tests/audio.mp3'; +const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath); + describe('AudioManager', () => { beforeEach(() => { jest.clearAllMocks(); @@ -82,7 +85,7 @@ describe('AudioManager', () => { it('connects and starts playback with resolved URL source', async () => { const manager = new AudioManager({ connection: connectionOptions, - source, + source: liveStreamSource, renewIntervalMs: false, }); @@ -92,23 +95,91 @@ describe('AudioManager', () => { expect(manager.isConnected).toBe(true); expect(manager.isPlaying).toBe(true); expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer); - expect(startFfmpeg).toHaveBeenCalledWith('https://example.com/audio.mp3', undefined); + expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined); expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); }); - it('resolves file sources against the current working directory', async () => { + it('starts playback with the committed mp3 test file', async () => { const manager = new AudioManager({ connection: connectionOptions, source: { type: 'file', - path: 'audio/song.mp3', + path: fileSourcePath, }, renewIntervalMs: false, }); await manager.start(); - expect(startFfmpeg).toHaveBeenCalledWith(expect.stringContaining(`audio${sep}song.mp3`), undefined); + expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined); + expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); + }); + + it('accepts the live stream URL as a valid source', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.connect(); + await manager.play(); + + expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined); + }); + + it('plays a source passed directly to play()', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + renewIntervalMs: false, + }); + + await manager.connect(); + await manager.play({ + type: 'file', + path: fileSourcePath, + }); + + expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined); + expect(manager.state).toBe('playing'); + }); + + it('replaces active playback when a new source is played', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.start(); + await manager.play({ + type: 'file', + path: fileSourcePath, + }); + + expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); + expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1); + expect(startFfmpeg).toHaveBeenLastCalledWith(resolvedFileSourcePath, undefined); + }); + + it('requires an active connection before play()', async () => { + const manager = new AudioManager({ + source: liveStreamSource, + renewIntervalMs: false, + }); + + await expect(manager.play()).rejects.toThrow(AudioManagerStateError); + }); + + it('requires a source before play()', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + renewIntervalMs: false, + }); + + await manager.connect(); + + await expect(manager.play()).rejects.toThrow(AudioManagerConfigError); }); it('rejects invalid URL sources', async () => { @@ -129,7 +200,7 @@ describe('AudioManager', () => { it('applies initial volume only when volume support is enabled', async () => { const manager = new AudioManager({ connection: connectionOptions, - source, + source: liveStreamSource, renewIntervalMs: false, volume: { enabled: true, @@ -148,10 +219,37 @@ describe('AudioManager', () => { expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError); }); + it('rejects invalid volume percentages', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + volume: { + enabled: true, + }, + }); + + await manager.start(); + + expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError); + expect(() => manager.setVolume(101)).toThrow(AudioManagerConfigError); + }); + + it('rejects volume changes before any resource exists', () => { + const manager = new AudioManager({ + renewIntervalMs: false, + volume: { + enabled: true, + }, + }); + + expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError); + }); + it('pauses and resumes only from valid playback states', async () => { const manager = new AudioManager({ connection: connectionOptions, - source, + source: liveStreamSource, renewIntervalMs: false, }); @@ -169,7 +267,7 @@ describe('AudioManager', () => { it('stops playback and voice resources idempotently', async () => { const manager = new AudioManager({ connection: connectionOptions, - source, + source: liveStreamSource, renewIntervalMs: false, }); @@ -183,12 +281,29 @@ describe('AudioManager', () => { expect(manager.state).toBe('stopped'); }); + it('restarts playback when the renewal timer fires', async () => { + jest.useFakeTimers(); + + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: 10_000, + }); + const startSpy = jest.spyOn(manager, 'start').mockResolvedValue(undefined); + + await manager.connect(); + jest.advanceTimersByTime(10_000); + await Promise.resolve(); + + expect(startSpy).toHaveBeenCalledTimes(1); + }); + it('clears existing renewal timer before reconnecting', async () => { jest.useFakeTimers(); const manager = new AudioManager({ connection: connectionOptions, - source, + source: liveStreamSource, renewIntervalMs: 10_000, }); @@ -200,10 +315,23 @@ describe('AudioManager', () => { expect(jest.getTimerCount()).toBe(0); }); + it('blocks source and connection changes after disposal', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + manager.dispose(); + + expect(() => manager.setSource(liveStreamSource)).toThrow(AudioManagerStateError); + expect(() => manager.setConnection(connectionOptions)).toThrow(AudioManagerStateError); + }); + it('prevents use after disposal', async () => { const manager = new AudioManager({ connection: connectionOptions, - source, + source: liveStreamSource, renewIntervalMs: false, }); diff --git a/tests/audio.mp3 b/tests/audio.mp3 new file mode 100644 index 0000000..f60c262 Binary files /dev/null and b/tests/audio.mp3 differ diff --git a/tests/ffmpeg.test.ts b/tests/ffmpeg.test.ts new file mode 100644 index 0000000..a68760d --- /dev/null +++ b/tests/ffmpeg.test.ts @@ -0,0 +1,139 @@ +import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; +import { spawn } from 'node:child_process'; + +import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg'; + +jest.mock('node:child_process', () => ({ + spawn: jest.fn(), +})); + +const mockSpawn = jest.mocked(spawn); + +type MockChildProcess = { + stdout: { + destroy: jest.Mock; + }; + stderr: { + destroy: jest.Mock; + resume: jest.Mock; + }; + once: jest.Mock; + removeAllListeners: jest.Mock; + kill: jest.Mock; + killed: boolean; + exitCode: number | null; + signalCode: NodeJS.Signals | null; +}; + +function createMockChildProcess(): MockChildProcess { + return { + stdout: { + destroy: jest.fn(), + }, + stderr: { + destroy: jest.fn(), + resume: jest.fn(), + }, + once: jest.fn(), + removeAllListeners: jest.fn(), + kill: jest.fn(), + killed: false, + exitCode: null, + signalCode: null, + }; +} + +describe('ffmpeg helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('prefers an explicit executable path', () => { + expect(resolveFfmpegExecutable({ executablePath: '/custom/ffmpeg' })).toBe('/custom/ffmpeg'); + }); + + it('uses the native ffmpeg binary by default', () => { + expect(resolveFfmpegExecutable()).toBe('ffmpeg'); + expect(resolveFfmpegExecutable({ mode: 'native' })).toBe('ffmpeg'); + }); + + it('spawns ffmpeg with the default argument set', () => { + const childProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(childProcess); + + startFfmpeg('https://synradiode.stream.laut.fm/synradiode'); + + expect(mockSpawn).toHaveBeenCalledWith( + 'ffmpeg', + [ + '-hide_banner', + '-loglevel', + 'error', + '-nostdin', + '-i', + 'https://synradiode.stream.laut.fm/synradiode', + '-vn', + '-f', + 's16le', + '-ar', + '48000', + '-ac', + '2', + 'pipe:1', + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function)); + expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1); + }); + + it('uses custom executable and argument overrides when provided', () => { + const childProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(childProcess); + + startFfmpeg('tests/audio.mp3', { + executablePath: '/custom/ffmpeg', + inputArgs: ['-re'], + outputArgs: ['-f', 'wav', 'pipe:1'], + }); + + expect(mockSpawn).toHaveBeenCalledWith( + '/custom/ffmpeg', + ['-re', '-i', 'tests/audio.mp3', '-f', 'wav', 'pipe:1'], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + }); + + it('stops a running child process and schedules a force kill fallback', () => { + jest.useFakeTimers(); + const childProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + handle.stop(); + + expect(childProcess.stdout.destroy).toHaveBeenCalledTimes(1); + expect(childProcess.stderr.destroy).toHaveBeenCalledTimes(1); + expect(childProcess.removeAllListeners).toHaveBeenCalledTimes(1); + expect(childProcess.kill).toHaveBeenCalledWith('SIGTERM'); + + jest.advanceTimersByTime(2_000); + + expect(childProcess.kill).toHaveBeenNthCalledWith(2, 'SIGKILL'); + }); + + it('does not signal a process that already exited', () => { + const childProcess = createMockChildProcess(); + childProcess.exitCode = 0; + mockSpawn.mockReturnValue(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + handle.stop(); + + expect(childProcess.kill).not.toHaveBeenCalled(); + }); +});