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

2989 lines
92 KiB
Markdown

# 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
```text
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`
```typescript
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 }`.
```typescript
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`
```typescript
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`
```markdown
# 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
```typescript
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.
```text
#### 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`
```typescript
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.
```typescript
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`
```typescript
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`
```markdown
# 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
```typescript
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.
```text
#### 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`
```typescript
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.
```typescript
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`
```typescript
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`
```markdown
# 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
```typescript
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.
```text
#### 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`
```typescript
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.
```typescript
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`
```typescript
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`
```markdown
# 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
```typescript
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.
```text
#### 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:
```typescript
// 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:
```json
"./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:
```bash
# 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
```bash
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`
```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`:
```json
"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`
```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`
```typescript
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`
```typescript
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`
```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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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`
```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`
```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`
```typescript
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`
```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`
```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`
```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`
```markdown
# 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
```tsx
// 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.
```text
#### `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`
```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`
```markdown
# 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
```tsx
// 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.
```text
#### `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`
```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`
```markdown
# 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
```tsx
// 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.
```text
#### `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`
```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`
```markdown
# 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
```tsx
// 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.
```text
#### `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`
```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`
```markdown
# 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
```tsx
// 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.
```text
#### `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`
```typescript
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`
```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`
```markdown
# 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
```tsx
<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.
```text
#### `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`
```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`
```markdown
# 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
```tsx
<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.
```text
#### `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`
```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`
```markdown
# 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
```tsx
// 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.
```text
#### `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`
```typescript
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`
```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`
```typescript
export { HomePage } from "./home-page.js";
```
File: `/mnt/s/web/genesis/apps/testbed/src/client/pages/home/home-page.md`
```markdown
# 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
```tsx
// 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"`.
```text
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`
```typescript
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`
```typescript
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
```bash
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
```bash
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
```bash
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
```bash
# 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
```bash
# 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/`
```text
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
```text
src/index.ts <- 4 new export blocks
package.json <- 4 new exports map entries
```
### Part B -- New files in `apps/testbed/`
```text
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.