Skip to content

Synced-Store Testing Patterns

Table of Contents

Unit Test Harness

typescript
import { afterEach, expect, test } from "bun:test";
import { createPoeTileTestHarness, waitForKeyExists } from "poe-tiles-sdk/v1/test-utils.js";
import { todoBackendConfig } from "../synced-store/backend-config";
import type { TodoSchema } from "../synced-store/schema";
import type { TodoItem } from "../synced-store/data/items";

const harnesses: ReturnType<typeof createPoeTileTestHarness<TodoSchema>>[] = [];
afterEach(() => {
  for (const harness of harnesses.splice(0)) harness.dispose();
});

function createStore() {
  const harness = createPoeTileTestHarness<TodoSchema>({
    store: { backendConfig: todoBackendConfig },
  });
  harnesses.push(harness);
  return harness;
}

type TodoStore = Awaited<
  ReturnType<ReturnType<typeof createStore>["createClient"]>
>["store"];

Keep the concrete schema parameter when extending fields or mutators; the store's inputs and queried rows then follow the schema without casts. The todo example uses TodoSchema; the default multiplayer starter uses AppSchema. createPoeTileTestHarness<Schema>().createClient() returns { store }. The lower-level multi-activity harness instead returns a ChildClient from mountChild(); read its store as child.poe.store, not child.store.

Querying Data

typescript
async function getItem(store: TodoStore, id: string): Promise<TodoItem | undefined> {
  return store.query((tx) => tx.table("items").get(id));
}

const harness = createStore();
const { store } = await harness.createClient({ userId: "alice" });
const item = await getItem(store, "todo-1");
const entries = await store.query((tx) => tx.table("items").entries().toArray());
const items = entries.map(([, value]) => value); // row types are inferred

Call store-taking helpers such as getItem(store, id) directly. Do not pass them into store.query or cast the transaction to a store. A query callback receives a read context; type reusable transaction readers with InferReadContext<TodoSchema> (or the scaffold's AppReadContext).

Multi-Client Tests

A peer read should wait for the state it asserts, including server confirmation:

typescript
test("another participant sees the server-confirmed todo", async () => {
  const harness = createStore();
  const { store: alice } = await harness.createClient({ userId: "alice" });
  const { store: bob } = await harness.createClient({ userId: "bob" });

  const result = await alice.mutate.setTodo({ id: "1", text: "hello", createdAt: 1000 });
  await result.confirmed;

  const item = await waitForKeyExists<TodoItem>(bob, { table: "items", key: "1" });
  expect(item).toMatchObject({ id: "1", text: "hello" });
});

But sequential mutations from different pre-existing clients race each other's optimistic state — bob's optimistic pass runs against his pre-mutation snapshot of the world, not alice's freshly-committed one. Use the waitFor* family from poe-tiles-sdk/v1/test-utils.js to gate on propagated state before the next mutation. The most flexible option is waitForKeyMatch:

store.query, store.subscribe, waitFor, and waitForAllClients use the same callback contract: pass (tx) => ... and read through that transaction. Do not pass a helper that expects the store/client object itself. The convenience helpers (waitForKeyExists, waitForValue, waitForKeyMatch, waitForKeyDeleted) take { table, key, ... } parameter objects instead.

Common failure: "all submitters trigger" mutators.

  • The pattern: a mutator scans a public table to detect a server-aggregate condition — "if every player has hasSubmitted: true, transition to revealing", "if every team has filled its slot, start the game", etc.
  • Why it breaks with bare await client.mutate.X(...) between clients: the final submitter's mutator scans the table before the prior submitters' writes are committed, sees stale flags, and the trigger never fires.
  • Symptom: timing-sensitive. Often passes with 2 clients (the one prior write happens to land in time) and fails at 3+.
  • Fix: always gate cross-client steps with waitForKeyMatch (or at minimum await r.confirmed) before the next client mutates.
typescript
import {
  waitForKeyExists,
  waitForKeyMatch,
} from "poe-tiles-sdk/v1/test-utils.js";

test("alice creates, bob completes, alice sees the merge", async () => {
  const harness = createStore();
  const { store: alice } = await harness.createClient({ userId: "alice" });
  const { store: bob } = await harness.createClient({ userId: "bob" });

  await alice.mutate.setTodo({ id: "1", text: "hello", completed: false, createdAt: 1000 });

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

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

  await waitForKeyMatch<TodoItem>(alice, {
    table: "items",
    key: "1",
    match: (i) => i.completed === true,
  });

  const item = await alice.query((tx) => tx.table("items").get("1"));
  expect(item).toMatchObject({ text: "hello", completed: true });
});

Family: waitForKeyExists, waitForValue, waitForKeyMatch, waitForKeyDeleted, waitForAllClients, waitFor. Each takes optional { timeoutMs, description } and emits a descriptive timeout message on failure. Don't hand-roll await mutate.X(); await confirmed; await waitForServerData() — these helpers already cover that flow with proper diagnostics. Full reference and table in Unit Tests → Multi-Client Testing.

typescript
test("ctx.userId is set per client", async () => {
  const harness = createStore();
  const { store } = await harness.createClient({ userId: "alice" });
  const result = await store.mutate.setTodo({ id: "1", createdAt: 1000 });
  await result.confirmed;

  const item = await store.query((tx) => tx.table("items").get("1"));
  expect(item?.createdBy).toBe("alice"); // set via ctx.userId in mutator
});

Awaiting Mutators That Enqueue Actions

Mutators that fire ctx.enqueueAction(...) for post-commit work need deterministic test handling — don't use setTimeout/tick. Call the action directly via store.action.X(...) after the mutator. See testing-actions.md → Awaiting Mutators That Enqueue Actions.

E2E Playwright Tests

typescript
import { test, expect } from "@playwright/test";
import { TestServer, waitForBlobFrame } from "poe-tiles-sdk/v1/test-utils/playwright.js";

const server = new TestServer();

test.beforeAll(async () => {
  await server.start();
  await server.registerTile({
    typeId: "my-app",
    content: { type: "directory", dir: DIST_DIR },
  });
});

test.afterAll(() => server.close());

function sessionUrl(config: { instanceId: string; userId?: string; clientId?: string }) {
  return server.sessionUrl({
    tileTypeId: "my-app",
    instanceId: config.instanceId,
    userId: config.userId ?? "alice",
    clientId: config.clientId ?? "client-alice",
  });
}

test("app loads", async ({ page }) => {
  await page.goto(sessionUrl({ instanceId: "test-load" }));
  const frame = await waitForBlobFrame(page);
  await expect(frame.locator("#app-title")).toBeVisible({ timeout: 15_000 });
});

Key points:

  • Each test must use a unique instanceId to avoid state leakage.
  • Always await waitForBlobFrame(page) — the app runs inside a blob: iframe.
  • Use timeout: 15_000 on first visibility check (cold start).
  • Build before running: bun run build && bunx playwright test.
  • Multi-browser tests: use browser.newContext() for each player with different userId/clientId.