test(voice): fix tests

This commit is contained in:
iCrawl
2022-01-11 21:53:08 +01:00
parent db25f529b2
commit 62c74b8333
19 changed files with 44 additions and 206 deletions

View File

@@ -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);
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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]);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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]),
};

View File

@@ -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();
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});