diff --git a/src/audio-manager.ts b/src/audio-manager.ts index 9eb97f1..cf9b8e7 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -82,14 +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); - await entersState(this.connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); + try { + await entersState(connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); + } catch (error) { + 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(); } @@ -108,17 +121,35 @@ export default class AudioManager { const resolvedSource = this.resolveSource(); this.stopCurrentPlayback(); this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg); - this.resource = createAudioResource(this.ffmpeg.process.stdout, { - inputType: StreamType.Raw, - inlineVolume: this.options.volume?.enabled === true, - }); + const ffmpeg = this.ffmpeg; + try { + 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) { + if (this.ffmpeg === ffmpeg) { + this.stopCurrentPlayback(); + } + throw error; + } 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 { @@ -169,7 +200,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.'); } @@ -227,7 +258,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/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 5d3d1d8..129ec06 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -1,11 +1,14 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; +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 } from '../src'; +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(), @@ -18,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(), @@ -26,10 +36,11 @@ 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(), }; @@ -45,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', () => ({ @@ -66,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(() => { @@ -99,6 +130,91 @@ describe('AudioManager', () => { expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); }); + it('cleans up when connection startup fails', 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('stopped'); + expect(manager.isConnected).toBe(false); + expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer); + expect(mockConnection.destroy).toHaveBeenCalledTimes(1); + 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, @@ -144,6 +260,138 @@ 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('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, @@ -233,6 +481,12 @@ 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(42)).not.toThrow(); + expect(() => manager.setVolume(100)).not.toThrow(); }); it('rejects volume changes before any resource exists', () => { @@ -281,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(); @@ -298,6 +566,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(); diff --git a/tests/ffmpeg.test.ts b/tests/ffmpeg.test.ts index a68760d..9db9d11 100644 --- a/tests/ffmpeg.test.ts +++ b/tests/ffmpeg.test.ts @@ -1,6 +1,8 @@ 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'; jest.mock('node:child_process', () => ({ @@ -12,12 +14,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 +35,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, @@ -43,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(); @@ -63,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'); @@ -88,12 +118,70 @@ 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(); + mockSpawnReturn(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + const errorHandler = getProcessHandler(childProcess, 'error'); + + 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(); + mockSpawnReturn(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + const stderrHandler = getStderrHandler(childProcess, 'data'); + const exitHandler = getProcessHandler(childProcess, 'exit'); + + stderrHandler('invalid input'); + exitHandler(1, null); + + 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', @@ -111,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(); @@ -129,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();