Skip to content

Unit Tests

This guide explains how to test canvas frame apps using createPoeTestHarness. The harness creates an isolated Poe-shaped instance with in-memory RPC — the same transport used in production — so stream(), call(), permissions, and store all work without a browser or host application.

Basic Setup

typescript
import { test, expect } from "bun:test";
import {
  createPoeTestHarness,
  textResponses,
} from "poe-apps-sdk/test-utils";

test("stream bot response", async () => {
  const poe = createPoeTestHarness({
    getBotResponse: textResponses(["Hello ", "World!"]),
  });

  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!");

  poe.dispose();
});

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 poe = createPoeTestHarness({
  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/test-utils";

test("tool call loop", async () => {
  const poe = createPoeTestHarness({
    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 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);
});

Permissions

By default the harness grants all permissions. Override with a static map or a handler function:

typescript
// Static map
const poe = createPoeTestHarness({
  permissions: { camera: true, microphone: false },
});

// Handler function
const poe = createPoeTestHarness({
  permissions: (scope) => scope.startsWith("read:"),
});

Set individual scopes mid-test:

typescript
poe.setPermission("camera", false);

Or replace the handler entirely:

typescript
poe.setPermissionHandler(() => false); // deny all

For full control over the RPC response shape (e.g. returning error fields), use the per-method handlers:

typescript
const poe = createPoeTestHarness({
  permissionsRequest: ({ scopes }) => ({
    granted: scopes.filter(s => s === "camera"),
    denied: scopes.filter(s => s !== "camera"),
    error: scopes.some(s => s !== "camera") ? "denied by policy" : undefined,
  }),
});

Request Capture

Every RPC call is recorded in poe.requests:

typescript
for await (const _ of poe.stream({ botName: "bot", prompts: "Hi" })) {}
await poe.permissions.check("camera");

expect(poe.requests).toHaveLength(2);
expect(poe.getRequests("getBotResponse")).toHaveLength(1);
expect(poe.getRequests("permissions.check")).toHaveLength(1);

Store

Pass store: {} to enable poe.setupStore():

typescript
test("store round-trip", async () => {
  const poe = createPoeTestHarness({ store: {} });

  const store = poe.setupStore({
    mutators: {
      setValue: async (ctx, input) => {
        await ctx.table("main").set({
          itemKey: input.key,
          value: input.value,
        });
      },
    },
  });

  await store.waitForLocalData();

  const result = await store.query((tx) =>
    tx.table("main").get("nonexistent"),
  );
  expect(result).toBeUndefined();

  store.dispose();
  poe.dispose();
});

The harness exposes poe.hostKvStorage and poe.hostNetworkTransport for host-side inspection.

Swapping Handlers Mid-Test

All handlers can be replaced at any point:

typescript
poe.setBotResponseHandler(textResponse("new response"));
poe.setPermissionHandler((scope) => scope === "camera");
poe.setPermissionsRequestHandler(({ scopes }) => ({ granted: [], denied: scopes, error: "nope" }));
poe.setPermissionsCheckHandler(({ scope }) => ({ granted: false }));
poe.setGetStoreEnvironmentHandler(() => ({ clientId: "c1", instanceId: "i1", optimisticUserId: "u1", storeType: "test" }));

Options Reference

createPoeTestHarness Options

OptionDefaultDescription
getBotResponseEmpty text responseBot response handler (async generator)
permissionsGrant allRecord<string, boolean> or (scope) => boolean
permissionsRequestDelegates to permissionsFull permissions.request handler
permissionsCheckDelegates to permissionsFull permissions.check handler
getStoreEnvironmentReturns store.envOverride store environment response
storeOmit to skip store. {} for defaults.
store.envTest defaultsPartial StoreEnvironmentConfig
store.networkTransportNo-op transportCustom NetworkTransport

Harness Methods (Poe-shaped)

MethodDescription
poe.stream(request)Stream bot response chunks
poe.call(request)Agentic tool execution loop
poe.createTool(options)Create an executable tool
poe.setupStore(config)Create a SyncedStoreClient (requires store option)
poe.permissions.request(scope)Request one or more permissions (string or string[])
poe.permissions.check(scope)Check a single permission

Harness Methods (Test Controls)

MethodDescription
poe.setBotResponseHandler(handler)Replace bot response handler
poe.setPermissionHandler(handler)Replace permission handler (resets non-explicit per-method handlers)
poe.setPermission(scope, granted)Set a single permission (accumulates across calls)
poe.setPermissionsRequestHandler(handler)Replace permissions.request handler
poe.setPermissionsCheckHandler(handler)Replace permissions.check handler
poe.setGetStoreEnvironmentHandler(handler)Replace getStoreEnvironment handler
poe.requestsAll captured RPC requests
poe.getRequests(method)Filter captured requests by method
poe.hostKvStorageHost-side KV storage (if store enabled)
poe.hostNetworkTransportHost-side network transport (if store enabled)
poe.dispose()Clean up all resources