@holochain/tryorama
Version:
Toolset to manage Holochain conductors and facilitate running test scenarios
233 lines (232 loc) • 9.2 kB
JavaScript
import msgpack from "@msgpack/msgpack";
import assert from "node:assert";
import { WebSocket } from "ws";
import { makeLogger } from "../logger.js";
import { createTryCpConductor as createConductor, } from "./conductor/conductor.js";
import { TRYCP_SUCCESS_RESPONSE, _TryCpResponseResult, } from "./types.js";
import { deserializeApiResponse, deserializeTryCpResponse, deserializeTryCpSignal, } from "./util.js";
const logger = makeLogger("TryCP client");
let requestId = 0;
/**
* A factory class to create client connections to a running TryCP server.
*
* With a client, conductors on the server can ba configured, started and
* stopped. All valid Admin and App API commands can be sent to the server too.
*
* @public
*/
export class TryCpClient {
ws;
requestPromises;
signalHandlers;
// can be set in local test cases
bootstrapServerUrl;
// can be set in local test cases
signalingServerUrl;
conductors;
constructor(serverUrl, timeout = 60000) {
this.ws = new WebSocket(serverUrl, { timeout });
this.requestPromises = {};
this.signalHandlers = {};
this.conductors = [];
}
/**
* Create a client connection to a running TryCP server.
*
* @param serverUrl - The URL of the TryCP server.
* @returns The created client connection.
*/
static async create(serverUrl, timeout) {
const tryCpClient = new TryCpClient(serverUrl, timeout);
const connectPromise = new Promise((resolve, reject) => {
tryCpClient.ws.once("open", () => {
logger.verbose(`connected to TryCP server @ ${serverUrl}`);
tryCpClient.ws.removeEventListener("error", reject);
resolve(tryCpClient);
});
tryCpClient.ws.once("error", (err) => {
logger.error(`could not connect to TryCP server @ ${serverUrl.href}: ${err}`);
reject(err);
});
});
tryCpClient.ws.on("message", (encodedResponse) => {
const responseWrapper = deserializeTryCpResponse(encodedResponse);
if (responseWrapper.type === "signal") {
const signalHandler = tryCpClient.signalHandlers[responseWrapper.port];
if (signalHandler) {
const signal = deserializeTryCpSignal(responseWrapper.data);
logger.debug(`received signal @ port ${responseWrapper.port}: ${JSON.stringify(signal, null, 4)}\n`);
signalHandler(signal);
}
else {
logger.info("received signal from TryCP server, but no signal handler registered");
}
}
else if (responseWrapper.type === "response") {
const { responseResolve, responseReject } = tryCpClient.requestPromises[responseWrapper.id];
// the server responds with an object
// it contains `Ok` as property for formally correct requests
// and `Err` when the format was incorrect
if (_TryCpResponseResult.Ok in responseWrapper.response) {
try {
const innerResponse = tryCpClient.processSuccessResponse(responseWrapper.response[_TryCpResponseResult.Ok]);
responseResolve(innerResponse);
}
catch (error) {
if (error instanceof Error) {
responseReject(error);
}
else {
const errorMessage = JSON.stringify(error, null, 4);
responseReject(errorMessage);
}
}
}
else if (_TryCpResponseResult.Err in responseWrapper.response) {
responseReject(responseWrapper.response[_TryCpResponseResult.Err]);
}
else {
logger.error("unknown response type:\n" +
JSON.stringify(responseWrapper, null, 4));
throw new Error("Unknown response type");
}
delete tryCpClient.requestPromises[responseWrapper.id];
}
});
tryCpClient.ws.on("error", (err) => {
logger.error(err);
});
return connectPromise;
}
setSignalHandler(port, signalHandler) {
this.signalHandlers[port] = signalHandler;
}
unsetSignalHandler(port) {
delete this.signalHandlers[port];
}
/**
* Closes the client connection.
*
* @returns A promise that resolves when the connection was closed.
*/
async close() {
const closePromise = new Promise((resolve) => {
this.ws.once("close", (code) => {
logger.verbose(`connection to TryCP server @ ${this.ws.url} closed with code ${code}`);
resolve(code);
});
});
this.ws.close(1000);
return closePromise;
}
/**
* Send a ping with data.
*
* @param data - Data to send and receive with the ping-pong.
* @returns A promise that resolves when the pong was received.
*/
async ping(data) {
const pongPromise = new Promise((resolve) => {
this.ws.once("pong", (data) => resolve(data));
});
this.ws.ping(data);
return pongPromise;
}
/**
* Send a call to the TryCP server.
*
* @param request - {@link TryCpRequest}
* @returns A promise that resolves to the {@link TryCpSuccessResponse}
*/
call(request) {
const requestDebugLog = JSON.stringify(request);
logger.debug(`request ${requestDebugLog}\n`);
const callPromise = new Promise((resolve, reject) => {
this.requestPromises[requestId] = {
responseResolve: resolve,
responseReject: reject,
};
});
const serverCall = {
id: requestId,
request,
};
// serialize message if the request is an Admin or App API call
if (serverCall.request.type === "call_admin_interface" ||
serverCall.request.type === "call_app_interface") {
serverCall.request.message = msgpack.encode(serverCall.request.message);
}
// serialize entire request
const serializedRequest = msgpack.encode(serverCall);
this.ws.send(serializedRequest);
requestId++;
return callPromise;
}
/**
* Create and add a conductor to the client.
*
* @param options - Conductor configuration, log level and other settings (optional).
* @returns The newly added conductor instance.
*/
async addConductor(options) {
const conductor = await createConductor(this, options);
this.conductors.push(conductor);
return conductor;
}
/**
* Shut down all conductors on the connected TryCP server and disconnect
* their app interfaces.
*/
async shutDownConductors() {
await Promise.all(this.conductors.map((conductor) => conductor.shutDown()));
}
/**
* Run the `reset` command on the TryCP server to delete all conductor data.
*
* @returns An empty success response.
*/
async cleanAllConductors() {
const response = await this.call({ type: "reset" });
assert(response === TRYCP_SUCCESS_RESPONSE);
return response;
}
/**
* Shut down all registered conductors and delete them, and close the client
* connection.
*/
async cleanUp() {
await this.cleanAllConductors();
await this.close();
}
processSuccessResponse(response) {
if (response === TRYCP_SUCCESS_RESPONSE || typeof response === "string") {
logger.debug(`response ${JSON.stringify(response, null, 4)}\n`);
return response;
}
const deserializedApiResponse = deserializeApiResponse(response);
// when the request fails, the response's type is "error"
if (deserializedApiResponse.type === "error") {
const errorMessage = `error response from Admin API\n${JSON.stringify(deserializedApiResponse.value, null, 4)}`;
throw new Error(errorMessage);
}
logger.debug(this.getFormattedResponseLog(deserializedApiResponse));
return deserializedApiResponse;
}
getFormattedResponseLog(response) {
let debugLog;
if ("value" in response &&
response.value &&
typeof response.value !== "string" &&
"BYTES_PER_ELEMENT" in response.value &&
response.value.length === 39) {
// Holochain hash
const hashB64 = Buffer.from(response.value).toString("base64");
const deserializedResponseForLog = Object.assign({}, { ...response }, { data: hashB64 });
debugLog = `response ${JSON.stringify(deserializedResponseForLog)}\n`;
}
else {
debugLog = `response ${JSON.stringify(response)}\n`;
}
return debugLog;
}
}