2168 lines
79 KiB
Markdown
2168 lines
79 KiB
Markdown
# 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
|