Skip to content

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

MethodDescription
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 userId and clientId — 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:

  1. The main HTTP server serves a host page containing an iframe
  2. The iframe loads your app from a per-bundle HTTP server
  3. 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
  4. The iframe app calls Poe.setupStore(), which connects to the store via RPC
  5. The store's network transport connects to the WebSocket sync server via the host
  6. 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 files

Tips

  • 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 #status element pattern (showing "loading" → "ready") is a reliable way to wait for store initialization
  • For sandboxed iframes, use page.frame({ url: /bundleId=/ }) instead of frameLocator