Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

feat(Util): support AbortSignal in entersState #171

Merged
merged 5 commits into from
Aug 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ jobs:
docs:
strategy:
matrix:
node: ['14', '16']
node: ['16']
name: Documentation (Node v${{ matrix.node }})
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ jobs:
test:
strategy:
matrix:
node: ['14', '16']
node: ['16']
name: ESLint (Node v${{ matrix.node }})
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ jobs:
test:
strategy:
matrix:
node: ['14', '16']
node: ['16']
name: Test (Node v${{ matrix.node }})
runs-on: ubuntu-latest
steps:
Expand Down
17 changes: 10 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"files": [
"dist/*"
],
"engines": {
"node": ">=16.0.0"
},
"dependencies": {
"@types/ws": "^7.4.4",
"discord-api-types": "^0.22.0",
Expand All @@ -53,7 +56,7 @@
"@commitlint/config-angular": "^12.1.4",
"@favware/rollup-type-bundler": "^1.0.2",
"@types/jest": "^26.0.23",
"@types/node": "^15.12.2",
"@types/node": "^16.4.13",
"@typescript-eslint/eslint-plugin": "^4.26.1",
"@typescript-eslint/parser": "^4.26.1",
"babel-jest": "^27.0.2",
Expand Down
24 changes: 24 additions & 0 deletions src/util/__tests__/abortAfter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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);
});
});
52 changes: 52 additions & 0 deletions src/util/__tests__/entersState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import EventEmitter from '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();
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();
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);
});
});
10 changes: 10 additions & 0 deletions src/util/abortAfter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Creates an abort controller that aborts after the given time.
* @param delay - The time in milliseconds to wait before aborting
*/
export function abortAfter(delay: number): [AbortController, AbortSignal] {
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), delay);
ac.signal.addEventListener('abort', () => clearTimeout(timeout));
return [ac, ac.signal];
}
49 changes: 22 additions & 27 deletions src/util/entersState.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,54 @@
import { VoiceConnection, VoiceConnectionStatus } from '../VoiceConnection';
import { AudioPlayer, AudioPlayerStatus } from '../audio/AudioPlayer';
import { abortAfter } from './abortAfter';
import EventEmitter, { once } from 'events';

/**
* Allows a voice connection a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The voice connection that we want to observe the state change for
* @param status - The status that the voice connection should be in
* @param maxTime - The maximum time we are allowing for this to occur
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export function entersState(
target: VoiceConnection,
status: VoiceConnectionStatus,
maxTime: number,
timeoutOrSignal: number | AbortSignal,
): Promise<VoiceConnection>;

/**
* Allows an audio player a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The audio player that we want to observe the state change for
* @param status - The status that the audio player should be in
* @param maxTime - The maximum time we are allowing for this to occur
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export function entersState(target: AudioPlayer, status: AudioPlayerStatus, maxTime: number): Promise<AudioPlayer>;
export function entersState(
target: AudioPlayer,
status: AudioPlayerStatus,
timeoutOrSignal: number | AbortSignal,
): Promise<AudioPlayer>;

/**
* Allows a target a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The object that we want to observe the state change for
* @param status - The status that the target should be in
* @param maxTime - The maximum time we are allowing for this to occur
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export function entersState<T extends VoiceConnection | AudioPlayer>(
export async function entersState<T extends VoiceConnection | AudioPlayer>(
target: T,
status: VoiceConnectionStatus | AudioPlayerStatus,
maxTime: number,
timeoutOrSignal: number | AbortSignal,
) {
if (target.state.status === status) {
return Promise.resolve(target);
if (target.state.status !== status) {
const [ac, signal] =
typeof timeoutOrSignal === 'number' ? abortAfter(timeoutOrSignal) : [undefined, timeoutOrSignal];
try {
await once(target as EventEmitter, status, { signal });
} finally {
ac?.abort();
amishshah marked this conversation as resolved.
Show resolved Hide resolved
}
}
let cleanup: () => void;
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Did not enter state ${status as string} within ${maxTime}ms`)),
maxTime,
);

(target as any).once(status as any, resolve);
(target as any).once('error', reject);

cleanup = () => {
clearTimeout(timeout);
(target as any).off(status as any, resolve);
(target as any).off('error', reject);
};
})
.then(() => target)
.finally(cleanup!);
return target;
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"alwaysStrict": true,
"pretty": true,
"target": "es2019",
"lib": ["ESNext"],
"lib": ["ESNext", "DOM"],
"sourceMap": true,
"inlineSources": true,
"module": "commonjs",
Expand Down