Files
suaveplan/docs/superpowers/plans/2026-04-24-testbed-foundation.md
2026-05-27 23:29:53 +01:00

92 KiB

Implementation Plan: Testbed Foundation

Date: 2026-04-24 Branch: genesis Scope: Part A — @suaveplan/testing-e2e 4 new modules; Part B — apps/testbed/ scaffold, server, client shell, layout + shared components


For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Do not proceed step-by-step manually — dispatch chunks to parallel or sequential subagents as the dependency graph permits. Stop only at: publish gates, destructive git operations, and explicit scope-change boundaries.


Goal

Deliver two things in dependency order:

  1. Part A — four new modules in @suaveplan/testing-e2e: multi-session, console-monitor, network-monitor, extension-fixture. Each follows TDD (failing tests first, then implementation), achieves 100% coverage, and is wired into the package exports map.

  2. Part B — apps/testbed/ workspace: Hono + Bun server, React 19 + Vite + Tailwind v4 client shell, React Router v7 SPA with sidebar/header layout, five layout components, three shared components, Storybook 8, Playwright config, Dockerfile. Library pages and E2E specs are in a separate plan.

Part A must be fully green before Part B begins (Part B imports from @suaveplan/testing-e2e).


Architecture

Part A — @suaveplan/testing-e2e additions
  src/multi-session/       <- createMultiSession(), simulateServerFailure()
  src/console-monitor/     <- attachConsoleMonitor()
  src/network-monitor/     <- attachNetworkMonitor()
  src/extension-fixture/   <- launchWithExtension()
  src/index.ts             <- updated barrel
  package.json             <- 4 new exports map entries

Part B — apps/testbed/
  src/server/
    index.ts               <- Hono: serves dist/client/ + mounts /api routes
    routes/api/
      storage.ts           <- proxy for storage-s3 demo
      database.ts          <- proxy for surrealdb demo
      test-control.ts      <- POST /api/test-control/disable-route (NODE_ENV=test only)
  src/client/
    main.tsx               <- React 19 root render
    app.tsx                <- React Router v7 SPA + top-level layout
    routes.ts              <- ROUTE MANIFEST
    components/layout/     <- sidebar, header, theme-toggle, locale-switcher, console-panel
    components/shared/     <- demo-card, code-block, status-badge
    styles/globals.css
  .storybook/              <- main.ts + preview.ts (ThemeDecorator + LocaleDecorator)
  playwright.config.ts
  vite.config.ts
  Dockerfile

Tech Stack

  • Runtime: Bun
  • Build (client): Vite + @vitejs/plugin-react
  • Styling: Tailwind v4 (@tailwindcss/vite)
  • Server: Hono (hono)
  • Router: React Router v7 (react-router-dom)
  • Storybook: v8, @storybook/react-vite
  • E2E: Playwright (config only in this plan; specs in a separate plan)
  • Tests (Part A): Vitest, pool forks, provider: 'istanbul', 100% coverage
  • Lint: Biome

Pre-flight Checklist

Before writing any code, verify:

  1. ls /mnt/s/web/genesis/universal/dev/testing-e2e/src/ — confirm the existing module directories.
  2. cat /mnt/s/web/genesis/universal/dev/testing-e2e/package.json — note current exports map entries.
  3. ls /mnt/s/web/genesis/apps/ — confirm apps/ directory exists (create if absent).
  4. cat /mnt/s/web/genesis/package.json — check workspaces array and catalog entries; ensure apps/* is included.

Chunk 1 — @suaveplan/testing-e2e: 4 New Modules (TDD)

All four modules live inside universal/dev/testing-e2e/src/. Each follows this cycle:

  1. Create <module>/<module>.types.ts and <module>/index.ts
  2. Write failing test file <module>/<module>.test.ts
  3. Run bunx turbo test --filter=@suaveplan/testing-e2e — confirm failures
  4. Write implementation <module>/<module>.ts and doc <module>/<module>.md
  5. Run tests again — confirm all pass
  6. After all 4 modules: update src/index.ts, update package.json exports
  7. Run pre-publish gate

Task 1.1 — multi-session/ module

File: src/multi-session/create-multi-session.types.ts

import type { Browser, BrowserContext, Page } from "@playwright/test";

export interface MultiSessionOptions {
  browser: Browser;
  count: number;
  baseUrl: string;
  roles: string[];
}

export interface MultiSessionResult {
  pages: Page[];
  contexts: BrowserContext[];
  teardown: () => Promise<void>;
}

File: src/multi-session/create-multi-session.test.ts

Write BEFORE the implementation. All tests use { timeout: 30000 }.

import type { Browser, BrowserContext, Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";

function makeFakePage(gotoFn = vi.fn(async () => null)): Page {
  return { goto: gotoFn } as unknown as Page;
}

function makeFakeContext(page: Page): BrowserContext {
  return {
    newPage: vi.fn(async () => page),
    close: vi.fn(async () => undefined),
  } as unknown as BrowserContext;
}

function makeFakeBrowser(contexts: BrowserContext[]): Browser {
  let i = 0;
  return {
    newContext: vi.fn(async () => contexts[i++]!),
  } as unknown as Browser;
}

function makeFetch(status: number) {
  return vi.fn(async () => ({ status })) as unknown as typeof fetch;
}

describe("createMultiSession", () => {
  it("creates count contexts and pages, navigates each to baseUrl?role=<role>", {
    timeout: 30000,
  }, async () => {
    const { createMultiSession } = await import("./create-multi-session.js");
    const pages = [makeFakePage(), makeFakePage(), makeFakePage()];
    const contexts = pages.map(makeFakeContext);
    const browser = makeFakeBrowser(contexts);

    const result = await createMultiSession({
      browser,
      count: 3,
      baseUrl: "http://localhost:3200/network-webrtc?session=abc",
      roles: ["host", "peer", "peer"],
    });

    expect(result.pages).toHaveLength(3);
    expect(result.pages[0]).toBe(pages[0]);
    expect((pages[0]!.goto as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
      "http://localhost:3200/network-webrtc?session=abc&role=host"
    );
    expect((pages[1]!.goto as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
      "http://localhost:3200/network-webrtc?session=abc&role=peer"
    );
  });

  it("teardown closes all contexts", { timeout: 30000 }, async () => {
    const { createMultiSession } = await import("./create-multi-session.js");
    const pages = [makeFakePage(), makeFakePage()];
    const contexts = pages.map(makeFakeContext);
    const browser = makeFakeBrowser(contexts);

    const result = await createMultiSession({
      browser, count: 2,
      baseUrl: "http://localhost:3200/test",
      roles: ["a", "b"],
    });

    await result.teardown();
    expect(contexts[0]!.close).toHaveBeenCalledOnce();
    expect(contexts[1]!.close).toHaveBeenCalledOnce();
  });

  it("appends role param with & when baseUrl has query string", {
    timeout: 30000,
  }, async () => {
    const { createMultiSession } = await import("./create-multi-session.js");
    const page = makeFakePage();
    const context = makeFakeContext(page);
    const browser = makeFakeBrowser([context]);
    await createMultiSession({
      browser, count: 1,
      baseUrl: "http://localhost:3200/room?session=xyz",
      roles: ["viewer"],
    });
    expect((page.goto as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
      "http://localhost:3200/room?session=xyz&role=viewer"
    );
  });

  it("appends role param with ? when baseUrl has no query string", {
    timeout: 30000,
  }, async () => {
    const { createMultiSession } = await import("./create-multi-session.js");
    const page = makeFakePage();
    const context = makeFakeContext(page);
    const browser = makeFakeBrowser([context]);
    await createMultiSession({
      browser, count: 1,
      baseUrl: "http://localhost:3200/room",
      roles: ["viewer"],
    });
    expect((page.goto as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
      "http://localhost:3200/room?role=viewer"
    );
  });
});

describe("simulateServerFailure", () => {
  it("sends POST to /api/test-control/disable-route and accepts 204", {
    timeout: 30000,
  }, async () => {
    const { simulateServerFailure } = await import("./create-multi-session.js");
    const fetchFn = makeFetch(204);
    await simulateServerFailure("http://localhost:3200", "/api/storage", fetchFn);
    expect(fetchFn).toHaveBeenCalledWith(
      "http://localhost:3200/api/test-control/disable-route",
      expect.objectContaining({ method: "POST" })
    );
  });

  it("throws when response status is not 204", { timeout: 30000 }, async () => {
    const { simulateServerFailure } = await import("./create-multi-session.js");
    const fetchFn = makeFetch(500);
    await expect(
      simulateServerFailure("http://localhost:3200", "/api/storage", fetchFn)
    ).rejects.toThrow("simulateServerFailure: expected 204, got 500");
  });
});

File: src/multi-session/create-multi-session.ts

import type { Browser, BrowserContext, Page } from "@playwright/test";
import type { MultiSessionOptions, MultiSessionResult } from "./create-multi-session.types.js";

/**
 * Opens `count` isolated browser contexts against the same base URL.
 * Each context gets a unique role appended as `?role=<value>` or `&role=<value>`.
 */
export async function createMultiSession(
  options: MultiSessionOptions
): Promise<MultiSessionResult> {
  const { browser, count, baseUrl, roles } = options;
  const separator = baseUrl.includes("?") ? "&" : "?";
  const contexts: BrowserContext[] = [];
  const pages: Page[] = [];

  for (let i = 0; i < count; i++) {
    const context = await browser.newContext();
    const page = await context.newPage();
    const role = roles[i] ?? String(i);
    await page.goto(`${baseUrl}${separator}role=${role}`);
    contexts.push(context);
    pages.push(page);
  }

  const teardown = async (): Promise<void> => {
    await Promise.all(contexts.map((ctx) => ctx.close()));
  };

  return { pages, contexts, teardown };
}

/**
 * Sends POST /api/test-control/disable-route to the testbed server.
 * Only works when NODE_ENV === "test". Asserts 204 response.
 */
export async function simulateServerFailure(
  apiBaseUrl: string,
  route: string,
  fetchFn: typeof fetch = fetch
): Promise<void> {
  const url = `${apiBaseUrl}/api/test-control/disable-route`;
  const response = await fetchFn(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ route }),
  });
  if (response.status !== 204) {
    throw new Error(`simulateServerFailure: expected 204, got ${response.status}`);
  }
}

File: src/multi-session/create-multi-session.md

# create-multi-session

## Purpose

`createMultiSession` opens multiple isolated Playwright browser contexts against the same
base URL, each navigated to a role-specific URL variant. It is the foundation for all
multi-participant E2E tests — WebRTC mesh, WebSocket rooms, BroadcastChannel cross-tab
messaging — where each context simulates a distinct user or browser tab.

`simulateServerFailure` sends a POST to the Hono testbed server's test-control endpoint
to disable a named route mid-test, enabling server-failure simulation scenarios.

## Features

- Opens N isolated browser contexts sequentially, each with its own page
- Appends `?role=<value>` or `&role=<value>` depending on whether baseUrl has a query string
- Returns `teardown()` that closes all contexts in parallel via `Promise.all`
- `simulateServerFailure` accepts injectable `fetchFn` for 100% unit coverage
- Throws a clear error if the test-control endpoint returns a non-204 status

## Usage: Basic Example

```typescript
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";

const { pages, teardown } = await createMultiSession({
  browser,
  count: 2,
  baseUrl: "http://localhost:3200/network-webrtc?session=abc",
  roles: ["host", "peer"],
});
await Promise.all(pages.map(p => p.waitForSelector('[data-status="connected"]')));
await teardown();

Usage: Advanced Example

import { createMultiSession, simulateServerFailure } from "@suaveplan/testing-e2e/multi-session";

test("mesh holds after signalling server goes down", async ({ browser }) => {
  const { pages, teardown } = await createMultiSession({
    browser, count: 3,
    baseUrl: "http://localhost:3200/network-webrtc?session=failover-test",
    roles: ["host", "peer", "peer"],
  });
  await Promise.all(pages.map(p => p.waitForSelector('[data-status="connected"]')));
  await simulateServerFailure("http://localhost:3200", "/api/signalling");
  await pages[1]!.waitForSelector('[data-status="connected"]');
  await teardown();
});

API Reference

createMultiSession(options): Promise<MultiSessionResult>

  • options.browser: Browser — Playwright Browser instance
  • options.count: number — number of contexts/pages to open
  • options.baseUrl: string — URL to navigate each page to; role appended automatically
  • options.roles: string[] — role labels; roles[i] appended as role=<value>
  • Returns MultiSessionResult with pages: Page[], contexts: BrowserContext[], teardown: () => Promise<void>

simulateServerFailure(apiBaseUrl, route, fetchFn?): Promise<void>

  • apiBaseUrl: string — e.g. "http://localhost:3200"
  • route: string — route path to disable, e.g. "/api/signalling"
  • fetchFn?: typeof fetch — injectable for unit tests; defaults to global fetch
  • Throws Error if response status is not 204

Implementation Notes

Contexts are opened sequentially rather than in parallel to avoid race conditions when sharing a Browser instance across many concurrent context creates. Teardown is parallel since close operations are independent.


#### File: `src/multi-session/index.ts`

```typescript
export { createMultiSession, simulateServerFailure } from "./create-multi-session.js";
export type { MultiSessionOptions, MultiSessionResult } from "./create-multi-session.types.js";

Task 1.2 — console-monitor/ module

File: src/console-monitor/console-monitor.types.ts

import type { ConsoleMessage } from "@playwright/test";

export interface ConsoleMonitorOptions {
  failOn?: Array<"error" | "warning" | "info">;
  ignore?: RegExp[];
}

export interface ConsoleMonitor {
  readonly messages: ConsoleMessage[];
  assertClean(): void;
}

File: src/console-monitor/console-monitor.test.ts

Write BEFORE the implementation.

import type { ConsoleMessage, Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";

function fakeMsg(type: string, text: string): ConsoleMessage {
  return { type: () => type, text: () => text } as unknown as ConsoleMessage;
}

function makeFakePage() {
  const listeners = new Map<string, ((msg: ConsoleMessage) => void)[]>();
  const page = {
    on: vi.fn((event: string, cb: (msg: ConsoleMessage) => void) => {
      if (!listeners.has(event)) listeners.set(event, []);
      listeners.get(event)!.push(cb);
    }),
  } as unknown as Page;
  function emit(msg: ConsoleMessage) {
    for (const cb of listeners.get("console") ?? []) cb(msg);
  }
  return { page, emit };
}

describe("attachConsoleMonitor", () => {
  it("registers console listener on the page", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page } = makeFakePage();
    attachConsoleMonitor(page);
    expect((page.on as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
      "console",
      expect.any(Function)
    );
  });

  it("collects console messages emitted on the page", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachConsoleMonitor(page);
    emit(fakeMsg("log", "hello"));
    expect(monitor.messages).toHaveLength(1);
    expect(monitor.messages[0]!.text()).toBe("hello");
  });

  it("assertClean does not throw when no failOn messages", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
    emit(fakeMsg("log", "safe message"));
    expect(() => monitor.assertClean()).not.toThrow();
  });

  it("assertClean throws when an error-level message is collected", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
    emit(fakeMsg("error", "uncaught TypeError"));
    expect(() => monitor.assertClean()).toThrow("uncaught TypeError");
  });

  it("assertClean ignores messages matching ignore regexps", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachConsoleMonitor(page, {
      failOn: ["error"],
      ignore: [/Download the React DevTools/],
    });
    emit(fakeMsg("error", "Download the React DevTools for a better development experience"));
    expect(() => monitor.assertClean()).not.toThrow();
  });

  it("defaults failOn to [error] when not specified", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachConsoleMonitor(page);
    emit(fakeMsg("error", "default failOn error"));
    expect(() => monitor.assertClean()).toThrow("default failOn error");
  });

  it("collects warning messages when failOn includes warning", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachConsoleMonitor(page, { failOn: ["warning"] });
    emit(fakeMsg("warning", "deprecation warning"));
    expect(() => monitor.assertClean()).toThrow("deprecation warning");
  });

  it("normalises warn type to warning", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachConsoleMonitor(page, { failOn: ["warning"] });
    emit(fakeMsg("warn", "normalised warn"));
    expect(() => monitor.assertClean()).toThrow("normalised warn");
  });

  it("assertClean lists all failing messages in the error text", { timeout: 5000 }, async () => {
    const { attachConsoleMonitor } = await import("./console-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
    emit(fakeMsg("error", "first error"));
    emit(fakeMsg("error", "second error"));
    expect(() => monitor.assertClean()).toThrow(/first error/);
  });
});

File: src/console-monitor/console-monitor.ts

import type { ConsoleMessage, Page } from "@playwright/test";
import type { ConsoleMonitor, ConsoleMonitorOptions } from "./console-monitor.types.js";

const PLAYWRIGHT_TYPE_MAP: Record<string, "error" | "warning" | "info"> = {
  error: "error",
  warning: "warning",
  warn: "warning",
  info: "info",
  log: "info",
};

/**
 * Attaches to a Playwright page and captures all console output.
 * Call `assertClean()` after interactions to fail the test on unexpected messages.
 * Defaults to failing on "error" level messages only.
 */
export function attachConsoleMonitor(
  page: Page,
  options: ConsoleMonitorOptions = {}
): ConsoleMonitor {
  const failOn = options.failOn ?? ["error"];
  const ignore = options.ignore ?? [];
  const messages: ConsoleMessage[] = [];

  page.on("console", (msg: ConsoleMessage) => {
    messages.push(msg);
  });

  return {
    get messages() {
      return messages;
    },
    assertClean() {
      const failing = messages.filter((msg) => {
        const level = PLAYWRIGHT_TYPE_MAP[msg.type()] ?? "info";
        if (!failOn.includes(level)) return false;
        const text = msg.text();
        return !ignore.some((re) => re.test(text));
      });
      if (failing.length > 0) {
        const lines = failing.map((m) => `  [${m.type()}] ${m.text()}`).join("\n");
        throw new Error(`ConsoleMonitor: unexpected console messages:\n${lines}`);
      }
    },
  };
}

File: src/console-monitor/console-monitor.md

# console-monitor

## Purpose

`attachConsoleMonitor` attaches to a Playwright `Page` and captures every console message
emitted during a test. The `assertClean()` method fails the test if any captured message
matches the configured severity levels — by default only `"error"` — and was not suppressed
by an `ignore` regexp pattern.

This enforces the spec-level invariant that every E2E spec must call `assertClean()` after
every meaningful interaction. It mirrors what a developer sees in the browser's DevTools
console, making CI failures easy to reproduce locally.

## Features

- Registers a single `page.on("console", ...)` listener
- Configurable `failOn` level list: `"error"`, `"warning"`, `"info"` (default: `["error"]`)
- Per-message `ignore` regexp list — matches against `msg.text()`
- Normalises Playwright's `"warn"` type to `"warning"` transparently
- `assertClean()` throws with a multi-line message listing every failing entry
- Mutable `messages` array for manual inspection mid-test

## Usage: Basic Example

```typescript
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";

test("crypto page has no console errors", async ({ page }) => {
  const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
  await page.goto("/crypto");
  await page.waitForSelector('[data-testid="page-title"]');
  monitor.assertClean();
});

Usage: Advanced Example

import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";

test("i18n page — no errors, ignore React DevTools prompt", async ({ page }) => {
  const monitor = attachConsoleMonitor(page, {
    failOn: ["error", "warning"],
    ignore: [
      /Download the React DevTools/,
      /ResizeObserver loop limit exceeded/,
    ],
  });
  await page.goto("/i18n");
  await page.click('[data-testid="switch-locale-fr"]');
  monitor.assertClean();
});

API Reference

attachConsoleMonitor(page, options?): ConsoleMonitor

  • page: Page — Playwright page; must be called before any navigation
  • options.failOn?: Array<"error" | "warning" | "info"> — levels that trigger assertion failure; default ["error"]
  • options.ignore?: RegExp[] — patterns matched against message text; matching messages excluded from failures
  • Returns ConsoleMonitor with messages: ConsoleMessage[] and assertClean(): void

ConsoleMonitor.assertClean(): void

Throws Error listing all failing messages if any were collected. Safe to call multiple times.

Implementation Notes

Playwright's ConsoleMessage.type() returns "warn" (not "warning") for browser warnings. The implementation normalises via PLAYWRIGHT_TYPE_MAP so callers always use "warning". The messages array is live — it accumulates across the full test lifecycle.


#### File: `src/console-monitor/index.ts`

```typescript
export { attachConsoleMonitor } from "./console-monitor.js";
export type { ConsoleMonitor, ConsoleMonitorOptions } from "./console-monitor.types.js";

Task 1.3 — network-monitor/ module

File: src/network-monitor/network-monitor.types.ts

export interface NetworkFailure {
  url: string;
  type: "status" | "failed";
  status?: number;
  errorText?: string;
}

export interface NetworkMonitor {
  readonly failures: NetworkFailure[];
  assertNoFailures(): void;
}

File: src/network-monitor/network-monitor.test.ts

Write BEFORE the implementation.

import type { Page, Request, Response } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";

type EventCallback = (arg: Request | Response) => void;

function makeFakePage() {
  const listeners = new Map<string, EventCallback[]>();
  const page = {
    on: vi.fn((event: string, cb: EventCallback) => {
      if (!listeners.has(event)) listeners.set(event, []);
      listeners.get(event)!.push(cb);
    }),
  } as unknown as Page;
  function emit(event: string, arg: Request | Response) {
    for (const cb of listeners.get(event) ?? []) cb(arg);
  }
  return { page, emit };
}

function fakeResponse(url: string, status: number): Response {
  return {
    url: () => url,
    status: () => status,
    request: () => ({ url: () => url }) as unknown as Request,
  } as unknown as Response;
}

function fakeFailedRequest(url: string, errorText: string): Request {
  return {
    url: () => url,
    failure: () => ({ errorText }),
  } as unknown as Request;
}

describe("attachNetworkMonitor", () => {
  it("registers response and requestfailed listeners", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page } = makeFakePage();
    attachNetworkMonitor(page);
    const onCalls = (page.on as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]);
    expect(onCalls).toContain("response");
    expect(onCalls).toContain("requestfailed");
  });

  it("records 4xx responses as failures", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachNetworkMonitor(page);
    emit("response", fakeResponse("http://localhost:3200/api/missing", 404));
    expect(monitor.failures).toHaveLength(1);
    expect(monitor.failures[0]!.status).toBe(404);
    expect(monitor.failures[0]!.type).toBe("status");
  });

  it("records 5xx responses as failures", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachNetworkMonitor(page);
    emit("response", fakeResponse("http://localhost:3200/api/boom", 500));
    expect(monitor.failures).toHaveLength(1);
    expect(monitor.failures[0]!.status).toBe(500);
  });

  it("does not record 2xx responses as failures", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachNetworkMonitor(page);
    emit("response", fakeResponse("http://localhost:3200/api/ok", 200));
    expect(monitor.failures).toHaveLength(0);
  });

  it("does not record 3xx responses as failures", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachNetworkMonitor(page);
    emit("response", fakeResponse("http://localhost:3200/redirect", 302));
    expect(monitor.failures).toHaveLength(0);
  });

  it("records requestfailed events as failures", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachNetworkMonitor(page);
    emit("requestfailed", fakeFailedRequest("http://localhost:3200/api/x", "net::ERR_CONNECTION_REFUSED"));
    expect(monitor.failures).toHaveLength(1);
    expect(monitor.failures[0]!.type).toBe("failed");
    expect(monitor.failures[0]!.errorText).toBe("net::ERR_CONNECTION_REFUSED");
  });

  it("assertNoFailures does not throw when no failures", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page } = makeFakePage();
    const monitor = attachNetworkMonitor(page);
    expect(() => monitor.assertNoFailures()).not.toThrow();
  });

  it("assertNoFailures throws when failures exist", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachNetworkMonitor(page);
    emit("response", fakeResponse("http://localhost:3200/api/missing", 404));
    expect(() => monitor.assertNoFailures()).toThrow("404");
  });

  it("records requestfailed with no failure() object gracefully", { timeout: 5000 }, async () => {
    const { attachNetworkMonitor } = await import("./network-monitor.js");
    const { page, emit } = makeFakePage();
    const monitor = attachNetworkMonitor(page);
    const req = {
      url: () => "http://localhost:3200/img.png",
      failure: () => null,
    } as unknown as Request;
    emit("requestfailed", req);
    expect(monitor.failures).toHaveLength(1);
    expect(monitor.failures[0]!.errorText).toBeUndefined();
  });
});

File: src/network-monitor/network-monitor.ts

import type { Page, Request, Response } from "@playwright/test";
import type { NetworkFailure, NetworkMonitor } from "./network-monitor.types.js";

/**
 * Intercepts all Playwright page requests and responses.
 * Tracks 4xx/5xx HTTP status responses and network-level request failures.
 * Call `assertNoFailures()` after interactions to fail the test on any failure.
 */
export function attachNetworkMonitor(page: Page): NetworkMonitor {
  const failures: NetworkFailure[] = [];

  page.on("response", (response: Response) => {
    const status = response.status();
    if (status >= 400) {
      failures.push({ url: response.url(), type: "status", status });
    }
  });

  page.on("requestfailed", (request: Request) => {
    const failure = request.failure();
    failures.push({
      url: request.url(),
      type: "failed",
      errorText: failure?.errorText,
    });
  });

  return {
    get failures() {
      return failures;
    },
    assertNoFailures() {
      if (failures.length === 0) return;
      const lines = failures
        .map((f) =>
          f.type === "status"
            ? `  [${f.status}] ${f.url}`
            : `  [FAILED] ${f.url}${f.errorText ? ` -- ${f.errorText}` : ""}`
        )
        .join("\n");
      throw new Error(`NetworkMonitor: unexpected network failures:\n${lines}`);
    },
  };
}

File: src/network-monitor/network-monitor.md

# network-monitor

## Purpose

`attachNetworkMonitor` intercepts all network activity on a Playwright `Page` and records
every HTTP 4xx/5xx response and every network-level request failure (connection refused,
CORS abort, missing assets). The `assertNoFailures()` method fails the test with a
structured list if any were recorded.

This enforces the spec-level invariant that a 200 HTTP response is not proof of correctness.
A page may load with status 200 while silently failing to fetch fonts, API responses, or
static assets. The network monitor catches all of these.

## Features

- Listens to both `response` (HTTP 4xx/5xx) and `requestfailed` (network-layer failures)
- Records URL, failure type, HTTP status, and error text
- `assertNoFailures()` throws with a formatted multi-line error listing every failure
- Live `failures` array accessible mid-test
- Zero external dependencies beyond `@playwright/test` types

## Usage: Basic Example

```typescript
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";

test("crypto page has no failed network requests", async ({ page }) => {
  const monitor = attachNetworkMonitor(page);
  await page.goto("/crypto");
  await page.waitForSelector('[data-testid="page-title"]');
  monitor.assertNoFailures();
});

Usage: Advanced Example

import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";

test("storage-s3 page -- full health check", async ({ page }) => {
  const consoleMon = attachConsoleMonitor(page, { failOn: ["error"] });
  const networkMon = attachNetworkMonitor(page);
  await page.goto("/storage-s3");
  await page.waitForSelector('[data-testid="output"]:not(:empty)');
  consoleMon.assertClean();
  networkMon.assertNoFailures();
});

API Reference

attachNetworkMonitor(page): NetworkMonitor

  • page: Page — must be called before navigation to capture all requests
  • Returns NetworkMonitor with failures: NetworkFailure[] and assertNoFailures(): void

NetworkMonitor.assertNoFailures(): void

Throws Error with multi-line message if any failures were recorded. Safe to call multiple times.

NetworkFailure

  • url: string — full request URL
  • type: "status" | "failed" — HTTP error vs network-layer failure
  • status?: number — HTTP status code (type === "status" only)
  • errorText?: string — Playwright error text (type === "failed" only)

Implementation Notes

request.failure() can return null when a request is cancelled rather than outright failed. The implementation handles this gracefully by omitting errorText. Tests that intentionally trigger error responses should inspect monitor.failures manually rather than calling assertNoFailures() for those interactions.


#### File: `src/network-monitor/index.ts`

```typescript
export { attachNetworkMonitor } from "./network-monitor.js";
export type { NetworkFailure, NetworkMonitor } from "./network-monitor.types.js";

Task 1.4 — extension-fixture/ module

File: src/extension-fixture/launch-with-extension.types.ts

import type { BrowserContext, BrowserType } from "@playwright/test";

export interface ExtensionFixtureOptions {
  chromium: BrowserType;
  extensionPath: string;
  userDataDir: string;
}

export interface ExtensionFixtureResult {
  context: BrowserContext;
  extensionId: string;
}

File: src/extension-fixture/launch-with-extension.test.ts

Write BEFORE the implementation.

import type { BrowserContext, BrowserType, Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";

function makeFakePage(url: string): Page {
  return { url: () => url } as unknown as Page;
}

function makeFakeContext(backgroundUrl: string) {
  return {
    backgroundPages: vi.fn(() => [makeFakePage(backgroundUrl)]),
    waitForEvent: vi.fn(async () => makeFakePage(backgroundUrl)),
    newPage: vi.fn(async () => makeFakePage("about:blank")),
  } as unknown as BrowserContext;
}

function makeFakeChromium(context: BrowserContext): BrowserType {
  return {
    launchPersistentContext: vi.fn(async () => context),
  } as unknown as BrowserType;
}

describe("launchWithExtension", () => {
  it("calls launchPersistentContext with extension args", {
    timeout: 30000,
  }, async () => {
    const { launchWithExtension } = await import("./launch-with-extension.js");
    const id = "abcdefghijklmnopqrstuvwxyz012345";
    const context = makeFakeContext(`chrome-extension://${id}/_generated_background_page.html`);
    const chromium = makeFakeChromium(context);
    await launchWithExtension({ chromium, extensionPath: "/tmp/ext", userDataDir: "/tmp/ud" });
    expect(chromium.launchPersistentContext).toHaveBeenCalledWith(
      "/tmp/ud",
      expect.objectContaining({
        args: expect.arrayContaining([
          "--disable-extensions-except=/tmp/ext",
          "--load-extension=/tmp/ext",
        ]),
      })
    );
  });

  it("extracts extensionId from chrome-extension:// background page URL", {
    timeout: 30000,
  }, async () => {
    const { launchWithExtension } = await import("./launch-with-extension.js");
    const id = "abcdefghijklmnopqrstuvwxyz012345";
    const context = makeFakeContext(`chrome-extension://${id}/_generated_background_page.html`);
    const chromium = makeFakeChromium(context);
    const result = await launchWithExtension({ chromium, extensionPath: "/tmp/ext", userDataDir: "/tmp/ud" });
    expect(result.extensionId).toBe(id);
    expect(result.context).toBe(context);
  });

  it("falls back to waitForEvent when backgroundPages() is empty", {
    timeout: 30000,
  }, async () => {
    const { launchWithExtension } = await import("./launch-with-extension.js");
    const id = "zyxwvutsrqponmlkjihgfedcba987654";
    const bgUrl = `chrome-extension://${id}/_generated_background_page.html`;
    const context = {
      backgroundPages: vi.fn(() => []),
      waitForEvent: vi.fn(async () => makeFakePage(bgUrl)),
      newPage: vi.fn(async () => makeFakePage("about:blank")),
    } as unknown as BrowserContext;
    const chromium = makeFakeChromium(context);
    const result = await launchWithExtension({ chromium, extensionPath: "/tmp/ext", userDataDir: "/tmp/ud" });
    expect(context.waitForEvent).toHaveBeenCalledWith("backgroundpage");
    expect(result.extensionId).toBe(id);
  });

  it("throws when background page URL does not match chrome-extension:// pattern", {
    timeout: 30000,
  }, async () => {
    const { launchWithExtension } = await import("./launch-with-extension.js");
    const context = {
      backgroundPages: vi.fn(() => [makeFakePage("about:blank")]),
      waitForEvent: vi.fn(async () => makeFakePage("about:blank")),
    } as unknown as BrowserContext;
    const chromium = makeFakeChromium(context);
    await expect(
      launchWithExtension({ chromium, extensionPath: "/tmp/ext", userDataDir: "/tmp/ud" })
    ).rejects.toThrow("Could not extract extension ID");
  });
});

File: src/extension-fixture/launch-with-extension.ts

import type { BrowserContext, Page } from "@playwright/test";
import type { ExtensionFixtureOptions, ExtensionFixtureResult } from "./launch-with-extension.types.js";

const EXTENSION_ID_RE = /^chrome-extension:\/\/([a-z0-9]+)\//;

/**
 * Launches a Playwright Chromium persistent context with an unpacked extension loaded.
 * Uses `chromium.launchPersistentContext()` with `--load-extension` and
 * `--disable-extensions-except` args. Only works with Chromium (MV3 requirement).
 *
 * Extracts the extension ID from the background service worker page URL.
 */
export async function launchWithExtension(
  options: ExtensionFixtureOptions
): Promise<ExtensionFixtureResult> {
  const { chromium, extensionPath, userDataDir } = options;

  const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, {
    headless: false,
    args: [
      `--disable-extensions-except=${extensionPath}`,
      `--load-extension=${extensionPath}`,
    ],
  });

  const backgroundPage = await getBackgroundPage(context);
  const match = EXTENSION_ID_RE.exec(backgroundPage.url());
  if (!match?.[1]) {
    throw new Error(
      `Could not extract extension ID from background page URL: ${backgroundPage.url()}`
    );
  }

  return { context, extensionId: match[1] };
}

async function getBackgroundPage(context: BrowserContext): Promise<Page> {
  const existing = context.backgroundPages();
  if (existing.length > 0) return existing[0]!;
  return context.waitForEvent("backgroundpage") as Promise<Page>;
}

File: src/extension-fixture/launch-with-extension.md

# launch-with-extension

## Purpose

`launchWithExtension` starts a Playwright Chromium persistent browser context with an
unpacked browser extension loaded from the local filesystem. It resolves the extension's
runtime ID by inspecting the background service worker page URL
(`chrome-extension://<id>/_generated_background_page.html`), which is required to
construct extension-internal URLs and assert extension behaviour in E2E tests.

This helper is the foundation for all browser extension E2E tests in the testbed.
It is only compatible with Chromium -- Firefox and Safari do not support `--load-extension`
or MV3 service workers in the same way.

## Features

- Calls `chromium.launchPersistentContext(userDataDir, { args: [...] })` with correct extension flags
- Handles both immediate (background page already registered) and async (waitForEvent) cases
- Extracts extension ID from `chrome-extension://` URL via regex
- Throws a clear error when the background page URL does not match the expected format
- Injectable `chromium` for 100% unit coverage without a real browser

## Usage: Basic Example

```typescript
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
import { chromium } from "@playwright/test";
import { mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

const userDataDir = await mkdtemp(join(tmpdir(), "ext-test-"));
const { context, extensionId } = await launchWithExtension({
  chromium,
  extensionPath: "/abs/path/to/dist/chrome",
  userDataDir,
});
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await context.close();

Usage: Advanced Example

test("extension injects UI on /extension-test", async ({ chromium }) => {
  const userDataDir = await mkdtemp(join(tmpdir(), "ext-"));
  const { context, extensionId } = await launchWithExtension({
    chromium,
    extensionPath: resolve(__dirname, "../../dist/chrome"),
    userDataDir,
  });
  const page = await context.newPage();
  await page.goto("http://localhost:3200/extension-test");
  await page.waitForSelector('[data-testid="extension-injected"]');
  const text = await page.textContent('[data-testid="extension-injected"]');
  expect(text).toBeTruthy();
  await context.close();
});

API Reference

launchWithExtension(options): Promise<ExtensionFixtureResult>

  • options.chromium: BrowserType — pass chromium from @playwright/test; injectable for unit tests
  • options.extensionPath: string — absolute path to unpacked extension directory
  • options.userDataDir: string — path to persistent context data directory; caller creates and cleans up
  • Returns { context: BrowserContext; extensionId: string }
  • Throws Error if background page URL does not contain a valid chrome-extension:// ID

Implementation Notes

headless: false is required -- Chromium does not load extensions in headless mode. In CI without a display, use Xvfb (DISPLAY=:99) or check whether the Playwright version in CI supports --headless=new with extensions. The persistent context approach is the most reliable across Playwright versions.

getBackgroundPage first checks context.backgroundPages() synchronously (extension already loaded) before falling back to waitForEvent("backgroundpage") (extension still loading). This avoids a race condition where a slow waitForEvent call misses the event if the extension loaded before the listener was attached.


#### File: `src/extension-fixture/index.ts`

```typescript
export { launchWithExtension } from "./launch-with-extension.js";
export type { ExtensionFixtureOptions, ExtensionFixtureResult } from "./launch-with-extension.types.js";

Task 1.5 — Update src/index.ts barrel

Append the following to /mnt/s/web/genesis/universal/dev/testing-e2e/src/index.ts after the existing // Page object export block:

// Multi-session
export { createMultiSession, simulateServerFailure } from "./multi-session/create-multi-session.js";
export type { MultiSessionOptions, MultiSessionResult } from "./multi-session/create-multi-session.types.js";
// Console monitor
export { attachConsoleMonitor } from "./console-monitor/console-monitor.js";
export type { ConsoleMonitor, ConsoleMonitorOptions } from "./console-monitor/console-monitor.types.js";
// Network monitor
export { attachNetworkMonitor } from "./network-monitor/network-monitor.js";
export type { NetworkFailure, NetworkMonitor } from "./network-monitor/network-monitor.types.js";
// Extension fixture
export { launchWithExtension } from "./extension-fixture/launch-with-extension.js";
export type { ExtensionFixtureOptions, ExtensionFixtureResult } from "./extension-fixture/launch-with-extension.types.js";

Task 1.6 — Update package.json exports map

Add these 4 entries to the exports object in /mnt/s/web/genesis/universal/dev/testing-e2e/package.json, after the "./fixtures" entry:

"./multi-session": {
  "types": "./dist/multi-session/index.d.ts",
  "default": "./dist/multi-session/index.js"
},
"./console-monitor": {
  "types": "./dist/console-monitor/index.d.ts",
  "default": "./dist/console-monitor/index.js"
},
"./network-monitor": {
  "types": "./dist/network-monitor/index.d.ts",
  "default": "./dist/network-monitor/index.js"
},
"./extension-fixture": {
  "types": "./dist/extension-fixture/index.d.ts",
  "default": "./dist/extension-fixture/index.js"
}

Task 1.7 — Pre-publish gate for Part A

Run in order:

# Step 1: all tests pass
bunx turbo test --filter=@suaveplan/testing-e2e

# Step 2: 100% coverage on all new modules
bunx turbo test:coverage --filter=@suaveplan/testing-e2e

# Step 3: full gate
bunx turbo lint typecheck test --filter=@suaveplan/testing-e2e

Expected: All 4 new modules show 100% Statements/Branches/Functions/Lines in the Istanbul coverage report. The thresholds in vitest.config.ts are already at 100% -- the run will fail automatically if any module falls short.

If lint fails: run bunx biome check --write --unsafe src/ inside the package directory.


Chunk 2 — apps/testbed/ Scaffolding + Server

Task 2.1 — Verify apps/ workspace inclusion

Check /mnt/s/web/genesis/package.json. The workspaces array must include "apps/*". If absent, add it. Also verify turbo.json has no exclusions blocking apps/testbed.

Task 2.2 — Create directory structure

mkdir -p /mnt/s/web/genesis/apps/testbed/src/server/routes/api
mkdir -p /mnt/s/web/genesis/apps/testbed/src/client/components/layout
mkdir -p /mnt/s/web/genesis/apps/testbed/src/client/components/shared
mkdir -p /mnt/s/web/genesis/apps/testbed/src/client/pages/home
mkdir -p /mnt/s/web/genesis/apps/testbed/src/client/styles
mkdir -p /mnt/s/web/genesis/apps/testbed/.storybook
mkdir -p /mnt/s/web/genesis/apps/testbed/e2e

Task 2.3 — package.json

File: /mnt/s/web/genesis/apps/testbed/package.json

{
  "name": "@suaveplan/testbed",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "bun run src/server/index.ts",
    "build": "vite build",
    "start": "bun run dist/server/index.js",
    "storybook": "storybook dev -p 6042",
    "build-storybook": "storybook build",
    "test:e2e": "playwright test",
    "typecheck": "bunx tsc --noEmit -p tsconfig.json"
  },
  "dependencies": {
    "@suaveplan/testing-e2e": "workspace:^",
    "hono": "catalog:",
    "react": "catalog:react",
    "react-dom": "catalog:react",
    "react-router-dom": "catalog:react"
  },
  "devDependencies": {
    "@playwright/test": "catalog:testing",
    "@storybook/addon-essentials": "catalog:storybook",
    "@storybook/addon-interactions": "catalog:storybook",
    "@storybook/react-vite": "catalog:storybook",
    "@storybook/test": "catalog:storybook",
    "@tailwindcss/vite": "catalog:build",
    "@types/node": "catalog:",
    "@types/react": "catalog:react",
    "@types/react-dom": "catalog:react",
    "@vitejs/plugin-react": "catalog:build",
    "tailwindcss": "catalog:build",
    "typescript": "catalog:",
    "vite": "catalog:build"
  }
}

Catalog entries to add if missing in root package.json workspaces.catalog:

"hono": "4.7.4",
"react-router-dom": "7.5.2",
"@storybook/react-vite": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/test": "8.6.12",
"storybook": "8.6.12",
"@tailwindcss/vite": "4.1.5",
"tailwindcss": "4.1.5",
"@vitejs/plugin-react": "4.4.1"

Run bun install from the monorepo root after editing package.json.

Task 2.4 — tsconfig.json

File: /mnt/s/web/genesis/apps/testbed/tsconfig.json

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", ".storybook/**/*.ts", "e2e/**/*.ts"],
  "exclude": ["dist", "node_modules", "coverage"]
}

Task 2.5 — vite.config.ts

File: /mnt/s/web/genesis/apps/testbed/vite.config.ts

import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
  build: {
    outDir: "dist/client",
    emptyOutDir: true,
    sourcemap: true,
  },
  root: ".",
  publicDir: "public",
});

Task 2.6 — playwright.config.ts

File: /mnt/s/web/genesis/apps/testbed/playwright.config.ts

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  retries: process.env["CI"] ? 2 : 0,
  reporter: [["html", { open: "never" }], ["github"]],
  use: {
    baseURL: "http://localhost:3200",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "on-first-retry",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
    { name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
    { name: "mobile-safari", use: { ...devices["iPhone 15"] } },
    {
      name: "chromium-extensions",
      testMatch: "**/extensions/**",
      use: { ...devices["Desktop Chrome"] },
    },
  ],
  webServer: {
    command: "bun run dev",
    url: "http://localhost:3200",
    reuseExistingServer: !process.env["CI"],
    timeout: 30_000,
  },
});

Task 2.7 — Dockerfile

File: /mnt/s/web/genesis/apps/testbed/Dockerfile

FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
EXPOSE 3000
CMD ["bun", "run", "start"]

Task 2.8 — Server entry point

File: /mnt/s/web/genesis/apps/testbed/src/server/index.ts

import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { databaseRoutes } from "./routes/api/database.js";
import { storageRoutes } from "./routes/api/storage.js";

const app = new Hono();

app.route("/api/storage", storageRoutes);
app.route("/api/database", databaseRoutes);

if (process.env["NODE_ENV"] === "test") {
  const { testControlRoutes } = await import("./routes/api/test-control.js");
  app.route("/api/test-control", testControlRoutes);
}

app.use("/*", serveStatic({ root: "./dist/client" }));
app.get("*", serveStatic({ path: "./dist/client/index.html" }));

const port = Number(process.env["PORT"] ?? 3200);

export default { port, fetch: app.fetch };

Note: Hono on Bun uses export default { port, fetch }. The serveStatic import is from "hono/bun". If the Hono version does not support this, use Bun.serve({ port, fetch: app.fetch }).

Task 2.9 — API route files

src/server/routes/api/storage.ts

import { Hono } from "hono";

export const storageRoutes = new Hono();

storageRoutes.get("/status", (c) => {
  return c.json({
    available: Boolean(
      process.env["GARAGE_S3_ENDPOINT"] && process.env["GARAGE_S3_ACCESS_KEY_ID"]
    ),
  });
});

src/server/routes/api/database.ts

import { Hono } from "hono";

export const databaseRoutes = new Hono();

databaseRoutes.get("/status", (c) => {
  return c.json({ available: Boolean(process.env["SURREALDB_URL"]) });
});

src/server/routes/api/test-control.ts

import { Hono } from "hono";

export const disabledRoutes = new Set<string>();

export const testControlRoutes = new Hono();

/**
 * POST /api/test-control/disable-route
 * Body: { route: string }
 * Response: 204 No Content
 * Only registered when NODE_ENV === "test".
 */
testControlRoutes.post("/disable-route", async (c) => {
  const body = await c.req.json<{ route: string }>();
  disabledRoutes.add(body.route);
  return c.body(null, 204);
});

/** POST /api/test-control/reset — clears all disabled routes. */
testControlRoutes.post("/reset", (c) => {
  disabledRoutes.clear();
  return c.body(null, 204);
});

Chunk 3 — Client Shell, Routes, Layout + Shared Components

Task 3.1 — index.html

File: /mnt/s/web/genesis/apps/testbed/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Suaveplan Testbed</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/client/main.tsx"></script>
  </body>
</html>

Task 3.2 — Styles

File: /mnt/s/web/genesis/apps/testbed/src/client/styles/globals.css

@import "tailwindcss";

:root {
  --color-bg: #ffffff;
  --color-fg: #111111;
  --color-sidebar: #f4f4f5;
  --color-border: #e4e4e7;
  --color-accent: #6366f1;
}

[data-theme="dark"] {
  --color-bg: #09090b;
  --color-fg: #fafafa;
  --color-sidebar: #18181b;
  --color-border: #27272a;
  --color-accent: #818cf8;
}

html,
body {
  background-color: var(--color-bg);
  color: var(--color-fg);
  margin: 0;
  padding: 0;
  font-family: system-ui, sans-serif;
}

Task 3.3 — Route manifest

File: /mnt/s/web/genesis/apps/testbed/src/client/routes.ts

export interface RouteEntry {
  path: string;
  label: string;
  category: string;
  requiresServices?: boolean;
}

export const routes: RouteEntry[] = [
  { path: "/crypto",        label: "@suaveplan/crypto",        category: "crypto" },
  { path: "/logger",        label: "@suaveplan/logger",        category: "observability" },
  { path: "/datetime",      label: "@suaveplan/datetime",      category: "primitives" },
  { path: "/i18n",          label: "@suaveplan/i18n",          category: "primitives" },
  { path: "/feature-flags", label: "@suaveplan/feature-flags", category: "primitives" },
  { path: "/storage-s3",    label: "@suaveplan/storage-s3",    category: "storage",    requiresServices: true },
  { path: "/surrealdb",     label: "@suaveplan/db-sqlite",     category: "database",   requiresServices: true },
  { path: "/network-webrtc",label: "Network WebRTC",           category: "network",    requiresServices: true },
  { path: "/extension-test",label: "Extension Test",           category: "extensions" },
];

Task 3.4 — React entry point

File: /mnt/s/web/genesis/apps/testbed/src/client/main.tsx

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/globals.css";
import { App } from "./app.js";

const root = document.getElementById("root");
if (!root) throw new Error("No #root element found");

createRoot(root).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Task 3.5 — App shell

File: /mnt/s/web/genesis/apps/testbed/src/client/app.tsx

import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Header } from "./components/layout/header.js";
import { Sidebar } from "./components/layout/sidebar.js";
import { HomePage } from "./pages/home/home-page.js";
import { routes } from "./routes.js";

function NotFound(): JSX.Element {
  return (
    <main className="flex-1 p-8">
      <h1 data-testid="page-title" className="text-2xl font-semibold">
        404 -- Page not found
      </h1>
    </main>
  );
}

function PlaceholderPage({ label }: { label: string }): JSX.Element {
  return (
    <main className="flex-1 p-8">
      <h1 data-testid="page-title" className="text-2xl font-semibold">{label}</h1>
      <p className="mt-2 text-sm text-gray-500">Library demo page -- implementation coming soon.</p>
      <div data-testid="demo-card" className="mt-6 rounded border p-4">
        <div data-testid="output" className="font-mono text-sm" />
      </div>
      <div data-testid="console-panel" className="mt-4" />
    </main>
  );
}

export function App(): JSX.Element {
  return (
    <BrowserRouter>
      <div className="flex h-screen flex-col">
        <Header />
        <div className="flex flex-1 overflow-hidden">
          <Sidebar />
          <Routes>
            <Route path="/" element={<HomePage />} />
            {routes.map((route) => (
              <Route
                key={route.path}
                path={route.path}
                element={<PlaceholderPage label={route.label} />}
              />
            ))}
            <Route path="*" element={<NotFound />} />
          </Routes>
        </div>
      </div>
    </BrowserRouter>
  );
}

Task 3.6 — Layout components

Each component: .tsx + .md + .stories.tsx. The barrel index.ts is at the end.

theme-toggle.tsx

File: /mnt/s/web/genesis/apps/testbed/src/client/components/layout/theme-toggle.tsx

import { useEffect, useState } from "react";

type Theme = "light" | "dark";

function getInitialTheme(): Theme {
  if (typeof localStorage === "undefined") return "light";
  const stored = localStorage.getItem("theme");
  if (stored === "light" || stored === "dark") return stored;
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

export function ThemeToggle(): JSX.Element {
  const [theme, setTheme] = useState<Theme>(getInitialTheme);

  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme);
    localStorage.setItem("theme", theme);
  }, [theme]);

  return (
    <button
      type="button"
      aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
      data-testid="theme-toggle"
      onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
      className="rounded px-2 py-1 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
    >
      {theme === "light" ? "Moon" : "Sun"}
    </button>
  );
}

Note on emoji: Emojis are avoided per CLAUDE.md. Use text labels "Moon"/"Sun" instead of emoji characters. If the user wants emoji, they can add them explicitly.

theme-toggle.md

# theme-toggle

## Purpose

`ThemeToggle` is a button that toggles between `light` and `dark` visual themes.
It applies the theme by setting a `data-theme` attribute on `<html>` (consumed by CSS
custom properties and Tailwind dark mode variants) and persists the choice to
`localStorage["theme"]`. On first render it reads the stored preference, falling back
to the OS `prefers-color-scheme` media query.

Every E2E test for the testbed's theme feature interacts via `data-testid="theme-toggle"`.

## Features

- Reads initial theme from `localStorage["theme"]` or `prefers-color-scheme`
- Applies `data-theme` attribute to `document.documentElement` on every toggle
- Persists selection to `localStorage` so choice survives page reloads
- Accessible: `aria-label` updates to describe the next action
- `data-testid="theme-toggle"` for deterministic Playwright targeting

## Usage: Basic Example

```tsx
import { ThemeToggle } from "@suaveplan/testbed/components/layout";
<ThemeToggle />

Usage: Advanced Example

// E2E -- toggle and verify data-theme attribute persists
await page.click('[data-testid="theme-toggle"]');
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
await page.reload();
expect(await page.getAttribute("html", "data-theme")).toBe("dark");

API Reference

ThemeToggle(): JSX.Element

No props. Reads and writes localStorage["theme"]. Sets document.documentElement.dataset.theme.

Implementation Notes

The component does not accept a controlled theme prop -- the theme is global document state. getInitialTheme runs synchronously on the first render pass to avoid a flash of the default theme before the stored preference is applied.


#### `theme-toggle.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { ThemeToggle } from "./theme-toggle.js";

const meta: Meta<typeof ThemeToggle> = {
  component: ThemeToggle,
  title: "Layout/ThemeToggle",
  tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ThemeToggle>;

export const Default: Story = {};
export const Playground: Story = {};

locale-switcher.tsx

File: /mnt/s/web/genesis/apps/testbed/src/client/components/layout/locale-switcher.tsx

import { useEffect, useState } from "react";

const LOCALES = ["en", "fr", "de", "ja"] as const;
type Locale = (typeof LOCALES)[number];

function getInitialLocale(): Locale {
  if (typeof localStorage === "undefined") return "en";
  const stored = localStorage.getItem("locale");
  return (LOCALES as readonly string[]).includes(stored ?? "") ? (stored as Locale) : "en";
}

export function LocaleSwitcher(): JSX.Element {
  const [locale, setLocale] = useState<Locale>(getInitialLocale);

  useEffect(() => {
    localStorage.setItem("locale", locale);
    document.documentElement.setAttribute("lang", locale);
  }, [locale]);

  function handleChange(e: React.ChangeEvent<HTMLSelectElement>): void {
    const value = e.target.value;
    if ((LOCALES as readonly string[]).includes(value)) setLocale(value as Locale);
  }

  return (
    <select
      aria-label="Select locale"
      data-testid="locale-switcher"
      value={locale}
      onChange={handleChange}
      className="rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
    >
      {LOCALES.map((loc) => (
        <option key={loc} value={loc}>{loc.toUpperCase()}</option>
      ))}
    </select>
  );
}

locale-switcher.md

# locale-switcher

## Purpose

`LocaleSwitcher` renders a `<select>` allowing the user to switch between the four supported
locales (`en`, `fr`, `de`, `ja`). The selected locale is persisted to `localStorage["locale"]`
and applied as a `lang` attribute on `<html>`. On first render it reads the stored preference,
defaulting to `"en"`.

The locale switcher is used by baseline E2E specs to verify every library page renders
correctly in all four locales. It is placed in the `Header` so it is accessible on every route.

## Features

- Supports four locales: en, fr, de, ja
- Persists selection to `localStorage["locale"]`
- Sets `document.documentElement.lang` on every change
- `data-testid="locale-switcher"` for Playwright targeting
- Runtime validation guards against DOM-injected invalid values

## Usage: Basic Example

```tsx
import { LocaleSwitcher } from "@suaveplan/testbed/components/layout";
<LocaleSwitcher />

Usage: Advanced Example

// E2E -- switch to French and verify lang attribute
await page.selectOption('[data-testid="locale-switcher"]', "fr");
await page.reload();
expect(await page.getAttribute("html", "lang")).toBe("fr");

API Reference

LocaleSwitcher(): JSX.Element

No props. Reads and writes localStorage["locale"]. Sets document.documentElement.lang.

Implementation Notes

The LOCALES tuple is as const to produce a literal union type. Runtime validation in handleChange checks membership before calling setLocale, preventing invalid locale strings from reaching localStorage or i18n providers.


#### `locale-switcher.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { LocaleSwitcher } from "./locale-switcher.js";

const meta: Meta<typeof LocaleSwitcher> = {
  component: LocaleSwitcher,
  title: "Layout/LocaleSwitcher",
  tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof LocaleSwitcher>;

export const Default: Story = {};
export const Playground: Story = {};

console-panel.tsx

File: /mnt/s/web/genesis/apps/testbed/src/client/components/layout/console-panel.tsx

import { useEffect, useRef, useState } from "react";

interface ConsoleEntry {
  level: "log" | "warn" | "error";
  message: string;
  timestamp: number;
}

const LEVEL_COLORS: Record<ConsoleEntry["level"], string> = {
  log: "text-gray-600 dark:text-gray-400",
  warn: "text-yellow-600 dark:text-yellow-400",
  error: "text-red-600 dark:text-red-400",
};

const LEVEL_BADGES: Record<ConsoleEntry["level"], string> = {
  log: "INFO",
  warn: "WARN",
  error: "ERROR",
};

export function ConsolePanel(): JSX.Element {
  const [entries, setEntries] = useState<ConsoleEntry[]>([]);
  const originals = useRef<{
    log: typeof console.log;
    warn: typeof console.warn;
    error: typeof console.error;
  } | null>(null);

  useEffect(() => {
    originals.current = { log: console.log, warn: console.warn, error: console.error };

    function capture(level: ConsoleEntry["level"]) {
      return (...args: unknown[]): void => {
        const message = args
          .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
          .join(" ");
        setEntries((prev) => [...prev, { level, message, timestamp: Date.now() }]);
        originals.current?.[level](...(args as Parameters<typeof console.log>));
      };
    }

    console.log = capture("log");
    console.warn = capture("warn");
    console.error = capture("error");

    return () => {
      if (originals.current) {
        console.log = originals.current.log;
        console.warn = originals.current.warn;
        console.error = originals.current.error;
      }
    };
  }, []);

  return (
    <div
      data-testid="console-panel"
      className="mt-4 max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-900"
    >
      {entries.length === 0 ? (
        <span className="text-gray-400">No console output</span>
      ) : (
        entries.map((entry, i) => (
          <div key={`${entry.timestamp}-${i}`} className={LEVEL_COLORS[entry.level]}>
            <span className="mr-2 font-bold">[{LEVEL_BADGES[entry.level]}]</span>
            <span>{entry.message}</span>
          </div>
        ))
      )}
    </div>
  );
}

console-panel.md

# console-panel

## Purpose

`ConsolePanel` intercepts `window.console.log`, `warn`, and `error` on mount and renders
each captured message in an in-page log with level badges. It restores the original console
methods on unmount. The panel is mounted on every library page so developers and Playwright
tests can see exactly what console output the library produces during a demo interaction.

This component mirrors exactly what the Playwright `attachConsoleMonitor` captures, making
it easy to reproduce CI failures locally.

## Features

- Intercepts `console.log`, `console.warn`, `console.error` via method replacement
- Restores originals on React unmount (cleanup effect)
- Renders each entry with level badge: [INFO], [WARN], [ERROR]
- Objects serialised via JSON.stringify; primitives via String()
- `data-testid="console-panel"` for Playwright targeting
- Scrollable: max-h-48 with overflow-y-auto

## Usage: Basic Example

```tsx
import { ConsolePanel } from "@suaveplan/testbed/components/layout";
<ConsolePanel />

Usage: Advanced Example

// E2E -- trigger demo and verify console panel shows output
await page.click('[data-testid="encrypt-button"]');
await page.waitForSelector('[data-testid="console-panel"] >> text=encrypt');

API Reference

ConsolePanel(): JSX.Element

No props. Side-effectful: replaces console methods on mount, restores on unmount. data-testid="console-panel".

Implementation Notes

Direct method replacement is used rather than a Proxy to avoid infinite recursion hazards when the console itself tries to log during render. The originals ref captures methods at mount time, ensuring remount (React StrictMode double-invocation) always restores the methods that were in place when the component first mounted.


#### `console-panel.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { ConsolePanel } from "./console-panel.js";

const meta: Meta<typeof ConsolePanel> = {
  component: ConsolePanel,
  title: "Layout/ConsolePanel",
  tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ConsolePanel>;

export const Default: Story = {};

export const WithMessages: Story = {
  play: async () => {
    console.log("Demo log message");
    console.warn("Demo warning");
    console.error("Demo error");
  },
};

export const Playground: Story = {};

sidebar.tsx

File: /mnt/s/web/genesis/apps/testbed/src/client/components/layout/sidebar.tsx

import { NavLink } from "react-router-dom";
import { routes } from "../../routes.js";

const categories = [...new Set(routes.map((r) => r.category))];

export function Sidebar(): JSX.Element {
  return (
    <nav
      data-testid="sidebar"
      aria-label="Library navigation"
      className="w-56 shrink-0 overflow-y-auto border-r border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900"
    >
      {categories.map((category) => (
        <div key={category} className="px-3 py-2">
          <p className="mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">
            {category}
          </p>
          {routes
            .filter((r) => r.category === category)
            .map((route) => (
              <NavLink
                key={route.path}
                to={route.path}
                data-testid={`nav-${route.path.replace("/", "")}`}
                className={({ isActive }) =>
                  `block truncate rounded px-2 py-1 text-sm ${
                    isActive
                      ? "bg-indigo-100 font-medium text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
                      : "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
                  }`
                }
              >
                {route.label}
              </NavLink>
            ))}
        </div>
      ))}
    </nav>
  );
}

sidebar.md

# sidebar

## Purpose

`Sidebar` renders the primary navigation for the testbed as a vertically scrollable nav panel.
Routes are grouped by their `category` field from `routes.ts` and rendered as React Router
`NavLink` elements. Active links receive distinct styling.

Every `data-testid="nav-<path>"` attribute maps directly to a route in the manifest, making
it trivial to enumerate and test all routes programmatically in E2E navigation specs.

## Features

- Groups routes by category, preserving insertion order from `routes.ts`
- Uses React Router `NavLink` with `isActive` callback for active-state styling
- Each nav item has `data-testid="nav-<route-path-without-slash>"` for Playwright
- Scrollable: overflow-y-auto handles large numbers of routes

## Usage: Basic Example

```tsx
import { Sidebar } from "@suaveplan/testbed/components/layout";
<Sidebar /> // requires BrowserRouter in tree

Usage: Advanced Example

// E2E -- click nav link and assert page title
await page.click('[data-testid="nav-crypto"]');
await page.waitForSelector('[data-testid="page-title"]');
expect(await page.textContent('[data-testid="page-title"]')).toContain("@suaveplan/crypto");

API Reference

Sidebar(): JSX.Element

No props. Reads routes from routes.ts. Requires BrowserRouter or MemoryRouter in tree.

Implementation Notes

Categories are derived via [...new Set(routes.map(r => r.category))]. ES2015+ Set preserves insertion order, so routes appear in the same order as the manifest. Adding routes to routes.ts automatically updates the sidebar.


#### `sidebar.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "./sidebar.js";

const meta: Meta<typeof Sidebar> = {
  component: Sidebar,
  title: "Layout/Sidebar",
  tags: ["autodocs"],
  decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof Sidebar>;

export const Default: Story = {};
export const WithActiveCrypto: Story = {
  decorators: [(Story) => <MemoryRouter initialEntries={["/crypto"]}><Story /></MemoryRouter>],
};
export const Playground: Story = {};

header.tsx

File: /mnt/s/web/genesis/apps/testbed/src/client/components/layout/header.tsx

import { LocaleSwitcher } from "./locale-switcher.js";
import { ThemeToggle } from "./theme-toggle.js";

export function Header(): JSX.Element {
  return (
    <header
      data-testid="header"
      className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900"
    >
      <span className="text-sm font-semibold tracking-tight text-gray-900 dark:text-white">
        Suaveplan Testbed
      </span>
      <div className="flex items-center gap-2">
        <LocaleSwitcher />
        <ThemeToggle />
      </div>
    </header>
  );
}

header.md

# header

## Purpose

`Header` is the top-level application bar for the testbed. It displays the app title on the
left and renders `LocaleSwitcher` and `ThemeToggle` on the right. It is mounted above the
sidebar/main split in `App` so it is visible on every route.

The `data-testid="header"` attribute is consumed by E2E tests that verify the presence of
header controls on every page load.

## Features

- Fixed height (h-12); does not scroll
- Title on left, controls (LocaleSwitcher + ThemeToggle) on right
- Composes control components -- no inline control logic
- `data-testid="header"` for Playwright

## Usage: Basic Example

```tsx
import { Header } from "@suaveplan/testbed/components/layout";
<Header />

Usage: Advanced Example

// E2E -- verify header present on every route
for (const route of routes) {
  await page.goto(route.path);
  await expect(page.locator('[data-testid="header"]')).toBeVisible();
}

API Reference

Header(): JSX.Element

No props.

Implementation Notes

Header is a pure layout compositor. All state lives in LocaleSwitcher and ThemeToggle. This keeps Header trivially testable in Storybook without mock setup.


#### `header.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { Header } from "./header.js";

const meta: Meta<typeof Header> = {
  component: Header,
  title: "Layout/Header",
  tags: ["autodocs"],
  decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof Header>;

export const Default: Story = {};
export const Playground: Story = {};

Layout barrel index.ts

File: /mnt/s/web/genesis/apps/testbed/src/client/components/layout/index.ts

export { ConsolePanel } from "./console-panel.js";
export { Header } from "./header.js";
export { LocaleSwitcher } from "./locale-switcher.js";
export { Sidebar } from "./sidebar.js";
export { ThemeToggle } from "./theme-toggle.js";

Task 3.7 — Shared components

demo-card.tsx

File: /mnt/s/web/genesis/apps/testbed/src/client/components/shared/demo-card.tsx

interface DemoCardProps {
  title?: string;
  children: React.ReactNode;
  output?: React.ReactNode;
}

export function DemoCard({ title, children, output }: DemoCardProps): JSX.Element {
  return (
    <div
      data-testid="demo-card"
      className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
    >
      {title && (
        <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500">
          {title}
        </h2>
      )}
      <div className="space-y-3">{children}</div>
      {output !== undefined && (
        <div
          data-testid="output"
          className="mt-3 min-h-6 rounded bg-gray-50 p-2 font-mono text-sm dark:bg-gray-900"
        >
          {output}
        </div>
      )}
    </div>
  );
}

demo-card.md

# demo-card

## Purpose

`DemoCard` is the standard wrapper for every interactive library demonstration in the testbed.
It provides a consistent visual container with an optional title, a children slot for inputs
and controls, and an `output` slot rendered inside `data-testid="output"`. Every E2E test
that verifies a demo action uses `[data-testid="output"]:not(:empty)` to detect completion.

## Features

- `data-testid="demo-card"` wrapper for Playwright targeting
- Optional `title` prop rendered as a section heading
- `children` slot for inputs, buttons, and controls
- `output` slot with `data-testid="output"` -- required for E2E test assertions
- Light/dark mode compatible via Tailwind classes

## Usage: Basic Example

```tsx
import { DemoCard } from "@suaveplan/testbed/components/shared";

<DemoCard title="Encrypt" output={ciphertext}>
  <input value={plaintext} onChange={e => setPlaintext(e.target.value)} />
  <button onClick={doEncrypt}>Encrypt</button>
</DemoCard>

Usage: Advanced Example

<DemoCard
  title="Key Generation"
  output={keypair ? JSON.stringify(keypair, null, 2) : null}
>
  <select value={algorithm} onChange={e => setAlgorithm(e.target.value)}>
    <option value="ed25519">Ed25519</option>
    <option value="x25519">X25519</option>
  </select>
  <button data-testid="generate-button" onClick={generateKey}>Generate</button>
</DemoCard>

API Reference

DemoCard(props): JSX.Element

  • props.title?: string -- optional section heading
  • props.children: ReactNode -- controls and inputs
  • props.output?: ReactNode -- rendered inside data-testid="output"

Implementation Notes

output accepts ReactNode for rich output (formatted JSON, status badges, etc.). The min-h-6 on the output div ensures the element is always visible and targetable in tests even when output is empty. E2E tests should wait for the output to contain non-empty content rather than testing the empty state.


#### `demo-card.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { DemoCard } from "./demo-card.js";

const meta: Meta<typeof DemoCard> = {
  component: DemoCard,
  title: "Shared/DemoCard",
  tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof DemoCard>;

export const Default: Story = {
  args: {
    title: "Example Demo",
    children: <button type="button">Run</button>,
    output: "result: ok",
  },
};
export const NoOutput: Story = {
  args: { title: "Inputs Only", children: <input placeholder="Enter value" /> },
};
export const Playground: Story = {
  args: { title: "Playground", children: <span>Edit me</span>, output: "" },
};

code-block.tsx

File: /mnt/s/web/genesis/apps/testbed/src/client/components/shared/code-block.tsx

interface CodeBlockProps {
  code: string;
  language?: string;
}

export function CodeBlock({ code, language }: CodeBlockProps): JSX.Element {
  return (
    <div data-testid="code-block" className="overflow-x-auto rounded bg-gray-900 p-4">
      {language && <span className="mb-2 block text-xs text-gray-400">{language}</span>}
      <pre className="font-mono text-sm text-gray-100"><code>{code}</code></pre>
    </div>
  );
}

code-block.md

# code-block

## Purpose

`CodeBlock` renders a preformatted code snippet with monospace styling and an optional
language label. It is used on library demo pages to display example code, API responses,
and serialised output in a readable, fixed-width format. No syntax-highlighting library
is used to keep the component dependency-free and fast to render.

## Features

- Renders `<pre><code>` block with monospace styling
- Optional `language` label displayed above the code
- `data-testid="code-block"` for Playwright targeting
- Horizontally scrollable for wide code snippets
- Light text on dark background for readability in both themes

## Usage: Basic Example

```tsx
import { CodeBlock } from "@suaveplan/testbed/components/shared";
<CodeBlock language="typescript" code="const key = await crypto.generateKey();" />

Usage: Advanced Example

<CodeBlock
  language="json"
  code={JSON.stringify({ ciphertext: "...", nonce: "..." }, null, 2)}
/>

API Reference

CodeBlock(props): JSX.Element

  • props.code: string -- the code string to display
  • props.language?: string -- optional language label

Implementation Notes

Syntax highlighting is deliberately omitted. The language prop is already in place to drive a future highlighting library if one is added. Adding highlight.js or prism would add ~50KB to the bundle, which is not justified for a developer-facing demo app.


#### `code-block.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { CodeBlock } from "./code-block.js";

const meta: Meta<typeof CodeBlock> = {
  component: CodeBlock,
  title: "Shared/CodeBlock",
  tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CodeBlock>;

export const Default: Story = {
  args: {
    language: "typescript",
    code: 'const result = await crypto.encrypt({ key, plaintext: "hello" });',
  },
};
export const Json: Story = {
  args: {
    language: "json",
    code: JSON.stringify({ ciphertext: "abc123", nonce: "xyz789" }, null, 2),
  },
};
export const Playground: Story = { args: { code: "// Edit me", language: "typescript" } };

status-badge.tsx

File: /mnt/s/web/genesis/apps/testbed/src/client/components/shared/status-badge.tsx

type Status = "ok" | "error" | "pending";

interface StatusBadgeProps {
  status: Status;
  label?: string;
}

const STATUS_CLASSES: Record<Status, string> = {
  ok: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
  error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
  pending: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
};

const STATUS_LABELS: Record<Status, string> = {
  ok: "OK",
  error: "ERROR",
  pending: "PENDING",
};

export function StatusBadge({ status, label }: StatusBadgeProps): JSX.Element {
  return (
    <span
      data-testid="status-badge"
      data-status={status}
      className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${STATUS_CLASSES[status]}`}
    >
      {label ?? STATUS_LABELS[status]}
    </span>
  );
}

status-badge.md

# status-badge

## Purpose

`StatusBadge` renders a colored pill badge indicating `ok`, `error`, or `pending` state.
It is used on library demo pages to communicate at a glance whether a demo operation
succeeded, failed, or is in progress. E2E tests use `data-status` to assert outcome
without relying on text content.

## Features

- Three states: ok (green), error (red), pending (yellow)
- Optional `label` prop overrides the default text
- `data-testid="status-badge"` and `data-status` attribute for Playwright
- Light/dark mode compatible

## Usage: Basic Example

```tsx
import { StatusBadge } from "@suaveplan/testbed/components/shared";
<StatusBadge status="ok" />
<StatusBadge status="error" label="Connection failed" />

Usage: Advanced Example

// E2E -- assert operation completed successfully
await page.waitForSelector('[data-testid="status-badge"][data-status="ok"]');
expect(await page.getAttribute('[data-testid="status-badge"]', 'data-status')).toBe('ok');

API Reference

StatusBadge(props): JSX.Element

  • props.status: "ok" | "error" | "pending" -- determines colour and default label
  • props.label?: string -- overrides the default status label text

Implementation Notes

data-status is the authoritative attribute for test assertions. This decouples tests from label text that might change with localisation.


#### `status-badge.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { StatusBadge } from "./status-badge.js";

const meta: Meta<typeof StatusBadge> = {
  component: StatusBadge,
  title: "Shared/StatusBadge",
  tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof StatusBadge>;

export const Ok: Story = { args: { status: "ok" } };
export const Error: Story = { args: { status: "error" } };
export const Pending: Story = { args: { status: "pending" } };
export const CustomLabel: Story = { args: { status: "error", label: "Connection failed" } };
export const Playground: Story = { args: { status: "pending", label: "Processing..." } };

Shared barrel index.ts

File: /mnt/s/web/genesis/apps/testbed/src/client/components/shared/index.ts

export { CodeBlock } from "./code-block.js";
export { DemoCard } from "./demo-card.js";
export { StatusBadge } from "./status-badge.js";

Task 3.8 — Home page

File: /mnt/s/web/genesis/apps/testbed/src/client/pages/home/home-page.tsx

export function HomePage(): JSX.Element {
  return (
    <main className="flex-1 p-8">
      <h1 data-testid="page-title" className="text-2xl font-semibold">
        Suaveplan Testbed
      </h1>
      <p className="mt-2 text-gray-600 dark:text-gray-400">
        Select a library from the sidebar to view its interactive demo.
      </p>
      <div data-testid="demo-card" className="mt-6" />
      <div data-testid="console-panel" className="mt-4" />
    </main>
  );
}

File: /mnt/s/web/genesis/apps/testbed/src/client/pages/home/index.ts

export { HomePage } from "./home-page.js";

File: /mnt/s/web/genesis/apps/testbed/src/client/pages/home/home-page.md

# home-page

## Purpose

`HomePage` is the default landing page shown when the testbed app loads at `/`.
It displays a welcome message directing the user to select a library from the sidebar.
It contains the required `data-testid` attributes (`page-title`, `demo-card`, `console-panel`)
so that baseline E2E specs that iterate over all routes do not fail when they visit the root.

## Features

- `data-testid="page-title"` with the app name
- Stub `data-testid="demo-card"` and `data-testid="console-panel"` for baseline E2E compatibility

## Usage: Basic Example

```tsx
import { HomePage } from "@suaveplan/testbed/pages/home";
<HomePage />

Usage: Advanced Example

// E2E -- home page loads correctly
await page.goto("/");
await expect(page.locator('[data-testid="page-title"]')).toContainText("Suaveplan Testbed");

API Reference

HomePage(): JSX.Element

No props.

Implementation Notes

The home page does not import any library package. It is intentionally thin -- it exists to satisfy the baseline E2E invariant that every route must render data-testid="page-title".


File: `/mnt/s/web/genesis/apps/testbed/src/client/pages/home/home-page.stories.tsx`

```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { HomePage } from "./home-page.js";

const meta: Meta<typeof HomePage> = {
  component: HomePage,
  title: "Pages/Home",
  tags: ["autodocs"],
  decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof HomePage>;

export const Default: Story = {};
export const Playground: Story = {};

Chunk 4 — Storybook Configuration + Final Wiring

Task 4.1 — Storybook main.ts

File: /mnt/s/web/genesis/apps/testbed/.storybook/main.ts

import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  framework: "@storybook/react-vite",
  stories: ["../src/**/*.stories.@(ts|tsx)"],
  addons: [
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
    "@storybook/test",
  ],
  docs: { autodocs: "tag" },
};

export default config;

Task 4.2 — Storybook preview.ts

File: /mnt/s/web/genesis/apps/testbed/.storybook/preview.ts

import type { Decorator, Preview } from "@storybook/react";
import { createElement } from "react";

/**
 * ThemeDecorator -- renders each story twice side by side:
 *   left: data-theme="light"
 *   right: data-theme="dark"
 */
const ThemeDecorator: Decorator = (Story) =>
  createElement(
    "div",
    { style: { display: "flex", gap: "16px" } },
    createElement("div", { "data-theme": "light", style: { flex: 1 } }, createElement(Story)),
    createElement("div", {
      "data-theme": "dark",
      style: { flex: 1, backgroundColor: "#09090b", padding: "8px" },
    }, createElement(Story))
  );

/**
 * LocaleDecorator -- wraps story with lang="en" as a lightweight i18n context placeholder
 * until @suaveplan/i18n is wired into the testbed.
 */
const LocaleDecorator: Decorator = (Story) =>
  createElement("div", { lang: "en" }, createElement(Story));

const preview: Preview = {
  decorators: [ThemeDecorator, LocaleDecorator],
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

Task 4.3 — Typecheck gate

bunx tsc --noEmit -p apps/testbed/tsconfig.json

Fix all TypeScript errors before proceeding. Common issues to watch for:

  • JSX.Element not found -- add "jsx": "react-jsx" to tsconfig and ensure @types/react is installed
  • MemoryRouter missing -- ensure react-router-dom types are in devDependencies
  • Hono serveStatic signature mismatch -- adjust the import if using a different Hono Bun adapter

Task 4.4 — Client build smoke test

cd /mnt/s/web/genesis/apps/testbed && bun run build

Must produce dist/client/index.html and dist/client/assets/. Fix any Vite/Tailwind errors.

Task 4.5 — Storybook build smoke test

cd /mnt/s/web/genesis/apps/testbed && bun run build-storybook

Must complete without errors and produce storybook-static/. Fix any decorator type errors (if createElement signature complains, use JSX syntax instead with a .tsx extension for preview.ts and rename it to preview.tsx).


Post-completion Verification

Part A

# All tests pass + 100% coverage
bunx turbo lint typecheck test --filter=@suaveplan/testing-e2e
bunx turbo test:coverage --filter=@suaveplan/testing-e2e

Expected: green on all 4 new modules, 100% coverage thresholds met.

Part B

# Typecheck
bunx turbo typecheck --filter=@suaveplan/testbed

# Build
bunx turbo build --filter=@suaveplan/testbed

Expected: TypeScript compiles clean, dist/client/ produced.


File Inventory

Part A -- New files in universal/dev/testing-e2e/src/

multi-session/
  index.ts
  create-multi-session.ts
  create-multi-session.types.ts
  create-multi-session.md
  create-multi-session.test.ts
console-monitor/
  index.ts
  console-monitor.ts
  console-monitor.types.ts
  console-monitor.md
  console-monitor.test.ts
network-monitor/
  index.ts
  network-monitor.ts
  network-monitor.types.ts
  network-monitor.md
  network-monitor.test.ts
extension-fixture/
  index.ts
  launch-with-extension.ts
  launch-with-extension.types.ts
  launch-with-extension.md
  launch-with-extension.test.ts

Part A -- Modified

src/index.ts         <- 4 new export blocks
package.json         <- 4 new exports map entries

Part B -- New files in apps/testbed/

package.json
tsconfig.json
vite.config.ts
playwright.config.ts
Dockerfile
index.html
.storybook/
  main.ts
  preview.ts
src/server/
  index.ts
  routes/api/
    storage.ts
    database.ts
    test-control.ts
src/client/
  main.tsx
  app.tsx
  routes.ts
  styles/globals.css
  pages/home/
    index.ts
    home-page.tsx
    home-page.md
    home-page.stories.tsx
  components/layout/
    index.ts
    sidebar.tsx + sidebar.md + sidebar.stories.tsx
    header.tsx + header.md + header.stories.tsx
    theme-toggle.tsx + theme-toggle.md + theme-toggle.stories.tsx
    locale-switcher.tsx + locale-switcher.md + locale-switcher.stories.tsx
    console-panel.tsx + console-panel.md + console-panel.stories.tsx
  components/shared/
    index.ts
    demo-card.tsx + demo-card.md + demo-card.stories.tsx
    code-block.tsx + code-block.md + code-block.stories.tsx
    status-badge.tsx + status-badge.md + status-badge.stories.tsx

Known Constraints and Edge Cases

  1. launchWithExtension -- headless: false: Chromium does not load extensions in headless mode. In CI without a display, use DISPLAY=:99 with Xvfb. The extension test Playwright project runs on Chromium only (MV3 requirement).

  2. Tailwind v4 -- no config file: Tailwind v4 uses @import "tailwindcss" in CSS with the @tailwindcss/vite plugin. No tailwind.config.js is required. Custom tokens go in CSS via @theme {} blocks.

  3. Hono serveStatic path: The root: "./dist/client" path is relative to the process working directory. When running bun run dev from apps/testbed/, this resolves correctly. Add "cwd": "apps/testbed" to the turbo task config if cwd is wrong.

  4. React Router v7 SPA fallback: All non-API routes must serve index.html for client- side routing to work. The app.get("*", serveStatic({ path: "./dist/client/index.html" })) line in the Hono server handles this.

  5. bun.lockb update: After adding apps/testbed/package.json and running bun install from the monorepo root, commit the updated bun.lockb. Use bun install (not --frozen-lockfile) when adding new dependencies.

  6. Storybook preview.ts vs preview.tsx: If TypeScript complains about JSX in preview.ts, rename to preview.tsx and use JSX syntax directly instead of createElement calls. Update main.ts stories pattern if needed.

  7. apps/* workspace registration: Bun workspaces must include "apps/*" in the root package.json. If absent, bun install will not link @suaveplan/testbed as a workspace package and the workspace:^ reference to @suaveplan/testing-e2e will fail to resolve.