Test your plugin
Exercise decide() in isolation before any live tick.
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.tsBuild 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.nowis injected.decide()must readctx.now, neverDate.now(), so a cooldown or interval check is deterministic: pass a fixed timestamp and the result is fixed.ctx.statehas an in-memory backing.InMemoryStateAPIis the realPluginStateAPIcontract without a database. Seed it withset(), rundecide(), assert what changed.- Assert the intent, not a side effect. The return value *is* the behavior. Check the
kindandparamsof eachAction; 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-strategyIt 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.