From 4b38900728bafe541373ba1c75df392118bba2f2 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Thu, 4 Aug 2022 20:01:24 +0100 Subject: [PATCH 1/8] Add preliminary ping support --- package-lock.json | 14 ++-- package.json | 7 +- src/aggregate-signal.ts | 36 ++++++++ src/procedure.ts | 175 ++++++++++++++++++++++++++++++--------- src/timeout-signal.ts | 12 ++- src/utils.ts | 46 ++++++++-- test/aggregate-signal.ts | 69 +++++++++++++++ test/procedure.ts | 4 +- test/timeout-signal.ts | 4 +- test/utils.ts | 9 +- 10 files changed, 310 insertions(+), 66 deletions(-) create mode 100644 src/aggregate-signal.ts create mode 100644 test/aggregate-signal.ts diff --git a/package-lock.json b/package-lock.json index 531af35..569e730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@types/chai-as-promised": "^7.1.5", "@types/chai-spies": "^1.0.3", "@types/mocha": "^9.1.1", - "@types/node": "^18.0.3", + "@types/node": "^18.6.3", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", @@ -789,9 +789,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.3.tgz", - "integrity": "sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ==", + "version": "18.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.6.3.tgz", + "integrity": "sha512-6qKpDtoaYLM+5+AFChLhHermMQxc3TOEFIDzrZLPRGHPrLEwqFkkT5Kx3ju05g6X7uDPazz3jHbKPX0KzCjntg==", "dev": true }, "node_modules/@types/uuid": { @@ -4594,9 +4594,9 @@ "dev": true }, "@types/node": { - "version": "18.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.3.tgz", - "integrity": "sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ==", + "version": "18.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.6.3.tgz", + "integrity": "sha512-6qKpDtoaYLM+5+AFChLhHermMQxc3TOEFIDzrZLPRGHPrLEwqFkkT5Kx3ju05g6X7uDPazz3jHbKPX0KzCjntg==", "dev": true }, "@types/uuid": { diff --git a/package.json b/package.json index 0e30f09..f3dda23 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "@toebeann/procedure.js", - "version": "0.1.0", + "version": "0.2.0", "description": "A simple RPC framework, built on top of nanomsg and msgpack.", "main": "./dist/index", "types": "./types/index.d.ts", "author": "Tobey Blaber (https://github.com/toebeann)", + "homepage": "https://github.com/toebeann/procedure.js", "repository": "github:toebeann/procedure.js", "funding": "https://paypal.me/tobeyblaber", "license": "UNLICENSED", @@ -13,7 +14,7 @@ "build": "tsc", "lint": "eslint . --ext .ts", "test": "mocha -r ts-node/register -r source-map-support/register ./test/**/*.ts", - "test:coverage": "nyc npm run test" + "test:coverage": "npm run build & nyc npm run test" }, "dependencies": { "@msgpack/msgpack": "^2.7.2", @@ -28,7 +29,7 @@ "@types/chai-as-promised": "^7.1.5", "@types/chai-spies": "^1.0.3", "@types/mocha": "^9.1.1", - "@types/node": "^18.0.3", + "@types/node": "^18.6.3", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", diff --git a/src/aggregate-signal.ts b/src/aggregate-signal.ts new file mode 100644 index 0000000..808a31d --- /dev/null +++ b/src/aggregate-signal.ts @@ -0,0 +1,36 @@ +import { isSignal } from './utils'; + +/** + * A helper class to create an AbortSignal which aborts as soon as any of the signals passed to its constructor do. + */ +export default class AggregateSignal { + /** The aggregate AbortSignal. */ + public readonly signal?: AbortSignal; + + /** + * Initializes a new AggregateSignal. + * @param {(AbortSignal | undefined)[]} abortSignals The AbortSignals to aggregate. + */ + constructor(...abortSignals: (AbortSignal | undefined)[]) { + const signals = abortSignals.filter(isSignal); + + if (signals.length === 1) { + this.signal = signals[0]; + } else if (signals.filter(s => s.aborted).length > 0) { + this.signal = signals.filter(s => s.aborted)[0]; + } else if (signals.length > 1) { + const ac = new AbortController(); + this.signal = ac.signal; + + for (const signal of signals) { + signal.addEventListener('abort', () => { + for (const signal of signals) { + signal.removeEventListener('abort'); + } + + ac.abort(); + }); + } + } + } +} diff --git a/src/procedure.ts b/src/procedure.ts index bfe3e18..8e2c9e9 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -1,13 +1,21 @@ /// +import { Ping, isPing, isErrorLike, cloneError } from './utils'; +import AggregateSignal from './aggregate-signal'; +import TimeoutSignal from './timeout-signal' import { createSocket, Socket } from 'nanomsg'; import { encode, decode, ExtensionCodec } from '@msgpack/msgpack' import { once, EventEmitter } from 'events' import TypedEmitter from 'typed-emitter' -import TimeoutSignal from './timeout-signal' -import { isErrorLike, cloneError } from './utils' +import { v5 as uuidv5 } from 'uuid'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const homepage: string = require('../package.json').funding; +const uuidNamespace = uuidv5(homepage, uuidv5.URL); /** - * A low level abstraction over Procedures (the P in RPC). + * A simple abstraction of a procedure (the P in RPC). + * Allows you to turn any generic function or callback into a procedure, which remote or local processes can call. + * Includes the functionality to ping procedures to check whether they are available. */ export default class Procedure extends (EventEmitter as new () => TypedEmitter) implements ProcedureOptions { [key: keyof ProcedureOptions]: ProcedureOptions[keyof ProcedureOptions]; @@ -17,6 +25,9 @@ export default class Procedure(endpoint: string, input: Input | null = null, options: Partial = {}): Promise { const socket = createSocket('req'); const opts: ProcedureCallOptions = { - ...{ timeout: 1000 }, + ...{ timeout: 1000, ping: false }, ...options }; - let timeoutSignal: TimeoutSignal | undefined = undefined; - if (!('signal' in opts)) { - timeoutSignal = new TimeoutSignal(opts.timeout); - opts.signal = timeoutSignal.signal; + const timeoutSignal = new TimeoutSignal(opts.timeout); + const signal = new AggregateSignal(timeoutSignal.signal, opts.signal).signal; + + if (signal?.aborted) { + throw new Error('signal was aborted'); + } else { + try { + if (opts.ping && !await Procedure.ping(endpoint, opts.ping, signal)) { + throw new Error(`ping returned false at endpoint: ${endpoint}`); + } + + socket.connect(endpoint); + socket.send(Procedure.#encode(input, opts.extensionCodec)); // send the encoded input data to the endpoint + + const [buffer]: [Buffer] = await once(socket, 'data', { signal }) as [Buffer]; // await a response from the endpoint + const response = Procedure.#decode>(buffer, opts.extensionCodec); // decode the response + if ('error' in response) { + throw response.error; + } else if (response.output !== undefined) { + return response.output; + } else { + throw new RangeError("response is not of valid shape"); + } + } finally { + socket.removeAllListeners().close(); // clear all listeners and close the socket + clearTimeout(timeoutSignal?.timeout); // clear the TimeoutSignal's timeout, if any + } } + } - try { - socket.connect(endpoint); - socket.send(Procedure.#encode(input, opts.extensionCodec)); // send the encoded input data to the endpoint - - const [buffer]: [Buffer] = await once(socket, 'data', { signal: opts.signal }) as [Buffer]; // await a response from the endpoint - const response = Procedure.#decode>(buffer, opts.extensionCodec); // decode the response - if ('error' in response) { - throw response.error; - } else { - return response.output; + /** + * Asynchonously pings a Procedure at a given endpoint to check that it is ready to respond to requests. + * @param {string} endpoint The endpoint to ping at which a Procedure is expected to be bound. + * @param {number | undefined} [timeout=100] How long to wait for a response before timing out. + * NaN, undefined or infinite values will result in the ping never timing out if no response is received, unless + * `signal` is a valid `AbortSignal` and gets aborted. + * Non-NaN, finite values will be clamped to between `0` and `Number.MAX_SAFE_INTEGER`. + * Defaults to `100`. + * @param {AbortSignal | undefined} [signal=undefined] An optional AbortSignal which, when passed, will be used to abort awaiting the ping. + * Defaults to `undefined`. + * @returns {Promise} A promise which, when resolved, indicates whether the endpoint successfully responded to the ping. + */ + static async ping(endpoint: string, timeout: number | undefined = 100, signal?: AbortSignal): Promise { + const socket = createSocket('req'); + + const timeoutSignal = new TimeoutSignal(timeout); + signal = new AggregateSignal(signal, timeoutSignal.signal).signal; + + if (signal?.aborted) { + throw new Error('signal was aborted'); + } else { + try { + socket.connect(endpoint); + + socket.send(Procedure.#encode({ ping: uuidv5(endpoint, uuidNamespace) })); + const [buffer]: [Buffer] = await once(socket, 'data', { signal }) as [Buffer]; + const pong = Procedure.#decode>(buffer); + + if ('pong' in pong && pong.pong !== undefined) { + return pong.pong; + } else if ('error' in pong) { + throw pong.error; + } else { + throw pong; + } + } finally { + socket.removeAllListeners().close(); + clearTimeout(timeoutSignal.timeout); } - } finally { - socket.removeAllListeners().close(); // clear all listeners and close the socket - clearTimeout(timeoutSignal?.timeout); // clear the TimeoutSignal's timeout, if any } } @@ -146,9 +208,9 @@ export default class Procedure(buffer, this.extensionCodec) }; + return { input: Procedure.#decode(buffer, this.extensionCodec) }; } catch (error) { this.#emitAndLogError('Procedure input data could not be decoded', error); return { error }; @@ -194,7 +256,7 @@ export default class Procedure { const { input, error } = this.#tryDecodeInput(data); - if (input !== undefined && this.verbose) { - console.log(`Received input data at endpoint: ${this.endpoint}`, input); - } + if (this.#tryHandlePing(input, socket)) { // input was a ping of valid uuid & pong was successfully sent + if (this.verbose) { + console.log(`PONG sent at endpoint ${this.endpoint}`); + } + } else { + if (input !== undefined && this.verbose) { + console.log(`Received input data at endpoint: ${this.endpoint}`, input); + } + + const response = input !== undefined + ? await this.#tryGetCallbackResponse(input as Input) + : { error }; - const response = input !== undefined - ? await this.#tryGetCallbackResponse(input) - : { error }; + if (response.output !== undefined && this.verbose) { + console.log(`Generated output data at endpoint: ${this.endpoint}`, response.output); + } - if (response.output !== undefined && this.verbose) { - console.log(`Generated output data at endpoint: ${this.endpoint}`, response.output); + if (this.#trySendBuffer(this.#tryEncodeResponse(response), socket) && this.verbose) { + console.log(`Response sent at endpoint ${this.endpoint}`, response); + } } + } - if (this.#trySendBuffer(this.#tryEncodeResponse(response), socket) && this.verbose) { - console.log(`Response sent at endpoint ${this.endpoint}`, response); + /** + * Handles ping requests for a given socket + * @param {unknown} object The decoded incoming data object. + * @param {Socket} socket The socket the data was received on. + * @returns {boolean} `true` when the decoded data object was a valid `Ping` and a `Pong` was successfully sent back, otherwise `false`. + */ + #tryHandlePing(object: unknown, socket: Socket): boolean { + if (isPing(object) && object.ping === this.uuid) { + if (this.verbose) { + console.log(`PING received at endpoint: ${this.endpoint}`); + } + return this.#trySendBuffer(this.#tryEncodeResponse({ pong: true }), socket); + } else { + return false; } } @@ -304,7 +389,10 @@ export type Callback = { output: Output, error?: never } | { output?: never, error: unknown }; +export type Response + = { output: Output, error?: never, pong?: never } + | { output?: never, error: unknown, pong?: never } + | { output?: never, error?: never, pong: true }; /** * Options for defining a Procedure. @@ -323,12 +411,21 @@ export interface ProcedureOptions { * Options for calling a Procedure. */ export interface ProcedureCallOptions { - /** The number of milliseconds after which the Procedure call will automatically be aborted. Ignored if the `signal` property is defined. Defaults to `1000`. */ + /** The number of milliseconds after which the Procedure call will automatically be aborted. + * Set to `Infinity` or `NaN` to never timeout. + * Non-NaN, finite values will be clamped between `0` and `Number.MAX_SAFE_INTEGER`. + * Defaults to `1000`. */ timeout: number; - /** An optional `AbortSignal` which, when signaled, will abort the Procedure call. If defined, the `timeout` property will be ignored. Defaults to `undefined`. */ - signal?: AbortSignal; - /** The msgpack `ExtensionCodec` to use for encoding and decoding messages. Defaults to `undefined`. */ + /** The number of millisceonds to wait for a ping-pong from the endpoint before calling the remote procedure. + * Set to `false` to skip pinging the endpoint. + * Defaults to `false`. */ + ping: number | false; + /** An optional msgpack `ExtensionCodec` to use for encoding and decoding messages. + * Defaults to `undefined`. */ extensionCodec?: ExtensionCodec; + /** An optional `AbortSignal` which will be used to abort the Procedure call. + * Defaults to `undefined`. */ + signal?: AbortSignal; } /** diff --git a/src/timeout-signal.ts b/src/timeout-signal.ts index a8c5644..889dce1 100644 --- a/src/timeout-signal.ts +++ b/src/timeout-signal.ts @@ -1,5 +1,5 @@ /** - * A helper class to either wrap a given AbortSignal or obtain one which will signal when a timeout is called. + * A helper class to create an AbortSignal based on a timeout. */ export default class TimeoutSignal { /** The underlying AbortSignal. */ @@ -9,11 +9,15 @@ export default class TimeoutSignal { /** * Initializes a new TimeoutSignal. - * @param {number} [timeout] When a non-NaN, finite and >=0 number is passed, constructs an AbortController and sets a - * timeout which will call the AbortController's `abort` method after the given number of milliseconds and wraps its signal. + * @param {number} [timeout] Constructs an AbortController and sets a timeout which will call the AbortController's `abort` + * method after the given number of milliseconds, exposing its signal via the `signal` property. + * Undefined, infinite or NaN values will result in the `signal` property being `undefined`. + * Finite values will be clamped between `0` and `Number.MAX_SAFE_INTEGER`. */ constructor(timeout?: number) { - if (timeout !== undefined && !isNaN(timeout) && isFinite(timeout) && timeout >= 0) { // number is not-NaN, finite and positive + if (timeout !== undefined && isFinite(timeout) && !isNaN(timeout)) { + timeout = Math.min(Math.max(timeout, 0), Number.MAX_SAFE_INTEGER); // clamp the timeout to a sensible range + const ac = new AbortController(); this.signal = ac.signal; // wrap the AbortController's signal this.timeout = setTimeout(() => ac.abort(), timeout); // abort after the given number of milliseconds diff --git a/src/utils.ts b/src/utils.ts index 6cfbbbf..1478cc5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,41 @@ +export interface Signal { + addEventListener: (event: 'abort', callback: () => void) => void; + removeEventListener: (event: 'abort') => void; + readonly aborted: boolean; +} + +// TODO: Write unit tests +export function isSignal(object: unknown): object is Signal { + return isAbortSignal(object) && 'addEventListener' in object && 'removeEventListener' in object + && typeof (object).addEventListener === 'function' && typeof (object).removeEventListener === 'function'; +} + +// TODO: Write units tests +export function isAbortSignal(object: unknown): object is AbortSignal { + return object instanceof AbortSignal; +} + +/** + * A simple Ping interface for internal use. + */ +export interface Ping { + ping: string; +} + +/** + * Type guard for determining whether a given object conforms to the `Ping` interface. + * @param {unknown} object The object. + * @returns {boolean} `true` if the object conforms to the `Ping` interface, otherwise `false`. + */ +// TODO: Write unit tests +export function isPing(object: unknown): object is Ping { + return typeof object === 'object' && object !== null && 'ping' in object && typeof (object as { ping: unknown }).ping === 'string'; +} + /** * Type guard for determining whether a given object is an `Error`. - * @param object The object. - * @returns `true` if the object is determined to be an `Error`, otherwise `false`. + * @param {unknown} object The object. + * @returns {boolean} `true` if the object is determined to be an `Error`, otherwise `false`. */ export function isError(object: unknown): object is Error { return object instanceof Error; @@ -9,8 +43,8 @@ export function isError(object: unknown): object is Error { /** * Type guard for determining whether a given object is `Error`-like, i.e. it matches the most basic `Error` interface. - * @param object The object. - * @returns `true` if the object is determined to fit the shape of an `Error`, otherwise `false`. + * @param {unknown} object The object. + * @returns {boolean} `true` if the object is determined to fit the shape of an `Error`, otherwise `false`. */ export function isErrorLike(object: unknown): object is Error { return typeof object === 'object' && object !== null && (isError(object) || 'name' in object); @@ -20,7 +54,7 @@ export function isErrorLike(object: unknown): object is Error { * A utility function for retrieving the properties of a given `Error` instance in `Object.entries` format. * @param {Error} error The error. * @param {boolean} [stack=false] Whether or not we should retrieve the `stack` property, if defined. Defaults to `false`. - * @returns An array of key-value pairs. + * @returns {Array} An array of key-value pairs. */ export function errorEntries(error: Error, stack = false): Array { return [ @@ -35,7 +69,7 @@ export function errorEntries(error: Error, stack = false): Array { * A utility function for cloning `Error` instances. * @param {Error} error The `Error` to clone. * @param {boolean} [stack=false] Whether or not we should clone the `stack` property, if defined. Defaults to `false`. - * @returns The cloned `Error`. + * @returns {Error} The cloned `Error`. */ export function cloneError(error: Error, stack = false): Error { if (isErrorLike(error)) { diff --git a/test/aggregate-signal.ts b/test/aggregate-signal.ts new file mode 100644 index 0000000..22518db --- /dev/null +++ b/test/aggregate-signal.ts @@ -0,0 +1,69 @@ +import 'mocha'; +import chai, { expect } from 'chai'; +import spies from 'chai-spies'; +import AggregateSignal from '../src/aggregate-signal'; +import { Signal, isSignal } from '../src/utils'; +import TimeoutSignal from '../src/timeout-signal'; + +chai.use(spies); + +describe('AggregateSignal', () => { + context('when no signals passed', () => { + const instance = new AggregateSignal(); + describe('signal', () => it('should be: undefined', () => + expect(instance.signal).to.be.undefined)); + }); + + context('when only undefined values are passed', () => { + const instance = new AggregateSignal(undefined, undefined, undefined); + describe('signal', () => it('should be: undefined', () => + expect(instance.signal).to.be.undefined)); + }); + + context('when one valid AbortSignal is passed', () => { + const ac = new AbortController(); + const instance = new AggregateSignal(ac.signal); + + describe('signal', () => { + it('should be the valid AbortSignal', () => + expect(instance.signal).to.equal(ac.signal) + .and.to.not.be.undefined); + + it('should implement the EventTarget interface', () => + expect(isSignal(instance.signal)).to.be.true); + + it('should be aborted when the input AbortSignal aborts', () => { + const abort = chai.spy(() => { return }); + ((ac.signal) as Signal).addEventListener('abort', abort); + ac.abort(); + expect(abort).to.have.been.called.exactly(1); + expect(instance.signal?.aborted).to.be.true; + }); + }); + }); + + context('when multiple valid AbortSignals are passed', () => { + const ac = new AbortController(); + const timeout = new TimeoutSignal(100); + const instance = new AggregateSignal(undefined, ac.signal, undefined, timeout.signal, undefined); + const abort = chai.spy(() => { return }); + ((timeout.signal) as Signal).addEventListener('abort', abort); + + describe('signal', () => { + it('should not equal either of the original AbortSignals', () => + expect(instance.signal).to.not.equal(ac.signal) + .and.to.not.equal(timeout.signal) + .and.to.not.be.undefined); + + it('should implement the EventTarget interface', () => + expect(isSignal(instance.signal)).to.be.true); + + it('should be aborted when either AbortSignals abort', () => { + expect(abort).to.have.been.called.exactly(1); + expect(instance.signal?.aborted).to.be.true; + }); + }); + }); + + // TODO: test when signals are already aborted +}); diff --git a/test/procedure.ts b/test/procedure.ts index 5110d52..03a59c7 100644 --- a/test/procedure.ts +++ b/test/procedure.ts @@ -1,12 +1,10 @@ import 'mocha' import chai, { expect } from 'chai' -import chaiQuantifiers from 'chai-quantifiers' import spies from 'chai-spies' import chaiAsPromised from 'chai-as-promised' import Procedure, { Callback } from '../src' import { ExtensionCodec } from '@msgpack/msgpack' -chai.use(chaiQuantifiers); chai.use(spies); chai.use(chaiAsPromised); @@ -247,6 +245,8 @@ describe('Procedure.call(endpoint: string, input: Input | null, options: Partial afterEach(() => callEndpoint = undefined); }); + // TODO: test ping option + // TODO: when endpoint: incorrect afterEach(() => procedure.unbind()); diff --git a/test/timeout-signal.ts b/test/timeout-signal.ts index 43ee840..bf2486a 100644 --- a/test/timeout-signal.ts +++ b/test/timeout-signal.ts @@ -23,8 +23,8 @@ describe('TimeoutSignal', () => { context('when timeout: < 0', () => { const instance = new TimeoutSignal(-1); - describe('signal', () => it('should be: undefined', () => expect(instance.signal).to.be.undefined)); - describe('timeout', () => it('should be: undefined', () => expect(instance.timeout).to.be.undefined)); + describe('signal', () => it('should not be: undefined', () => expect(instance.signal).to.not.be.undefined)); + describe('timeout', () => it('should not be: undefined', () => expect(instance.timeout).to.not.be.undefined)); }); context('when timeout: 1000', () => { diff --git a/test/utils.ts b/test/utils.ts index 829b688..69c9d71 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,6 +1,9 @@ -import 'mocha' -import { expect } from 'chai' -import { cloneError, errorEntries, isError, isErrorLike } from '../src/utils' +import 'mocha'; +import chai, { expect } from 'chai'; +import chaiQuantifiers from 'chai-quantifiers'; +import { cloneError, errorEntries, isError, isErrorLike } from '../src/utils'; + +chai.use(chaiQuantifiers); describe('isError(object: unknown): object is Error', () => { let object: unknown; From 14e6ac92ee6d6253feed32e4dd02c553ffdce90a Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Thu, 4 Aug 2022 22:09:26 +0100 Subject: [PATCH 2/8] Update package lock --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 569e730..bdf3788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@toebeann/procedure.js", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@toebeann/procedure.js", - "version": "0.1.0", + "version": "0.2.0", "license": "UNLICENSED", "dependencies": { "@msgpack/msgpack": "^2.7.2", From a907798bc6f10dc7119a0d15d674873d2e2202ef Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Thu, 4 Aug 2022 22:50:23 +0100 Subject: [PATCH 3/8] Added data event for monitoring/logging purposes --- src/procedure.ts | 65 +++++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/src/procedure.ts b/src/procedure.ts index 8e2c9e9..57c84a6 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -1,4 +1,4 @@ -/// +/// import { Ping, isPing, isErrorLike, cloneError } from './utils'; import AggregateSignal from './aggregate-signal'; import TimeoutSignal from './timeout-signal' @@ -17,7 +17,7 @@ const uuidNamespace = uuidv5(homepage, uuidv5.URL); * Allows you to turn any generic function or callback into a procedure, which remote or local processes can call. * Includes the functionality to ping procedures to check whether they are available. */ -export default class Procedure extends (EventEmitter as new () => TypedEmitter) implements ProcedureOptions { +export default class Procedure extends (EventEmitter as { new (): TypedEmitter> }) implements ProcedureOptions { [key: keyof ProcedureOptions]: ProcedureOptions[keyof ProcedureOptions]; /** The options in use by the procedure, including defaults. */ @@ -131,7 +131,7 @@ export default class Procedure} A promise which, when resolved, indicates whether the endpoint successfully responded to the ping. + * @returns {Promise} A promise which, when resolved, indicates whether the endpoint correctly responded to the ping. */ - static async ping(endpoint: string, timeout: number | undefined = 100, signal?: AbortSignal): Promise { + static async ping(endpoint: string, timeout: number | undefined = 100, signal?: AbortSignal): Promise { const socket = createSocket('req'); const timeoutSignal = new TimeoutSignal(timeout); @@ -162,18 +162,19 @@ export default class Procedure>(buffer); + const response = Procedure.#decode>(buffer); - if ('pong' in pong && pong.pong !== undefined) { - return pong.pong; - } else if ('error' in pong) { - throw pong.error; + if ('pong' in response) { + return response.pong === uuidv5(endpoint, ping); + } else if ('error' in response) { + throw response.error; } else { - throw pong; + throw response; } } finally { socket.removeAllListeners().close(); @@ -281,8 +282,8 @@ export default class Procedure = { output: Output, error?: never, pong?: never } | { output?: never, error: unknown, pong?: never } - | { output?: never, error?: never, pong: true }; + | { output?: never, error?: never, pong: string }; /** * Options for defining a Procedure. @@ -431,7 +449,8 @@ export interface ProcedureCallOptions { /** * A map of the names of events emitted by Procedures and their function signatures. */ -type ProcedureEvents = { - error: (error: unknown) => void - unbind: () => void +type ProcedureEvents = { + data: (data: Input) => void; + error: (error: unknown) => void; + unbind: () => void; } From 36065cb32d2d055bd7b54c664bdc98721486b176 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Thu, 4 Aug 2022 23:30:36 +0100 Subject: [PATCH 4/8] Update `AggregateSignal` unit tests --- test/aggregate-signal.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/test/aggregate-signal.ts b/test/aggregate-signal.ts index 22518db..7060766 100644 --- a/test/aggregate-signal.ts +++ b/test/aggregate-signal.ts @@ -34,7 +34,7 @@ describe('AggregateSignal', () => { it('should be aborted when the input AbortSignal aborts', () => { const abort = chai.spy(() => { return }); - ((ac.signal) as Signal).addEventListener('abort', abort); + ((instance.signal) as Signal).addEventListener('abort', abort); ac.abort(); expect(abort).to.have.been.called.exactly(1); expect(instance.signal?.aborted).to.be.true; @@ -47,7 +47,7 @@ describe('AggregateSignal', () => { const timeout = new TimeoutSignal(100); const instance = new AggregateSignal(undefined, ac.signal, undefined, timeout.signal, undefined); const abort = chai.spy(() => { return }); - ((timeout.signal) as Signal).addEventListener('abort', abort); + ((instance.signal) as Signal).addEventListener('abort', abort); describe('signal', () => { it('should not equal either of the original AbortSignals', () => @@ -65,5 +65,27 @@ describe('AggregateSignal', () => { }); }); - // TODO: test when signals are already aborted + context('when multiple valid AbortSignals are passed, but one of them is already aborted', () => { + const ac = new AbortController(); + ac.abort(); + const timeout = new TimeoutSignal(100); + const instance = new AggregateSignal(undefined, ac.signal, undefined, timeout.signal, undefined); + const abort = chai.spy(() => { return }); + ((instance.signal) as Signal).addEventListener('abort', abort); + + describe('signal', () => { + it('should equal the already aborted AbortSignal', () => + expect(instance.signal).to.equal(ac.signal) + .and.to.not.equal(timeout.signal) + .and.to.not.be.undefined); + + it('should implement the EventTarget interface', () => + expect(isSignal(instance.signal)).to.be.true); + + it('should immediately register as aborted', () => { + expect(abort).to.have.been.called.exactly(0); + expect(instance.signal?.aborted).to.be.true; + }); + }); + }); }); From ef0fb4fef796dc2dc7cc0625c941e7dd3e279c9b Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Thu, 4 Aug 2022 23:32:44 +0100 Subject: [PATCH 5/8] Added new util func tests --- src/utils.ts | 32 +++++---- test/utils.ts | 177 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 153 insertions(+), 56 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 1478cc5..8c9b6a5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,20 +1,31 @@ +/** + * Type guard for determining whether a given object is an `AbortSignal` instance. + * @param {unknown} object The object. + * @returns {object is AbortSignal} `true` if the object is determined to be an `AbortSignal`, otherwise false. + */ +export function isAbortSignal(object: unknown): object is AbortSignal { + return object instanceof AbortSignal; +} + +/** + * A helpful interface to allow use of AbortSignal EventTarget interface when TypeScript hates us. + */ export interface Signal { addEventListener: (event: 'abort', callback: () => void) => void; removeEventListener: (event: 'abort') => void; readonly aborted: boolean; } -// TODO: Write unit tests +/** + * Type guard for determining whether a given object conforms to the `Signal` interface. + * @param {unknown} object The object. + * @returns {object is Signal} `true` if the object conforms to the `Signal` interface, otherwise `false`. + */ export function isSignal(object: unknown): object is Signal { return isAbortSignal(object) && 'addEventListener' in object && 'removeEventListener' in object && typeof (object).addEventListener === 'function' && typeof (object).removeEventListener === 'function'; } -// TODO: Write units tests -export function isAbortSignal(object: unknown): object is AbortSignal { - return object instanceof AbortSignal; -} - /** * A simple Ping interface for internal use. */ @@ -25,17 +36,16 @@ export interface Ping { /** * Type guard for determining whether a given object conforms to the `Ping` interface. * @param {unknown} object The object. - * @returns {boolean} `true` if the object conforms to the `Ping` interface, otherwise `false`. + * @returns {object is Ping} `true` if the object conforms to the `Ping` interface, otherwise `false`. */ -// TODO: Write unit tests export function isPing(object: unknown): object is Ping { return typeof object === 'object' && object !== null && 'ping' in object && typeof (object as { ping: unknown }).ping === 'string'; } /** - * Type guard for determining whether a given object is an `Error`. + * Type guard for determining whether a given object is an `Error` instance. * @param {unknown} object The object. - * @returns {boolean} `true` if the object is determined to be an `Error`, otherwise `false`. + * @returns {object is Error} `true` if the object is determined to be an `Error`, otherwise `false`. */ export function isError(object: unknown): object is Error { return object instanceof Error; @@ -44,7 +54,7 @@ export function isError(object: unknown): object is Error { /** * Type guard for determining whether a given object is `Error`-like, i.e. it matches the most basic `Error` interface. * @param {unknown} object The object. - * @returns {boolean} `true` if the object is determined to fit the shape of an `Error`, otherwise `false`. + * @returns {object is Error} `true` if the object is determined to fit the shape of an `Error`, otherwise `false`. */ export function isErrorLike(object: unknown): object is Error { return typeof object === 'object' && object !== null && (isError(object) || 'name' in object); diff --git a/test/utils.ts b/test/utils.ts index 69c9d71..9b73a14 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,10 +1,100 @@ import 'mocha'; import chai, { expect } from 'chai'; import chaiQuantifiers from 'chai-quantifiers'; -import { cloneError, errorEntries, isError, isErrorLike } from '../src/utils'; +import { cloneError, errorEntries, isAbortSignal, isError, isErrorLike, isPing, isSignal } from '../src/utils'; chai.use(chaiQuantifiers); +describe('cloneError(error: Error, stack: boolean): Error', () => { + let error: Error; + let stack: boolean; + beforeEach(() => error = new SyntaxError('Foo')); + + it('return should be: ErrorLike', () => expect(isErrorLike(cloneError(error))).to.be.true); + + context('when stack: false', () => { + beforeEach(() => stack = false); + it('should not have property: stack', () => expect(cloneError(error, stack)).to.not.haveOwnProperty('stack')); + }); + + context('when stack: true', () => { + beforeEach(() => stack = true); + it('should have property: stack', () => expect(cloneError(error, stack)).to.haveOwnProperty('stack')); + it('stack should be: typeof string', () => expect(typeof cloneError(error, stack).stack).to.equal('string')); + }); + + context('when error.cause: instanceof RangeError', () => { + beforeEach(() => (error as Error & { cause: unknown }).cause = new RangeError()); + it('should have property: cause', () => expect(cloneError(error, stack)).to.haveOwnProperty('cause')); + it('cause should be: ErrorLike', () => expect(isErrorLike((cloneError(error, stack) as Error & { cause: Error }).cause)).to.be.true); + it('cause.name should be: \'RangeError\'', () => expect((cloneError(error, stack) as Error & { cause: Error }).cause.name).to.equal('RangeError')); + }); + + context('when error: { name: \'Foobar\' }', () => { + beforeEach(() => error = { name: 'Foobar' } as Error); + it('return should be: { name: \'Foobar\' }', () => expect(cloneError(error)).to.deep.equal({ name: 'Foobar' })); + }); + + context('when error: {}', () => { + beforeEach(() => error = {} as Error); + it('should throw: TypeError', () => expect(() => cloneError(error)).to.throw(TypeError, 'error does not match interface for type Error')); + }); +}); + +describe('errorEntries(error: Error, stack: boolean): Array', () => { + let error: Error; + let stack: boolean; + beforeEach(() => error = new TypeError()); + + it('should return: Array', () => expect(errorEntries(error)).to.be.instanceof(Array)); + + context('when stack: false', () => { + beforeEach(() => stack = false); + it('should not include: keyof \'stack\'', () => expect(errorEntries(error, stack)).to.containAll(x => x[0] !== 'stack')); + }); + + context('when stack: true', () => { + beforeEach(() => stack = true); + it('should include: keyof \'stack\'', () => expect(errorEntries(error, stack)).to.containExactlyOne(x => x[0] === 'stack')); + it('keyof \'stack\' should be: typeof string', () => expect(typeof errorEntries(error, stack).filter(x => x[0] === 'stack')[0][1]).to.equal('string')); + }); + + context('when error.cause: instanceof RangeError', () => { + beforeEach(() => (error as Error & { cause: unknown }).cause = new RangeError()); + it('should include: keyof \'cause\'', () => expect(errorEntries(error, stack)).to.containExactlyOne(x => x[0] === 'cause')); + it('keyof \'cause\' should be: ErrorLike', () => expect(isErrorLike(errorEntries(error, stack).filter(x => x[0] === 'cause')[0][1])).to.be.true); + }); +}); + +describe('isAbortSignal(object: unknown): object is AbortSignal', () => { + let object: unknown; + + context('when object: instanceof AbortSignal', () => { + beforeEach(() => object = new AbortController().signal); + it('should return: true', () => expect(isAbortSignal(object)).to.be.true); + }); + + context('when object: undefined', () => { + beforeEach(() => object = undefined); + it('should return: false', () => expect(isAbortSignal(object)).to.be.false); + }); + + context('when object: null', () => { + beforeEach(() => object = null); + it('should return: false', () => expect(isAbortSignal(object)).to.be.false); + }); + + context('when object: instanceof TypeError', () => { + beforeEach(() => object = new TypeError()); + it('should return: true', () => expect(isAbortSignal(object)).to.be.false); + }); + + context('when object: { name: \'Foo\', message: \'Bar\' }', () => { + beforeEach(() => object = { name: 'Foo', message: 'Bar' }); + it('should return: false', () => expect(isAbortSignal(object)).to.be.false); + }); +}); + describe('isError(object: unknown): object is Error', () => { let object: unknown; context('when object: instanceof Error', () => { @@ -15,7 +105,7 @@ describe('isError(object: unknown): object is Error', () => { context('when object: undefined', () => { beforeEach(() => object = undefined); it('should return: false', () => expect(isError(object)).to.be.false); - }) + }); context('when object: null', () => { beforeEach(() => object = null); @@ -25,7 +115,7 @@ describe('isError(object: unknown): object is Error', () => { context('when object: instanceof TypeError', () => { beforeEach(() => object = new TypeError()); it('should return: true', () => expect(isError(object)).to.be.true); - }) + }); context('when object: { name: \'Foo\', message: \'Bar\' }', () => { beforeEach(() => object = { name: 'Foo', message: 'Bar' }); @@ -43,7 +133,7 @@ describe('isErrorLike(object: unknown): object is Error', () => { context('when object: undefined', () => { beforeEach(() => object = undefined); it('should return: false', () => expect(isErrorLike(object)).to.be.false); - }) + }); context('when object: null', () => { beforeEach(() => object = null); @@ -53,7 +143,7 @@ describe('isErrorLike(object: unknown): object is Error', () => { context('when object: instanceof TypeError', () => { beforeEach(() => object = new TypeError()); it('should return: true', () => expect(isErrorLike(object)).to.be.true); - }) + }); context('when object: { name: \'Foo\', message: \'Bar\' }', () => { beforeEach(() => object = { name: 'Foo', message: 'Bar' }); @@ -61,63 +151,60 @@ describe('isErrorLike(object: unknown): object is Error', () => { }); }); -describe('errorEntries(error: Error, stack: boolean): Array', () => { - let error: Error; - let stack: boolean; - beforeEach(() => error = new TypeError()); +describe('isPing(object: unknown): object is Ping', () => { + let object: unknown; - it('should return: Array', () => expect(errorEntries(error)).to.be.instanceof(Array)); + context('when object: { ping: \'foobar\' }', () => { + beforeEach(() => object = { ping: 'foobar' }); + it('should return: true', () => expect(isPing(object)).to.be.true); + }); - context('when stack: false', () => { - beforeEach(() => stack = false); - it('should not include: keyof \'stack\'', () => expect(errorEntries(error, stack)).to.containAll(x => x[0] !== 'stack')); + context('when object: undefined', () => { + beforeEach(() => object = undefined); + it('should return: false', () => expect(isPing(object)).to.be.false); }); - context('when stack: true', () => { - beforeEach(() => stack = true); - it('should include: keyof \'stack\'', () => expect(errorEntries(error, stack)).to.containExactlyOne(x => x[0] === 'stack')); - it('keyof \'stack\' should be: typeof string', () => expect(typeof errorEntries(error, stack).filter(x => x[0] === 'stack')[0][1]).to.equal('string')); + context('when object: null', () => { + beforeEach(() => object = null); + it('should return: false', () => expect(isPing(object)).to.be.false); }); - context('when error.cause: instanceof RangeError', () => { - beforeEach(() => (error as Error & { cause: unknown }).cause = new RangeError()); - it('should include: keyof \'cause\'', () => expect(errorEntries(error, stack)).to.containExactlyOne(x => x[0] === 'cause')); - it('keyof \'cause\' should be: ErrorLike', () => expect(isErrorLike(errorEntries(error, stack).filter(x => x[0] === 'cause')[0][1])).to.be.true); + context('when object: instanceof TypeError', () => { + beforeEach(() => object = new TypeError()); + it('should return: true', () => expect(isPing(object)).to.be.false); }); -}); -describe('cloneError(error: Error, stack: boolean): Error', () => { - let error: Error; - let stack: boolean; - beforeEach(() => error = new SyntaxError('Foo')); + context('when object: { name: \'Foo\', message: \'Bar\' }', () => { + beforeEach(() => object = { name: 'Foo', message: 'Bar' }); + it('should return: false', () => expect(isPing(object)).to.be.false); + }); +}); - it('return should be: ErrorLike', () => expect(isErrorLike(cloneError(error))).to.be.true); +describe('isSignal(object: unknown): object is Signal', () => { + let object: unknown; - context('when stack: false', () => { - beforeEach(() => stack = false); - it('should not have property: stack', () => expect(cloneError(error, stack)).to.not.haveOwnProperty('stack')); + context('when object: instanceof AbortSignal', () => { + beforeEach(() => object = new AbortController().signal); + it('should return: true', () => expect(isSignal(object)).to.be.true); }); - context('when stack: true', () => { - beforeEach(() => stack = true); - it('should have property: stack', () => expect(cloneError(error, stack)).to.haveOwnProperty('stack')); - it('stack should be: typeof string', () => expect(typeof cloneError(error, stack).stack).to.equal('string')); + context('when object: undefined', () => { + beforeEach(() => object = undefined); + it('should return: false', () => expect(isSignal(object)).to.be.false); }); - context('when error.cause: instanceof RangeError', () => { - beforeEach(() => (error as Error & { cause: unknown }).cause = new RangeError()); - it('should have property: cause', () => expect(cloneError(error, stack)).to.haveOwnProperty('cause')); - it('cause should be: ErrorLike', () => expect(isErrorLike((cloneError(error, stack) as Error & { cause: Error }).cause)).to.be.true); - it('cause.name should be: \'RangeError\'', () => expect((cloneError(error, stack) as Error & { cause: Error }).cause.name).to.equal('RangeError')); + context('when object: null', () => { + beforeEach(() => object = null); + it('should return: false', () => expect(isSignal(object)).to.be.false); }); - context('when error: { name: \'Foobar\' }', () => { - beforeEach(() => error = { name: 'Foobar' } as Error); - it('return should be: { name: \'Foobar\' }', () => expect(cloneError(error)).to.deep.equal({ name: 'Foobar' })); + context('when object: instanceof TypeError', () => { + beforeEach(() => object = new TypeError()); + it('should return: true', () => expect(isSignal(object)).to.be.false); }); - context('when error: {}', () => { - beforeEach(() => error = {} as Error); - it('should throw: TypeError', () => expect(() => cloneError(error)).to.throw(TypeError, 'error does not match interface for type Error')); + context('when object: { name: \'Foo\', message: \'Bar\' }', () => { + beforeEach(() => object = { name: 'Foo', message: 'Bar' }); + it('should return: false', () => expect(isSignal(object)).to.be.false); }); }); From d67f795f30fa106a7c2f9264b40b8570c78b3538 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Thu, 4 Aug 2022 23:33:20 +0100 Subject: [PATCH 6/8] Added `data` event unit tests --- src/procedure.ts | 1 - test/procedure.ts | 10 +++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/procedure.ts b/src/procedure.ts index 57c84a6..4753e0e 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -341,7 +341,6 @@ export default class Procedure { sandbox.on(console, 'log', () => { return }); }); describe('instance', () => it('should call console.log', () => { - // const log = chai.spy.on(console, 'log', () => { return; }) instance.unbind(); expect(console.log).to.have.been.called.twice; })); @@ -197,6 +196,15 @@ describe('Procedure.call(endpoint: string, input: Input | null, options: Partial context('when input: 0', () => { beforeEach(() => input = 0); + it('should emit: data, with parameter: 0', async () => { + let x: unknown = undefined; + const data = chai.spy((data: unknown) => x = data); + procedure.on('data', data); + await Procedure.call(callEndpoint, input); + expect(data).to.have.been.called.once; + expect(x).to.equal(0); + }); + it('should return: 0', async () => await expect(Procedure.call(callEndpoint, input)).to.eventually.equal(0)); afterEach(() => input = undefined); From 1e1f88b591bbaaf4961e3bf3e17c8d9602ec0608 Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Thu, 4 Aug 2022 23:57:53 +0100 Subject: [PATCH 7/8] Add basic `Procedure.ping` unit tests --- src/procedure.ts | 9 ++++----- test/procedure.ts | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/procedure.ts b/src/procedure.ts index 4753e0e..12ab0ca 100644 --- a/src/procedure.ts +++ b/src/procedure.ts @@ -153,14 +153,13 @@ export default class Procedure} A promise which, when resolved, indicates whether the endpoint correctly responded to the ping. */ static async ping(endpoint: string, timeout: number | undefined = 100, signal?: AbortSignal): Promise { - const socket = createSocket('req'); - - const timeoutSignal = new TimeoutSignal(timeout); - signal = new AggregateSignal(signal, timeoutSignal.signal).signal; - if (signal?.aborted) { throw new Error('signal was aborted'); } else { + const socket = createSocket('req'); + const timeoutSignal = new TimeoutSignal(timeout); + signal = new AggregateSignal(signal, timeoutSignal.signal).signal; + try { const ping = uuidv5(endpoint, uuidNamespace); diff --git a/test/procedure.ts b/test/procedure.ts index dd599ea..18a2e5d 100644 --- a/test/procedure.ts +++ b/test/procedure.ts @@ -262,3 +262,54 @@ describe('Procedure.call(endpoint: string, input: Input | null, options: Partial // TODO: when callback asynchronous (completes normally, times out, throws error, infinite timeout, abortion signaled during execution, abortion signaled before execution) }); + +describe('Procedure.ping(endpoint: string, timeout: number | undefined = 100, signal?: AbortSignal): Promise', () => { + let func: Callback; + let spy: ChaiSpies.SpyFunc1; + let procedure: Procedure; + let procedureEndpoint: string; + let pingEndpoint: string | undefined; + + context('when procedure callback: Callback (simple accumulator function)', () => { + beforeEach(() => { + let i = 0; + func = >((n: number) => { + if (typeof n !== 'number') { + throw new TypeError('Expected a number'); + } + + return i += n; + }); + spy = chai.spy(func); + procedureEndpoint = 'ipc://Procedure/Add.ipc'; + procedure = new Procedure(procedureEndpoint, spy, { workers: 3 }); + procedure.bind(); + }); + + context('when endpoint: correct', () => { + beforeEach(() => pingEndpoint = procedureEndpoint); + + it('should not emit: data', async () => { + const data = chai.spy(() => { return }); + procedure.on('data', data); + await Procedure.ping(pingEndpoint); + expect(data).to.have.been.called.exactly(0); + }); + + it('should return: true', async () => await expect(Procedure.ping(pingEndpoint)).to.eventually.equal(true)); + + context('when signal: already aborted AbortSignal', () => { + let ac: AbortController; + + beforeEach(() => { + ac = new AbortController(); + ac.abort(); + }); + + it('should throw: Error', async () => await expect(Procedure.ping(pingEndpoint, 500, ac.signal)).to.be.rejectedWith('signal was aborted')); + }); + }); + + afterEach(() => procedure.unbind()); + }); +}); From 2915632451c8837069d49144f0e8f00192f520da Mon Sep 17 00:00:00 2001 From: Tobey Blaber Date: Fri, 5 Aug 2022 00:09:04 +0100 Subject: [PATCH 8/8] Added basic tests for `Procedure.call` ping option --- test/procedure.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/test/procedure.ts b/test/procedure.ts index 18a2e5d..3b137be 100644 --- a/test/procedure.ts +++ b/test/procedure.ts @@ -250,11 +250,68 @@ describe('Procedure.call(endpoint: string, input: Input | null, options: Partial it('should throw: TypeError', async () => await expect(Procedure.call(callEndpoint, input)).to.be.rejectedWith('Expected a number')); }); + context('when ping: 100', () => { + context('when input: 0', () => { + beforeEach(() => input = 0); + + it('should emit: data, with parameter: 0', async () => { + let x: unknown = undefined; + const data = chai.spy((data: unknown) => x = data); + procedure.on('data', data); + await Procedure.call(callEndpoint, input, { ping: 100 }); + expect(data).to.have.been.called.once; + expect(x).to.equal(0); + }); + + it('should return: 0', async () => await expect(Procedure.call(callEndpoint, input, { ping: 100 })).to.eventually.equal(0)); + + afterEach(() => input = undefined); + + context('when verbose: true', () => { + const sandbox = chai.spy.sandbox(); + beforeEach(() => { + procedure.verbose = true; + sandbox.on(console, 'log', () => { return }); + }); + + it('should call console.log', async () => { + await Procedure.call(callEndpoint, input, { ping: 100 }); + expect(console.log).to.have.been.called.exactly(5); + }); + + afterEach(() => { + procedure.verbose = false; + sandbox.restore(); + }); + }); + }); + + context('when input: \'foo\'', () => { + beforeEach(() => input = 'foo'); + + it('should throw: TypeError', async () => await expect(Procedure.call(callEndpoint, input, { ping: 100 })).to.be.rejectedWith('Expected a number')); + + afterEach(() => input = undefined); + }); + + context('when input: 1000', () => { + beforeEach(() => input = 1000); + + it('should return: 1000', async () => await expect(Procedure.call(callEndpoint, input, { ping: 100 })).to.eventually.equal(input)); + + afterEach(() => input = undefined); + }); + + context('when input: undefined', () => { + beforeEach(() => input = undefined); + + it('should throw: TypeError', async () => await expect(Procedure.call(callEndpoint, input, { ping: 100 })).to.be.rejectedWith('Expected a number')); + }); + }); + afterEach(() => callEndpoint = undefined); }); - // TODO: test ping option - // TODO: when endpoint: incorrect afterEach(() => procedure.unbind()); @@ -310,6 +367,10 @@ describe('Procedure.ping(endpoint: string, timeout: number | undefined = 100, si }); }); + // TODO: when endpoint: incorrect + // TODO: when timeout infinity, NaN + // TODO: when abortion signaled during ping + afterEach(() => procedure.unbind()); }); });