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

79 KiB
Raw Blame History

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-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
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:

  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:

{ 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.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