Appearance
E2E Tests With Playwright
End-to-end tests verify that your app works correctly in a real browser, including store initialization, mutations, subscriptions, and multi-user sync over WebSockets. The platform provides a TestServer that spins up a complete in-process environment for Playwright tests.
TestServer
TestServer is a thin wrapper around startE2EServer() that creates:
- An in-memory blob storage for app bundles
- A WebSocket sync server (synced-store backend with in-memory SQLite)
- HTTP servers for host pages and static app content
- Platform script injection (import maps,
data-store-config)
typescript
import { TestServer } from "poe-apps-sdk/test-utils";API
| Method | Description |
|---|---|
new TestServer(options?) | Create a server (accepts optional createPlatformCaller callback) |
server.start() | Start the E2E server (async) |
server.registerApp({ typeId, content }) | Register an app from a local directory |
server.sessionUrl({ appTypeId, instanceId, userId, clientId }) | Generate a URL for a user session |
server.close() | Shut down all servers and workers |
Basic Test Setup
typescript
import { test, expect } from "@playwright/test";
import { TestServer } from "poe-apps-sdk/test-utils";
const server = new TestServer();
test.beforeAll(async () => {
await server.start();
await server.registerApp({
typeId: "my-todo-app",
content: { type: "directory", dir: "./path/to/app" },
});
});
test.afterAll(() => {
server.close();
});Registering Apps
registerApp takes a directory containing your app files (HTML, JS, CSS). The directory is zipped and uploaded to the in-memory blob storage, just like the production deployment pipeline.
For bundled apps, build first then register the output directory:
typescript
import * as esbuild from "esbuild";
import { buildBackend } from "poe-apps-sdk/bundler";
test.beforeAll(async () => {
// Build frontend + backend
await Promise.all([
esbuild.build({
entryPoints: [join(FIXTURE_DIR, "src/App.tsx")],
bundle: true, format: "esm", target: ["es2020"], platform: "browser",
outfile: join(OUT_DIR, "app-frontend.js"),
external: ["poe-apps-sdk/embed-api/v1.js", "@synced-store/client", "@synced-store/shared"],
logLevel: "error",
}),
buildBackend({
entryPoint: join(FIXTURE_DIR, "src/backend.ts"),
outDir: OUT_DIR,
}),
]);
// Copy the HTML entry point
copyFileSync(join(FIXTURE_DIR, "index.html"), join(OUT_DIR, "index.html"));
// Start server and register
await server.start();
await server.registerApp({
typeId: "bundled-todo",
content: { type: "directory", dir: OUT_DIR },
});
});Writing Tests
Accessing the App Iframe
Apps run inside an iframe with data-testid="app-iframe". Use Playwright's frameLocator to interact with elements inside the iframe:
typescript
test("app loads and shows ready status", async ({ page }) => {
await page.goto(
server.sessionUrl({
appTypeId: "my-todo-app",
instanceId: "test-instance",
userId: "alice",
clientId: "client-alice",
}),
);
const iframe = page.frameLocator('[data-testid="app-iframe"]');
// Wait for the store subscription to fire
await expect(iframe.locator("#status")).toHaveText("ready", {
timeout: 15_000,
});
});Testing Mutations
typescript
test("adds a todo item via Poe.store.mutate", async ({ page }) => {
await page.goto(
server.sessionUrl({
appTypeId: "my-todo-app",
instanceId: "add-test",
userId: "alice",
clientId: "client-alice",
}),
);
const iframe = page.frameLocator('[data-testid="app-iframe"]');
await expect(iframe.locator("#status")).toHaveText("ready", {
timeout: 15_000,
});
// Type a todo and click Add
await iframe.locator("#todo-input").fill("Buy milk");
await iframe.locator("#add-btn").click();
// The subscription should update the list
await expect(iframe.locator("#todo-list")).toContainText("Buy milk", {
timeout: 10_000,
});
});Multi-User Sync Tests
To test real-time synchronization between users, create separate browser contexts. Each context gets its own cookies and storage, simulating independent users:
typescript
test("syncs mutations between two users via WebSocket", async ({
browser,
}) => {
test.setTimeout(60_000);
// Create separate browser contexts for each user
const context1 = await browser.newContext();
const context2 = await browser.newContext();
try {
const page1 = await context1.newPage();
const page2 = await context2.newPage();
// Both users connect to the SAME app instance
await page1.goto(
server.sessionUrl({
appTypeId: "my-todo-app",
instanceId: "sync-test", // Same instance
userId: "alice",
clientId: "client-alice",
}),
);
await page2.goto(
server.sessionUrl({
appTypeId: "my-todo-app",
instanceId: "sync-test", // Same instance
userId: "bob",
clientId: "client-bob",
}),
);
const iframe1 = page1.frameLocator('[data-testid="app-iframe"]');
const iframe2 = page2.frameLocator('[data-testid="app-iframe"]');
// Wait for both to be ready
await expect(iframe1.locator("#status")).toHaveText("ready", {
timeout: 15_000,
});
await expect(iframe2.locator("#status")).toHaveText("ready", {
timeout: 15_000,
});
// User 1 adds a todo
await iframe1.locator("#todo-input").fill("Alice's todo");
await iframe1.locator("#add-btn").click();
// User 2 sees it via WebSocket sync
await expect(iframe2.locator("#todo-list")).toContainText(
"Alice's todo",
{ timeout: 15_000 },
);
// User 2 adds a todo
await iframe2.locator("#todo-input").fill("Bob's todo");
await iframe2.locator("#add-btn").click();
// User 1 sees it via sync
await expect(iframe1.locator("#todo-list")).toContainText("Bob's todo", {
timeout: 15_000,
});
} finally {
await context1.close();
await context2.close();
}
});Key Multi-User Patterns
- Same
instanceId— both users connect to the same app instance (shared data) - Different
userIdandclientId— each user has their own identity and client - Separate
browser.newContext()— independent browser sessions (cookies, storage) - Sync via WebSocket — mutations propagate through the in-memory sync server
Testing with Platform Services
If your app's actions depend on platform capabilities (e.g. ctx.platform.call("getPoeApiKey", {})), pass a createPlatformCaller callback to TestServer. The callback is invoked once per action/mutation request with context about the store and client, and returns a BasePlatformCaller.
Setup
typescript
import { test, expect } from "@playwright/test";
import { TestServer } from "poe-apps-sdk/test-utils";
import type { BasePlatformCaller } from "@synced-store/shared";
const server = new TestServer({
createPlatformCaller: ({ typeId, instanceId, clientId, userId }): BasePlatformCaller => ({
call: async (name: string, input: unknown): Promise<unknown> => {
switch (name) {
case "getPoeApiKey":
return { apiKey: "test-api-key-12345" };
case "env.get":
return { BASE_URL: "http://localhost", BLOB_HOST: "http://localhost" };
default:
throw new Error(`Unhandled service: ${name}`);
}
},
}),
});
test.beforeAll(async () => {
await server.start();
await server.registerApp({
typeId: "my-app",
content: { type: "directory", dir: "./path/to/app" },
});
});
test.afterAll(() => {
server.close();
});Backend config
Define actions that access ctx.platform:
javascript
// synced-store-backend-config.js
async function getApiKey(ctx) {
const { apiKey } = await ctx.platform.call("getPoeApiKey", {});
return { apiKey };
}
export default {
mutators: {},
actions: { getApiKey },
};Test
typescript
test("action returns the API key from platform", async ({ page }) => {
await page.goto(
server.sessionUrl({
appTypeId: "my-app",
instanceId: "test",
userId: "alice",
clientId: "client-alice",
}),
);
const iframe = page.frameLocator('[data-testid="app-iframe"]');
await expect(iframe.locator("#status")).toHaveText("ready", {
timeout: 15_000,
});
// Trigger the action (e.g. via a button click)
await iframe.locator("#get-api-key-btn").click();
// Verify the action result was rendered
await expect(iframe.locator("#api-key-result")).toHaveText(
"test-api-key-12345",
{ timeout: 10_000 },
);
});How it works
createPlatformCallerreturns aBasePlatformCallerwith a singlecall(name, input)method- The caller is converted to a
PlatformTransportfunction and proxied via RPC to the worker thread - Inside action handlers,
ctx.platform.call(name, input)dispatches to the transport - See Platform for the full list of available service names
How the Test Infrastructure Works
When page.goto(sessionUrl) is called, the following happens:
- The main HTTP server serves a host page containing an iframe
- The iframe loads your app from a per-bundle HTTP server
- The host page runs a host bundle that sets up RPC responders for the iframe:
__poe_store__channel — proxies kv storage, network transport, and device channel__poe_iframe_rpc__channel — proxies bot API calls
- The iframe app calls
Poe.setupStore(), which connects to the store via RPC - The store's network transport connects to the WebSocket sync server via the host
- Pull/push/poke messages flow through the WebSocket connection
All of this runs in-process with in-memory storage — no external services needed.
Test File Naming
E2E test files should use the .test.playwright.ts extension:
my-app/
├── __tests__/
│ └── e2e/
│ ├── my-app.test.playwright.ts # Playwright E2E tests
│ └── fixtures/ # App fixture filesTips
- Use generous timeouts (10-15s) for initial load — the first
Poe.setupStore()needs to complete a pull handshake - Use
test.setTimeout(60_000)for multi-user tests that involve multiple page loads - The
#statuselement pattern (showing "loading" → "ready") is a reliable way to wait for store initialization - For sandboxed iframes, use
page.frame({ url: /bundleId=/ })instead offrameLocator