mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-15 19:13:31 +01:00
test(voice): fix tests
This commit is contained in:
@@ -1,392 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/dot-notation */
|
||||
import { AudioResource } from '../../audio/AudioResource';
|
||||
import { createAudioPlayer, AudioPlayerStatus, AudioPlayer, SILENCE_FRAME } from '../AudioPlayer';
|
||||
import { Readable } from 'node:stream';
|
||||
import { addAudioPlayer, deleteAudioPlayer } from '../../DataStore';
|
||||
import { NoSubscriberBehavior } from '../..';
|
||||
import { VoiceConnection, VoiceConnectionStatus } from '../../VoiceConnection';
|
||||
import { once } from 'node:events';
|
||||
import { AudioPlayerError } from '../AudioPlayerError';
|
||||
|
||||
jest.mock('../../DataStore');
|
||||
jest.mock('../../VoiceConnection');
|
||||
jest.mock('../AudioPlayerError');
|
||||
|
||||
const addAudioPlayerMock = addAudioPlayer as unknown as jest.Mock<typeof addAudioPlayer>;
|
||||
const deleteAudioPlayerMock = deleteAudioPlayer as unknown as jest.Mock<typeof deleteAudioPlayer>;
|
||||
const AudioPlayerErrorMock = AudioPlayerError as unknown as jest.Mock<typeof AudioPlayerError>;
|
||||
const VoiceConnectionMock = VoiceConnection as unknown as jest.Mock<VoiceConnection>;
|
||||
|
||||
function* silence() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
yield Buffer.from([0xf8, 0xff, 0xfe]);
|
||||
}
|
||||
}
|
||||
|
||||
function createVoiceConnectionMock() {
|
||||
const connection = new VoiceConnectionMock();
|
||||
connection.state = {
|
||||
status: VoiceConnectionStatus.Signalling,
|
||||
adapter: {
|
||||
sendPayload: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
};
|
||||
connection.subscribe = jest.fn((player) => player['subscribe'](connection));
|
||||
return connection;
|
||||
}
|
||||
|
||||
function wait() {
|
||||
return new Promise((resolve) => process.nextTick(resolve));
|
||||
}
|
||||
|
||||
async function started(resource: AudioResource) {
|
||||
while (!resource.started) {
|
||||
await wait();
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
let player: AudioPlayer | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
AudioPlayerErrorMock.mockReset();
|
||||
VoiceConnectionMock.mockReset();
|
||||
addAudioPlayerMock.mockReset();
|
||||
deleteAudioPlayerMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
player?.stop(true);
|
||||
});
|
||||
|
||||
describe('State transitions', () => {
|
||||
test('Starts in Idle state', () => {
|
||||
player = createAudioPlayer();
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(0);
|
||||
expect(deleteAudioPlayerMock).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('Playing resource with pausing and resuming', async () => {
|
||||
// Call AudioResource constructor directly to avoid analysing pipeline for stream
|
||||
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
|
||||
player = createAudioPlayer();
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
|
||||
// Pause and unpause should not affect the status of an Idle player
|
||||
expect(player.pause()).toBe(false);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
expect(player.unpause()).toBe(false);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(0);
|
||||
|
||||
player.play(resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(1);
|
||||
|
||||
// Expect pause() to return true and transition to paused state
|
||||
expect(player.pause()).toBe(true);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Paused);
|
||||
|
||||
// further calls to pause() should be unsuccessful
|
||||
expect(player.pause()).toBe(false);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Paused);
|
||||
|
||||
// unpause() should transition back to Playing
|
||||
expect(player.unpause()).toBe(true);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
|
||||
// further calls to unpause() should be unsuccessful
|
||||
expect(player.unpause()).toBe(false);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
|
||||
// The audio player should not have been deleted throughout these changes
|
||||
expect(deleteAudioPlayerMock).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('Playing to Stopping', async () => {
|
||||
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
|
||||
player = createAudioPlayer();
|
||||
|
||||
// stop() shouldn't do anything in Idle state
|
||||
expect(player.stop(true)).toBe(false);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
|
||||
player.play(resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(1);
|
||||
expect(deleteAudioPlayerMock).toBeCalledTimes(0);
|
||||
|
||||
expect(player.stop()).toBe(true);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(1);
|
||||
expect(deleteAudioPlayerMock).toBeCalledTimes(0);
|
||||
expect(resource.silenceRemaining).toBe(5);
|
||||
});
|
||||
|
||||
test('Buffering to Playing', async () => {
|
||||
const resource = new AudioResource([], [Readable.from(silence())], null, 5);
|
||||
player = createAudioPlayer();
|
||||
|
||||
player.play(resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Buffering);
|
||||
|
||||
await started(resource);
|
||||
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
expect(addAudioPlayerMock).toHaveBeenCalled();
|
||||
expect(deleteAudioPlayerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('NoSubscriberBehavior transitions', () => {
|
||||
test('NoSubscriberBehavior.Pause', async () => {
|
||||
const connection = createVoiceConnectionMock();
|
||||
if (connection.state.status !== VoiceConnectionStatus.Signalling) {
|
||||
throw new Error('Voice connection should have been Signalling');
|
||||
}
|
||||
|
||||
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
|
||||
player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Pause } });
|
||||
connection.subscribe(player);
|
||||
|
||||
player.play(resource);
|
||||
expect(player.checkPlayable()).toBe(true);
|
||||
player['_stepPrepare']();
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.AutoPaused);
|
||||
|
||||
connection.state = {
|
||||
...connection.state,
|
||||
status: VoiceConnectionStatus.Ready,
|
||||
networking: null as any,
|
||||
};
|
||||
|
||||
expect(player.checkPlayable()).toBe(true);
|
||||
player['_stepPrepare']();
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
});
|
||||
|
||||
test('NoSubscriberBehavior.Play', async () => {
|
||||
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
|
||||
player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||||
|
||||
player.play(resource);
|
||||
expect(player.checkPlayable()).toBe(true);
|
||||
player['_stepPrepare']();
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
});
|
||||
|
||||
test('NoSubscriberBehavior.Stop', async () => {
|
||||
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
|
||||
player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Stop } });
|
||||
|
||||
player.play(resource);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(1);
|
||||
expect(player.checkPlayable()).toBe(true);
|
||||
player['_stepPrepare']();
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
expect(deleteAudioPlayerMock).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('Normal playing state', async () => {
|
||||
const connection = createVoiceConnectionMock();
|
||||
if (connection.state.status !== VoiceConnectionStatus.Signalling) {
|
||||
throw new Error('Voice connection should have been Signalling');
|
||||
}
|
||||
connection.state = {
|
||||
...connection.state,
|
||||
status: VoiceConnectionStatus.Ready,
|
||||
networking: null as any,
|
||||
};
|
||||
|
||||
const buffer = Buffer.from([1, 2, 4, 8]);
|
||||
const resource = await started(
|
||||
new AudioResource([], [Readable.from([buffer, buffer, buffer, buffer, buffer])], null, 5),
|
||||
);
|
||||
player = createAudioPlayer();
|
||||
connection.subscribe(player);
|
||||
|
||||
player.play(resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(1);
|
||||
expect(player.checkPlayable()).toBe(true);
|
||||
|
||||
// Run through a few packet cycles
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
player['_stepDispatch']();
|
||||
expect(connection.dispatchAudio).toHaveBeenCalledTimes(i);
|
||||
|
||||
await wait(); // Wait for the stream
|
||||
|
||||
player['_stepPrepare']();
|
||||
expect(connection.prepareAudioPacket).toHaveBeenCalledTimes(i);
|
||||
expect(connection.prepareAudioPacket).toHaveBeenLastCalledWith(buffer);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
if (player.state.status === AudioPlayerStatus.Playing) {
|
||||
expect(player.state.playbackDuration).toStrictEqual(i * 20);
|
||||
}
|
||||
}
|
||||
|
||||
// Expect silence to be played
|
||||
player['_stepDispatch']();
|
||||
expect(connection.dispatchAudio).toHaveBeenCalledTimes(6);
|
||||
await wait();
|
||||
player['_stepPrepare']();
|
||||
const prepareAudioPacket = connection.prepareAudioPacket as unknown as jest.Mock<
|
||||
typeof connection.prepareAudioPacket
|
||||
>;
|
||||
expect(prepareAudioPacket).toHaveBeenCalledTimes(6);
|
||||
expect(prepareAudioPacket.mock.calls[5][0]).toEqual(silence().next().value);
|
||||
|
||||
player.stop(true);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
expect(connection.setSpeaking).toBeCalledTimes(1);
|
||||
expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
|
||||
expect(deleteAudioPlayerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('stop() causes resource to use silence padding frames', async () => {
|
||||
const connection = createVoiceConnectionMock();
|
||||
if (connection.state.status !== VoiceConnectionStatus.Signalling) {
|
||||
throw new Error('Voice connection should have been Signalling');
|
||||
}
|
||||
connection.state = {
|
||||
...connection.state,
|
||||
status: VoiceConnectionStatus.Ready,
|
||||
networking: null as any,
|
||||
};
|
||||
|
||||
const buffer = Buffer.from([1, 2, 4, 8]);
|
||||
const resource = await started(
|
||||
new AudioResource([], [Readable.from([buffer, buffer, buffer, buffer, buffer])], null, 5),
|
||||
);
|
||||
player = createAudioPlayer();
|
||||
connection.subscribe(player);
|
||||
|
||||
player.play(resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(1);
|
||||
expect(player.checkPlayable()).toBe(true);
|
||||
|
||||
player.stop();
|
||||
|
||||
// Run through a few packet cycles
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
player['_stepDispatch']();
|
||||
expect(connection.dispatchAudio).toHaveBeenCalledTimes(i);
|
||||
|
||||
await wait(); // Wait for the stream
|
||||
|
||||
player['_stepPrepare']();
|
||||
expect(connection.prepareAudioPacket).toHaveBeenCalledTimes(i);
|
||||
expect(connection.prepareAudioPacket).toHaveBeenLastCalledWith(SILENCE_FRAME);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
if (player.state.status === AudioPlayerStatus.Playing) {
|
||||
expect(player.state.playbackDuration).toStrictEqual(i * 20);
|
||||
}
|
||||
}
|
||||
await wait();
|
||||
expect(player.checkPlayable()).toBe(false);
|
||||
const prepareAudioPacket = connection.prepareAudioPacket as unknown as jest.Mock<
|
||||
typeof connection.prepareAudioPacket
|
||||
>;
|
||||
expect(prepareAudioPacket).toHaveBeenCalledTimes(5);
|
||||
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
expect(connection.setSpeaking).toBeCalledTimes(1);
|
||||
expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
|
||||
expect(deleteAudioPlayerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Plays silence 5 times for unreadable stream before quitting', async () => {
|
||||
const connection = createVoiceConnectionMock();
|
||||
if (connection.state.status !== VoiceConnectionStatus.Signalling) {
|
||||
throw new Error('Voice connection should have been Signalling');
|
||||
}
|
||||
connection.state = {
|
||||
...connection.state,
|
||||
status: VoiceConnectionStatus.Ready,
|
||||
networking: null as any,
|
||||
};
|
||||
|
||||
const resource = await started(new AudioResource([], [Readable.from([1])], null, 0));
|
||||
resource.playStream.read();
|
||||
player = createAudioPlayer({ behaviors: { maxMissedFrames: 5 } });
|
||||
connection.subscribe(player);
|
||||
|
||||
player.play(resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
expect(addAudioPlayerMock).toBeCalledTimes(1);
|
||||
expect(player.checkPlayable()).toBe(true);
|
||||
|
||||
const prepareAudioPacket = connection.prepareAudioPacket as unknown as jest.Mock<
|
||||
typeof connection.prepareAudioPacket
|
||||
>;
|
||||
|
||||
// Run through a few packet cycles
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
if (player.state.status !== AudioPlayerStatus.Playing) throw new Error('Error');
|
||||
expect(player.state.playbackDuration).toStrictEqual((i - 1) * 20);
|
||||
expect(player.state.missedFrames).toBe(i - 1);
|
||||
player['_stepDispatch']();
|
||||
expect(connection.dispatchAudio).toHaveBeenCalledTimes(i);
|
||||
player['_stepPrepare']();
|
||||
expect(prepareAudioPacket).toHaveBeenCalledTimes(i);
|
||||
expect(prepareAudioPacket.mock.calls[i - 1][0]).toEqual(silence().next().value);
|
||||
}
|
||||
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
expect(connection.setSpeaking).toBeCalledTimes(1);
|
||||
expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
|
||||
expect(deleteAudioPlayerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('checkPlayable() transitions to Idle for unreadable stream', async () => {
|
||||
const resource = await started(new AudioResource([], [Readable.from([1])], null, 0));
|
||||
player = createAudioPlayer();
|
||||
player.play(resource);
|
||||
expect(player.checkPlayable()).toBe(true);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
resource.playStream.read();
|
||||
await wait();
|
||||
}
|
||||
expect(resource.playStream.readableEnded).toBe(true);
|
||||
expect(player.checkPlayable()).toBe(false);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
});
|
||||
});
|
||||
|
||||
test('play() throws when playing a resource that has already ended', async () => {
|
||||
const resource = await started(new AudioResource([], [Readable.from([1])], null, 5));
|
||||
player = createAudioPlayer();
|
||||
player.play(resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
resource.playStream.read();
|
||||
await wait();
|
||||
}
|
||||
expect(resource.playStream.readableEnded).toBe(true);
|
||||
player.stop(true);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
expect(() => player?.play(resource)).toThrow();
|
||||
});
|
||||
|
||||
test('Propagates errors from streams', async () => {
|
||||
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
|
||||
player = createAudioPlayer();
|
||||
player.play(resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
|
||||
const error = new Error('AudioPlayer test error');
|
||||
process.nextTick(() => resource.playStream.emit('error', error));
|
||||
const res = await once(player, 'error');
|
||||
const playerError = res[0] as AudioPlayerError;
|
||||
expect(playerError).toBeInstanceOf(AudioPlayerError);
|
||||
expect(AudioPlayerErrorMock).toHaveBeenCalledWith(error, resource);
|
||||
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import { opus, VolumeTransformer } from 'prism-media';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import { SILENCE_FRAME } from '../AudioPlayer';
|
||||
import { AudioResource, createAudioResource, NO_CONSTRAINT, VOLUME_CONSTRAINT } from '../AudioResource';
|
||||
import { Edge, findPipeline as _findPipeline, StreamType, TransformerType } from '../TransformerGraph';
|
||||
|
||||
jest.mock('prism-media');
|
||||
jest.mock('../TransformerGraph');
|
||||
|
||||
function wait() {
|
||||
return new Promise((resolve) => process.nextTick(resolve));
|
||||
}
|
||||
|
||||
async function started(resource: AudioResource) {
|
||||
while (!resource.started) {
|
||||
await wait();
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
const findPipeline = _findPipeline as unknown as jest.MockedFunction<typeof _findPipeline>;
|
||||
|
||||
beforeAll(() => {
|
||||
findPipeline.mockImplementation((from: StreamType, constraint: (path: Edge[]) => boolean) => {
|
||||
const base = [
|
||||
{
|
||||
cost: 1,
|
||||
transformer: () => new PassThrough(),
|
||||
type: TransformerType.FFmpegPCM,
|
||||
},
|
||||
];
|
||||
if (constraint === VOLUME_CONSTRAINT) {
|
||||
base.push({
|
||||
cost: 1,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
transformer: () => new VolumeTransformer({} as any),
|
||||
type: TransformerType.InlineVolume,
|
||||
});
|
||||
}
|
||||
return base as any[];
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
findPipeline.mockClear();
|
||||
});
|
||||
|
||||
describe('createAudioResource', () => {
|
||||
test('Creates a resource from string path', () => {
|
||||
const resource = createAudioResource('mypath.mp3');
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
|
||||
expect(resource.volume).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Creates a resource from string path (volume)', () => {
|
||||
const resource = createAudioResource('mypath.mp3', { inlineVolume: true });
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, VOLUME_CONSTRAINT);
|
||||
expect(resource.volume).toBeInstanceOf(VolumeTransformer);
|
||||
});
|
||||
|
||||
test('Only infers type if not explicitly given', () => {
|
||||
const resource = createAudioResource(new opus.Encoder(), { inputType: StreamType.Arbitrary });
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
|
||||
expect(resource.volume).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Infers from opus.Encoder', () => {
|
||||
const resource = createAudioResource(new opus.Encoder(), { inlineVolume: true });
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, VOLUME_CONSTRAINT);
|
||||
expect(resource.volume).toBeInstanceOf(VolumeTransformer);
|
||||
expect(resource.encoder).toBeInstanceOf(opus.Encoder);
|
||||
});
|
||||
|
||||
test('Infers from opus.OggDemuxer', () => {
|
||||
const resource = createAudioResource(new opus.OggDemuxer());
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, NO_CONSTRAINT);
|
||||
expect(resource.volume).toBeUndefined();
|
||||
expect(resource.encoder).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Infers from opus.WebmDemuxer', () => {
|
||||
const resource = createAudioResource(new opus.WebmDemuxer());
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, NO_CONSTRAINT);
|
||||
expect(resource.volume).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Infers from opus.Decoder', () => {
|
||||
const resource = createAudioResource(new opus.Decoder());
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Raw, NO_CONSTRAINT);
|
||||
expect(resource.volume).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Infers from VolumeTransformer', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const stream = new VolumeTransformer({} as any);
|
||||
const resource = createAudioResource(stream, { inlineVolume: true });
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Raw, NO_CONSTRAINT);
|
||||
expect(resource.volume).toBe(stream);
|
||||
});
|
||||
|
||||
test('Falls back to Arbitrary for unknown stream type', () => {
|
||||
const resource = createAudioResource(new PassThrough());
|
||||
expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
|
||||
expect(resource.volume).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Appends silence frames when ended', async () => {
|
||||
const stream = Readable.from(Buffer.from([1]));
|
||||
|
||||
const resource = new AudioResource([], [stream], null, 5);
|
||||
|
||||
await started(resource);
|
||||
expect(resource.readable).toBe(true);
|
||||
expect(resource.read()).toEqual(Buffer.from([1]));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await wait();
|
||||
expect(resource.readable).toBe(true);
|
||||
expect(resource.read()).toBe(SILENCE_FRAME);
|
||||
}
|
||||
await wait();
|
||||
expect(resource.readable).toBe(false);
|
||||
expect(resource.read()).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Edge, findPipeline, StreamType, TransformerType } from '../TransformerGraph';
|
||||
|
||||
const noConstraint = () => true;
|
||||
|
||||
/**
|
||||
* Converts a pipeline into an easier-to-parse list of stream types within the pipeline
|
||||
*
|
||||
* @param pipeline - The pipeline of edges returned by findPipeline(...)
|
||||
*/
|
||||
function reducePath(pipeline: Edge[]) {
|
||||
const streams = [pipeline[0].from.type];
|
||||
for (const edge of pipeline.slice(1)) {
|
||||
streams.push(edge.from.type);
|
||||
}
|
||||
streams.push(pipeline[pipeline.length - 1].to.type);
|
||||
return streams;
|
||||
}
|
||||
|
||||
const isVolume = (edge: Edge) => edge.type === TransformerType.InlineVolume;
|
||||
const containsVolume = (edges: Edge[]) => edges.some(isVolume);
|
||||
|
||||
describe('findPipeline (no constraints)', () => {
|
||||
test.each([StreamType.Arbitrary, StreamType.OggOpus, StreamType.WebmOpus, StreamType.Raw])(
|
||||
'%s maps to opus with no inline volume',
|
||||
(type) => {
|
||||
const pipeline = findPipeline(type, noConstraint);
|
||||
const path = reducePath(pipeline);
|
||||
expect(path.length).toBeGreaterThanOrEqual(2);
|
||||
expect(path[0]).toBe(type);
|
||||
expect(path.pop()).toBe(StreamType.Opus);
|
||||
expect(pipeline.some(isVolume)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test('opus is unchanged', () => {
|
||||
expect(findPipeline(StreamType.Opus, noConstraint)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPipeline (volume constraint)', () => {
|
||||
test.each(Object.values(StreamType))('%s maps to opus with inline volume', (type) => {
|
||||
const pipeline = findPipeline(type, containsVolume);
|
||||
const path = reducePath(pipeline);
|
||||
expect(path.length).toBeGreaterThanOrEqual(2);
|
||||
expect(path[0]).toBe(type);
|
||||
expect(path.pop()).toBe(StreamType.Opus);
|
||||
expect(pipeline.some(isVolume)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { createSocket as _createSocket } from 'node:dgram';
|
||||
import EventEmitter, { once } from 'node:events';
|
||||
import { VoiceUDPSocket } from '../VoiceUDPSocket';
|
||||
|
||||
jest.mock('node:dgram');
|
||||
jest.useFakeTimers();
|
||||
|
||||
const createSocket = _createSocket as unknown as jest.Mock<typeof _createSocket>;
|
||||
|
||||
beforeEach(() => {
|
||||
createSocket.mockReset();
|
||||
});
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
public send(buffer: Buffer, port: number, address: string) {}
|
||||
public close() {
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
// ip = 91.90.123.93, port = 54148
|
||||
const VALID_RESPONSE = Buffer.from([
|
||||
0x0, 0x2, 0x0, 0x46, 0x0, 0x4, 0xeb, 0x23, 0x39, 0x31, 0x2e, 0x39, 0x30, 0x2e, 0x31, 0x32, 0x33, 0x2e, 0x39, 0x33,
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd3, 0x84,
|
||||
]);
|
||||
|
||||
function wait() {
|
||||
return new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
jest.advanceTimersToNextTimer();
|
||||
});
|
||||
}
|
||||
|
||||
describe('VoiceUDPSocket#performIPDiscovery', () => {
|
||||
let socket: VoiceUDPSocket;
|
||||
|
||||
afterEach(() => {
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
/*
|
||||
Ensures that the UDP socket sends data and parses the response correctly
|
||||
*/
|
||||
test('Resolves and cleans up with a successful flow', async () => {
|
||||
const fake = new FakeSocket();
|
||||
fake.send = jest.fn().mockImplementation((buffer: Buffer, port: number, address: string) => {
|
||||
fake.emit('message', VALID_RESPONSE);
|
||||
});
|
||||
createSocket.mockImplementation((type) => fake as any);
|
||||
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
|
||||
|
||||
expect(createSocket).toHaveBeenCalledWith('udp4');
|
||||
expect(fake.listenerCount('message')).toBe(1);
|
||||
await expect(socket.performIPDiscovery(1234)).resolves.toEqual({
|
||||
ip: '91.90.123.93',
|
||||
port: 54148,
|
||||
});
|
||||
// Ensure clean up occurs
|
||||
expect(fake.listenerCount('message')).toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
In the case where an unrelated message is received before the IP discovery buffer,
|
||||
the UDP socket should wait indefinitely until the correct buffer arrives.
|
||||
*/
|
||||
test('Waits for a valid response in an unexpected flow', async () => {
|
||||
const fake = new FakeSocket();
|
||||
const fakeResponse = Buffer.from([1, 2, 3, 4, 5]);
|
||||
fake.send = jest.fn().mockImplementation(async (buffer: Buffer, port: number, address: string) => {
|
||||
fake.emit('message', fakeResponse);
|
||||
await wait();
|
||||
fake.emit('message', VALID_RESPONSE);
|
||||
});
|
||||
createSocket.mockImplementation(() => fake as any);
|
||||
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
|
||||
|
||||
expect(createSocket).toHaveBeenCalledWith('udp4');
|
||||
expect(fake.listenerCount('message')).toBe(1);
|
||||
await expect(socket.performIPDiscovery(1234)).resolves.toEqual({
|
||||
ip: '91.90.123.93',
|
||||
port: 54148,
|
||||
});
|
||||
// Ensure clean up occurs
|
||||
expect(fake.listenerCount('message')).toBe(1);
|
||||
});
|
||||
|
||||
test('Rejects if socket closes before IP discovery can be completed', async () => {
|
||||
const fake = new FakeSocket();
|
||||
fake.send = jest.fn().mockImplementation(async (buffer: Buffer, port: number, address: string) => {
|
||||
await wait();
|
||||
fake.close();
|
||||
});
|
||||
createSocket.mockImplementation(() => fake as any);
|
||||
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
|
||||
|
||||
expect(createSocket).toHaveBeenCalledWith('udp4');
|
||||
await expect(socket.performIPDiscovery(1234)).rejects.toThrowError();
|
||||
});
|
||||
|
||||
test('Stays alive when messages are echoed back', async () => {
|
||||
const fake = new FakeSocket();
|
||||
fake.send = jest.fn().mockImplementation(async (buffer: Buffer) => {
|
||||
await wait();
|
||||
fake.emit('message', buffer);
|
||||
});
|
||||
createSocket.mockImplementation(() => fake as any);
|
||||
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
|
||||
|
||||
let closed = false;
|
||||
// @ts-expect-error
|
||||
socket.on('close', () => (closed = true));
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
jest.advanceTimersToNextTimer();
|
||||
await wait();
|
||||
}
|
||||
|
||||
expect(closed).toBe(false);
|
||||
});
|
||||
|
||||
test('Emits an error when no response received to keep alive messages', async () => {
|
||||
const fake = new FakeSocket();
|
||||
fake.send = jest.fn();
|
||||
createSocket.mockImplementation(() => fake as any);
|
||||
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
|
||||
|
||||
let closed = false;
|
||||
// @ts-expect-error
|
||||
socket.on('close', () => (closed = true));
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
jest.advanceTimersToNextTimer();
|
||||
await wait();
|
||||
}
|
||||
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
test('Recovers from intermittent responses', async () => {
|
||||
const fake = new FakeSocket();
|
||||
const fakeSend = jest.fn();
|
||||
fake.send = fakeSend;
|
||||
createSocket.mockImplementation(() => fake as any);
|
||||
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
|
||||
|
||||
let closed = false;
|
||||
// @ts-expect-error
|
||||
socket.on('close', () => (closed = true));
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
jest.advanceTimersToNextTimer();
|
||||
await wait();
|
||||
}
|
||||
fakeSend.mockImplementation(async (buffer: Buffer) => {
|
||||
await wait();
|
||||
fake.emit('message', buffer);
|
||||
});
|
||||
expect(closed).toBe(false);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
jest.advanceTimersToNextTimer();
|
||||
await wait();
|
||||
}
|
||||
expect(closed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
|
||||
import EventEmitter, { once } from 'node:events';
|
||||
import WS from 'jest-websocket-mock';
|
||||
import { VoiceWebSocket } from '../VoiceWebSocket';
|
||||
|
||||
beforeEach(() => {
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
function onceIgnoreError<T extends EventEmitter>(target: T, event: string) {
|
||||
return new Promise((resolve) => {
|
||||
target.on(event, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function onceOrThrow<T extends EventEmitter>(target: T, event: string, after: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
target.on(event, resolve);
|
||||
setTimeout(() => reject(new Error('Time up')), after);
|
||||
});
|
||||
}
|
||||
|
||||
describe('VoiceWebSocket: packet parsing', () => {
|
||||
test('Parses and emits packets', async () => {
|
||||
const endpoint = 'ws://localhost:1234';
|
||||
const server = new WS(endpoint, { jsonProtocol: true });
|
||||
const ws = new VoiceWebSocket(endpoint, false);
|
||||
await server.connected;
|
||||
const dummy = { value: 3 };
|
||||
const rcv = once(ws, 'packet');
|
||||
server.send(dummy);
|
||||
await expect(rcv).resolves.toEqual([dummy]);
|
||||
});
|
||||
|
||||
test('Recovers from invalid packets', async () => {
|
||||
const endpoint = 'ws://localhost:1234';
|
||||
const server = new WS(endpoint);
|
||||
const ws = new VoiceWebSocket(endpoint, false);
|
||||
await server.connected;
|
||||
|
||||
let rcv = once(ws, 'packet');
|
||||
server.send('asdf');
|
||||
await expect(rcv).rejects.toThrowError();
|
||||
|
||||
const dummy = { op: 1234 };
|
||||
rcv = once(ws, 'packet');
|
||||
server.send(JSON.stringify(dummy));
|
||||
await expect(rcv).resolves.toEqual([dummy]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VoiceWebSocket: event propagation', () => {
|
||||
test('open', async () => {
|
||||
const endpoint = 'ws://localhost:1234';
|
||||
const server = new WS(endpoint);
|
||||
const ws = new VoiceWebSocket(endpoint, false);
|
||||
const rcv = once(ws, 'open');
|
||||
await server.connected;
|
||||
await expect(rcv).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('close (clean)', async () => {
|
||||
const endpoint = 'ws://localhost:1234';
|
||||
const server = new WS(endpoint);
|
||||
const ws = new VoiceWebSocket(endpoint, false);
|
||||
await server.connected;
|
||||
const rcv = once(ws, 'close');
|
||||
server.close();
|
||||
await expect(rcv).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('close (error)', async () => {
|
||||
const endpoint = 'ws://localhost:1234';
|
||||
const server = new WS(endpoint);
|
||||
const ws = new VoiceWebSocket(endpoint, false);
|
||||
await server.connected;
|
||||
const rcvError = once(ws, 'error');
|
||||
const rcvClose = onceIgnoreError(ws, 'close');
|
||||
server.error();
|
||||
await expect(rcvError).resolves.toBeTruthy();
|
||||
await expect(rcvClose).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VoiceWebSocket: heartbeating', () => {
|
||||
test('Normal heartbeat flow', async () => {
|
||||
const endpoint = 'ws://localhost:1234';
|
||||
const server = new WS(endpoint, { jsonProtocol: true });
|
||||
const ws = new VoiceWebSocket(endpoint, false);
|
||||
await server.connected;
|
||||
const rcv = onceOrThrow(ws, 'close', 750);
|
||||
ws.setHeartbeatInterval(50);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const packet: any = await server.nextMessage;
|
||||
expect(packet).toMatchObject({
|
||||
op: VoiceOpcodes.Heartbeat,
|
||||
});
|
||||
server.send({
|
||||
op: VoiceOpcodes.HeartbeatAck,
|
||||
d: packet.d,
|
||||
});
|
||||
expect(ws.ping).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
ws.setHeartbeatInterval(-1);
|
||||
await expect(rcv).rejects.toThrowError();
|
||||
});
|
||||
|
||||
test('Closes when no ack is received', async () => {
|
||||
const endpoint = 'ws://localhost:1234';
|
||||
const server = new WS(endpoint, { jsonProtocol: true });
|
||||
const ws = new VoiceWebSocket(endpoint, false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
ws.on('error', () => {});
|
||||
await server.connected;
|
||||
const rcv = onceIgnoreError(ws, 'close');
|
||||
ws.setHeartbeatInterval(50);
|
||||
await expect(rcv).resolves.toBeTruthy();
|
||||
expect(ws.ping).toBe(undefined);
|
||||
expect(server.messages.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { SILENCE_FRAME } from '../../audio/AudioPlayer';
|
||||
import { AudioReceiveStream, EndBehaviorType } from '../AudioReceiveStream';
|
||||
|
||||
const DUMMY_BUFFER = Buffer.allocUnsafe(16);
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function stepSilence(stream: AudioReceiveStream, increment: number) {
|
||||
stream.push(SILENCE_FRAME);
|
||||
await wait(increment);
|
||||
expect(stream.readable).toBe(true);
|
||||
}
|
||||
|
||||
describe('AudioReceiveStream', () => {
|
||||
test('Manual end behavior', async () => {
|
||||
const stream = new AudioReceiveStream({ end: { behavior: EndBehaviorType.Manual } });
|
||||
stream.push(DUMMY_BUFFER);
|
||||
expect(stream.readable).toBe(true);
|
||||
await wait(200);
|
||||
stream.push(DUMMY_BUFFER);
|
||||
expect(stream.readable).toBe(true);
|
||||
});
|
||||
|
||||
// TODO: Fix this test
|
||||
// test('AfterSilence end behavior', async () => {
|
||||
// const duration = 100;
|
||||
// const increment = 20;
|
||||
|
||||
// const stream = new AudioReceiveStream({ end: { behavior: EndBehaviorType.AfterSilence, duration: 100 } });
|
||||
// stream.resume();
|
||||
|
||||
// for (let i = increment; i < duration / 2; i += increment) {
|
||||
// await stepSilence(stream, increment);
|
||||
// }
|
||||
|
||||
// stream.push(DUMMY_BUFFER);
|
||||
|
||||
// for (let i = increment; i < duration; i += increment) {
|
||||
// await stepSilence(stream, increment);
|
||||
// }
|
||||
|
||||
// await wait(increment);
|
||||
// expect(stream.readableEnded).toBe(true);
|
||||
// });
|
||||
|
||||
test('AfterInactivity end behavior', async () => {
|
||||
const duration = 100;
|
||||
const increment = 20;
|
||||
|
||||
const stream = new AudioReceiveStream({ end: { behavior: EndBehaviorType.AfterInactivity, duration: 100 } });
|
||||
stream.resume();
|
||||
|
||||
for (let i = increment; i < duration / 2; i += increment) {
|
||||
await stepSilence(stream, increment);
|
||||
}
|
||||
|
||||
stream.push(DUMMY_BUFFER);
|
||||
|
||||
for (let i = increment; i < duration; i += increment) {
|
||||
await stepSilence(stream, increment);
|
||||
}
|
||||
|
||||
await wait(increment);
|
||||
expect(stream.readableEnded).toBe(false);
|
||||
|
||||
await wait(duration - increment);
|
||||
|
||||
expect(stream.readableEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import EventEmitter, { once } from 'node:events';
|
||||
import { SSRCMap, VoiceUserData } from '../SSRCMap';
|
||||
|
||||
function onceOrThrow<T extends EventEmitter>(target: T, event: string, after: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
target.on(event, resolve);
|
||||
setTimeout(() => reject(new Error('Time up')), after);
|
||||
});
|
||||
}
|
||||
|
||||
describe('SSRCMap', () => {
|
||||
test('update persists data and emits correctly', async () => {
|
||||
const fixture1: VoiceUserData = {
|
||||
audioSSRC: 1,
|
||||
userId: '123',
|
||||
};
|
||||
|
||||
const fixture2: VoiceUserData = {
|
||||
...fixture1,
|
||||
videoSSRC: 2,
|
||||
};
|
||||
|
||||
const map = new SSRCMap();
|
||||
process.nextTick(() => map.update(fixture1));
|
||||
let [oldData, newData] = await once(map, 'update');
|
||||
expect(oldData).toBeUndefined();
|
||||
expect(newData).toMatchObject(fixture1);
|
||||
expect(map.get(fixture1.audioSSRC)).toMatchObject(fixture1);
|
||||
|
||||
process.nextTick(() => map.update(fixture2));
|
||||
[oldData, newData] = await once(map, 'update');
|
||||
expect(oldData).toMatchObject(fixture1);
|
||||
expect(newData).toMatchObject(fixture2);
|
||||
expect(map.get(fixture1.userId)).toMatchObject(fixture2);
|
||||
});
|
||||
|
||||
test('delete removes data and emits correctly', async () => {
|
||||
const fixture1: VoiceUserData = {
|
||||
audioSSRC: 1,
|
||||
userId: '123',
|
||||
};
|
||||
const map = new SSRCMap();
|
||||
|
||||
map.delete(fixture1.audioSSRC);
|
||||
await expect(onceOrThrow(map, 'delete', 5)).rejects.toThrow();
|
||||
|
||||
map.update(fixture1);
|
||||
process.nextTick(() => map.delete(fixture1.audioSSRC));
|
||||
await expect(once(map, 'delete')).resolves.toMatchObject([fixture1]);
|
||||
|
||||
map.delete(fixture1.audioSSRC);
|
||||
await expect(onceOrThrow(map, 'delete', 5)).rejects.toThrow();
|
||||
|
||||
map.update(fixture1);
|
||||
process.nextTick(() => map.delete(fixture1.userId));
|
||||
await expect(once(map, 'delete')).resolves.toMatchObject([fixture1]);
|
||||
expect(map.get(fixture1.audioSSRC)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { noop } from '../../util/util';
|
||||
import { SpeakingMap } from '../SpeakingMap';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('SpeakingMap', () => {
|
||||
test('Emits start and end', () => {
|
||||
const speaking = new SpeakingMap();
|
||||
const userId = '123';
|
||||
|
||||
const starts: string[] = [];
|
||||
const ends: string[] = [];
|
||||
|
||||
speaking.on('start', (userId) => void starts.push(userId));
|
||||
speaking.on('end', (userId) => void ends.push(userId));
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
speaking.onPacket(userId);
|
||||
setTimeout(noop, SpeakingMap.DELAY / 2);
|
||||
jest.advanceTimersToNextTimer();
|
||||
|
||||
expect(starts).toEqual([userId]);
|
||||
expect(ends).toEqual([]);
|
||||
}
|
||||
jest.advanceTimersToNextTimer();
|
||||
expect(ends).toEqual([userId]);
|
||||
|
||||
speaking.onPacket(userId);
|
||||
jest.advanceTimersToNextTimer();
|
||||
expect(starts).toEqual([userId, userId]);
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/dot-notation */
|
||||
import { VoiceReceiver } from '../VoiceReceiver';
|
||||
import { VoiceConnection as _VoiceConnection, VoiceConnectionStatus } from '../../VoiceConnection';
|
||||
import { RTP_PACKET_DESKTOP, RTP_PACKET_CHROME, RTP_PACKET_ANDROID } from './fixtures/rtp';
|
||||
import { once } from 'node:events';
|
||||
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
|
||||
import { methods } from '../../util/Secretbox';
|
||||
|
||||
jest.mock('../../VoiceConnection');
|
||||
jest.mock('../SSRCMap');
|
||||
|
||||
const openSpy = jest.spyOn(methods, 'open');
|
||||
|
||||
openSpy.mockImplementation((buffer) => buffer);
|
||||
|
||||
const VoiceConnection = _VoiceConnection as unknown as jest.Mocked<typeof _VoiceConnection>;
|
||||
|
||||
function nextTick() {
|
||||
return new Promise((resolve) => process.nextTick(resolve));
|
||||
}
|
||||
|
||||
function* rangeIter(start: number, end: number) {
|
||||
for (let i = start; i <= end; i++) {
|
||||
yield i;
|
||||
}
|
||||
}
|
||||
|
||||
function range(start: number, end: number) {
|
||||
return Buffer.from([...rangeIter(start, end)]);
|
||||
}
|
||||
|
||||
describe('VoiceReceiver', () => {
|
||||
let voiceConnection: _VoiceConnection;
|
||||
let receiver: VoiceReceiver;
|
||||
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
voiceConnection = new VoiceConnection({} as any, {} as any);
|
||||
voiceConnection.state = {
|
||||
status: VoiceConnectionStatus.Signalling,
|
||||
} as any;
|
||||
receiver = new VoiceReceiver(voiceConnection);
|
||||
receiver['connectionData'] = {
|
||||
encryptionMode: 'dummy',
|
||||
nonceBuffer: Buffer.alloc(0),
|
||||
secretKey: Buffer.alloc(0),
|
||||
};
|
||||
});
|
||||
|
||||
test.each([
|
||||
['RTP Packet Desktop', RTP_PACKET_DESKTOP],
|
||||
['RTP Packet Chrome', RTP_PACKET_CHROME],
|
||||
['RTP Packet Android', RTP_PACKET_ANDROID],
|
||||
])('onUdpMessage: %s', async (testName, RTP_PACKET) => {
|
||||
receiver['decrypt'] = jest.fn().mockImplementationOnce(() => RTP_PACKET.decrypted);
|
||||
|
||||
const spy = jest.spyOn(receiver.ssrcMap, 'get');
|
||||
spy.mockImplementation(() => ({
|
||||
audioSSRC: RTP_PACKET.ssrc,
|
||||
userId: '123',
|
||||
}));
|
||||
|
||||
const stream = receiver.subscribe('123');
|
||||
|
||||
receiver['onUdpMessage'](RTP_PACKET.packet);
|
||||
await nextTick();
|
||||
expect(stream.read()).toEqual(RTP_PACKET.opusFrame);
|
||||
});
|
||||
|
||||
test('onUdpMessage: <8 bytes packet', () => {
|
||||
expect(() => receiver['onUdpMessage'](Buffer.alloc(4))).not.toThrow();
|
||||
});
|
||||
|
||||
test('onUdpMessage: destroys stream on decrypt failure', async () => {
|
||||
receiver['decrypt'] = jest.fn().mockImplementationOnce(() => null);
|
||||
|
||||
const spy = jest.spyOn(receiver.ssrcMap, 'get');
|
||||
spy.mockImplementation(() => ({
|
||||
audioSSRC: RTP_PACKET_DESKTOP.ssrc,
|
||||
userId: '123',
|
||||
}));
|
||||
|
||||
const stream = receiver.subscribe('123');
|
||||
|
||||
const errorEvent = once(stream, 'error');
|
||||
|
||||
receiver['onUdpMessage'](RTP_PACKET_DESKTOP.packet);
|
||||
await nextTick();
|
||||
await expect(errorEvent).resolves.toMatchObject([expect.any(Error)]);
|
||||
expect(receiver.subscriptions.size).toBe(0);
|
||||
});
|
||||
|
||||
test('subscribe: only allows one subscribe stream per SSRC', () => {
|
||||
const spy = jest.spyOn(receiver.ssrcMap, 'get');
|
||||
spy.mockImplementation(() => ({
|
||||
audioSSRC: RTP_PACKET_DESKTOP.ssrc,
|
||||
userId: '123',
|
||||
}));
|
||||
|
||||
const stream = receiver.subscribe('123');
|
||||
expect(receiver.subscribe('123')).toBe(stream);
|
||||
});
|
||||
|
||||
describe('onWsPacket', () => {
|
||||
test('CLIENT_DISCONNECT packet', () => {
|
||||
const spy = jest.spyOn(receiver.ssrcMap, 'delete');
|
||||
receiver['onWsPacket']({
|
||||
op: VoiceOpcodes.ClientDisconnect,
|
||||
d: {
|
||||
user_id: '123abc',
|
||||
},
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith('123abc');
|
||||
});
|
||||
|
||||
test('SPEAKING packet', () => {
|
||||
const spy = jest.spyOn(receiver.ssrcMap, 'update');
|
||||
receiver['onWsPacket']({
|
||||
op: VoiceOpcodes.Speaking,
|
||||
d: {
|
||||
ssrc: 123,
|
||||
user_id: '123abc',
|
||||
speaking: 1,
|
||||
},
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
audioSSRC: 123,
|
||||
userId: '123abc',
|
||||
});
|
||||
});
|
||||
|
||||
test('CLIENT_CONNECT packet', () => {
|
||||
const spy = jest.spyOn(receiver.ssrcMap, 'update');
|
||||
receiver['onWsPacket']({
|
||||
op: VoiceOpcodes.ClientConnect,
|
||||
d: {
|
||||
audio_ssrc: 123,
|
||||
video_ssrc: 43,
|
||||
user_id: '123abc',
|
||||
},
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
audioSSRC: 123,
|
||||
videoSSRC: 43,
|
||||
userId: '123abc',
|
||||
});
|
||||
receiver['onWsPacket']({
|
||||
op: VoiceOpcodes.ClientConnect,
|
||||
d: {
|
||||
audio_ssrc: 123,
|
||||
video_ssrc: 0,
|
||||
user_id: '123abc',
|
||||
},
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
audioSSRC: 123,
|
||||
videoSSRC: undefined,
|
||||
userId: '123abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt', () => {
|
||||
const secretKey = new Uint8Array([1, 2, 3, 4]);
|
||||
|
||||
beforeEach(() => {
|
||||
openSpy.mockClear();
|
||||
});
|
||||
|
||||
test('decrypt: xsalsa20_poly1305_lite', () => {
|
||||
// Arrange
|
||||
const buffer = range(1, 32);
|
||||
const nonce = Buffer.alloc(4);
|
||||
|
||||
// Act
|
||||
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305_lite', nonce, secretKey);
|
||||
|
||||
// Assert
|
||||
expect(nonce.equals(range(29, 32))).toBe(true);
|
||||
expect(decrypted.equals(range(13, 28))).toBe(true);
|
||||
});
|
||||
|
||||
test('decrypt: xsalsa20_poly1305_suffix', () => {
|
||||
// Arrange
|
||||
const buffer = range(1, 64);
|
||||
const nonce = Buffer.alloc(24);
|
||||
|
||||
// Act
|
||||
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305_suffix', nonce, secretKey);
|
||||
|
||||
// Assert
|
||||
expect(nonce.equals(range(41, 64))).toBe(true);
|
||||
expect(decrypted.equals(range(13, 40))).toBe(true);
|
||||
});
|
||||
|
||||
test('decrypt: xsalsa20_poly1305', () => {
|
||||
// Arrange
|
||||
const buffer = range(1, 64);
|
||||
const nonce = Buffer.alloc(12);
|
||||
|
||||
// Act
|
||||
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305', nonce, secretKey);
|
||||
|
||||
// Assert
|
||||
expect(nonce.equals(range(1, 12))).toBe(true);
|
||||
expect(decrypted.equals(range(13, 64))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
export const RTP_PACKET_DESKTOP = {
|
||||
ssrc: 341124,
|
||||
packet: Buffer.from([
|
||||
0x90, 0x78, 0x27, 0xe9, 0xf7, 0xcb, 0xbc, 0xd1, 0x0, 0x5, 0x34, 0x84, 0x8a, 0xbb, 0xe2, 0x97, 0x21, 0x9f, 0x1f,
|
||||
0x67, 0xcd, 0x17, 0x91, 0x56, 0x43, 0xa0, 0x98, 0xfd, 0xa9, 0x25, 0x81, 0x63, 0x13, 0xb4, 0x1e, 0xae, 0x88, 0xe4,
|
||||
0x0, 0xed, 0x0, 0x0, 0x0,
|
||||
]),
|
||||
decrypted: Buffer.from([0xbe, 0xde, 0x0, 0x1, 0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
|
||||
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
|
||||
};
|
||||
|
||||
export const RTP_PACKET_CHROME = {
|
||||
ssrc: 172360,
|
||||
packet: Buffer.from([
|
||||
0x80, 0x78, 0x46, 0xdf, 0x27, 0x59, 0x2a, 0xd7, 0x0, 0x2, 0xa1, 0x48, 0x42, 0x9e, 0x53, 0xec, 0x73, 0xc1, 0x71,
|
||||
0x22, 0x71, 0x60, 0x90, 0xff, 0x1b, 0x20, 0x47, 0x2c, 0xdc, 0x86, 0xc4, 0x9a, 0x0, 0x0, 0x0,
|
||||
]),
|
||||
decrypted: Buffer.from([0xf8, 0xff, 0xfe]),
|
||||
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
|
||||
};
|
||||
|
||||
export const RTP_PACKET_ANDROID = {
|
||||
ssrc: 172596,
|
||||
packet: Buffer.from([
|
||||
0x90, 0x78, 0x39, 0xd0, 0xe0, 0x59, 0xf5, 0x47, 0x0, 0x2, 0xa2, 0x34, 0x12, 0x6d, 0x87, 0x56, 0x25, 0xc8, 0x3e,
|
||||
0x96, 0xc0, 0x71, 0x9a, 0x1, 0x83, 0xe, 0x1, 0x62, 0x91, 0x95, 0x1f, 0x76, 0x57, 0x15, 0x41, 0xab, 0xee, 0x5b, 0xac,
|
||||
0x8b, 0x0, 0x0, 0x0,
|
||||
]),
|
||||
decrypted: Buffer.from([0xbe, 0xde, 0x0, 0x1, 0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
|
||||
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { methods } from '../Secretbox';
|
||||
|
||||
jest.mock('tweetnacl');
|
||||
|
||||
test('Does not throw error with a package installed', () => {
|
||||
// @ts-expect-error
|
||||
expect(() => methods.open()).not.toThrowError();
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { abortAfter } from '../abortAfter';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
|
||||
|
||||
describe('abortAfter', () => {
|
||||
test('Aborts after the given delay', () => {
|
||||
const [ac, signal] = abortAfter(100);
|
||||
expect(ac.signal).toBe(signal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
jest.runAllTimers();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test('Cleans up when manually aborted', () => {
|
||||
const [ac, signal] = abortAfter(100);
|
||||
expect(ac.signal).toBe(signal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
clearTimeoutSpy.mockClear();
|
||||
ac.abort();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { demuxProbe } from '../demuxProbe';
|
||||
import { opus as _opus } from 'prism-media';
|
||||
import { Readable } from 'node:stream';
|
||||
import { StreamType } from '../../audio';
|
||||
import EventEmitter, { once } from 'node:events';
|
||||
|
||||
jest.mock('prism-media');
|
||||
|
||||
const WebmDemuxer = _opus.WebmDemuxer as unknown as jest.Mock<_opus.WebmDemuxer>;
|
||||
const OggDemuxer = _opus.OggDemuxer as unknown as jest.Mock<_opus.OggDemuxer>;
|
||||
|
||||
function nextTick() {
|
||||
return new Promise((resolve) => process.nextTick(resolve));
|
||||
}
|
||||
|
||||
async function* gen(n: number) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
yield Buffer.from([i]);
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
function range(n: number) {
|
||||
return Buffer.from(Array.from(Array(n).keys()));
|
||||
}
|
||||
|
||||
const validHead = Buffer.from([
|
||||
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x02, 0x38, 0x01, 0x80, 0xbb, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
|
||||
const invalidHead = Buffer.from([
|
||||
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xbb, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
|
||||
async function collectStream(stream: Readable): Promise<Buffer> {
|
||||
let output = Buffer.alloc(0);
|
||||
await once(stream, 'readable');
|
||||
for await (const data of stream) {
|
||||
output = Buffer.concat([output, data]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
describe('demuxProbe', () => {
|
||||
const webmWrite: jest.Mock<(buffer: Buffer) => void> = jest.fn();
|
||||
const oggWrite: jest.Mock<(buffer: Buffer) => void> = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
WebmDemuxer.prototype = {
|
||||
...WebmDemuxer,
|
||||
...EventEmitter.prototype,
|
||||
write: webmWrite,
|
||||
};
|
||||
OggDemuxer.prototype = {
|
||||
...OggDemuxer,
|
||||
...EventEmitter.prototype,
|
||||
write: oggWrite,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
webmWrite.mockReset();
|
||||
oggWrite.mockReset();
|
||||
});
|
||||
|
||||
test('Defaults to arbitrary', async () => {
|
||||
const stream = Readable.from(gen(10), { objectMode: false });
|
||||
const probe = await demuxProbe(stream);
|
||||
expect(probe.type).toBe(StreamType.Arbitrary);
|
||||
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
|
||||
});
|
||||
|
||||
test('Detects WebM', async () => {
|
||||
const stream = Readable.from(gen(10), { objectMode: false });
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
webmWrite.mockImplementation(function mock(data: Buffer) {
|
||||
if (data[0] === 5) this.emit('head', validHead);
|
||||
} as any);
|
||||
const probe = await demuxProbe(stream);
|
||||
expect(probe.type).toBe(StreamType.WebmOpus);
|
||||
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
|
||||
});
|
||||
|
||||
test('Detects Ogg', async () => {
|
||||
const stream = Readable.from(gen(10), { objectMode: false });
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
oggWrite.mockImplementation(function mock(data: Buffer) {
|
||||
if (data[0] === 5) this.emit('head', validHead);
|
||||
} as any);
|
||||
const probe = await demuxProbe(stream);
|
||||
expect(probe.type).toBe(StreamType.OggOpus);
|
||||
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
|
||||
});
|
||||
|
||||
test('Rejects invalid OpusHead', async () => {
|
||||
const stream = Readable.from(gen(10), { objectMode: false });
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
oggWrite.mockImplementation(function mock(data: Buffer) {
|
||||
if (data[0] === 5) this.emit('head', invalidHead);
|
||||
} as any);
|
||||
const probe = await demuxProbe(stream);
|
||||
expect(probe.type).toBe(StreamType.Arbitrary);
|
||||
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
|
||||
});
|
||||
|
||||
test('Gives up on larger streams', async () => {
|
||||
const stream = Readable.from(gen(8192), { objectMode: false });
|
||||
const probe = await demuxProbe(stream);
|
||||
expect(probe.type).toBe(StreamType.Arbitrary);
|
||||
await expect(collectStream(probe.stream)).resolves.toEqual(range(8192));
|
||||
});
|
||||
|
||||
test('Propagates errors', async () => {
|
||||
const testError = new Error('test error');
|
||||
const stream = new Readable({
|
||||
read() {
|
||||
this.destroy(testError);
|
||||
},
|
||||
});
|
||||
await expect(demuxProbe(stream)).rejects.toBe(testError);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import EventEmitter from 'node:events';
|
||||
import { VoiceConnection, VoiceConnectionStatus } from '../../VoiceConnection';
|
||||
import { entersState } from '../entersState';
|
||||
|
||||
function createFakeVoiceConnection(status = VoiceConnectionStatus.Signalling) {
|
||||
const vc = new EventEmitter() as any;
|
||||
vc.state = { status };
|
||||
return vc as VoiceConnection;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
describe('entersState', () => {
|
||||
test('Returns the target once the state has been entered before timeout', async () => {
|
||||
jest.useRealTimers();
|
||||
const vc = createFakeVoiceConnection();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
process.nextTick(() => vc.emit(VoiceConnectionStatus.Ready, null as any, null as any));
|
||||
const result = await entersState(vc, VoiceConnectionStatus.Ready, 1000);
|
||||
expect(result).toBe(vc);
|
||||
});
|
||||
|
||||
test('Rejects once the timeout is exceeded', async () => {
|
||||
const vc = createFakeVoiceConnection();
|
||||
const promise = entersState(vc, VoiceConnectionStatus.Ready, 1000);
|
||||
jest.runAllTimers();
|
||||
await expect(promise).rejects.toThrowError();
|
||||
});
|
||||
|
||||
test('Returns the target once the state has been entered before signal is aborted', async () => {
|
||||
jest.useRealTimers();
|
||||
const vc = createFakeVoiceConnection();
|
||||
const ac = new AbortController();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
process.nextTick(() => vc.emit(VoiceConnectionStatus.Ready, null as any, null as any));
|
||||
const result = await entersState(vc, VoiceConnectionStatus.Ready, ac.signal);
|
||||
expect(result).toBe(vc);
|
||||
});
|
||||
|
||||
test('Rejects once the signal is aborted', async () => {
|
||||
const vc = createFakeVoiceConnection();
|
||||
const ac = new AbortController();
|
||||
const promise = entersState(vc, VoiceConnectionStatus.Ready, ac.signal);
|
||||
ac.abort();
|
||||
await expect(promise).rejects.toThrowError();
|
||||
});
|
||||
|
||||
test('Resolves immediately when target already in desired state', async () => {
|
||||
const vc = createFakeVoiceConnection();
|
||||
await expect(entersState(vc, VoiceConnectionStatus.Signalling, 1000)).resolves.toBe(vc);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user