# 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 `/.types.ts` and `/index.ts` 2. Write failing test file `/.test.ts` 3. Run `bunx turbo test --filter=@suaveplan/testing-e2e` — confirm failures 4. Write implementation `/.ts` and doc `/.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; } ``` #### 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=", { 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)).toHaveBeenCalledWith( "http://localhost:3200/network-webrtc?session=abc&role=host" ); expect((pages[1]!.goto as ReturnType)).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)).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)).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=` or `&role=`. */ export async function createMultiSession( options: MultiSessionOptions ): Promise { 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 => { 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 { 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=` or `&role=` 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` - `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=` - Returns `MultiSessionResult` with `pages: Page[]`, `contexts: BrowserContext[]`, `teardown: () => Promise` ### `simulateServerFailure(apiBaseUrl, route, fetchFn?): Promise` - `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 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)).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 = { 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(); 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).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 { 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 { const existing = context.backgroundPages(); if (existing.length > 0) return existing[0]!; return context.waitForEvent("backgroundpage") as Promise; } ``` #### 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:///_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` - `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(); 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 Suaveplan Testbed
``` ### 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( ); ``` ### 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 (

404 -- Page not found

); } function PlaceholderPage({ label }: { label: string }): JSX.Element { return (

{label}

Library demo page -- implementation coming soon.

); } export function App(): JSX.Element { return (
} /> {routes.map((route) => ( } /> ))} } />
); } ``` ### 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(getInitialTheme); useEffect(() => { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); }, [theme]); return ( ); } ``` **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 `` (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"; ``` ## 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 = { component: ThemeToggle, title: "Layout/ThemeToggle", tags: ["autodocs"], }; export default meta; type Story = StoryObj; 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(getInitialLocale); useEffect(() => { localStorage.setItem("locale", locale); document.documentElement.setAttribute("lang", locale); }, [locale]); function handleChange(e: React.ChangeEvent): void { const value = e.target.value; if ((LOCALES as readonly string[]).includes(value)) setLocale(value as Locale); } return ( ); } ``` #### `locale-switcher.md` ```markdown # locale-switcher ## Purpose `LocaleSwitcher` renders a ` setPlaintext(e.target.value)} /> ``` ## Usage: Advanced Example ```tsx ``` ## 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 = { component: DemoCard, title: "Shared/DemoCard", tags: ["autodocs"], }; export default meta; type Story = StoryObj; export const Default: Story = { args: { title: "Example Demo", children: , output: "result: ok", }, }; export const NoOutput: Story = { args: { title: "Inputs Only", children: }, }; export const Playground: Story = { args: { title: "Playground", children: Edit me, 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 (
{language && {language}}
{code}
); } ``` #### `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 `
` 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";

```

## Usage: Advanced Example

```tsx

```

## 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 = {
  component: CodeBlock,
  title: "Shared/CodeBlock",
  tags: ["autodocs"],
};
export default meta;
type Story = StoryObj;

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 = {
  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 = {
  ok: "OK",
  error: "ERROR",
  pending: "PENDING",
};

export function StatusBadge({ status, label }: StatusBadgeProps): JSX.Element {
  return (
    
      {label ?? STATUS_LABELS[status]}
    
  );
}
```

#### `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";


```

## 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 = {
  component: StatusBadge,
  title: "Shared/StatusBadge",
  tags: ["autodocs"],
};
export default meta;
type Story = StoryObj;

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 (
    

Suaveplan Testbed

Select a library from the sidebar to view its interactive demo.

); } ``` 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"; ``` ## 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 = { component: HomePage, title: "Pages/Home", tags: ["autodocs"], decorators: [(Story) => ], }; export default meta; type Story = StoryObj; 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.