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 "@ai-app/canvas-frame-lib/test-utils";API
| Method | Description |
|---|---|
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 "@ai-app/canvas-frame-lib/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 { buildApp } from "@ai-app/canvas-frame-lib/bundler";
test.beforeAll(async () => {
// Build the app
await buildApp({
frontend: join(FIXTURE_DIR, "src/App.tsx"),
backend: 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
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