@holochain/tryorama
Version:
Toolset to manage Holochain conductors and facilitate running test scenarios
242 lines (241 loc) • 8.97 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() {
await this.ensureLocalServices();
assert(this.serviceProcess);
assert(this.signalingServerUrl);
const conductor = await createConductor(this.signalingServerUrl, {
timeout: this.timeout,
bootstrapServerUrl: this.bootstrapServerUrl,
});
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.
*
* @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 () => {
const conductor = await this.addConductor();
if (networkConfig) {
conductor.setNetworkConfig(networkConfig);
}
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.
*
* # 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.");
}
await this.ensureLocalServices();
return Promise.all(appsWithOptions.map((appWithOptions, i) => {
const player = players[i];
appWithOptions.options = appWithOptions.options ?? {};
appWithOptions.options.agentPubKey = player.agentPubKey;
return this.installPlayerApp(player.conductor, appWithOptions);
}));
}
/**
* Installs the same provided app for the provided players.
*
* @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();
return Promise.all(players.map((player) => {
appWithOptions.options = appWithOptions.options ?? {};
appWithOptions.options.agentPubKey = player.agentPubKey;
return this.installPlayerApp(player.conductor, appWithOptions);
}));
}
/**
* Create and add a single player with an app installed to the scenario.
*
* @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();
if (appWithOptions.options?.networkConfig) {
conductor.setNetworkConfig(appWithOptions.options.networkConfig);
}
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.
*
* @param appsWithOptions - An app to be installed for each player
* @returns All created player apps.
*/
async addPlayersWithSameApp(appWithOptions, amount) {
await this.ensureLocalServices();
return Promise.all(new Array(amount)
.fill(0)
.map(() => this.addPlayerWithApp(appWithOptions)));
}
/**
* 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();
return Promise.all(appsWithOptions.map((appWithOptions) => this.addPlayerWithApp(appWithOptions)));
}
/**
* 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());
}
}
}
/**
* 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();
}
}
};