improve: tests
This commit is contained in:
+27
-9
@@ -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();
|
||||
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;
|
||||
} catch (error) {
|
||||
this.stopCurrentPlayback();
|
||||
throw error;
|
||||
await ffmpeg.ready;
|
||||
if (this.ffmpeg !== ffmpeg) {
|
||||
throw new AudioManagerStateError('Playback was stopped before ffmpeg became ready.');
|
||||
}
|
||||
this.resource = createAudioResource(this.ffmpeg.process.stdout, {
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
this.audioPlayer.play(this.resource);
|
||||
this.playbackState = 'playing';
|
||||
} catch (error) {
|
||||
this.stopCurrentPlayback();
|
||||
this.playbackState = 'ready';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
|
||||
+228
-5
@@ -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<typeof entersState>;
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<VoiceConnection>();
|
||||
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<VoiceConnection>();
|
||||
const secondReady = deferred<VoiceConnection>();
|
||||
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<VoiceConnection>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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();
|
||||
|
||||
|
||||
+58
-9
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user