Tryorama
Tryorama provides a convenient way to run an arbitrary amount of Holochain
conductors on your local machine, as well as on network nodes that are running
the TryCP service. In combination with the test runner and assertion library of
your choice, you can test the behavior of multiple Holochain nodes in a
network. Included functions to clean up used resources make sure that all state
is deleted between tests so that they are independent of one another.
npm install @holochain/tryorama
Complete API reference
Example
With a few lines of code you can start testing your Holochain application. This
example uses tape as test runner and
assertion library. You can choose any other runner and library.
import { DnaSource, HeaderHash } from "@holochain/client";
import { pause, runScenario, Scenario } from "@holochain/tryorama";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import test from "tape-promise/tape.js";
test("Create 2 players and create and read an entry", async (t) => {
await runScenario(async (scenario: Scenario) => {
const testDnaPath = dirname(fileURLToPath(import.meta.url)) + "/test.dna";
const dnas: DnaSource[] = [{ path: testDnaPath }];
const [alice, bob] = await scenario.addPlayersWithHapps([dnas, dnas]);
await scenario.shareAllAgents();
const content = "Hello Tryorama";
const createEntryHash: HeaderHash = await alice.cells[0].callZome({
zome_name: "crud",
fn_name: "create",
payload: content,
});
await pause(100);
const readContent: typeof content = await bob.cells[0].callZome({
zome_name: "crud",
fn_name: "read",
payload: createEntryHash,
});
t.equal(readContent, content);
});
});
Have a look at the tests for many more examples.
Example without wrapper
Written out without the wrapper function, the same example looks like this:
import { DnaSource, HeaderHash } from "@holochain/client";
import { pause, Scenario } from "@holochain/tryorama";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import test from "tape-promise/tape.js";
test("Create 2 players and create and read an entry", async (t) => {
const testDnaPath = dirname(fileURLToPath(import.meta.url)) + "/test.dna";
const dnas: DnaSource[] = [{ path: testDnaPath }];
const scenario = new Scenario();
const [alice, bob] = await scenario.addPlayersWithHapps([dnas, dnas]);
await scenario.shareAllAgents();
const content = "Hello Tryorama";
const createEntryHash: EntryHash = await alice.cells[0].callZome({
zome_name: "crud",
fn_name: "create",
payload: content,
});
await pause(100);
const readContent: typeof content = await bob.cells[0].callZome({
zome_name: "crud",
fn_name: "read",
payload: createEntryHash,
});
t.equal(readContent, content);
await scenario.cleanUp();
});
The wrapper takes care of creating a scenario and shutting down or deleting
all conductors involved in the test scenario.
Error handling with test runners like tape
When writing the test, it might be necessary to handle errors while developing,
depending on the test runner. With a test runner like "tape", uncaught errors
will cause the conductor process and therefore the test to hang or output
`[object Object]`` as the only error message. If you're facing this issue, you
can treat errors like this:
const scenario = new LocalScenario();
try {
} catch (error) {
console.error("an error occurred during the test", error);
} finally (
await scenario.cleanUp()
}
Concepts
Scenarios provide high-level functions to
interact with the Holochain Conductor API. Players
consist of a conductor, an agent and installed hApps, and can be added to a
Scenario. Access to installed hApps is made available through the cells,
which can either be destructured according to the sequence during installation
or retrieved by their role id.
One level underneath the Scenario is the
Conductor. Apart from methods for
creation, startup and shutdown, it comes with complete functionality of Admin
and App API that the JavaScript client offers.
Conductor example
Here is the above example that just uses a Conductor
without a Scenario
:
const testDnaPath = dirname(fileURLToPath(import.meta.url)) + "/test.dna";
const dnas: DnaSource[] = [{ path: testDnaPath }];
const conductor1 = await createConductor();
const conductor2 = await createConductor();
const [aliceHapps, bobHapps] = await conductor1.installAgentsHapps({
agentsDnas: [dnas, dnas],
});
await addAllAgentsToAllConductors([conductor1, conductor2]);
const entryContent = "test-content";
const createEntryHash: EntryHash = await aliceHapps.cells[0].callZome({
zome_name: "crud",
fn_name: "create",
payload: entryContent,
});
await pause(100);
const readEntryResponse: typeof entryContent =
await bobHapps.cells[0].callZome({
zome_name: "crud",
fn_name: "read",
payload: createEntryHash,
});
await conductor1.shutDown();
await conductor2.shutDown();
await cleanAllConductors();
Note that you need to set a UID
manually when registering DNAs. This is taken care of automatically when using
a Scenario
.
hApp Installation
Conductors are equipped with a method for easy hApp installation,
installAgentsHapps. It has a
almost identical signature to Scenario.addPlayers
and takes an array of DNAs
for each agent, resulting in a 2-dimensional array, e. g.
[[agent1dna1, agent1dna2], [agent2dna1], [agent3dna1, agent3dna2, agent3dna3]]
.
const testDnaPath = dirname(fileURLToPath(import.meta.url)) + "/test.dna";
const dnas: DnaSource[] = [{ path: testDnaPath }];
const conductor = await createLocalConductor();
const [aliceHapps] = await conductor.installAgentsHapps({
agentsDnas: [dnas],
});
const entryContent = "test-content";
const createEntryHash: EntryHash = await aliceHapps.cells[0].callZome({
zome_name: "crud",
fn_name: "create",
payload: entryContent,
});
await conductor.shutDown();
Convenience function for Zome calls
When testing a Zome, there are usually a lot of calls to the cell with this
particular Zome. Specifying the Cell and the Zome name for every call is
repetitive. It is therefore convenient to use a handle to a particular
combination of Cell and Zome.
Instead of
const [aliceHapps] = await conductor.installAgentsHapps({
agentsDnas: [dnas],
});
const createEntryHash: EntryHash = await aliceHapps.cells[0].callZome({
zome_name: "crud",
fn_name: "create",
payload: entryContent,
});
const readEntryHash: string = await aliceHapps.cells[0].callZome({
zome_name: "crud",
fn_name: "read",
payload: createEntryHash,
});
the shorthand access to the Zome can be called
const [aliceHapps] = await conductor.installAgentsHapps({
agentsDnas: [dnas],
});
const aliceCrudZomeCall = getZomeCaller(aliceHapps.cells[0], "crud");
const entryHeaderHash: HeaderHash = await crudZomeCall(
"create",
"test-entry"
);
const readEntryHash: string = await crudZomeCall(
"read",
entryHeaderHash
);
Signals
Scenario.addPlayer
as well as Conductor.installAgentsHapps
allow for an
optional signal handler to be specified. Signal handlers are registered with
the conductor and act as a callback when a signal is received.
const scenario = new LocalScenario();
const testDnaPath = dirname(fileURLToPath(import.meta.url)) + "/test.dna";
const dnas: DnaSource[] = [{ path: testDnaPath }];
d
let signalHandler: AppSignalCb | undefined;
const signalReceived = new Promise<AppSignal>((resolve) => {
signalHandler = (signal) => {
resolve(signal);
};
});
const alice = await scenario.addPlayerWithHapp(dna, signalHandler);
const signal = { value: "hello alice" };
alice.cells[0].callZome({
zome_name: "crud",
fn_name: "signal_loopback",
payload: signal,
});
const actualSignalAlice = await signalReceived;
t.deepEqual(actualSignalAlice.data.payload, signal);
await scenario.cleanUp();