@holochain/tryorama
Version:
Toolset to manage Holochain conductors and facilitate running test scenarios
296 lines (295 loc) • 11.1 kB
JavaScript
import assert from "node:assert";
import { v4 as uuidv4 } from "uuid";
import { addAllAgentsToAllConductors, enableAndGetAgentApp, runLocalServices, stopLocalServices, } from "./conductor-helpers.js";
import { cleanAllConductors, createConductor, } from "./conductor.js";
/**
* An abstraction of a test scenario to write tests against Holochain hApps,
* running on a local conductor.
*
* @public
*/
export class Scenario {
timeout;
noDpki;
dpkiNetworkSeed;
networkSeed;
disableLocalServices;
serviceProcess;
bootstrapServerUrl;
signalingServerUrl;
conductors;
/**
* Scenario constructor.
*
* @param options - Timeout for requests to Admin and App API calls.
*/
constructor(options) {
this.timeout = options?.timeout;
this.noDpki = false;
this.dpkiNetworkSeed = uuidv4();
this.networkSeed = uuidv4();
this.disableLocalServices = options?.disableLocalServices ?? false;
this.serviceProcess = undefined;
this.bootstrapServerUrl = undefined;
this.signalingServerUrl = undefined;
this.conductors = [];
}
/**
* Create and add a conductor to the scenario.
*
* @returns The newly added conductor instance.
*/
async addConductor(networkConfig, label) {
await this.ensureLocalServices();
assert(this.serviceProcess);
assert(this.signalingServerUrl);
const defaultCreateOptions = {
timeout: this.timeout,
bootstrapServerUrl: this.bootstrapServerUrl,
label,
};
const createOptions = networkConfig === undefined
? defaultCreateOptions
: {
...defaultCreateOptions,
...networkConfig,
};
const conductor = await createConductor(this.signalingServerUrl, createOptions);
this.conductors.push(conductor);
return conductor;
}
/**
* Create conductors with agents and add them to the scenario.
*
* The specified number of conductors is created and one agent is
* generated on each conductor.
*
* Each conductor is created sequentially, once the previous has
* completed startup.
*
* @param amount - The number of players to be created.
* @param networkConfig - Optional {@link NetworkConfig}
* @returns An array of {@link Player}s
*/
async addPlayers(amount, networkConfig) {
await this.ensureLocalServices();
return Promise.all(new Array(amount).fill(0).map(async (_, i) => {
const conductor = await this.addConductor(networkConfig, this.generatePlayerLabel(i));
const agentPubKey = await conductor.adminWs().generateAgentPubKey();
return { conductor, agentPubKey };
}));
}
/**
* Installs the provided apps for the provided players.
*
* The number of players must be at least as high as the number of apps.
*
* Each app is installed sequentially, once the previous has
* completed installation.
*
* # Errors
*
* If any of the app options contains an agent pub key, an error is thrown,
* because the agent pub keys of the players will be used for app installation.
*
* @param appsWithOptions - The apps with options to be installed
* @param players - The players the apps are installed for
* @returns An array of player apps.
*/
async installAppsForPlayers(appsWithOptions, players) {
if (appsWithOptions.some((appWithOptions) => appWithOptions.options?.agentPubKey)) {
throw new Error("Agent pub key in app options must not be set. Agent pub keys are taken from the players.");
}
if (appsWithOptions.length !== players.length) {
throw new Error("The number of AppsWithOptions must match the number of Players.");
}
await this.ensureLocalServices();
// Sequentially install apps.
// TODO This is a workaround to avoid connection failures.
// See https://github.com/holochain/tryorama/issues/297
const playerApps = [];
for (const i in players) {
const playerApp = await this.installPlayerApp(players[i].conductor, {
...appsWithOptions[i],
options: {
...appsWithOptions[i].options,
agentPubKey: players[i].agentPubKey,
},
});
playerApps.push(playerApp);
}
return playerApps;
}
/**
* Installs the same provided app for the provided players.
*
* The app is installed into each player's conductor sequentially, once the previous has
* completed installation.
*
* @param appsWithOptions - The app with options to be installed for all players
* @param players - The players the apps are installed for
* @returns An array of player apps.
*/
async installSameAppForPlayers(appWithOptions, players) {
if (appWithOptions.options?.agentPubKey) {
throw new Error("Agent pub key in app options must not be set. Agent pub keys are taken from the players.");
}
await this.ensureLocalServices();
// Sequentially install apps.
// TODO This is a workaround to avoid connection failures.
// See https://github.com/holochain/tryorama/issues/297
const playerApps = [];
for (const i in players) {
const options = appWithOptions.options ?? {};
options.agentPubKey = players[i].agentPubKey;
const playerApp = await this.installPlayerApp(players[i].conductor, {
...appWithOptions,
options,
});
playerApps.push(playerApp);
}
return playerApps;
}
/**
* Create and add a single player with an app installed to the scenario.
*
* This should not be called multiple times in parallel. Instead use `addPlayersWithApps` or `addPlayersWithSameApp`.
*
* @param appBundleSource - The bundle or path to the bundle.
* @param options - {@link AppOptions}.
* @returns A player with the installed app.
*/
async addPlayerWithApp(appWithOptions) {
await this.ensureLocalServices();
const conductor = await this.addConductor(appWithOptions.options?.networkConfig, appWithOptions.label);
appWithOptions.options = {
...appWithOptions.options,
networkSeed: appWithOptions.options?.networkSeed ?? this.networkSeed,
};
return this.installPlayerApp(conductor, appWithOptions);
}
async installPlayerApp(conductor, appWithOptions) {
const appInfo = await conductor.installApp(appWithOptions);
const adminWs = conductor.adminWs();
const port = await conductor.attachAppInterface();
const issued = await adminWs.issueAppAuthenticationToken({
installed_app_id: appInfo.installed_app_id,
});
const appWs = await conductor.connectAppWs(issued.token, port);
const agentApp = await enableAndGetAgentApp(adminWs, appWs, appInfo);
return { conductor, appWs, ...agentApp };
}
/**
* Create and add multiple players to the scenario, with the same app installed
* for each player.
*
* Each conductor is created sequentially, once the previous has
* completed startup.
*
* @param appsWithOptions - An app to be installed for each player
* @returns All created player apps.
*/
async addPlayersWithSameApp(appWithOptions, amount) {
await this.ensureLocalServices();
// Sequentially create conductors and install apps.
// TODO This is a workaround to avoid connection failures.
// See https://github.com/holochain/tryorama/issues/297
const playerApps = [];
for (let i = 0; i < amount; i++) {
const playerApp = await this.addPlayerWithApp(appWithOptions);
playerApps.push(playerApp);
}
return playerApps;
}
/**
* Create and add multiple players to the scenario, with an app installed
* for each player.
*
* @param appsWithOptions - An array with an app for each player.
* @returns All created player apps.
*/
async addPlayersWithApps(appsWithOptions) {
await this.ensureLocalServices();
// Sequentially create conductors and install apps.
// TODO This is a workaround to avoid connection failures.
// See https://github.com/holochain/tryorama/issues/297
const playerApps = [];
for (const i in appsWithOptions) {
const playerApp = await this.addPlayerWithApp(appsWithOptions[i]);
playerApps.push(playerApp);
}
return playerApps;
}
/**
* Register all agents of all passed in conductors to each other. This skips
* peer discovery through gossip and thus accelerates test runs.
*
* @public
*/
async shareAllAgents() {
return addAllAgentsToAllConductors(this.conductors);
}
/**
* Shut down all conductors in the scenario.
*/
async shutDown() {
await Promise.all(this.conductors.map((conductor) => conductor.shutDown()));
if (this.serviceProcess) {
await stopLocalServices(this.serviceProcess);
}
}
/**
* Shut down and delete all conductors in the scenario.
*/
async cleanUp() {
await this.shutDown();
await cleanAllConductors();
this.conductors = [];
this.serviceProcess = undefined;
this.bootstrapServerUrl = undefined;
this.signalingServerUrl = undefined;
}
async ensureLocalServices() {
if (this.disableLocalServices) {
this.bootstrapServerUrl = new URL("https://BAD_URL");
this.signalingServerUrl = new URL("wss://BAD_URL");
}
if (!this.serviceProcess) {
({
servicesProcess: this.serviceProcess,
bootstrapServerUrl: this.bootstrapServerUrl,
signalingServerUrl: this.signalingServerUrl,
} = await runLocalServices());
}
}
generatePlayerLabel(index) {
return `Player ${this.conductors.length + index}`;
}
}
/**
* A wrapper function to create and run a scenario. A scenario is created
* and all involved conductors are shut down and cleaned up after running.
*
* @param testScenario - The test to be run.
* @param cleanUp - Whether to delete conductors after running. @defaultValue true
*
* @public
*/
export const runScenario = async (testScenario, cleanUp = true, options) => {
const scenario = new Scenario(options);
try {
await testScenario(scenario);
}
catch (error) {
console.error("error occurred during test run:", error);
throw error;
}
finally {
if (cleanUp) {
await scenario.cleanUp();
}
else {
await scenario.shutDown();
}
}
};