src/screenplay/abilities/ManageALocalServer.ts
import { Ability, ConfigurationError, UsesAbilities } from '@serenity-js/core';
import * as http from 'http';
import withShutdownSupport = require('http-shutdown');
import * as https from 'https';
import * as net from 'net';
import { getPortPromise } from 'portfinder';
/**
* @desc
* An {@link @serenity-js/core/lib/screenplay~Ability} that enables the {@link @serenity-js/core/lib/screenplay/actor~Actor}
* to manage a local [Node.js](https://nodejs.org/) server.
*
* @example <caption>Using a raw Node.js server</caption>
* import { Actor } from '@serenity-js/core';
* import { CallAnApi, GetRequest, Send } from '@serenity-js/rest';
* import { ManageALocalServer, LocalTestServer, StartLocalTestServer, StopLocalTestServer } from '@serenity-js/local-server'
* import { Ensure, equals } from '@serenity-js/assertions';
*
* import axios from 'axios';
* import * as http from 'http';
*
* const server = http.createServer(function (request, response) {
* response.setHeader('Connection', 'close');
* response.end('Hello!');
* })
*
* const actor = Actor.named('Apisit').whoCan(
* ManageALocalServer.using(server),
* CallAnApi.using(axios.create()),
* );
*
* actor.attemptsTo(
* StartLocalTestServer.onRandomPort(),
* Send.a(GetRequest.to(LocalServer.url())),
* Ensure.that(LastResponse.status(), equals(200)),
* Ensure.that(LastResponse.body<string>(), equals('Hello!')),
* StopLocalTestServer.ifRunning(),
* );
*
* @see https://nodejs.org/en/docs/guides/anatomy-of-an-http-transaction/
* @see https://nodejs.org/api/http.html#http_class_http_server
*
* @implements {@link @serenity-js/core/lib/screenplay~Ability}
*/
export class ManageALocalServer implements Ability {
private readonly server: ServerWithShutdown;
/**
* @desc
* {@link @serenity-js/core/lib/screenplay~Ability} to manage a Node.js HTTP server using the provided `requestListener`.
*
* @param {RequestListener | net~Server} listener
* @returns {ManageALocalServer}
*/
static runningAHttpListener(listener: RequestListener | net.Server): ManageALocalServer {
const server = typeof listener === 'function'
? http.createServer(listener)
: listener;
return new ManageALocalServer(SupportedProtocols.HTTP, server);
}
/**
* @desc
* {@link @serenity-js/core/lib/screenplay~Ability} to manage a Node.js HTTPS server using the provided server `requestListener`.
*
* @param {RequestListener | https~Server} listener
* @param {https~ServerOptions} options - Accepts options from `tls.createServer()`, `tls.createSecureContext()` and `http.createServer()`.
* @returns {ManageALocalServer}
*
* @see https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener
*/
static runningAHttpsListener(listener: RequestListener | https.Server, options: https.ServerOptions = {}): ManageALocalServer {
const server = typeof listener === 'function'
? https.createServer(options, listener)
: listener;
return new ManageALocalServer(SupportedProtocols.HTTPS, server);
}
/**
* @desc
* Used to access the {@link @serenity-js/core/lib/screenplay/actor~Actor}'s {@link @serenity-js/core/lib/screenplay~Ability} to {@link ManageALocalServer}
* from within the {@link @serenity-js/core/lib/screenplay~Interaction} classes,
* such as {@link StartLocalServer}.
*
* @param {@serenity-js/core/lib/screenplay~UsesAbilities} actor
* @return {ManageALocalServer}
*/
static as(actor: UsesAbilities): ManageALocalServer {
return actor.abilityTo(ManageALocalServer);
}
/**
* @param {string} protocol - Protocol to be used when communicating with the running server; `http` or `https`
*
* @param {net~Server} server - A Node.js server requestListener, with support for [server shutdown](https://www.npmjs.com/package/http-shutdown).
*
* @see https://www.npmjs.com/package/http-shutdown
*/
constructor(private readonly protocol: SupportedProtocols, server: net.Server) {
this.server = withShutdownSupport(server);
}
/**
* @desc
* Starts the server on the first free port between `preferredPort` and `highestPort`, inclusive.
*
* @param {number} [preferredPort=8000]
* Lower bound of the preferred port range
*
* @param {number} [highestPort=65535] highestPort
* Upper bound of the preferred port range
*
* @returns {Promise<void>}
*/
listen(preferredPort = 8000, highestPort = 65535): Promise<void> {
return getPortPromise({ port: preferredPort, stopPort: highestPort })
.then(port => new Promise<void>((resolve, reject) => {
function errorHandler(error: Error & {code: string}) {
if (error.code === 'EADDRINUSE') {
return reject(new ConfigurationError(`Server address is in use. Is there another server running on port ${ port }?`, error));
}
return reject(error);
}
this.server.once('error', errorHandler);
this.server.listen(port, '127.0.0.1', () => {
this.server.removeListener('error', errorHandler);
resolve();
});
}));
}
/**
* @desc
* Provides access to the server requestListener
*
* @param {function(server: ServerWithShutdown, protocol?: SupportedProtocols): T} fn
* @returns {T}
*/
mapInstance<T>(fn: (server: ServerWithShutdown, protocol?: SupportedProtocols) => T): T {
return fn(this.server, this.protocol);
}
}
/**
* @desc
* A `requestListener` function that Node's
* [`http.createServer`](https://nodejs.org/api/http.html#http_http_createserver_options_requestlistener)
* or [`https.createServer`](https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener)
* would accept.
*
* @public
*
* @typedef {function(request: http.IncomingMessage, response: http.ServerResponse): void} RequestListener
*/
export type RequestListener = (request: http.IncomingMessage, response: http.ServerResponse) => void;
/**
* @desc
* A {@link net~Server} with an added shutdown method.
*
* @see https://www.npmjs.com/package/http-shutdown
*
* @public
*
* @typedef {net~Server & { shutdown: (callback: (error?: Error) => void) => void }} ServerWithShutdown
*/
export type ServerWithShutdown = net.Server & {
shutdown: (callback: (error?: Error) => void) => void,
forceShutdown: (callback: (error?: Error) => void) => void,
};
/**
* @desc
* The protocol supported by the instance of the {@link ServerWithShutdown},
* wrapped by the {@link ManageALocalServer} {@link @serenity-js/core/lib/screenplay~Ability}.
*
* @see {@link ManageALocalServer#mapInstance}
*
* @public
*
* @typedef {Object} SupportedProtocols
* @property {string} HTTP
* @property {string} HTTPS
*/
export enum SupportedProtocols {
HTTP = 'http',
HTTPS = 'https',
}