Testing

posipaki ships helpers for writing actor tests under posipaki/testing. The core idea: instead of polling state, you collect the messages an actor emits and wait for a match — event-driven, with a timeout and a readable failure message.

Collecting messages

createCollector(filter) returns a plugin plus a collector object. Install the plugin via addPlugins when you spawn, then await collector.resolved():

import { defineActor, defineMessages } from "posipaki";
import { createCollector } from "posipaki/testing";

const Emitter = defineActor({
  name: "emitter",
  inMessages: defineMessages<{ type: "POKE"; n: number }>(),
  outMessages: defineMessages<{ type: "PONG"; n: number }>(),
  setup: () => ({ count: 0 }),
  handlers: {
    POKE(msg) {
      this.state.count += msg.n;
      this.emit({ type: "PONG", n: this.state.count });
    },
  },
});

const collector = createCollector<{ type: "PONG"; n: number }>({ type: "PONG" });
const proc = await Emitter.spawn({}, { addPlugins: [collector.plugin] });
await proc.ready();

proc.send({ type: "POKE", n: 1 });

const result = await collector.resolved();
if (!result.ok) throw new Error(result.detail);
// collector.messages[0] is { type: "PONG", n: 1 }

The collector observes every message the actor emits (onEmit), scoped to the actor and its children by default.

Matching

A match spec can be three things:

times(spec, n) matches once the nth occurrence has arrived:

import { times } from "posipaki/testing";

proc.send({ type: "POKE", n: 1 });
proc.send({ type: "POKE", n: 1 });
await collector.next(times({ type: "PONG" }, 3)); // wait for the 3rd PONG

resolved() waits on the current filter (4.5s default). next(filter) swaps in a new filter and waits. On timeout or actor exit the result is { ok: false, detail } with what was expected and what was seen.

Waiting on state

When the thing you care about is state rather than emitted messages, subscribe for the next change:

import { nextState } from "posipaki/testing";

proc.send({ type: "POKE", n: 5 });
const state = await nextState(proc); // resolves on the next state change

nextMessage(proc) is the same idea for the message channel.

Cleaning up

createRootTracker() remembers every root it was installed on, so a test can tear everything down in one call:

import { createRootTracker } from "posipaki/testing";

const tracker = createRootTracker();
const proc = await Emitter.spawn({}, { addPlugins: [tracker.plugin, collector.plugin] });

// ... run the test ...

await tracker.stopAll(); // exit every root still running

Scoping and names

The collector filters by process name with the same tree-prefix language as the debug logger — "*", an exact name, or "prefix:*" for a subtree. Pass { scope } to createCollector, or use pnameMatch directly for your own assertions.