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

2168 lines
79 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Implementation Plan: Testbed Library Pages + Playwright E2E Suite
**Date:** 2026-04-24
**Branch:** genesis
**App path:** `apps/testbed/`
**Depends on:** `2026-04-24-testbed-foundation.md` (app shell, layout components, testing-e2e modules must exist first)
---
## Goal
Implement all 10 library pages for `apps/testbed/` and the complete Playwright E2E test suite. Every page drives real `@suaveplan/*` library code — no mocks, no hardcoded responses. Every spec enforces zero console errors, zero network failures, and locates elements exclusively by `data-testid`.
---
## Architecture
```text
apps/testbed/
src/client/pages/ ← 10 page directories, one per library
home/ ← grid of all library cards
crypto/ ← XChaCha20-Poly1305 encrypt/decrypt round-trip
logger/ ← ConsoleLogger + EventLogger demo
datetime/ ← business-day arithmetic
i18n/ ← message catalog + pluralization + locale switcher
feature-flags/ ← FlagManager with Percentage + UserTarget strategies
storage-s3/ ← upload/list/download via /api/storage proxy
surrealdb/ ← create/query/delete record via /api/database proxy
network-webrtc/ ← WebRTC peer connection (?role=host|peer&session=<id>)
extension-test/ ← login form + injection target for extension E2E
e2e/
layout/ ← navigation, theme, locale, console-baseline, network-baseline
libraries/ ← one spec per library page
multi-session/ ← webrtc-mesh, server-failover, websocket-gateway, broadcast-channel
extensions/ ← content-script, autofill-detection
```
**Standard page contract** (every page, without exception):
- `<h1 data-testid="page-title">` — package name
- `<div data-testid="demo-card">` — wraps each interactive demo
- `<div data-testid="output">` — live result display inside demo card
- `<div data-testid="console-panel">` — console capture panel below demo
---
## Tech Stack
- **React 19** with hooks — no class components
- **Tailwind v4** — utility classes, `data-theme` dark mode
- **`@suaveplan/crypto`**, **`@suaveplan/logger`**, **`@suaveplan/datetime`**, **`@suaveplan/i18n`**, **`@suaveplan/feature-flags`** — real library imports
- **Hono** API proxy — `/api/storage`, `/api/database`, `/api/test-control`
- **`@suaveplan/testing-e2e`** — `attachConsoleMonitor`, `attachNetworkMonitor`, `createMultiSession`, `launchWithExtension`
- **Playwright** — `chromium`, `firefox`, `webkit`, `mobile-chrome`, `mobile-safari`, `chromium-extensions`
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan.
---
## Pre-flight Checklist
Before writing any code:
1. Confirm the app shell exists: `ls /mnt/s/web/genesis/apps/testbed/src/client/` — must contain `main.tsx`, `app.tsx`, `routes.ts`, `components/`.
2. Confirm layout components exist: `ls /mnt/s/web/genesis/apps/testbed/src/client/components/layout/` — must contain `sidebar.tsx`, `header.tsx`, `console-panel.tsx`, `locale-switcher.tsx`, `theme-toggle.tsx`.
3. Confirm shared components exist: `ls /mnt/s/web/genesis/apps/testbed/src/client/components/shared/` — must contain `demo-card.tsx`, `code-block.tsx`, `status-badge.tsx`.
4. Confirm testing-e2e has new modules: `ls /mnt/s/web/genesis/universal/dev/testing-e2e/src/` — must include `console-monitor/`, `network-monitor/`, `multi-session/`, `extension-fixture/`.
5. Confirm `@suaveplan/crypto`, `@suaveplan/logger`, `@suaveplan/datetime`, `@suaveplan/i18n`, `@suaveplan/feature-flags` are in `apps/testbed/package.json` dependencies.
6. Confirm `routes.ts` exists and exports the `RouteEntry[]` array with all 10 routes.
7. Confirm the Hono server has `/api/storage`, `/api/database`, and `/api/test-control` route files.
If any of the above are missing, stop and complete `2026-04-24-testbed-foundation.md` first.
---
## Chunk 1 — Home Page + Crypto Page + Logger Page + Datetime Page
**Goal:** Four pages wired to real library code. Commit after this chunk.
### Task 1.1 — Home page
**File:** `apps/testbed/src/client/pages/home/home-page.tsx`
The home page renders a responsive grid of library cards. It imports the `routes` array from `../../routes.js` and maps over it, rendering one card per entry. Each card links to the library's route.
Required elements:
- `data-testid="page-title"` on `<h1>Testbed</h1>` (the home page title is "Testbed", not a package name)
- `data-testid="library-card"` on each card
```tsx
import { routes } from "../../routes.js";
import { Link } from "react-router-dom";
export function HomePage() {
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-8">
Testbed
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Live demonstrations of every @suaveplan library against real services.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{routes.map((route) => (
<Link
key={route.path}
to={route.path}
data-testid="library-card"
className="block rounded-xl border border-gray-200 dark:border-gray-700 p-5 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1">
{route.category}
</p>
<p className="font-semibold text-gray-900 dark:text-white">
{route.label}
</p>
</Link>
))}
</div>
</main>
);
}
```
**File:** `apps/testbed/src/client/pages/home/home-page.md`
Required sections: Purpose (home page renders route manifest as card grid), Features (dynamic card grid, category grouping, links), Basic Example (import and use), Advanced Example (adding a new library card via routes.ts), API Reference (no props — uses routes manifest), Implementation Notes (reads from routes.ts, no hardcoded list).
Minimum 200 words excluding code blocks.
**File:** `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> = {
title: "Pages/Home",
component: HomePage,
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
export const Default: StoryObj<typeof HomePage> = {};
```
**File:** `apps/testbed/src/client/pages/home/index.ts`
```ts
export { HomePage } from "./home-page.js";
```
---
### Task 1.2 — Crypto page
**File:** `apps/testbed/src/client/pages/crypto/crypto-page.tsx`
Demonstrates XChaCha20-Poly1305 encrypt/decrypt round-trip from `@suaveplan/crypto`. On mount, generate a random key. When the user types plaintext and clicks "Encrypt", call `encrypt()`, display the ciphertext hex. Click "Decrypt" to call `decrypt()` and show the recovered plaintext.
Use `useState` for: `plaintext`, `ciphertext`, `decrypted`, `error`.
Use `useEffect` on mount to call `crypto.generateKey()` and store the key in a `useRef`.
```tsx
import { useState, useRef, useEffect } from "react";
import { createCrypto } from "@suaveplan/crypto";
const crypto = createCrypto();
export function CryptoPage() {
const keyRef = useRef<Uint8Array | null>(null);
const [plaintext, setPlaintext] = useState("Hello, Suaveplan!");
const [ciphertext, setCiphertext] = useState("");
const [decrypted, setDecrypted] = useState("");
const [error, setError] = useState("");
useEffect(() => {
crypto.generateKey().then((k) => { keyRef.current = k; });
}, []);
async function handleEncrypt() {
setError("");
try {
const key = keyRef.current!;
const nonce = crypto.randomBytes(24);
const ct = await crypto.encrypt({ key, nonce, plaintext: new TextEncoder().encode(plaintext) });
setCiphertext(Buffer.from(ct).toString("hex"));
} catch (e) {
setError(String(e));
}
}
async function handleDecrypt() {
setError("");
try {
// For demo: re-encrypt to get a fresh round-trip
const key = keyRef.current!;
const nonce = crypto.randomBytes(24);
const ct = await crypto.encrypt({ key, nonce, plaintext: new TextEncoder().encode(plaintext) });
const pt = await crypto.decrypt({ key, nonce, ciphertext: ct });
setDecrypted(new TextDecoder().decode(pt));
} catch (e) {
setError(String(e));
}
}
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
@suaveplan/crypto
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">XChaCha20-Poly1305 Round-Trip</h2>
<textarea
aria-label="Plaintext input"
value={plaintext}
onChange={(e) => setPlaintext(e.target.value)}
rows={3}
className="w-full rounded border border-gray-300 dark:border-gray-600 p-2 mb-4 font-mono text-sm"
/>
<div className="flex gap-3 mb-4">
<button
type="button"
onClick={handleEncrypt}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700"
>
Encrypt
</button>
<button
type="button"
onClick={handleDecrypt}
className="rounded-lg bg-green-600 text-white px-4 py-2 hover:bg-green-700"
>
Decrypt
</button>
</div>
<div data-testid="output" className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[3rem]">
{error && <p className="text-red-500">Error: {error}</p>}
{ciphertext && <p><span className="text-gray-500">ciphertext:</span> {ciphertext.slice(0, 64)}…</p>}
{decrypted && <p><span className="text-gray-500">decrypted:</span> {decrypted}</p>}
</div>
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
{/* ConsolePanelContext populated by layout */}
</div>
</main>
);
}
```
**Note on the API:** The exact `@suaveplan/crypto` API depends on what shipped. Check `universal/crypto/crypto/src/` before writing the page. The demo above uses a plausible surface — adjust to match real exports. The invariant is: the demo calls real library functions, shows ciphertext in `data-testid="output"` after Encrypt, shows decrypted text after Decrypt.
**File:** `apps/testbed/src/client/pages/crypto/crypto-page.md` — ≥200 words, all six sections.
**File:** `apps/testbed/src/client/pages/crypto/crypto-page.stories.tsx` — Default + WithLongPlaintext variants.
**File:** `apps/testbed/src/client/pages/crypto/index.ts` — barrel export.
---
### Task 1.3 — Logger page
**File:** `apps/testbed/src/client/pages/logger/logger-page.tsx`
Demonstrates `ConsoleLogger` and `EventLogger` from `@suaveplan/logger`. Four buttons (DEBUG, INFO, WARN, ERROR) each fire the corresponding log method. The `output` div shows a running list of structured log entries rendered from state.
```tsx
import { useState } from "react";
import { ConsoleLogger } from "@suaveplan/logger";
interface LogEntry {
level: string;
message: string;
timestamp: string;
}
const logger = new ConsoleLogger({ name: "testbed-demo", level: "debug" });
export function LoggerPage() {
const [entries, setEntries] = useState<LogEntry[]>([]);
function logAt(level: "debug" | "info" | "warn" | "error") {
const entry: LogEntry = {
level,
message: `${level.toUpperCase()} message from testbed at ${new Date().toISOString()}`,
timestamp: new Date().toISOString(),
};
logger[level](entry.message, { demo: true });
setEntries((prev) => [...prev, entry]);
}
const levelColors: Record<string, string> = {
debug: "text-gray-400",
info: "text-blue-400",
warn: "text-yellow-400",
error: "text-red-400",
};
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
@suaveplan/logger
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Structured Logger Demo</h2>
<div className="flex gap-3 mb-4">
{(["debug", "info", "warn", "error"] as const).map((level) => (
<button
key={level}
type="button"
onClick={() => logAt(level)}
className="rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2 uppercase text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
{level}
</button>
))}
<button
type="button"
onClick={() => setEntries([])}
className="ml-auto rounded-lg text-gray-400 px-3 py-2 text-sm hover:text-gray-600"
>
Clear
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[6rem] space-y-1"
>
{entries.length === 0 && (
<p className="text-gray-400">Click a level button to log a message.</p>
)}
{entries.map((entry, i) => (
<p key={i} className={levelColors[entry.level]}>
[{entry.level.toUpperCase()}] {entry.message}
</p>
))}
</div>
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}
```
**Note:** Verify `ConsoleLogger` constructor signature against `universal/logger/` source before writing. If the package exports a factory or builder pattern instead, use it. The output div must show entries after clicking any level button — this is what E2E asserts.
**File:** `apps/testbed/src/client/pages/logger/logger-page.md` — ≥200 words.
**File:** `apps/testbed/src/client/pages/logger/logger-page.stories.tsx` — Default + WithEntries variants.
**File:** `apps/testbed/src/client/pages/logger/index.ts` — barrel.
---
### Task 1.4 — Datetime page
**File:** `apps/testbed/src/client/pages/datetime/datetime-page.tsx`
Demonstrates business-day arithmetic from `@suaveplan/datetime`. Two inputs: start date (date picker) and number of business days to add (number input). Click "Calculate" to call `addBusinessDays(start, n)` and display the result date.
```tsx
import { useState } from "react";
import { addBusinessDays } from "@suaveplan/datetime";
export function DatetimePage() {
const today = new Date().toISOString().split("T")[0];
const [startDate, setStartDate] = useState(today);
const [days, setDays] = useState(5);
const [result, setResult] = useState("");
function calculate() {
const start = new Date(startDate);
const end = addBusinessDays(start, days);
setResult(end.toISOString().split("T")[0]);
}
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
@suaveplan/datetime
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Business-Day Arithmetic</h2>
<div className="flex flex-wrap gap-4 mb-4">
<label className="flex flex-col gap-1 text-sm font-medium">
Start Date
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded border border-gray-300 dark:border-gray-600 p-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium">
Business Days to Add
<input
type="number"
value={days}
min={0}
max={365}
onChange={(e) => setDays(Number(e.target.value))}
className="rounded border border-gray-300 dark:border-gray-600 p-2 w-24"
/>
</label>
</div>
<button
type="button"
onClick={calculate}
className="rounded-lg bg-blue-600 text-white px-4 py-2 mb-4 hover:bg-blue-700"
>
Calculate
</button>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[3rem]"
>
{result
? <p>{days} business days after {startDate} = <strong>{result}</strong></p>
: <p className="text-gray-400">Click Calculate to see result.</p>
}
</div>
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}
```
**Note:** Verify `addBusinessDays` export path and signature against `universal/primitives/datetime/src/`. If the function name or signature differs (e.g. returns a `Date` wrapped in a result type, or uses a different parameter order), adjust accordingly. The invariant: clicking Calculate populates `data-testid="output"` with the result date string.
**File:** `apps/testbed/src/client/pages/datetime/datetime-page.md` — ≥200 words.
**File:** `apps/testbed/src/client/pages/datetime/datetime-page.stories.tsx` — Default + NegativeDays + WeekendBoundary variants.
**File:** `apps/testbed/src/client/pages/datetime/index.ts` — barrel.
---
### Task 1.5 — Wire Chunk 1 pages into app router
In `apps/testbed/src/client/app.tsx` (or wherever `<Routes>` is defined), add routes for all four pages:
```tsx
import { HomePage } from "./pages/home/index.js";
import { CryptoPage } from "./pages/crypto/index.js";
import { LoggerPage } from "./pages/logger/index.js";
import { DatetimePage } from "./pages/datetime/index.js";
// Inside <Routes>:
<Route path="/" element={<HomePage />} />
<Route path="/crypto" element={<CryptoPage />} />
<Route path="/logger" element={<LoggerPage />} />
<Route path="/datetime" element={<DatetimePage />} />
```
### Task 1.6 — Commit
```bash
git add apps/testbed/src/client/pages/home \
apps/testbed/src/client/pages/crypto \
apps/testbed/src/client/pages/logger \
apps/testbed/src/client/pages/datetime \
apps/testbed/src/client/app.tsx
git commit -m "feat(testbed): add home, crypto, logger, datetime pages"
```
---
## Chunk 2 — i18n + Feature-Flags + Storage-S3 + SurrealDB + Network-WebRTC + Extension-Test Pages
**Goal:** Remaining six pages. Commit after this chunk.
### Task 2.1 — i18n page
**File:** `apps/testbed/src/client/pages/i18n/i18n-page.tsx`
Demonstrates `@suaveplan/i18n` message catalog with pluralization. The page renders a counter (default 0) with +/– buttons and a "You have N items" message resolved through the i18n layer. A locale switcher select element (en/fr/de/ja) triggers re-render in the selected locale.
```tsx
import { useState, useMemo } from "react";
import { createI18n } from "@suaveplan/i18n";
const catalogs = {
en: { items: "{count, plural, one {You have # item} other {You have # items}}" },
fr: { items: "{count, plural, one {Vous avez # article} other {Vous avez # articles}}" },
de: { items: "{count, plural, one {Sie haben # Artikel} other {Sie haben # Artikel}}" },
ja: { items: "{count, plural, other {アイテムが #個あります}}" },
};
export function I18nPage() {
const [locale, setLocale] = useState<"en" | "fr" | "de" | "ja">("en");
const [count, setCount] = useState(1);
const i18n = useMemo(() => createI18n({ locale, messages: catalogs[locale] }), [locale]);
const message = i18n.t("items", { count });
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
@suaveplan/i18n
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Pluralization Demo</h2>
<div className="flex items-center gap-4 mb-4">
<label className="text-sm font-medium">Locale</label>
<select
value={locale}
onChange={(e) => setLocale(e.target.value as typeof locale)}
className="rounded border border-gray-300 dark:border-gray-600 px-2 py-1"
>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="ja">日本語</option>
</select>
</div>
<div className="flex items-center gap-3 mb-4">
<button
type="button"
onClick={() => setCount((c) => Math.max(0, c - 1))}
className="rounded-lg border px-3 py-1 text-lg"
>
–
</button>
<span className="text-2xl font-bold w-10 text-center">{count}</span>
<button
type="button"
onClick={() => setCount((c) => c + 1)}
className="rounded-lg border px-3 py-1 text-lg"
>
+
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[3rem]"
>
{message}
</div>
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}
```
**Note:** Verify `createI18n` factory API against `universal/primitives/i18n/src/`. If the package uses a different API (e.g. `MessageCatalog`, `interpolate`, `createResolver`), adapt. The invariant: output shows a locale-specific string that changes when the locale selector changes.
**File:** `apps/testbed/src/client/pages/i18n/i18n-page.md` — ≥200 words.
**File:** `apps/testbed/src/client/pages/i18n/i18n-page.stories.tsx` — Default + French + Japanese variants.
**File:** `apps/testbed/src/client/pages/i18n/index.ts` — barrel.
---
### Task 2.2 — Feature-flags page
**File:** `apps/testbed/src/client/pages/feature-flags/feature-flags-page.tsx`
Demonstrates `FlagManager` with `PercentageStrategy` and `UserTargetStrategy`. Pre-configures three flags: `beta-ui` (50% rollout), `new-checkout` (UserTarget: user IDs `alice`, `bob`), `dark-mode` (100% enabled). Input: user ID text field. On "Evaluate" click, call `FlagManager.evaluate()` for each flag and display results.
```tsx
import { useState } from "react";
import { FlagManager, PercentageStrategy, UserTargetStrategy } from "@suaveplan/feature-flags";
const manager = new FlagManager([
{ key: "beta-ui", strategy: new PercentageStrategy({ percentage: 50 }) },
{ key: "new-checkout", strategy: new UserTargetStrategy({ users: ["alice", "bob"] }) },
{ key: "dark-mode", strategy: new PercentageStrategy({ percentage: 100 }) },
]);
interface FlagResult { key: string; enabled: boolean; }
export function FeatureFlagsPage() {
const [userId, setUserId] = useState("alice");
const [results, setResults] = useState<FlagResult[]>([]);
async function evaluate() {
const context = { userId };
const flagResults = await Promise.all(
["beta-ui", "new-checkout", "dark-mode"].map(async (key) => ({
key,
enabled: await manager.isEnabled(key, context),
}))
);
setResults(flagResults);
}
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
@suaveplan/feature-flags
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Flag Evaluation Demo</h2>
<div className="flex gap-3 mb-4">
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="User ID (try: alice, bob, carol)"
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1"
/>
<button
type="button"
onClick={evaluate}
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700"
>
Evaluate
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[4rem] space-y-1"
>
{results.length === 0
? <p className="text-gray-400">Enter a user ID and click Evaluate.</p>
: results.map((r) => (
<p key={r.key}>
<span className={r.enabled ? "text-green-400" : "text-red-400"}>
{r.enabled ? "✓" : "✗"}
</span>{" "}
<span className="text-gray-300">{r.key}</span>
</p>
))
}
</div>
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}
```
**Note:** Verify `FlagManager`, `PercentageStrategy`, and `UserTargetStrategy` constructor shapes against `universal/feature-flags/`. The `isEnabled` method may be `evaluate(key, context)` returning `{ enabled: boolean }` — check and adapt.
**File:** `apps/testbed/src/client/pages/feature-flags/feature-flags-page.md` — ≥200 words.
**File:** `apps/testbed/src/client/pages/feature-flags/feature-flags-page.stories.tsx` — Default + TargetedUser + UnknownUser.
**File:** `apps/testbed/src/client/pages/feature-flags/index.ts` — barrel.
---
### Task 2.3 — Storage-S3 page (requiresServices: true)
**File:** `apps/testbed/src/client/pages/storage-s3/storage-s3-page.tsx`
Uses `/api/storage` Hono proxy routes (GET for list, POST for upload, GET `?key=` for download). The server proxy connects to `@suaveplan/storage-s3` against Garage S3.
```tsx
import { useState } from "react";
interface S3Object { key: string; size: number; }
export function StorageS3Page() {
const [objects, setObjects] = useState<S3Object[]>([]);
const [uploadContent, setUploadContent] = useState("Hello from testbed!");
const [downloadResult, setDownloadResult] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "error-unavailable">("idle");
async function listObjects() {
setStatus("loading");
try {
const res = await fetch("/api/storage/list");
if (!res.ok) { setStatus("error-unavailable"); return; }
const data = await res.json() as { objects: S3Object[] };
setObjects(data.objects);
setStatus("idle");
} catch {
setStatus("error-unavailable");
}
}
async function upload() {
setStatus("loading");
try {
const key = `demo-${Date.now()}.txt`;
const res = await fetch("/api/storage/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, content: uploadContent }),
});
if (!res.ok) { setStatus("error-unavailable"); return; }
await listObjects();
} catch {
setStatus("error-unavailable");
}
}
async function download(key: string) {
setStatus("loading");
try {
const res = await fetch(`/api/storage/download?key=${encodeURIComponent(key)}`);
if (!res.ok) { setStatus("error-unavailable"); return; }
const text = await res.text();
setDownloadResult(text);
setStatus("idle");
} catch {
setStatus("error-unavailable");
}
}
const isUnavailable = status === "error-unavailable";
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
@suaveplan/storage-s3
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Garage S3 Demo</h2>
{isUnavailable && (
<div
data-testid="output"
className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-4 mb-4"
>
Service unavailable — start Docker services with{" "}
<code>bun run --filter=@suaveplan/docker dev:up</code>
</div>
)}
{!isUnavailable && (
<>
<div className="flex gap-3 mb-4">
<input
type="text"
value={uploadContent}
onChange={(e) => setUploadContent(e.target.value)}
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1"
placeholder="File content to upload"
/>
<button type="button" onClick={upload} className="rounded-lg bg-blue-600 text-white px-4 py-2">
Upload
</button>
<button type="button" onClick={listObjects} className="rounded-lg border px-4 py-2">
List
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[4rem]"
>
{status === "loading" && <p className="text-gray-400">Loading…</p>}
{objects.map((obj) => (
<div key={obj.key} className="flex justify-between items-center py-1">
<span>{obj.key}</span>
<button
type="button"
onClick={() => download(obj.key)}
className="text-blue-400 text-xs hover:underline"
>
download
</button>
</div>
))}
{downloadResult && (
<p className="mt-2 border-t pt-2">Downloaded: {downloadResult}</p>
)}
</div>
</>
)}
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}
```
**File:** `apps/testbed/src/client/pages/storage-s3/storage-s3-page.md` — ≥200 words.
**File:** `apps/testbed/src/client/pages/storage-s3/storage-s3-page.stories.tsx` — Default + ServiceUnavailable variants.
**File:** `apps/testbed/src/client/pages/storage-s3/index.ts` — barrel.
---
### Task 2.4 — SurrealDB page (requiresServices: true)
**File:** `apps/testbed/src/client/pages/surrealdb/surrealdb-page.tsx`
Uses `/api/database` proxy routes (POST create, GET query, DELETE delete). Shows "Service unavailable" gracefully.
```tsx
import { useState } from "react";
interface Record { id: string; name: string; createdAt: string; }
export function SurrealdbPage() {
const [records, setRecords] = useState<Record[]>([]);
const [name, setName] = useState("testbed-demo");
const [status, setStatus] = useState<"idle" | "loading" | "error-unavailable">("idle");
async function createRecord() {
setStatus("loading");
try {
const res = await fetch("/api/database/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ table: "demo", data: { name, createdAt: new Date().toISOString() } }),
});
if (!res.ok) { setStatus("error-unavailable"); return; }
await queryRecords();
} catch {
setStatus("error-unavailable");
}
}
async function queryRecords() {
setStatus("loading");
try {
const res = await fetch("/api/database/query?table=demo");
if (!res.ok) { setStatus("error-unavailable"); return; }
const data = await res.json() as { records: Record[] };
setRecords(data.records);
setStatus("idle");
} catch {
setStatus("error-unavailable");
}
}
async function deleteRecord(id: string) {
await fetch(`/api/database/delete?id=${encodeURIComponent(id)}`, { method: "DELETE" });
await queryRecords();
}
const isUnavailable = status === "error-unavailable";
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
SurrealDB
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Record CRUD Demo</h2>
{isUnavailable ? (
<div
data-testid="output"
className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-4"
>
Service unavailable — start Docker services with{" "}
<code>bun run --filter=@suaveplan/docker dev:up</code>
</div>
) : (
<>
<div className="flex gap-3 mb-4">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1"
placeholder="Record name"
/>
<button type="button" onClick={createRecord} className="rounded-lg bg-blue-600 text-white px-4 py-2">
Create
</button>
<button type="button" onClick={queryRecords} className="rounded-lg border px-4 py-2">
Query
</button>
</div>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[4rem] space-y-1"
>
{status === "loading" && <p className="text-gray-400">Loading…</p>}
{records.map((r) => (
<div key={r.id} className="flex justify-between items-center">
<span>{r.id} — {r.name}</span>
<button
type="button"
onClick={() => deleteRecord(r.id)}
className="text-red-400 text-xs hover:underline"
>
delete
</button>
</div>
))}
{records.length === 0 && status === "idle" && (
<p className="text-gray-400">No records. Click Create or Query.</p>
)}
</div>
</>
)}
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}
```
**File:** `apps/testbed/src/client/pages/surrealdb/surrealdb-page.md` — ≥200 words.
**File:** `apps/testbed/src/client/pages/surrealdb/surrealdb-page.stories.tsx` — Default + ServiceUnavailable.
**File:** `apps/testbed/src/client/pages/surrealdb/index.ts` — barrel.
---
### Task 2.5 — Network-WebRTC page (requiresServices: true)
**File:** `apps/testbed/src/client/pages/network-webrtc/network-webrtc-page.tsx`
Reads `?role=host|peer` and `?session=<id>` from `window.location.search`. Host creates an RTCPeerConnection offer and signals via `/api/signalling`. Peer polls for the offer, creates an answer, signals back. Shows `data-status="connected"` on the connection state display once ICE completes.
```tsx
import { useEffect, useState, useRef } from "react";
type Role = "host" | "peer";
type ConnStatus = "idle" | "connecting" | "connected" | "failed";
export function NetworkWebRtcPage() {
const params = new URLSearchParams(window.location.search);
const role = (params.get("role") ?? "host") as Role;
const sessionId = params.get("session") ?? "default";
const [connStatus, setConnStatus] = useState<ConnStatus>("idle");
const [iceLog, setIceLog] = useState<string[]>([]);
const pcRef = useRef<RTCPeerConnection | null>(null);
useEffect(() => {
if (role === "host") {
startHost();
} else {
startPeer();
}
return () => { pcRef.current?.close(); };
}, []);
function log(msg: string) {
setIceLog((prev) => [...prev, `${new Date().toISOString().slice(11, 19)} ${msg}`]);
}
async function startHost() {
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
pcRef.current = pc;
pc.onicecandidate = (e) => {
if (e.candidate) log(`ICE candidate: ${e.candidate.type}`);
};
pc.onconnectionstatechange = () => {
log(`State: ${pc.connectionState}`);
if (pc.connectionState === "connected") setConnStatus("connected");
if (pc.connectionState === "failed") setConnStatus("failed");
};
setConnStatus("connecting");
const dc = pc.createDataChannel("demo");
dc.onopen = () => log("DataChannel open");
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await fetch(`/api/signalling/offer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, sdp: offer.sdp }),
});
// Poll for answer
const pollAnswer = async () => {
for (let i = 0; i < 30; i++) {
const res = await fetch(`/api/signalling/answer?sessionId=${sessionId}`);
if (res.ok) {
const { sdp } = await res.json() as { sdp: string };
await pc.setRemoteDescription({ type: "answer", sdp });
log("Remote description set");
return;
}
await new Promise((r) => setTimeout(r, 1000));
}
setConnStatus("failed");
};
pollAnswer();
}
async function startPeer() {
// Poll for offer
setConnStatus("connecting");
let offerSdp: string | null = null;
for (let i = 0; i < 30; i++) {
const res = await fetch(`/api/signalling/offer?sessionId=${sessionId}`);
if (res.ok) {
const { sdp } = await res.json() as { sdp: string };
offerSdp = sdp;
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
if (!offerSdp) { setConnStatus("failed"); return; }
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
pcRef.current = pc;
pc.onicecandidate = (e) => {
if (e.candidate) log(`ICE candidate: ${e.candidate.type}`);
};
pc.onconnectionstatechange = () => {
log(`State: ${pc.connectionState}`);
if (pc.connectionState === "connected") setConnStatus("connected");
if (pc.connectionState === "failed") setConnStatus("failed");
};
await pc.setRemoteDescription({ type: "offer", sdp: offerSdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await fetch(`/api/signalling/answer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, sdp: answer.sdp }),
});
}
const statusColors: Record<ConnStatus, string> = {
idle: "text-gray-400",
connecting: "text-yellow-400",
connected: "text-green-400",
failed: "text-red-400",
};
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
Network — WebRTC
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-2">P2P Mesh Demo</h2>
<p className="text-sm text-gray-500 mb-4">
Role: <strong>{role}</strong> — Session: <strong>{sessionId}</strong>
</p>
<p
data-status={connStatus}
className={`text-lg font-semibold mb-4 ${statusColors[connStatus]}`}
>
{connStatus.charAt(0).toUpperCase() + connStatus.slice(1)}
</p>
<div
data-testid="output"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded p-3 min-h-[6rem] space-y-1"
>
{iceLog.map((entry, i) => <p key={i}>{entry}</p>)}
</div>
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}
```
The Hono server must have `/api/signalling` routes: `POST /offer`, `GET /offer`, `POST /answer`, `GET /answer`. These use in-memory session state (a `Map<string, {offer?, answer?}>`) — no persistence needed. The `/api/test-control/disable-route` endpoint (registered only when `NODE_ENV === "test"`) can mark a route as disabled, returning 503 on subsequent calls — this enables the server-failover E2E spec.
**File:** `apps/testbed/src/client/pages/network-webrtc/network-webrtc-page.md` — ≥200 words.
**File:** `apps/testbed/src/client/pages/network-webrtc/network-webrtc-page.stories.tsx` — HostRole + PeerRole variants (note: WebRTC won't actually connect in Storybook, show static connecting state).
**File:** `apps/testbed/src/client/pages/network-webrtc/index.ts` — barrel.
---
### Task 2.6 — Extension-test page
**File:** `apps/testbed/src/client/pages/extension-test/extension-test-page.tsx`
Static page — no dynamic library calls. Contains exactly the elements the extension content script and E2E specs need.
```tsx
export function ExtensionTestPage() {
return (
<main className="p-8">
<h1 data-testid="page-title" className="text-3xl font-bold mb-6">
Extension Test
</h1>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Login Form</h2>
<form data-testid="login-form" className="space-y-4" onSubmit={(e) => e.preventDefault()}>
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
data-testid="username-field"
type="text"
name="username"
autoComplete="username"
className="w-full rounded border border-gray-300 dark:border-gray-600 px-3 py-2"
placeholder="Enter username"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
data-testid="password-field"
type="password"
name="password"
autoComplete="current-password"
className="w-full rounded border border-gray-300 dark:border-gray-600 px-3 py-2"
placeholder="Enter password"
/>
</div>
<button type="submit" className="rounded-lg bg-blue-600 text-white px-4 py-2">
Sign In
</button>
</form>
</div>
<div data-testid="demo-card" className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Standalone Password Field</h2>
<input
data-testid="password-field-standalone"
type="password"
name="standalone-password"
className="w-full rounded border border-gray-300 dark:border-gray-600 px-3 py-2"
placeholder="Standalone password input"
/>
</div>
<div
data-testid="output"
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 min-h-[3rem]"
>
{/* Extension interaction results rendered here by the page or extension */}
</div>
<div
data-testid="extension-injected"
className="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-4 mb-6 min-h-[3rem]"
>
{/* Extension content script populates this element */}
</div>
<div data-testid="console-panel" className="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
</div>
</main>
);
}
```
**File:** `apps/testbed/src/client/pages/extension-test/extension-test-page.md` — ≥200 words.
**File:** `apps/testbed/src/client/pages/extension-test/extension-test-page.stories.tsx` — Default + WithExtensionInjected variants.
**File:** `apps/testbed/src/client/pages/extension-test/index.ts` — barrel.
---
### Task 2.7 — Wire Chunk 2 pages into app router
Add routes in `app.tsx`:
```tsx
import { I18nPage } from "./pages/i18n/index.js";
import { FeatureFlagsPage } from "./pages/feature-flags/index.js";
import { StorageS3Page } from "./pages/storage-s3/index.js";
import { SurrealdbPage } from "./pages/surrealdb/index.js";
import { NetworkWebRtcPage } from "./pages/network-webrtc/index.js";
import { ExtensionTestPage } from "./pages/extension-test/index.js";
// Inside <Routes>:
<Route path="/i18n" element={<I18nPage />} />
<Route path="/feature-flags" element={<FeatureFlagsPage />} />
<Route path="/storage-s3" element={<StorageS3Page />} />
<Route path="/surrealdb" element={<SurrealdbPage />} />
<Route path="/network-webrtc" element={<NetworkWebRtcPage />} />
<Route path="/extension-test" element={<ExtensionTestPage />} />
```
Also verify `routes.ts` has entries for all 10 pages with correct `requiresServices` flags:
```typescript
export const routes: RouteEntry[] = [
{ path: "/", label: "Testbed", category: "home" },
{ 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: "SurrealDB", category: "database", requiresServices: true },
{ path: "/network-webrtc", label: "Network — WebRTC", category: "network", requiresServices: true },
{ path: "/extension-test", label: "Extension Test", category: "extensions" },
];
```
### Task 2.8 — Commit
```bash
git add apps/testbed/src/client/pages/i18n \
apps/testbed/src/client/pages/feature-flags \
apps/testbed/src/client/pages/storage-s3 \
apps/testbed/src/client/pages/surrealdb \
apps/testbed/src/client/pages/network-webrtc \
apps/testbed/src/client/pages/extension-test \
apps/testbed/src/client/app.tsx \
apps/testbed/src/client/routes.ts
git commit -m "feat(testbed): add i18n, feature-flags, storage-s3, surrealdb, network-webrtc, extension-test pages"
```
---
## Chunk 3 — Layout Specs + Library Specs
**Goal:** All `e2e/layout/` and `e2e/libraries/` specs passing. Commit after this chunk.
### Task 3.1 — `e2e/layout/navigation.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor, attachNetworkMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { routes } from "../../src/client/routes.js";
test.describe("Navigation", () => {
for (const route of routes) {
test(`navigates to ${route.label}`, async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto(route.path);
await page.waitForSelector('[data-testid="page-title"]');
const titleText = await page.locator('[data-testid="page-title"]').textContent();
expect(titleText).toBeTruthy();
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 30000 });
}
});
```
---
### Task 3.2 — `e2e/layout/theme.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor, attachNetworkMonitor } from "@suaveplan/testing-e2e/console-monitor";
test.describe("Theme toggle", () => {
test("toggles data-theme attribute and persists across reload", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/");
await page.waitForSelector('[data-testid="page-title"]');
// Initial state should be light (default)
const initialTheme = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
expect(["light", null]).toContain(initialTheme);
// Click the theme toggle
await page.click('[data-testid="theme-toggle"]');
const afterToggle = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
expect(afterToggle).toBe("dark");
// Reload and confirm persistence
await page.reload();
await page.waitForSelector('[data-testid="page-title"]');
const afterReload = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
expect(afterReload).toBe("dark");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 30000 });
test("toggles back to light", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/");
await page.waitForSelector('[data-testid="page-title"]');
await page.click('[data-testid="theme-toggle"]');
await page.click('[data-testid="theme-toggle"]');
const theme = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
expect(theme).toBe("light");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 15000 });
});
```
---
### Task 3.3 — `e2e/layout/locale.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor, attachNetworkMonitor } from "@suaveplan/testing-e2e/console-monitor";
const locales = [
{ code: "en", label: "English" },
{ code: "fr", label: "Français" },
{ code: "de", label: "Deutsch" },
{ code: "ja", label: "日本語" },
];
test.describe("Locale switcher", () => {
test("switching locale changes UI text on i18n page", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/i18n");
await page.waitForSelector('[data-testid="page-title"]');
const outputs: string[] = [];
for (const locale of locales) {
await page.selectOption('[data-testid="locale-select"], select', locale.code);
await page.waitForTimeout(300);
const text = await page.locator('[data-testid="output"]').textContent();
outputs.push(text ?? "");
}
// All four outputs must be different (locale actually changes the text)
const uniqueOutputs = new Set(outputs);
expect(uniqueOutputs.size).toBe(4);
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 30000 });
});
```
---
### Task 3.4 — `e2e/layout/console-baseline.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { routes } from "../../src/client/routes.js";
for (const route of routes) {
test(`${route.label} — no console errors`, async ({ page }) => {
const monitor = attachConsoleMonitor(page, {
failOn: ["error"],
ignore: [/Download the React DevTools/, /\[HMR\]/],
});
await page.goto(route.path);
await page.waitForSelector('[data-testid="page-title"]');
await page.waitForTimeout(500); // allow async effects to settle
await monitor.assertClean();
}, { timeout: 30000 });
}
```
---
### Task 3.5 — `e2e/layout/network-baseline.spec.ts`
```typescript
import { test } from "@playwright/test";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
import { routes } from "../../src/client/routes.js";
for (const route of routes.filter((r) => !r.requiresServices)) {
test(`${route.label} — no network failures`, async ({ page }) => {
const monitor = attachNetworkMonitor(page);
await page.goto(route.path);
await page.waitForSelector('[data-testid="page-title"]');
await page.waitForTimeout(500);
await monitor.assertNoFailures();
}, { timeout: 30000 });
}
```
Service-dependent routes are excluded from the network baseline because their `/api/*` calls return errors when Docker is not running. Those routes have their own specs that handle the "Service unavailable" graceful degradation.
---
### Task 3.6 — `e2e/libraries/crypto.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
test.describe("@suaveplan/crypto page", () => {
test("encrypt produces non-empty ciphertext in output", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/crypto");
await page.waitForSelector('[data-testid="page-title"]');
const title = await page.locator('[data-testid="page-title"]').textContent();
expect(title).toContain("crypto");
await page.waitForTimeout(500); // key generation on mount
await page.click("button:has-text('Encrypt')");
await page.waitForTimeout(300);
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toBeTruthy();
expect(output).toContain("ciphertext");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 30000 });
test("decrypt shows recovered plaintext in output", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/crypto");
await page.waitForSelector('[data-testid="page-title"]');
await page.waitForTimeout(500);
await page.click("button:has-text('Decrypt')");
await page.waitForTimeout(300);
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("decrypted");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 30000 });
});
```
---
### Task 3.7 — `e2e/libraries/logger.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
test.describe("@suaveplan/logger page", () => {
for (const level of ["debug", "info", "warn", "error"] as const) {
test(`clicking ${level.toUpperCase()} appends entry to output`, async ({ page }) => {
const console_ = attachConsoleMonitor(page, {
failOn: ["error"],
// Suppress the actual log output the logger writes to window.console
ignore: [new RegExp(level.toUpperCase(), "i")],
});
const network = attachNetworkMonitor(page);
await page.goto("/logger");
await page.waitForSelector('[data-testid="page-title"]');
const title = await page.locator('[data-testid="page-title"]').textContent();
expect(title).toContain("logger");
await page.click(`button:has-text('${level}')`);
await page.waitForTimeout(200);
const output = await page.locator('[data-testid="output"]').textContent();
expect(output?.toLowerCase()).toContain(level);
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 15000 });
}
});
```
---
### Task 3.8 — `e2e/libraries/datetime.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
test.describe("@suaveplan/datetime page", () => {
test("calculates business days and shows result date", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/datetime");
await page.waitForSelector('[data-testid="page-title"]');
const title = await page.locator('[data-testid="page-title"]').textContent();
expect(title).toContain("datetime");
// Set a known Monday start date
await page.fill('input[type="date"]', "2026-04-27");
await page.fill('input[type="number"]', "5");
await page.click("button:has-text('Calculate')");
await page.waitForTimeout(200);
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toBeTruthy();
// 5 business days from Monday 2026-04-27 = Friday 2026-05-01
expect(output).toContain("2026-05-01");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 15000 });
});
```
---
### Task 3.9 — `e2e/libraries/i18n.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
test.describe("@suaveplan/i18n page", () => {
test("output shows pluralized message in English", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/i18n");
await page.waitForSelector('[data-testid="page-title"]');
const title = await page.locator('[data-testid="page-title"]').textContent();
expect(title).toContain("i18n");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toBeTruthy();
expect(output?.toLowerCase()).toContain("item");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 15000 });
test("switching to French changes output text", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/i18n");
await page.waitForSelector('[data-testid="page-title"]');
const englishOutput = await page.locator('[data-testid="output"]').textContent();
await page.selectOption("select", "fr");
await page.waitForTimeout(300);
const frenchOutput = await page.locator('[data-testid="output"]').textContent();
expect(frenchOutput).not.toBe(englishOutput);
expect(frenchOutput?.toLowerCase()).toContain("article");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 15000 });
});
```
---
### Task 3.10 — `e2e/libraries/feature-flags.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
test.describe("@suaveplan/feature-flags page", () => {
test("evaluates flags for user alice — new-checkout enabled", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/feature-flags");
await page.waitForSelector('[data-testid="page-title"]');
const title = await page.locator('[data-testid="page-title"]').textContent();
expect(title).toContain("feature-flags");
await page.fill('input[type="text"]', "alice");
await page.click("button:has-text('Evaluate')");
await page.waitForTimeout(300);
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toBeTruthy();
expect(output).toContain("new-checkout");
expect(output).toContain("dark-mode");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 15000 });
test("dark-mode flag is always enabled regardless of user", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
const network = attachNetworkMonitor(page);
await page.goto("/feature-flags");
await page.waitForSelector('[data-testid="page-title"]');
await page.fill('input[type="text"]', "unknown-user-xyz");
await page.click("button:has-text('Evaluate')");
await page.waitForTimeout(300);
const output = await page.locator('[data-testid="output"]').textContent();
// dark-mode is 100% — must be enabled for any user
const lines = output?.split("\n").map((l) => l.trim()).filter(Boolean) ?? [];
const darkModeLine = lines.find((l) => l.includes("dark-mode"));
expect(darkModeLine).toContain("✓");
await console_.assertClean();
await network.assertNoFailures();
}, { timeout: 15000 });
});
```
---
### Task 3.11 — `e2e/libraries/storage-s3.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
test.describe("@suaveplan/storage-s3 page", () => {
test("shows Service unavailable gracefully when Docker is not running", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
await page.goto("/storage-s3");
await page.waitForSelector('[data-testid="page-title"]');
const title = await page.locator('[data-testid="page-title"]').textContent();
expect(title).toContain("storage-s3");
// Click List to trigger the API call
const listBtn = page.locator("button:has-text('List')");
const uploadBtn = page.locator("button:has-text('Upload')");
// One of List or Upload may be visible
const hasListBtn = await listBtn.count() > 0;
const hasUnavailable = await page.locator('[data-testid="output"]').textContent()
.then((t) => t?.includes("unavailable") ?? false)
.catch(() => false);
if (hasListBtn && !hasUnavailable) {
await listBtn.click();
await page.waitForTimeout(2000);
}
const output = await page.locator('[data-testid="output"]').textContent();
// When Docker unavailable, must show graceful message
// When Docker available, must show object list (possibly empty)
expect(output).toBeTruthy();
await console_.assertClean();
}, { timeout: 30000 });
});
```
---
### Task 3.12 — `e2e/libraries/surrealdb.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
test.describe("SurrealDB page", () => {
test("shows Service unavailable or record list", async ({ page }) => {
const console_ = attachConsoleMonitor(page, { failOn: ["error"] });
await page.goto("/surrealdb");
await page.waitForSelector('[data-testid="page-title"]');
const title = await page.locator('[data-testid="page-title"]').textContent();
expect(title).toContain("SurrealDB");
const queryBtn = page.locator("button:has-text('Query')");
const hasQueryBtn = await queryBtn.count() > 0;
if (hasQueryBtn) {
await queryBtn.click();
await page.waitForTimeout(2000);
}
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toBeTruthy();
await console_.assertClean();
}, { timeout: 30000 });
});
```
---
### Task 3.13 — Commit
```bash
git add apps/testbed/e2e/layout \
apps/testbed/e2e/libraries
git commit -m "feat(testbed): add layout and library E2E specs"
```
---
## Chunk 4 — Multi-Session Specs + Extension Specs + README
**Goal:** Multi-session and extension E2E specs. Final README. Commit.
### Task 4.1 — `e2e/multi-session/webrtc-mesh.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
test.describe("WebRTC mesh — 2 peers", () => {
test("both peers reach connected state", async ({ browser }) => {
const sessionId = `test-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-webrtc?session=${sessionId}`,
roles: ["host", "peer"],
});
try {
// Attach monitors to both pages
for (const p of pages) {
attachConsoleMonitor(p, {
failOn: ["error"],
ignore: [/ICE/, /STUN/, /RTCPeerConnection/],
});
}
// Wait for both to reach connected state (up to 20s for ICE)
await Promise.all(
pages.map((p) =>
p.waitForSelector('[data-status="connected"]', { timeout: 20000 })
)
);
for (const p of pages) {
const status = await p.locator('[data-status]').getAttribute("data-status");
expect(status).toBe("connected");
}
} finally {
await teardown();
}
}, { timeout: 60000 });
});
```
---
### Task 4.2 — `e2e/multi-session/webrtc-server-failover.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { createMultiSession, simulateServerFailure } from "@suaveplan/testing-e2e/multi-session";
test.describe("WebRTC server failover", () => {
test("P2P connection holds after signalling server disabled", async ({ browser }) => {
const sessionId = `failover-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-webrtc?session=${sessionId}`,
roles: ["host", "peer"],
});
try {
// Wait for both peers to connect
await Promise.all(
pages.map((p) =>
p.waitForSelector('[data-status="connected"]', { timeout: 20000 })
)
);
// Disable the signalling route
await simulateServerFailure("http://localhost:3200", "/api/signalling");
// Wait 3 seconds and confirm connection still holds
await pages[0].waitForTimeout(3000);
for (const p of pages) {
const status = await p.locator('[data-status]').getAttribute("data-status");
expect(status).toBe("connected");
}
} finally {
await teardown();
}
}, { timeout: 60000 });
});
```
---
### Task 4.3 — `e2e/multi-session/websocket-gateway.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
test.describe("WebSocket gateway — 2 clients", () => {
test("broadcast message received by second client", async ({ browser }) => {
const sessionId = `ws-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["sender", "receiver"],
});
try {
for (const p of pages) {
attachConsoleMonitor(p, { failOn: ["error"] });
await p.waitForSelector('[data-testid="page-title"]', { timeout: 10000 });
}
// Wait for both to show connected status
await Promise.all(
pages.map((p) => p.waitForSelector('[data-status="connected"]', { timeout: 10000 }))
);
// Sender broadcasts a message
const message = `hello-${Date.now()}`;
await pages[0].fill('[data-testid="message-input"]', message);
await pages[0].click('[data-testid="send-button"]');
// Receiver output should contain the message
await pages[1].waitForSelector(`[data-testid="output"]:has-text("${message}")`, {
timeout: 5000,
});
const receiverOutput = await pages[1].locator('[data-testid="output"]').textContent();
expect(receiverOutput).toContain(message);
} finally {
await teardown();
}
}, { timeout: 60000 });
});
```
---
### Task 4.4 — `e2e/multi-session/broadcast-channel.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
test.describe("BroadcastChannel cross-tab messaging", () => {
test("message sent in tab 1 received in tab 2", async ({ context }) => {
// Both tabs share the same browser context (same origin = BroadcastChannel works)
const [page1, page2] = await Promise.all([
context.newPage(),
context.newPage(),
]);
try {
for (const p of [page1, page2]) {
attachConsoleMonitor(p, { failOn: ["error"] });
}
await Promise.all([
page1.goto("http://localhost:3200/network-broadcast?role=sender"),
page2.goto("http://localhost:3200/network-broadcast?role=receiver"),
]);
await Promise.all([
page1.waitForSelector('[data-testid="page-title"]'),
page2.waitForSelector('[data-testid="page-title"]'),
]);
const message = `bc-${Date.now()}`;
await page1.fill('[data-testid="message-input"]', message);
await page1.click('[data-testid="send-button"]');
await page2.waitForSelector(`[data-testid="output"]:has-text("${message}")`, {
timeout: 5000,
});
const output = await page2.locator('[data-testid="output"]').textContent();
expect(output).toContain(message);
} finally {
await page1.close();
await page2.close();
}
}, { timeout: 30000 });
});
```
---
### Task 4.5 — `e2e/extensions/content-script.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { chromium } from "@playwright/test";
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
import { mkdtemp } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
test.describe("Extension content script", () => {
test("extension injects UI into extension-injected div", async () => {
const userDataDir = await mkdtemp(join(tmpdir(), "ext-test-"));
const extensionPath = resolve(__dirname, "../../dist/chrome");
const { context } = await launchWithExtension({
chromium,
extensionPath,
userDataDir,
});
try {
const page = await context.newPage();
await page.goto("http://localhost:3200/extension-test");
await page.waitForSelector('[data-testid="page-title"]');
// Wait for extension to inject content
await page.waitForSelector('[data-testid="extension-injected"]:not(:empty)', {
timeout: 10000,
});
const injected = await page.locator('[data-testid="extension-injected"]').textContent();
expect(injected).toBeTruthy();
expect(injected!.trim().length).toBeGreaterThan(0);
} finally {
await context.close();
}
}, { timeout: 30000 });
});
```
---
### Task 4.6 — `e2e/extensions/autofill-detection.spec.ts`
```typescript
import { test, expect } from "@playwright/test";
import { chromium } from "@playwright/test";
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
import { mkdtemp } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
test.describe("Extension autofill detection", () => {
test("extension detects login form and autofill button fills fields", async () => {
const userDataDir = await mkdtemp(join(tmpdir(), "ext-autofill-"));
const extensionPath = resolve(__dirname, "../../dist/chrome");
const { context } = await launchWithExtension({
chromium,
extensionPath,
userDataDir,
});
try {
const page = await context.newPage();
await page.goto("http://localhost:3200/extension-test");
await page.waitForSelector('[data-testid="login-form"]');
// Extension should inject an autofill button near the login form
await page.waitForSelector('[data-testid="autofill-button"], [data-extension-autofill]', {
timeout: 10000,
});
const autofillBtn = page.locator('[data-testid="autofill-button"], [data-extension-autofill]').first();
await autofillBtn.click();
await page.waitForTimeout(500);
// After autofill, username and password fields should have non-empty values
const username = await page.locator('[data-testid="username-field"]').inputValue();
const password = await page.locator('[data-testid="password-field"]').inputValue();
expect(username.trim().length).toBeGreaterThan(0);
expect(password.trim().length).toBeGreaterThan(0);
} finally {
await context.close();
}
}, { timeout: 30000 });
});
```
---
### Task 4.7 — `apps/testbed/README.md`
The testbed README must be comprehensive enough for an AI agent reading it cold to operate every part of the testbed. Required sections:
1. **Overview** — what testbed is and what it tests
2. **Quick Start** — `bun run dev` (port 3200), `bun run storybook` (port 6042)
3. **Services** — `bun run --filter=@suaveplan/docker dev:up`, port allocation table (3200, 6042, 8001–8006, 8900, 8902)
4. **Running E2E Tests** — `bun run test:e2e`, requires `bun run dev` in another terminal
5. **Multi-Session Tests** — which Docker services they require, `?role=&session=` query param contract
6. **Extension Tests** — how `launchWithExtension()` works, extension must be built to `dist/chrome/` first, `NODE_ENV=test` requirement
7. **data-testid Contract** — complete table of all `data-testid` attributes every library page must implement
8. **Console Panel** — what `console-panel.tsx` intercepts (`window.console.log/warn/error`), what it renders
9. **Test Control API** — `POST /api/test-control/disable-route`, only when `NODE_ENV=test`, used for server-failover tests
10. **Env-Bridge Variables** — all env vars, how they interact with testcontainers
11. **Routes Manifest** — `src/client/routes.ts`, how to add a new library page (5-step process)
12. **Port Allocation Table** — all ports: 3200 (testbed), 6042 (storybook), 7004–7011 (test stack), 8001–8006+8900+8902 (dev stack)
---
### Task 4.8 — Update `routes.ts` exports and add missing network pages
If the WebSocket gateway and BroadcastChannel pages are needed for their E2E specs, add minimal placeholder pages:
**`src/client/pages/network-websocket/network-websocket-page.tsx`** — WebSocket demo using `/api/ws` Hono WebSocket route. `?role=sender|receiver&session=<id>`. Shows `data-status="connected"` when WS opens. Has `data-testid="message-input"` and `data-testid="send-button"` for the sender role.
**`src/client/pages/network-broadcast/network-broadcast-page.tsx`** — BroadcastChannel demo (no server needed). `?role=sender|receiver`. Sender has `data-testid="message-input"` and `data-testid="send-button"`. Receiver shows received messages in `data-testid="output"`.
Add to `routes.ts`:
```typescript
{ path: "/network-websocket", label: "Network — WebSocket", category: "network", requiresServices: true },
{ path: "/network-broadcast", label: "Network — BroadcastChannel", category: "network" },
```
Each page needs `.md`, `.stories.tsx`, and `index.ts` per standard page anatomy.
---
### Task 4.9 — Verify Playwright configuration
Confirm `apps/testbed/playwright.config.ts` has all five browser projects plus the `chromium-extensions` project. Exact configuration from spec Section 7.5:
```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 4.10 — Final verification
Run the full E2E suite against the dev server:
```bash
# Terminal 1
cd /mnt/s/web/genesis/apps/testbed && bun run dev
# Terminal 2 (after dev server is ready at http://localhost:3200)
cd /mnt/s/web/genesis/apps/testbed
bunx playwright test --project=chromium
# All five browser projects (requires DISPLAY for webkit/firefox)
bunx playwright test
# Extension tests separately (requires dist/chrome to exist)
bunx playwright test --project=chromium-extensions
```
Expected: all layout specs pass, all library specs pass (service-dependent specs show graceful unavailable message if Docker not running), multi-session specs pass when Docker is running.
---
### Task 4.11 — Commit
```bash
git add apps/testbed/e2e/multi-session \
apps/testbed/e2e/extensions \
apps/testbed/src/client/pages/network-websocket \
apps/testbed/src/client/pages/network-broadcast \
apps/testbed/README.md \
apps/testbed/playwright.config.ts
git commit -m "feat(testbed): add multi-session specs, extension specs, network pages, README"
```
---
## Summary
| Chunk | Files | Commit message |
|---|---|---|
| 1 | home, crypto, logger, datetime pages + router wiring | `feat(testbed): add home, crypto, logger, datetime pages` |
| 2 | i18n, feature-flags, storage-s3, surrealdb, network-webrtc, extension-test pages + routes.ts | `feat(testbed): add i18n, feature-flags, storage-s3, surrealdb, network-webrtc, extension-test pages` |
| 3 | `e2e/layout/` (5 specs) + `e2e/libraries/` (7 specs) | `feat(testbed): add layout and library E2E specs` |
| 4 | `e2e/multi-session/` (4 specs) + `e2e/extensions/` (2 specs) + network pages + README | `feat(testbed): add multi-session specs, extension specs, network pages, README` |
**Total deliverables:**
- 12 page directories (10 library pages + 2 network pages), each with `.tsx` + `.md` + `.stories.tsx` + `index.ts`
- 16 Playwright spec files across 4 directories
- 1 `README.md` at `apps/testbed/`
**Invariants that must hold for every page:**
- `data-testid="page-title"` on `<h1>`
- `data-testid="demo-card"` on each demo wrapper
- `data-testid="output"` inside each demo (non-empty after user interaction)
- `data-testid="console-panel"` on the console capture panel
- Real library code — no mocks, no hardcoded responses
- Sibling `.md` ≥200 words with all six required sections
- Sibling `.stories.tsx` with Default + at least one variant
**Invariants that must hold for every E2E spec:**
- `attachConsoleMonitor(page, { failOn: ["error"] })` before any navigation
- `attachNetworkMonitor(page)` before any navigation
- Elements located by `data-testid` only — never CSS class or visible text
- `monitor.assertClean()` after each meaningful interaction
- `data-testid="page-title"` asserted to contain expected library name
- `data-testid="output"` asserted to have non-empty content after demo action