Skip to content

Unit Tests

This guide explains how to test Poe apps using createPoeAppTestHarness. The harness creates multi-client test scenarios using the child app architecture — each createClient() call exercises the full production code path (AppsKernel → HostKernelRpc → nonce routing → PostMessageEnvironment → createPoe).

Basic Setup — Store Tests

typescript
import { test, expect } from "bun:test";
import { createPoeAppTestHarness } from "poe-apps-sdk/v1/test-utils.js";
import { myBackendConfig } from "./synced-store/backend-config";
import type { MySchema } from "./synced-store/schema";

test("mutation round-trip", async () => {
  const harness = createPoeAppTestHarness<MySchema>({
    store: { backendConfig: myBackendConfig },
  });
  const { store } = await harness.createClient({ userId: "alice" });

  const { confirmed } = await store.mutate.setValue({
    key: "greeting",
    value: "hello",
  });
  await confirmed;

  const result = await store.query((tx) => tx.table("data").get("greeting"));
  expect(result).toBe("hello");

  harness.dispose();
});

createClient() returns { Poe, store, dispose } where Poe is the full production API and store is the typed SyncedStoreClient (already synced with server data).

Basic Setup — Bot Streaming Tests

typescript
import { test, expect } from "bun:test";
import {
  createPoeAppTestHarness,
  textResponses,
} from "poe-apps-sdk/v1/test-utils.js";

test("stream bot response", async () => {
  const harness = createPoeAppTestHarness({
    getBotResponse: textResponses(["Hello ", "World!"]),
  });
  const { Poe } = await harness.createClient();

  const chunks = [];
  for await (const chunk of Poe.stream({
    botName: "Claude-3.5-Sonnet",
    prompts: "Hi",
  })) {
    chunks.push(chunk);
  }

  expect(chunks).toHaveLength(2);
  expect(chunks[0].text).toBe("Hello ");
  expect(chunks[1].text).toBe("World!");

  harness.dispose();
});

Multi-Client Testing

Multiple clients can share the same store instance.

Simple case: second client created after the first mutation

typescript
test("multi-user sync", async () => {
  const harness = createPoeAppTestHarness<MySchema>({
    store: { backendConfig: myBackendConfig },
  });

  const alice = await harness.createClient({ userId: "alice" });
  await alice.store.mutate.setValue({ key: "k1", value: "from alice" });

  // bob's bootstrap pull picks up alice's mutation automatically.
  const bob = await harness.createClient({ userId: "bob" });
  const result = await bob.store.query((tx) => tx.table("data").get("k1"));
  expect(result).toBe("from alice");

  harness.dispose();
});

Pre-existing clients with sequential mutations: use waitFor*

If both clients exist before the mutations and you need them to observe each other's state in order, the second client's optimistic pass races the first client's server confirmation. Use one of the waitFor* helpers from poe-apps-sdk/v1/test-utils.js to gate on the propagated state:

HelperUse when
waitForKeyExists(client, { table, key })A row needs to appear before the next step
waitForValue(client, { table, key, value })A row needs to deep-equal a specific value
waitForKeyMatch(client, { table, key, match })A row needs to satisfy a predicate (most flexible)
waitForKeyDeleted(client, { table, key })A row needs to disappear
waitForAllClients([...], { queryFn })Multiple clients need to converge to the same truthy state
waitFor(client, { queryFn })A general query needs to return truthy

Each takes an optional { timeoutMs, description } and emits a descriptive timeout error on failure. Source: packages/synced-store-client/test-utils/wait-for.ts.

typescript
import {
  createPoeAppTestHarness,
  waitForKeyExists,
  waitForKeyMatch,
} from "poe-apps-sdk/v1/test-utils.js";

test("two pre-existing clients merge edits", async () => {
  const harness = createPoeAppTestHarness<MySchema>({
    store: { backendConfig: myBackendConfig },
  });

  const { store: alice } = await harness.createClient({ userId: "alice" });
  const { store: bob } = await harness.createClient({ userId: "bob" });

  await alice.mutate.setTodo({ id: "t1", text: "Buy milk", completed: false });

  // biome-ignore lint/suspicious/noExplicitAny: waitFor* generic constraints reject schema-inferred store types
  await waitForKeyExists(bob as any, { table: "items", key: "t1" });

  await bob.mutate.setTodo({ id: "t1", completed: true });

  // biome-ignore lint/suspicious/noExplicitAny: waitFor* generic constraints reject schema-inferred store types
  await waitForKeyMatch(alice as any, {
    table: "items",
    key: "t1",
    match: (t) => (t as { completed: boolean }).completed === true,
  });

  expect(await alice.query((tx) => tx.table("items").get("t1"))).toMatchObject({
    text: "Buy milk",
    completed: true,
    createdBy: "alice",
  });

  harness.dispose();
});

Don't hand-roll a await { confirmed } = mutate.X(); await confirmed; await waitForServerData() pattern — it lacks the timeout/description infrastructure these helpers provide, and the pattern doesn't generalize to predicate-based waits.

Response Helpers

The harness provides convenience helpers for common bot response patterns:

HelperDescription
textResponse("Hello")Single text event
textResponses(["a", "b"])Multiple text events
sseResponses([...])Raw SSEEvent array
sequentialResponses([[...], [...]])Different responses per call
errorResponse("msg")Throws an error

Custom Response Handler

For full control, pass an async generator that receives the request params:

typescript
const harness = createPoeAppTestHarness({
  getBotResponse: async function* (params) {
    if (params.botName === "Claude") {
      yield { event: "text", data: { text: "I'm Claude" } };
    } else {
      yield { event: "error", data: { text: "Unknown bot" } };
    }
  },
});

Sequential Responses (Tool Loops)

sequentialResponses is useful for testing Poe.call() with tools, where the bot makes a tool call on the first request and gives a final answer on the second:

typescript
import { sequentialResponses } from "poe-apps-sdk/v1/test-utils.js";

test("tool call loop", async () => {
  const harness = createPoeAppTestHarness({
    getBotResponse: sequentialResponses([
      // First call: bot requests a tool
      [
        {
          event: "json",
          data: {
            choices: [{
              delta: {
                tool_calls: [{
                  id: "call_1",
                  function: {
                    name: "get_weather",
                    arguments: '{"city":"Tokyo"}',
                  },
                }],
              },
            }],
          },
        },
      ],
      // Second call: bot gives final answer
      [{ event: "text", data: { text: "It's sunny in Tokyo!" } }],
    ]),
  });
  const { Poe } = await harness.createClient();

  const weatherTool = Poe.createTool({
    name: "get_weather",
    description: "Get weather",
    parameters: {
      type: "object",
      properties: { city: { type: "string" } },
    },
    run: async (input) => `Weather in ${input.city}: 72F sunny`,
  });

  const events = [];
  for await (const event of Poe.call({
    botName: "bot",
    prompts: "What's the weather in Tokyo?",
    tools: [weatherTool],
  })) {
    events.push(event);
  }

  expect(events.filter((e) => e.type === "tool_call")).toHaveLength(1);
  expect(events.filter((e) => e.type === "tool_result")).toHaveLength(1);
});

Request Capture

Every bot query, model list, and app list request is captured in harness.requests:

typescript
for await (const _ of Poe.stream({ botName: "bot", prompts: "Hi" })) {}

expect(harness.requests).toHaveLength(1);
expect(harness.getRequests("getBotResponse")).toHaveLength(1);

Cross-App Testing with otherStores

Use otherStores to register additional store backends for cross-app testing. Each key is a storeTypeId. In your app code, call Poe.externalStore({ storeTypeId, instanceId }) to get a read-only handle for querying another store's data:

typescript
test("read from external store", async () => {
  const harness = createPoeAppTestHarness({
    store: {
      storeTypeId: "chat",
      backendConfig: { mutators: chatMutators },
    },
    otherStores: {
      manager: {
        backendConfig: { mutators: managerMutators },
      },
    },
  });
  const { Poe, store } = await harness.createClient();

  // Mutations can trigger ctx.mutateExternal() to write to other stores
  await store.mutate.sendMessage({ id: "msg-1", text: "hello" });

  // Read from the external store
  const external = Poe.externalStore({
    storeTypeId: "manager",
    instanceId: "test-instance",
  });
  await external.waitForBootstrap();
  // Query the external store's data (read-only)...

  harness.dispose();
});

Controlled Flush (Cache Testing)

Use createControlledClient() to control when server data arrives — useful for testing cached data behavior:

typescript
const { store, transport } = await harness.createControlledClient();

// Store is set up but NOT synced — server data hasn't arrived
// Do assertions on cached/empty state here...

// Now flush to let server data through
await transport.flushUntil(store.waitForServerData());

// Server data has arrived

Swapping Handlers Mid-Test

All handlers can be replaced at any point:

typescript
harness.setBotResponseHandler(textResponse("new response"));
harness.setListModelsData([createTestModel({ id: "claude-4" })]);
harness.setListAppsData([{ id: "app-1", handle: "my-app", ... }]);
harness.setPlatformCaller(() => createMockPlatformCaller({ ... }));

Options Reference

createPoeAppTestHarness Options

OptionDefaultDescription
apiHarnessnew ApiTestServer()IApiTestHarness instance — override with a custom implementation
getBotResponseEmpty text responseBot response handler (async generator)
storeOmit to skip store. { backendConfig: { mutators: {} } } for defaults.
store.storeTypeId"test"Store type ID for registration
store.backendConfig(required)Server-side backend config (mutators, actions, schema)
otherStoresAdditional store backends keyed by storeTypeId for cross-app testing
listModels[]Initial models for Poe.listModels()
listApps[]Initial apps for Poe.apps.list()
openPropsnullJSON data for Poe.getOpenProps()
createPlatformCallerMockCustom platform caller factory for actions

createClient() Result

PropertyDescription
PoeFull production Poe API (stream, call, listModels, etc.)
storeTyped SyncedStoreClient (already synced with server data)
dispose()Clean up this client's resources

createControlledClient() Result

Same as createClient() plus:

PropertyDescription
transportQueuedTransport for manual flush control

Harness Methods

MethodDescription
harness.createClient(opts?)Create a connected client with server data loaded
harness.createControlledClient(opts?)Create a client with manual flush control
harness.removeUser({ userId, removedBy? })Fire the onRemoveUser system mutator on the test backend (default removedBy: "system"). Useful for testing mid-game disconnect flows that client.dispose() doesn't exercise.
harness.setBotResponseHandler(handler)Replace bot response handler
harness.setListModelsData(models)Replace models list
harness.setListAppsData(apps)Replace apps list
harness.setPlatformCaller(factory)Replace platform caller factory
harness.requestsAll captured requests
harness.getRequests(method)Filter captured requests by method
harness.multiAppHarnessThe underlying multi-app harness (advanced)
harness.dispose()Clean up all resources