Skip to content

Unit Tests

This guide explains how to test Joiner activities using createPoeTileTestHarness. 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).

Run UI and mutator tests through bun run test. The scaffold's script explicitly preloads tests/setup-dom.ts before importing Testing Library, so screen binds to the Happy DOM document. Do not replace it with an ad hoc bun test <file> command or rely on an in-file setup import; ESM import evaluation can bind Testing Library before the document exists. The same preload registers @testing-library/jest-dom with Bun's expect and supplies its matcher types. Prefer expressive assertions such as toBeDisabled() and toHaveAttribute() over manual DOM property checks.

Manager assertions

For turn markers, unread, Recents, or push assertions, start with the default multiplayer scaffold's synced-store/manager.test.ts. It runs in bun run test and extends the starter schema only inside the example; replace its demonstration mutator with your activity's real mutators/configs as you implement them.

createPoeTileInManagerTestHarness({ tile: { typeId, clientConfig, backendConfig }, users: ["alice", "bob"] }) infers the store from clientConfig. h.tile("alice").poe is a real, typed Poe client; use its .store directly, without casts or explicit generic arguments. Always dispose the harness.

Await the mutation's .confirmed, then h.manager(userId).wait(predicate) for the specific manager effects being asserted. Initial seating or an earlier turn may already have marked the actor unread: wait for initialization, capture a baseline, and assert that the actor's action adds no new unread rather than asserting their absolute count is zero. Assert the opponent's expected turn/unread change and the intended push delta separately. Do not clear unread or sleep merely to make an assertion pass.

Basic Setup — Store Tests

typescript
import { test, expect } from "bun:test";
import { createPoeTileTestHarness } from "poe-tiles-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 = createPoeTileTestHarness<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).

Kernel config diagnostics

Headless clients can share one document, so creating another root can replace the previous root's config div. createPoeTileTestHarness and createPoeMultiTileTestHarness capture these mismatches without printing them. Inspect harness.poeConfigDivMismatches when testing config consistency: this read-only list retains code, message, tileTypeId, and tileInstanceIdRaw for each report. An explicit createRoot({ clientErrorSink }) still receives the reports. Captured config mismatches do not fail dispose(); store-error checks and production console diagnostics remain unchanged.

Membership, profiles, and onAddUsers are deterministic

createClient({ userId }) creates the client's $users and $userInfo rows, then fires onAddUsers. By default, $userInfo.displayName and $userInfo.username equal the supplied user ID and profilePicture is empty. Pass userInfo when a test needs a specific name or avatar:

typescript
const { store: alice } = await harness.createClient({
  userId: "alice",
  userInfo: {
    displayName: "Alice Example",
    profilePicture: "https://example.com/alice.png",
  },
});

User seeding is idempotent and never overwrites an existing profile. Reopening the same userId may omit userInfo or repeat the same fields; conflicting profile fields throw instead of being silently ignored.

The blank scaffold's synced-store/hooks.ts and mutators.test.ts form an executable auto-seating example: the first member receives X, the second O, and later members remain spectators. Type reusable readers with the exported AppReadContext; it includes app tables and system tables, so the same reader works with query, subscribe, mutator contexts, and query-based wait helpers:

typescript
import type { AppReadContext } from "../client";

async function readLobby(ctx: AppReadContext) {
  return {
    players: await ctx.table("players").scan().values().toArray(),
    members: await ctx.table("$users").scan().values().toArray(),
    profiles: await ctx.table("$userInfo").scan().values().toArray(),
  };
}

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

expect((await alice.store.query(readLobby)).players).toEqual([
  { userId: "alice", mark: "X" },
  { userId: "bob", mark: "O" },
  { userId: "carol", mark: null },
]);

Do not create a temporary $userInfo inspection test to discover these semantics; they are part of the harness contract. Use harness.addUser(...) when the test needs a membership system mutation without opening another client, and always finish with harness.dispose().

Asserting that a mutator rejects

store.mutate.someMutator(input) returns Promise<{ id, confirmed }>. The outer promise runs the mutator's client pass, so a guard that throws there — "it's not your turn", "that cell is taken", "waiting for both players" — rejects the outer promise. Assert it directly, without awaiting the call first:

typescript
// `createClient()` returns the client wrapper, so destructure the store —
// `client.mutate` does not exist.
const { store: alice } = await harness.createClient({ userId: "alice" });

await expect(alice.mutate.makeMove({ cellIndex: 1 })).rejects.toThrow(
  "It's not your turn",
);

confirmed is a separate promise for awaiting server confirmation of a mutation that already passed the client pass. Do not route a validation assertion through it. Both shapes below are wrong and fail in confusing ways — the mutation has already rejected by the time you reach .confirmed, so the await throws before expect ever runs:

typescript
// WRONG — the outer await throws before .confirmed is read.
await expect(
  (await alice.mutate.makeMove({ cellIndex: 1 })).confirmed,
).rejects.toThrow("It's not your turn");

// WRONG — same problem, just destructured.
const { confirmed } = await alice.mutate.makeMove({ cellIndex: 1 });
await expect(confirmed).rejects.toThrow("It's not your turn");

Keep using await (await store.mutate.x({})).confirmed for the success path, where you genuinely want to wait for the server to confirm.

Match a message that identifies the rule, and pick an input that violates only that rule. rejects.toThrow(...) passes on any rejection carrying the substring, so a mutator that throws one generic message for every illegal move — or a validator typed boolean, which cannot say more — makes this assertion unable to distinguish the rule it was written for from any other. That test then keeps passing after its rule is deleted, because a neighbouring rule refuses the same input:

typescript
// WEAK — "Invalid move" is thrown for out-of-order, non-adjacent, and occupied
// alike, and the chosen cell violates all three. Deleting the order check
// leaves this green.
await expect(alice.mutate.extend({ cell: farCheckpoint })).rejects.toThrow(
  "Invalid move",
);

// STRONG — a distinct reason per rule, and a cell that is adjacent and free, so
// only the order rule can refuse it.
await expect(alice.mutate.extend({ cell: adjacentLaterCheckpoint })).rejects.toThrow(
  "checkpoint-order",
);

See test-quality.md for the standard this serves, including the delete-the-rule check that proves an assertion is load-bearing.

Basic Setup — Model Call Tests

An activity reaches a model from an ACTION, so what a test scripts is the poe.modelProxy.stream platform capability, not an HTTP response. Pass a createPlatformCaller that answers it with SSE frames in the shape the codec emits:

typescript
import { test, expect } from "bun:test";
import {
  createMockPlatformCaller,
  createPoeTileTestHarness,
} from "poe-tiles-sdk/v1/test-utils.js";
import { myBackendConfig } from "../synced-store/backend-config";

function frame(event: Record<string, unknown>): string {
  return `event: ${String(event["type"])}\ndata: ${JSON.stringify(event)}\n\n`;
}

function modelReply(text: string): ReadableStream<Uint8Array> {
  const encoder = new TextEncoder();
  return new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(encoder.encode(frame({ type: "text", delta: text })));
      controller.enqueue(
        encoder.encode(frame({ type: "finish", finishReason: "stop" })),
      );
      controller.close();
    },
  });
}

test("summarize returns the model's reply", async () => {
  const harness = createPoeTileTestHarness({
    store: { backendConfig: myBackendConfig },
    createPlatformCaller: () =>
      createMockPlatformCaller({
        "poe.modelProxy.stream": async () => modelReply("Hello World!"),
      }),
  });
  const { store } = await harness.createClient();

  const { text } = await store.action.summarize({ prompt: "Hi" });

  expect(text).toBe("Hello World!");

  harness.dispose();
});

The same shape scripts images — answer "poe.modelProxy.generateHostedImage" with { imageUrl }.

Assert what left the activity, not just what came back. Record the dispatched input and check the model id and the exact key set: a model id that drifts to something the catalog does not stock fails silently, and an activity must never forward a credential field of any name.

typescript
const rounds: Array<{ model: string; keys: string[] }> = [];
createMockPlatformCaller({
  "poe.modelProxy.stream": async (input) => {
    rounds.push({ model: input.model, keys: Object.keys(input).sort() });
    return modelReply("ok");
  },
});
// ...
expect(rounds[0].keys).toEqual(["model", "request"]);

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 = createPoeTileTestHarness<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-tiles-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.

For waitFor / waitForAllClients, the queryFn shape is the same as store.query / store.subscribe: pass (tx) => tx.table(...).get(...), not a helper that expects the store/client object. The key/value wait helpers use { table, key, ... } options instead. If you have a reusable read helper, type it to accept a read context (InferReadContext<TileSchema>) so it can be called from queries, subscribes, mutators, and query-based wait helpers.

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

test("two pre-existing clients merge edits", async () => {
  const harness = createPoeTileTestHarness<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 });

  await waitForKeyExists(bob, { table: "items", key: "t1" });

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

  await waitForKeyMatch(alice, {
    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.

Happy-dom UI tests: never setTimeout(resolve, N) to wait for propagation

Don't paper over UI propagation timing with a hardcoded await new Promise(r => setTimeout(r, 50)) — slow CI, GC pauses, or any scheduler hiccup turns a 50ms cushion into a flake. Bundles also lint-fail on setTimeout in tests.

Use waitFor* from poe-tiles-sdk/v1/test-utils.js. waitFor* is subscription-based, not polling-based — internally it does both a client.subscribe(queryFn, ...) AND an immediate client.query(queryFn) so the predicate gets a synchronous first look at current state. There's still a UI-side gotcha to know about:

  • Subscribe's "initial fire" is async (microtask), not synchronous. mountApp(root, store) returns before its registered subscribe has invoked its callback. If the data the UI needs is already in the store, waitFor's immediate client.query can resolve waitFor before mountApp's subscribe callback has had a chance to update the DOM. Asserting on the DOM right after await waitFor(...) then races.
  • Fix: force a real store change after mounting so both subscribes fire in order. Issue a no-op mutation, then waitFor on a predicate that combines store state AND the DOM. Total wait is bounded by the mutation roundtrip — no setTimeout.
typescript
import { waitFor } from "poe-tiles-sdk/v1/test-utils.js";

const root = document.createElement("div");
mountApp(root, store);

// Force a store change so mountApp's subscribe fires AFTER the test's
// subscribe is set up. Pick any cheap mutation already in your schema
// (e.g. re-record the current score) — the goal is propagation, not a
// real state change.
await (await store.mutate.someCheapMutation({})).confirmed;

await waitFor(store, {
  queryFn: async (tx) => {
    const row = (await tx.table("players").get("alice")) as
      | { bestScore: number }
      | undefined;
    return (
      row?.bestScore === 11 &&
      root.querySelector("#best")?.textContent === "11"
    );
  },
  description: "HUD #best to reflect alice's bestScore=11",
});

If your app has no convenient cheap mutation, add one — the cost is one mutation per UI test, the gain is a bounded, deterministic wait.

Vitest browser mode: when happy-dom can't fake the web API

happy-dom is fine for DOM, layout, and timers, but it has no WebGL, no OffscreenCanvas, no AudioWorklet, and other web APIs that the in-memory harness can't reliably stub. For those, run the same UI flow in real Chromium via Vitest browser mode and connect each client to a real TestServer (HTTP + WebSocket) via createPoeTileBrowserTestHarness.

typescript
// vitest.browser.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
  test: {
    include: ["ui/**/*.test.browser.{ts,tsx}"],
    globalSetup: ["./tests/global-setup.browser.ts"],
    browser: {
      enabled: true,
      provider: "playwright",
      instances: [{ browser: "chromium" }],
      headless: true,
    },
    coverage: {
      enabled: true,
      provider: "v8",
      reporter: ["lcov"],
      reportsDirectory: "./coverage/browser",
    },
  },
});
typescript
// tests/global-setup.browser.ts — starts TestServer once, exposes ports
import { TestServer } from "poe-tiles-sdk/v1/test-utils/playwright.js";
import type { TestProject } from "vitest/node";

declare module "vitest" {
  export interface ProvidedContext {
    syncPort: number;
    tileTypeId: string;
  }
}

export default async function setup(project: TestProject) {
  const server = new TestServer();
  await server.start();
  await server.registerTile({
    typeId: "my-app",
    content: { type: "directory", dir: "./dist" },
  });
  project.provide("syncPort", server.syncPort);
  project.provide("tileTypeId", "my-app");
  return () => server.close();
}
typescript
// ui/App.test.browser.tsx
import { afterEach, beforeEach, describe, expect, test, inject } from "vitest";
import { createPoeTileBrowserTestHarness } from "poe-tiles-sdk/v1/test-utils/browser.js";
import { tileMutators } from "../client";
import { mountApp } from "./App";

describe("real WebGL", () => {
  let harness: ReturnType<typeof createPoeTileBrowserTestHarness>;
  beforeEach(() => {
    harness = createPoeTileBrowserTestHarness({
      storeTypeId: inject("tileTypeId"),
      instanceId: `test-${crypto.randomUUID()}`,
      syncWsUrl: `ws://localhost:${inject("syncPort")}`,
      mutators: tileMutators,
      schemaVersion: 1, // match your app's schemaVersion
    });
  });
  afterEach(() => harness.dispose());

  test("renders scene graph", async () => {
    const { store } = await harness.createClient({ userId: "alice" });
    const root = document.createElement("div");
    document.body.appendChild(root);
    const game = mountApp(root, store);
    // Drive store.mutate / store.query, assert against scene-graph debug, etc.
    game?.stop();
  });
});

When to reach for it:

  • Real WebGL / Three.js scene-graph assertions (happy-dom returns null from getContext("webgl")).
  • Audio / OffscreenCanvas / clipboard / pointer-capture flows.
  • Anywhere a fake DOM diverges from real browser behavior in a way that hides bugs.

Otherwise prefer the in-memory + happy-dom harness — it runs in ~50 ms per test vs ~1.5 s for browser mode. Only use browser-mode coverage where the real API is the point. Add new browser tests under *.test.browser.tsx so they don't run under bun test. See e2e-tests.md for the TestServer API reference.

Testing UI actions that call host RPCs (openProfile, pickMembers, tileEnd, ...)

For host-navigation calls like Poe.users.openProfile(), Poe.room.pickMembers(), or Poe.room.tileEnd(), pass the client's real Poe object into your component instead of hand-writing a fake poe prop object. createClient()'s Poe routes through the full production path (AppsKernel → HostKernelRpc → your app), so it enforces the same request-shape validation and host-side behavior the real host enforces — for example users.openProfile requires a userId and the host silently drops an open it can't route. A hand-rolled stub like { users: { openProfile: async () => {} } } accepts anything, including payloads the real host would reject or drop, which can hide real bugs (a profile button that dispatches an unroutable open shows a broken "profile unavailable" state in production, but a loose stub happily "succeeds" in the test):

typescript
test("tapping a player's avatar opens their profile", async () => {
  const harness = createPoeTileTestHarness({ store: { backendConfig } });
  const { Poe, store } = await harness.createClient({ userId: "alice" });
  const { container } = render(() => <App store={store} poe={Poe} />);

  (container.querySelector('[data-testid="profile-bob"]') as HTMLButtonElement).click();

  await waitFor(() => harness.getRequests("users.openProfile").length === 1);
  // Keyed on `userId` — the host resolves the profile from it.
  expect(harness.getRequests("users.openProfile")[0].params).toEqual({
    userId: "bob",
  });
});

Only reach for a hand-written poe fake when you need a return value the harness can't produce (e.g. simulating a rejected promise to test error handling) — and even then, prefer wrapping the real Poe object ({ ...Poe, users: { ...Poe.users, openProfile: async () => { throw new Error(...) } } }) so every other call still goes through the validated path.

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 = createPoeTileTestHarness({
    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). For private rows, use
  // external.privateOfUser(userId).table("prefs").get("current").

  harness.dispose();
});

Inspecting File Upload Retention

harness.files reads the tested store's authoritative upload state. Use it after awaiting a claiming mutation's confirmed promise; serving the upload URL alone does not prove a strong claim exists because a newly published, unclaimed upload is temporarily servable too.

typescript
const file = await store.files.upload({ data: photoBytes });
expect(file).toMatchObject({ status: "staged", fileKey: null, url: null });

await (await store.mutate.attachPhoto({ fileId: file.fileId })).confirmed;

expect(
  await harness.files.status({
    fileId: file.fileId,
    uploaderUserId: "alice",
  }),
).toMatchObject({ state: "active", claimCount: 1 });
expect(await harness.files.claims(file.fileId)).toEqual([
  expect.objectContaining({
    fileId: file.fileId,
    sourceTableName: "photos",
    sourceItemKey: "today",
    visibility: { kind: "public" },
  }),
]);

status() returns null once lifecycle cleanup removes the upload record. claims() reads every platform-derived $files claim, including private and server-only tiers, and returns its authoritative source row and visibility.

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

createPoeTileTestHarness Options

OptionDefaultDescription
apiHarnessnew ApiTestServer()IApiTestHarness instance — override with a custom implementation
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.tiles.list() and Poe.tiles.get()
openPropsnullJSON data for Poe.getOpenProps()
allowedStoreErrors[]Allowlist for the harness's default assertion that no client logged a logger.error(...) call (see storeErrorLogs below). Each entry is a string (substring match) or RegExp tested against the captured log's formatted text. Only list the specific error(s) a test intentionally provokes — never a catch-all pattern.
createPlatformCallerMockCustom platform caller factory for actions and discouraged idempotent/read-only guarded server-side mutator calls

createClient() Result

PropertyDescription
PoeFull production Poe API (stream, call, listModels, etc.)
storeTyped SyncedStoreClient (already synced with server data)
storeErrorLogsEvery logger.error(...) from this client's store stack, in order (raw args arrays). In production these land in the user's console; the harness captures them instead. Common signals: `"Synced State
simulateOffline()Cut this client's network: the host API fetch throws TypeError("Failed to fetch") and the sync transport disconnects with reconnect blocked. Anything the host answers locally — already-pulled store data, device caches, host-derived URLs — keeps working, so this is how you drive real offline paths. Reopen "the same device" by creating a second client with the same deviceId.
simulateOnline()Restore the network and reconnect the sync transport
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 membership-removal flows; client.dispose() only disconnects and does not remove room membership.
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