Appearance
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 inferredCall 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 minimumawait 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
instanceIdto avoid state leakage. - Always
await waitForBlobFrame(page)— the app runs inside a blob: iframe. - Use
timeout: 15_000on 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.