Appearance
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:
| Helper | Description |
|---|---|
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 allFor 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
| Option | Default | Description |
|---|---|---|
getBotResponse | Empty text response | Bot response handler (async generator) |
permissions | Grant all | Record<string, boolean> or (scope) => boolean |
permissionsRequest | Delegates to permissions | Full permissions.request handler |
permissionsCheck | Delegates to permissions | Full permissions.check handler |
getStoreEnvironment | Returns store.env | Override store environment response |
store | — | Omit to skip store. {} for defaults. |
store.env | Test defaults | Partial StoreEnvironmentConfig |
store.networkTransport | No-op transport | Custom NetworkTransport |
Harness Methods (Poe-shaped)
| Method | Description |
|---|---|
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)
| Method | Description |
|---|---|
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.requests | All captured RPC requests |
poe.getRequests(method) | Filter captured requests by method |
poe.hostKvStorage | Host-side KV storage (if store enabled) |
poe.hostNetworkTransport | Host-side network transport (if store enabled) |
poe.dispose() | Clean up all resources |