79 KiB
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
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-themedark 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:
- Confirm the app shell exists:
ls /mnt/s/web/genesis/apps/testbed/src/client/— must containmain.tsx,app.tsx,routes.ts,components/. - Confirm layout components exist:
ls /mnt/s/web/genesis/apps/testbed/src/client/components/layout/— must containsidebar.tsx,header.tsx,console-panel.tsx,locale-switcher.tsx,theme-toggle.tsx. - Confirm shared components exist:
ls /mnt/s/web/genesis/apps/testbed/src/client/components/shared/— must containdemo-card.tsx,code-block.tsx,status-badge.tsx. - Confirm testing-e2e has new modules:
ls /mnt/s/web/genesis/universal/dev/testing-e2e/src/— must includeconsole-monitor/,network-monitor/,multi-session/,extension-fixture/. - Confirm
@suaveplan/crypto,@suaveplan/logger,@suaveplan/datetime,@suaveplan/i18n,@suaveplan/feature-flagsare inapps/testbed/package.jsondependencies. - Confirm
routes.tsexists and exports theRouteEntry[]array with all 10 routes. - Confirm the Hono server has
/api/storage,/api/database, and/api/test-controlroute 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
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
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
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.
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.
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.
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:
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
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.
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.
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.
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.
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.
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.
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:
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:
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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:
- Overview — what testbed is and what it tests
- Quick Start —
bun run dev(port 3200),bun run storybook(port 6042) - Services —
bun run --filter=@suaveplan/docker dev:up, port allocation table (3200, 6042, 8001–8006, 8900, 8902) - Running E2E Tests —
bun run test:e2e, requiresbun run devin another terminal - Multi-Session Tests — which Docker services they require,
?role=&session=query param contract - Extension Tests — how
launchWithExtension()works, extension must be built todist/chrome/first,NODE_ENV=testrequirement - data-testid Contract — complete table of all
data-testidattributes every library page must implement - Console Panel — what
console-panel.tsxintercepts (window.console.log/warn/error), what it renders - Test Control API —
POST /api/test-control/disable-route, only whenNODE_ENV=test, used for server-failover tests - Env-Bridge Variables — all env vars, how they interact with testcontainers
- Routes Manifest —
src/client/routes.ts, how to add a new library page (5-step process) - 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:
{ 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:
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:
# 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
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.mdatapps/testbed/
Invariants that must hold for every page:
data-testid="page-title"on<h1>data-testid="demo-card"on each demo wrapperdata-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.tsxwith Default + at least one variant
Invariants that must hold for every E2E spec:
attachConsoleMonitor(page, { failOn: ["error"] })before any navigationattachNetworkMonitor(page)before any navigation- Elements located by
data-testidonly — never CSS class or visible text monitor.assertClean()after each meaningful interactiondata-testid="page-title"asserted to contain expected library namedata-testid="output"asserted to have non-empty content after demo action