From a811feaede27b3c92585be27066646d588d07628 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Mon, 22 Jun 2026 12:09:21 +0200 Subject: [PATCH 1/6] Handle renewal restart failures --- src/audio-manager.ts | 10 +++++++++- tests/audio-manager.test.ts | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/audio-manager.ts b/src/audio-manager.ts index 9eb97f1..a25b1e3 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -227,7 +227,15 @@ export default class AudioManager { } this.renewTimer = setTimeout(() => { - void this.start(); + void this.start().catch(() => { + this.clearRenewTimer(); + this.stopCurrentPlayback(); + this.audioPlayer.stop(true); + this.connection?.disconnect(); + this.connection?.destroy(); + this.connection = undefined; + this.playbackState = 'stopped'; + }); }, renewIntervalMs); if (typeof this.renewTimer.unref === 'function') { diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index 5d3d1d8..0d185e2 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -298,6 +298,27 @@ describe('AudioManager', () => { expect(startSpy).toHaveBeenCalledTimes(1); }); + it('cleans up when renewal restart fails', async () => { + jest.useFakeTimers(); + + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: 10_000, + }); + const startSpy = jest.spyOn(manager, 'start').mockRejectedValue(new Error('renewal failed')); + + await manager.connect(); + jest.advanceTimersByTime(10_000); + await Promise.resolve(); + + expect(startSpy).toHaveBeenCalledTimes(1); + expect(mockAudioPlayer.stop).toHaveBeenCalledWith(true); + expect(mockConnection.destroy).toHaveBeenCalled(); + expect(manager.state).toBe('stopped'); + expect(jest.getTimerCount()).toBe(0); + }); + it('clears existing renewal timer before reconnecting', async () => { jest.useFakeTimers(); From 1a77896c3243dc7fc4e9632187afff083c8764a7 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Mon, 22 Jun 2026 12:12:48 +0200 Subject: [PATCH 2/6] Add failed connection startup regression test --- tests/audio-manager.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index 0d185e2..4d7a7f6 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -1,4 +1,5 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; +import { entersState } from '@discordjs/voice'; import { resolve } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -99,6 +100,24 @@ describe('AudioManager', () => { expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); }); + it('keeps failed connection startup observable', async () => { + jest.useFakeTimers(); + + const manager = new AudioManager({ + connection: connectionOptions, + renewIntervalMs: 10_000, + }); + jest.mocked(entersState).mockRejectedValueOnce(new Error('voice connection timeout')); + + await expect(manager.connect()).rejects.toThrow('voice connection timeout'); + + expect(manager.state).toBe('connecting'); + expect(manager.isConnected).toBe(true); + expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer); + expect(mockConnection.destroy).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + it('starts playback with the committed mp3 test file', async () => { const manager = new AudioManager({ connection: connectionOptions, From d10dae1b533d693c41ed33c1c9b3c0377a6ac5f8 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Mon, 22 Jun 2026 12:16:21 +0200 Subject: [PATCH 3/6] Surface ffmpeg startup failures --- src/audio-manager.ts | 6 +++++ src/ffmpeg.ts | 53 +++++++++++++++++++++++++++++++++++-- tests/audio-manager.test.ts | 23 +++++++++++++++- tests/ffmpeg.test.ts | 39 +++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/src/audio-manager.ts b/src/audio-manager.ts index a25b1e3..0271f35 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -108,6 +108,12 @@ export default class AudioManager { const resolvedSource = this.resolveSource(); this.stopCurrentPlayback(); this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg); + try { + await this.ffmpeg.ready; + } catch (error) { + this.stopCurrentPlayback(); + throw error; + } this.resource = createAudioResource(this.ffmpeg.process.stdout, { inputType: StreamType.Raw, inlineVolume: this.options.volume?.enabled === true, diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index 7e6e891..e207920 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -3,7 +3,7 @@ import type { ChildProcessByStdio } from 'node:child_process'; import { createRequire } from 'node:module'; import type { Readable } from 'node:stream'; -import { AudioManagerConfigError } from './errors'; +import { AudioManagerConfigError, FfmpegProcessError } from './errors'; import type { FfmpegOptions } from './types'; const requireFromCurrentModule = createRequire(__filename); @@ -11,9 +11,11 @@ const requireFromCurrentModule = createRequire(__filename); const DEFAULT_INPUT_ARGS = ['-hide_banner', '-loglevel', 'error', '-nostdin'] as const; const DEFAULT_OUTPUT_ARGS = ['-vn', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'] as const; const FORCE_KILL_TIMEOUT_MS = 2_000; +const STDERR_TAIL_BYTES = 4_096; export type FfmpegProcessHandle = { process: ChildProcessByStdio; + ready: Promise; stop(): void; }; @@ -50,18 +52,65 @@ export function startFfmpeg(input: string, options: FfmpegOptions = {}): FfmpegP ...(options.outputArgs ?? DEFAULT_OUTPUT_ARGS), ]; const childProcess = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const ready = waitForFfmpegOutput(childProcess); - childProcess.once('error', () => undefined); childProcess.stderr.resume(); return { process: childProcess, + ready, stop: (): void => { stopProcess(childProcess); }, }; } +function waitForFfmpegOutput(childProcess: ChildProcessByStdio): Promise { + let stderrTail = ''; + + const appendStderr = (chunk: Buffer | string): void => { + stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES); + }; + + childProcess.stderr.on('data', appendStderr); + + return new Promise((resolve, reject) => { + const cleanup = (): void => { + childProcess.off('error', onError); + childProcess.off('exit', onExit); + childProcess.stdout.off('readable', onReadable); + }; + + const fail = (message: string, cause?: unknown): void => { + cleanup(); + reject(new FfmpegProcessError(addStderrTail(message, stderrTail), cause)); + }; + + const onError = (error: Error): void => { + fail(`Unable to start ffmpeg. Cause: ${error.message}`, error); + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + fail(`ffmpeg exited before producing audio. Exit code: ${code ?? 'none'}, signal: ${signal ?? 'none'}.`); + }; + + const onReadable = (): void => { + cleanup(); + resolve(); + }; + + childProcess.once('error', onError); + childProcess.once('exit', onExit); + childProcess.stdout.once('readable', onReadable); + }); +} + +function addStderrTail(message: string, stderrTail: string): string { + const trimmedTail = stderrTail.trim(); + + return trimmedTail ? `${message} stderr: ${trimmedTail}` : message; +} + function stopProcess(childProcess: ChildProcessByStdio): void { childProcess.stdout.destroy(); childProcess.stderr.destroy(); diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index 4d7a7f6..f874041 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import AudioManager from '../src/audio-manager'; -import { AudioManagerConfigError, AudioManagerStateError } from '../src'; +import { AudioManagerConfigError, AudioManagerStateError, FfmpegProcessError } from '../src'; import { startFfmpeg } from '../src/ffmpeg'; import type { AudioSource, VoiceConnectionOptions } from '../src'; @@ -31,6 +31,7 @@ const mockFfmpegHandle = { process: { stdout: new PassThrough(), }, + ready: Promise.resolve(), stop: jest.fn(), }; @@ -163,6 +164,26 @@ describe('AudioManager', () => { expect(manager.state).toBe('playing'); }); + it('does not report playback when ffmpeg startup fails', async () => { + const startupError = new FfmpegProcessError('Unable to start ffmpeg.'); + jest.mocked(startFfmpeg).mockReturnValueOnce({ + ...mockFfmpegHandle, + ready: Promise.reject(startupError), + }); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.connect(); + await expect(manager.play()).rejects.toBe(startupError); + + expect(manager.state).toBe('ready'); + expect(mockAudioPlayer.play).not.toHaveBeenCalled(); + expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); + }); + it('replaces active playback when a new source is played', async () => { const manager = new AudioManager({ connection: connectionOptions, diff --git a/tests/ffmpeg.test.ts b/tests/ffmpeg.test.ts index a68760d..d54e867 100644 --- a/tests/ffmpeg.test.ts +++ b/tests/ffmpeg.test.ts @@ -1,6 +1,7 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; import { spawn } from 'node:child_process'; +import { FfmpegProcessError } from '../src'; import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg'; jest.mock('node:child_process', () => ({ @@ -12,12 +13,16 @@ const mockSpawn = jest.mocked(spawn); type MockChildProcess = { stdout: { destroy: jest.Mock; + once: jest.Mock; + off: jest.Mock; }; stderr: { destroy: jest.Mock; + on: jest.Mock; resume: jest.Mock; }; once: jest.Mock; + off: jest.Mock; removeAllListeners: jest.Mock; kill: jest.Mock; killed: boolean; @@ -29,12 +34,16 @@ function createMockChildProcess(): MockChildProcess { return { stdout: { destroy: jest.fn(), + once: jest.fn(), + off: jest.fn(), }, stderr: { destroy: jest.fn(), + on: jest.fn(), resume: jest.fn(), }, once: jest.fn(), + off: jest.fn(), removeAllListeners: jest.fn(), kill: jest.fn(), killed: false, @@ -88,9 +97,39 @@ describe('ffmpeg helpers', () => { { stdio: ['ignore', 'pipe', 'pipe'] }, ); expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function)); + expect(childProcess.once).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(childProcess.stdout.once).toHaveBeenCalledWith('readable', expect.any(Function)); + expect(childProcess.stderr.on).toHaveBeenCalledWith('data', expect.any(Function)); expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1); }); + it('rejects readiness when ffmpeg startup emits an error', async () => { + const childProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + const errorHandler = childProcess.once.mock.calls.find(([event]) => event === 'error')?.[1]; + + errorHandler(new Error('spawn ENOENT')); + + await expect(handle.ready).rejects.toThrow(FfmpegProcessError); + await expect(handle.ready).rejects.toThrow('spawn ENOENT'); + }); + + it('includes stderr when ffmpeg exits before producing audio', async () => { + const childProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + const stderrHandler = childProcess.stderr.on.mock.calls.find(([event]) => event === 'data')?.[1]; + const exitHandler = childProcess.once.mock.calls.find(([event]) => event === 'exit')?.[1]; + + stderrHandler('invalid input'); + exitHandler(1, null); + + await expect(handle.ready).rejects.toThrow('invalid input'); + }); + it('uses custom executable and argument overrides when provided', () => { const childProcess = createMockChildProcess(); mockSpawn.mockReturnValue(childProcess); From b29fda5ae953b2e075643a2ffcd45d8d9b0fb98e Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Mon, 22 Jun 2026 12:18:35 +0200 Subject: [PATCH 4/6] Clean up failed voice connections --- src/audio-manager.ts | 9 ++++++++- tests/audio-manager.test.ts | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/audio-manager.ts b/src/audio-manager.ts index 0271f35..756f397 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -89,7 +89,14 @@ export default class AudioManager { }); this.connection.subscribe(this.audioPlayer); - await entersState(this.connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); + try { + await entersState(this.connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); + } catch (error) { + this.connection.destroy(); + this.connection = undefined; + this.playbackState = 'stopped'; + throw error; + } this.playbackState = 'ready'; this.scheduleRenewal(); } diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index f874041..36f12e1 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -101,7 +101,7 @@ describe('AudioManager', () => { expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); }); - it('keeps failed connection startup observable', async () => { + it('cleans up when connection startup fails', async () => { jest.useFakeTimers(); const manager = new AudioManager({ @@ -112,10 +112,10 @@ describe('AudioManager', () => { await expect(manager.connect()).rejects.toThrow('voice connection timeout'); - expect(manager.state).toBe('connecting'); - expect(manager.isConnected).toBe(true); + expect(manager.state).toBe('stopped'); + expect(manager.isConnected).toBe(false); expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer); - expect(mockConnection.destroy).not.toHaveBeenCalled(); + expect(mockConnection.destroy).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); }); From 215d67ce89d750152030c1f145c5aa0b3c1b1ec8 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Mon, 22 Jun 2026 12:20:41 +0200 Subject: [PATCH 5/6] Reject non-finite volume values --- src/audio-manager.ts | 2 +- tests/audio-manager.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/audio-manager.ts b/src/audio-manager.ts index 756f397..23b5e9b 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -182,7 +182,7 @@ export default class AudioManager { throw new AudioManagerStateError('Volume control requires volume.enabled to be true.'); } - if (volumeInPercent < 0 || volumeInPercent > 100) { + if (!Number.isFinite(volumeInPercent) || volumeInPercent < 0 || volumeInPercent > 100) { throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.'); } diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index 36f12e1..9f5badc 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -273,6 +273,11 @@ describe('AudioManager', () => { expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError); expect(() => manager.setVolume(101)).toThrow(AudioManagerConfigError); + expect(() => manager.setVolume(Number.NaN)).toThrow(AudioManagerConfigError); + expect(() => manager.setVolume(Number.POSITIVE_INFINITY)).toThrow(AudioManagerConfigError); + expect(() => manager.setVolume(Number.NEGATIVE_INFINITY)).toThrow(AudioManagerConfigError); + expect(() => manager.setVolume(0)).not.toThrow(); + expect(() => manager.setVolume(100)).not.toThrow(); }); it('rejects volume changes before any resource exists', () => { From b4e92b9b7b558b55b194ff08fd1bebd3928eb6aa Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Mon, 22 Jun 2026 12:40:00 +0200 Subject: [PATCH 6/6] improve: tests --- src/audio-manager.ts | 44 +++++-- tests/audio-manager.test.ts | 233 +++++++++++++++++++++++++++++++++++- tests/ffmpeg.test.ts | 67 +++++++++-- 3 files changed, 317 insertions(+), 27 deletions(-) diff --git a/src/audio-manager.ts b/src/audio-manager.ts index 23b5e9b..cf9b8e7 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -82,21 +82,27 @@ export default class AudioManager { this.clearRenewTimer(); this.playbackState = 'connecting'; this.connection?.destroy(); - this.connection = joinVoiceChannel({ + const connection = joinVoiceChannel({ guildId: this.connectionOptions.guildId, channelId: this.connectionOptions.channelId, adapterCreator: this.connectionOptions.adapterCreator, }); - this.connection.subscribe(this.audioPlayer); + this.connection = connection; + connection.subscribe(this.audioPlayer); try { - await entersState(this.connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); + await entersState(connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); } catch (error) { - this.connection.destroy(); - this.connection = undefined; + connection.destroy(); + if (this.connection === connection) { + this.connection = undefined; + } this.playbackState = 'stopped'; throw error; } + if (this.connection !== connection) { + throw new AudioManagerStateError('Voice connection was stopped before it became ready.'); + } this.playbackState = 'ready'; this.scheduleRenewal(); } @@ -115,23 +121,35 @@ export default class AudioManager { const resolvedSource = this.resolveSource(); this.stopCurrentPlayback(); this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg); + const ffmpeg = this.ffmpeg; try { - await this.ffmpeg.ready; + await ffmpeg.ready; + if (this.ffmpeg !== ffmpeg) { + throw new AudioManagerStateError('Playback was stopped before ffmpeg became ready.'); + } + this.resource = createAudioResource(ffmpeg.process.stdout, { + inputType: StreamType.Raw, + inlineVolume: this.options.volume?.enabled === true, + }); } catch (error) { - this.stopCurrentPlayback(); + if (this.ffmpeg === ffmpeg) { + this.stopCurrentPlayback(); + } throw error; } - this.resource = createAudioResource(this.ffmpeg.process.stdout, { - inputType: StreamType.Raw, - inlineVolume: this.options.volume?.enabled === true, - }); if (this.options.volume?.enabled === true && this.options.volume.initialPercent !== undefined) { this.setVolume(this.options.volume.initialPercent); } - this.audioPlayer.play(this.resource); - this.playbackState = 'playing'; + try { + this.audioPlayer.play(this.resource); + this.playbackState = 'playing'; + } catch (error) { + this.stopCurrentPlayback(); + this.playbackState = 'ready'; + throw error; + } } public async start(): Promise { diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index 9f5badc..129ec06 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -1,12 +1,14 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; -import { entersState } from '@discordjs/voice'; +import { createAudioResource, entersState, joinVoiceChannel } from '@discordjs/voice'; import { resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import AudioManager from '../src/audio-manager'; import { AudioManagerConfigError, AudioManagerStateError, FfmpegProcessError } from '../src'; import { startFfmpeg } from '../src/ffmpeg'; +import type { FfmpegProcessHandle } from '../src/ffmpeg'; import type { AudioSource, VoiceConnectionOptions } from '../src'; +import type { VoiceConnection } from '@discordjs/voice'; const mockAudioPlayer = { play: jest.fn(), @@ -19,6 +21,13 @@ const mockConnection = { disconnect: jest.fn(), destroy: jest.fn(), }; +const mockSecondConnection = { + subscribe: jest.fn(), + disconnect: jest.fn(), + destroy: jest.fn(), +}; +const mockVoiceConnection = mockConnection as unknown as VoiceConnection; +const mockSecondVoiceConnection = mockSecondConnection as unknown as VoiceConnection; const mockAudioResource = { playStream: { destroy: jest.fn(), @@ -27,10 +36,10 @@ const mockAudioResource = { setVolume: jest.fn(), }, }; -const mockFfmpegHandle = { +const mockFfmpegHandle: FfmpegProcessHandle = { process: { stdout: new PassThrough(), - }, + } as unknown as FfmpegProcessHandle['process'], ready: Promise.resolve(), stop: jest.fn(), }; @@ -47,8 +56,8 @@ jest.mock('@discordjs/voice', () => ({ }, createAudioPlayer: jest.fn(() => mockAudioPlayer), createAudioResource: jest.fn(() => mockAudioResource), - entersState: jest.fn(() => Promise.resolve(mockConnection)), - joinVoiceChannel: jest.fn(() => mockConnection), + entersState: jest.fn(() => Promise.resolve(mockVoiceConnection)), + joinVoiceChannel: jest.fn(() => mockVoiceConnection), })); jest.mock('../src/ffmpeg', () => ({ @@ -68,6 +77,26 @@ const liveStreamSource: AudioSource = { const fileSourcePath = 'tests/audio.mp3'; const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath); +type MockedEntersStateReturn = ReturnType; + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + +function createMockFfmpegHandle(): FfmpegProcessHandle { + return { + process: { + stdout: new PassThrough(), + } as unknown as FfmpegProcessHandle['process'], + ready: Promise.resolve(), + stop: jest.fn(), + }; +} describe('AudioManager', () => { beforeEach(() => { @@ -119,6 +148,73 @@ describe('AudioManager', () => { expect(jest.getTimerCount()).toBe(0); }); + it('does not become ready when stopped during connection startup', async () => { + jest.useFakeTimers(); + const ready = deferred(); + jest.mocked(entersState).mockReturnValueOnce(ready.promise as unknown as MockedEntersStateReturn); + const manager = new AudioManager({ + connection: connectionOptions, + renewIntervalMs: 10_000, + }); + + const connectPromise = manager.connect(); + await manager.stop(); + ready.resolve(mockVoiceConnection); + + await expect(connectPromise).rejects.toThrow(AudioManagerStateError); + + expect(manager.state).toBe('stopped'); + expect(manager.isConnected).toBe(false); + expect(jest.getTimerCount()).toBe(0); + }); + + it('keeps the latest connection when concurrent connects resolve out of order', async () => { + const firstReady = deferred(); + const secondReady = deferred(); + jest.mocked(joinVoiceChannel) + .mockReturnValueOnce(mockVoiceConnection) + .mockReturnValueOnce(mockSecondVoiceConnection); + jest.mocked(entersState) + .mockReturnValueOnce(firstReady.promise as unknown as MockedEntersStateReturn) + .mockReturnValueOnce(secondReady.promise as unknown as MockedEntersStateReturn); + const manager = new AudioManager({ + connection: connectionOptions, + renewIntervalMs: false, + }); + + const firstConnect = manager.connect(); + const secondConnect = manager.connect(); + firstReady.resolve(mockVoiceConnection); + secondReady.resolve(mockSecondVoiceConnection); + + await expect(firstConnect).rejects.toThrow(AudioManagerStateError); + await expect(secondConnect).resolves.toBeUndefined(); + + expect(manager.state).toBe('ready'); + expect(manager.isConnected).toBe(true); + expect(mockConnection.destroy).toHaveBeenCalledTimes(1); + expect(mockSecondConnection.destroy).not.toHaveBeenCalled(); + }); + + it('does not become ready when disposed during connection startup', async () => { + const ready = deferred(); + jest.mocked(entersState).mockReturnValueOnce(ready.promise as unknown as MockedEntersStateReturn); + const manager = new AudioManager({ + connection: connectionOptions, + renewIntervalMs: false, + }); + + const connectPromise = manager.connect(); + manager.dispose(); + ready.resolve(mockVoiceConnection); + + await expect(connectPromise).rejects.toThrow(AudioManagerStateError); + + expect(manager.state).toBe('disposed'); + expect(manager.isConnected).toBe(false); + expect(mockConnection.destroy).toHaveBeenCalledTimes(1); + }); + it('starts playback with the committed mp3 test file', async () => { const manager = new AudioManager({ connection: connectionOptions, @@ -184,6 +280,118 @@ describe('AudioManager', () => { expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); }); + it('cleans up when audio resource creation fails', async () => { + const resourceError = new Error('resource failed'); + jest.mocked(createAudioResource).mockImplementationOnce(() => { + throw resourceError; + }); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.connect(); + await expect(manager.play()).rejects.toBe(resourceError); + + expect(manager.state).toBe('ready'); + expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); + expect(mockAudioPlayer.play).not.toHaveBeenCalled(); + }); + + it('cleans up when the audio player rejects playback', async () => { + const playerError = new Error('player failed'); + mockAudioPlayer.play.mockImplementationOnce(() => { + throw playerError; + }); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.connect(); + await expect(manager.play()).rejects.toBe(playerError); + + expect(manager.state).toBe('ready'); + expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); + expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1); + }); + + it('does not report playback when stopped before ffmpeg is ready', async () => { + const ready = deferred(); + jest.mocked(startFfmpeg).mockReturnValueOnce({ + ...mockFfmpegHandle, + ready: ready.promise, + }); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.connect(); + const playPromise = manager.play(); + await manager.stop(); + ready.resolve(); + + await expect(playPromise).rejects.toThrow(AudioManagerStateError); + + expect(manager.state).toBe('stopped'); + expect(mockAudioPlayer.play).not.toHaveBeenCalled(); + }); + + it('does not report playback when disposed before ffmpeg is ready', async () => { + const ready = deferred(); + jest.mocked(startFfmpeg).mockReturnValueOnce({ + ...mockFfmpegHandle, + ready: ready.promise, + }); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.connect(); + const playPromise = manager.play(); + manager.dispose(); + ready.resolve(); + + await expect(playPromise).rejects.toThrow(AudioManagerStateError); + + expect(manager.state).toBe('disposed'); + expect(mockAudioPlayer.play).not.toHaveBeenCalled(); + }); + + it('keeps only the latest concurrent playback', async () => { + const firstReady = deferred(); + const firstHandle = { + ...createMockFfmpegHandle(), + ready: firstReady.promise, + }; + const secondHandle = createMockFfmpegHandle(); + jest.mocked(startFfmpeg).mockReturnValueOnce(firstHandle).mockReturnValueOnce(secondHandle); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.connect(); + const firstPlay = manager.play(); + const secondPlay = manager.play({ type: 'file', path: fileSourcePath }); + firstReady.resolve(); + + await expect(firstPlay).rejects.toThrow(AudioManagerStateError); + await expect(secondPlay).resolves.toBeUndefined(); + + expect(manager.state).toBe('playing'); + expect(mockAudioPlayer.play).toHaveBeenCalledTimes(1); + expect(firstHandle.stop).toHaveBeenCalledTimes(1); + expect(secondHandle.stop).not.toHaveBeenCalled(); + }); + it('replaces active playback when a new source is played', async () => { const manager = new AudioManager({ connection: connectionOptions, @@ -277,6 +485,7 @@ describe('AudioManager', () => { expect(() => manager.setVolume(Number.POSITIVE_INFINITY)).toThrow(AudioManagerConfigError); expect(() => manager.setVolume(Number.NEGATIVE_INFINITY)).toThrow(AudioManagerConfigError); expect(() => manager.setVolume(0)).not.toThrow(); + expect(() => manager.setVolume(42)).not.toThrow(); expect(() => manager.setVolume(100)).not.toThrow(); }); @@ -326,6 +535,20 @@ describe('AudioManager', () => { expect(manager.state).toBe('stopped'); }); + it('rejects pause and resume after stop', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.start(); + await manager.stop(); + + expect(() => manager.pause()).toThrow(AudioManagerStateError); + expect(() => manager.resume()).toThrow(AudioManagerStateError); + }); + it('restarts playback when the renewal timer fires', async () => { jest.useFakeTimers(); diff --git a/tests/ffmpeg.test.ts b/tests/ffmpeg.test.ts index d54e867..9db9d11 100644 --- a/tests/ffmpeg.test.ts +++ b/tests/ffmpeg.test.ts @@ -1,5 +1,6 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; import { spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; import { FfmpegProcessError } from '../src'; import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg'; @@ -52,6 +53,26 @@ function createMockChildProcess(): MockChildProcess { }; } +function mockSpawnReturn(childProcess: MockChildProcess): void { + mockSpawn.mockReturnValue(childProcess as unknown as ChildProcess); +} + +function getProcessHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void { + return childProcess.once.mock.calls.find(([event]) => event === eventName)?.[1] as (...args: unknown[]) => void; +} + +function getStdoutHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void { + return childProcess.stdout.once.mock.calls.find(([event]) => event === eventName)?.[1] as ( + ...args: unknown[] + ) => void; +} + +function getStderrHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void { + return childProcess.stderr.on.mock.calls.find(([event]) => event === eventName)?.[1] as ( + ...args: unknown[] + ) => void; +} + describe('ffmpeg helpers', () => { beforeEach(() => { jest.clearAllMocks(); @@ -72,7 +93,7 @@ describe('ffmpeg helpers', () => { it('spawns ffmpeg with the default argument set', () => { const childProcess = createMockChildProcess(); - mockSpawn.mockReturnValue(childProcess); + mockSpawnReturn(childProcess); startFfmpeg('https://synradiode.stream.laut.fm/synradiode'); @@ -105,10 +126,10 @@ describe('ffmpeg helpers', () => { it('rejects readiness when ffmpeg startup emits an error', async () => { const childProcess = createMockChildProcess(); - mockSpawn.mockReturnValue(childProcess); + mockSpawnReturn(childProcess); const handle = startFfmpeg('tests/audio.mp3'); - const errorHandler = childProcess.once.mock.calls.find(([event]) => event === 'error')?.[1]; + const errorHandler = getProcessHandler(childProcess, 'error'); errorHandler(new Error('spawn ENOENT')); @@ -118,11 +139,11 @@ describe('ffmpeg helpers', () => { it('includes stderr when ffmpeg exits before producing audio', async () => { const childProcess = createMockChildProcess(); - mockSpawn.mockReturnValue(childProcess); + mockSpawnReturn(childProcess); const handle = startFfmpeg('tests/audio.mp3'); - const stderrHandler = childProcess.stderr.on.mock.calls.find(([event]) => event === 'data')?.[1]; - const exitHandler = childProcess.once.mock.calls.find(([event]) => event === 'exit')?.[1]; + const stderrHandler = getStderrHandler(childProcess, 'data'); + const exitHandler = getProcessHandler(childProcess, 'exit'); stderrHandler('invalid input'); exitHandler(1, null); @@ -130,9 +151,37 @@ describe('ffmpeg helpers', () => { await expect(handle.ready).rejects.toThrow('invalid input'); }); + it('keeps the useful tail of long ffmpeg stderr output', async () => { + const childProcess = createMockChildProcess(); + mockSpawnReturn(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + const stderrHandler = getStderrHandler(childProcess, 'data'); + const exitHandler = getProcessHandler(childProcess, 'exit'); + + stderrHandler(`${'x'.repeat(5_000)}final diagnostic`); + exitHandler(1, null); + + await expect(handle.ready).rejects.toThrow('final diagnostic'); + }); + + it('keeps readiness resolved when ffmpeg exits after producing audio', async () => { + const childProcess = createMockChildProcess(); + mockSpawnReturn(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + const readableHandler = getStdoutHandler(childProcess, 'readable'); + const exitHandler = getProcessHandler(childProcess, 'exit'); + + readableHandler(); + exitHandler(1, null); + + await expect(handle.ready).resolves.toBeUndefined(); + }); + it('uses custom executable and argument overrides when provided', () => { const childProcess = createMockChildProcess(); - mockSpawn.mockReturnValue(childProcess); + mockSpawnReturn(childProcess); startFfmpeg('tests/audio.mp3', { executablePath: '/custom/ffmpeg', @@ -150,7 +199,7 @@ describe('ffmpeg helpers', () => { it('stops a running child process and schedules a force kill fallback', () => { jest.useFakeTimers(); const childProcess = createMockChildProcess(); - mockSpawn.mockReturnValue(childProcess); + mockSpawnReturn(childProcess); const handle = startFfmpeg('tests/audio.mp3'); handle.stop(); @@ -168,7 +217,7 @@ describe('ffmpeg helpers', () => { it('does not signal a process that already exited', () => { const childProcess = createMockChildProcess(); childProcess.exitCode = 0; - mockSpawn.mockReturnValue(childProcess); + mockSpawnReturn(childProcess); const handle = startFfmpeg('tests/audio.mp3'); handle.stop();