Skip to content
Runner · Write your pluginStep 6 of 6

Test your plugin

Exercise decide() in isolation before any live tick.

Derived from runner/src/engine/bin/dry-run.ts

decide() is a pure function of state. It takes a PluginContext and returns Action[] — no key, no RPC, no transaction. That makes it the easiest thing in the system to test: hand it a context, assert the intents it returns.

decide() is just input → output

You don't need a chain, a wallet, or the runtime to test the decision. You need a fabricated ctx and an assertion on what comes back.

// src/plugins/my-strategy/my-strategy.test.ts
import { it } from "node:test";
import assert from "node:assert/strict";
import { InMemoryStateAPI } from "../../engine/runtime-core";
import createMyStrategy from "./index";

it("mints UP when nav clears the floor", async () => {
  const plugin = createMyStrategy();   // the factory validates env, so set PLUGIN_* vars in the test
  const ctx = {
    vault: makeVault({ nav: 1_000_000n }),   // your fixture of the SDK Vault snapshot
    suiClient: {} as any,                     // decide() never touches it
    now: 1_700_000_000_000,                   // injected clock — decide() must read ctx.now, not Date.now()
    state: new InMemoryStateAPI(),            // KV scoped to this plugin, no Postgres
    logger: makeLogger(),
    lifecycle: { closures: [] },              // [] on a first tick
  };

  const actions = await plugin.decide(ctx as any);

  assert.deepEqual(actions, [
    { kind: "vault.mintBinary", params: { marketId: "0xmkt", strike: 100n, isUp: true, quantity: 5n } },
  ]);
});

Run it directly with the test runner the scaffold already uses:

node --import tsx --test src/plugins/my-strategy/my-strategy.test.ts

Build the fixture from the real PluginContext fields — vault, suiClient, now, state, logger, lifecycle. The full surface (every ctx.vault getter, the Action variants, ctx.state) is in Plugin context.

A few things make this clean:

  • ctx.now is injected. decide() must read ctx.now, never Date.now(), so a cooldown or interval check is deterministic: pass a fixed timestamp and the result is fixed.
  • ctx.state has an in-memory backing. InMemoryStateAPI is the real PluginStateAPI contract without a database. Seed it with set(), run decide(), assert what changed.
  • Assert the intent, not a side effect. The return value *is* the behavior. Check the kind and params of each Action; check that a guard returns [{ kind: "noop", reason }] instead of trading.

dry-run is the integration harness

Unit tests cover the logic. dry-run covers the seam between your logic and the chain: it runs decide() and then simulates the resulting transaction via devInspect — read-only, no signature, no gas, no on-chain state change.

pnpm dry-run my-strategy

It prints the decision it took — actions, noop reasons, and the simulated gas estimate — and returns what the transaction *would* do. The digest is undefined (nothing was submitted), but the simulated effects, events, and a gas estimate are populated, so you see that your Action[] actually builds and that the contract would accept it. Two requirements: the plugin must be listed in RUNNER_PLUGINS (or the command throws dry-run: plugin "<name>" not in RUNNER_PLUGINS list), and OPERATOR_PRIVATE_KEY must be set — the simulation runs with your operator address as the sender, but never signs.

A unit test proves decide() returns the right intent. A dry-run proves that intent becomes a transaction the chain accepts.

One caveat for stateful plugins: a dry-run skips lifecycle hooks, so onExecuted and onError never fire. If a hook persists outcomes, it should check result.dryRun and skip writes on simulations anyway — but in a dry-run the runtime won't call it at all.

Next: walk it up the ramp to a live tick → in Run & observe.