2989 lines
92 KiB
Markdown
2989 lines
92 KiB
Markdown
# Implementation Plan: Testbed Foundation
|
|
|
|
**Date:** 2026-04-24
|
|
**Branch:** genesis
|
|
**Scope:** Part A — `@suaveplan/testing-e2e` 4 new modules; Part B — `apps/testbed/` scaffold, server, client shell, layout + shared components
|
|
|
|
---
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Do not proceed step-by-step manually — dispatch chunks to parallel or sequential subagents as the dependency graph permits. Stop only at: publish gates, destructive git operations, and explicit scope-change boundaries.
|
|
|
|
---
|
|
|
|
## Goal
|
|
|
|
Deliver two things in dependency order:
|
|
|
|
1. **Part A** — four new modules in `@suaveplan/testing-e2e`: `multi-session`, `console-monitor`, `network-monitor`, `extension-fixture`. Each follows TDD (failing tests first, then implementation), achieves 100% coverage, and is wired into the package exports map.
|
|
|
|
2. **Part B** — `apps/testbed/` workspace: Hono + Bun server, React 19 + Vite + Tailwind v4 client shell, React Router v7 SPA with sidebar/header layout, five layout components, three shared components, Storybook 8, Playwright config, Dockerfile. Library pages and E2E specs are in a separate plan.
|
|
|
|
Part A must be fully green before Part B begins (Part B imports from `@suaveplan/testing-e2e`).
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```text
|
|
Part A — @suaveplan/testing-e2e additions
|
|
src/multi-session/ <- createMultiSession(), simulateServerFailure()
|
|
src/console-monitor/ <- attachConsoleMonitor()
|
|
src/network-monitor/ <- attachNetworkMonitor()
|
|
src/extension-fixture/ <- launchWithExtension()
|
|
src/index.ts <- updated barrel
|
|
package.json <- 4 new exports map entries
|
|
|
|
Part B — apps/testbed/
|
|
src/server/
|
|
index.ts <- Hono: serves dist/client/ + mounts /api routes
|
|
routes/api/
|
|
storage.ts <- proxy for storage-s3 demo
|
|
database.ts <- proxy for surrealdb demo
|
|
test-control.ts <- POST /api/test-control/disable-route (NODE_ENV=test only)
|
|
src/client/
|
|
main.tsx <- React 19 root render
|
|
app.tsx <- React Router v7 SPA + top-level layout
|
|
routes.ts <- ROUTE MANIFEST
|
|
components/layout/ <- sidebar, header, theme-toggle, locale-switcher, console-panel
|
|
components/shared/ <- demo-card, code-block, status-badge
|
|
styles/globals.css
|
|
.storybook/ <- main.ts + preview.ts (ThemeDecorator + LocaleDecorator)
|
|
playwright.config.ts
|
|
vite.config.ts
|
|
Dockerfile
|
|
```
|
|
|
|
---
|
|
|
|
## Tech Stack
|
|
|
|
- **Runtime:** Bun
|
|
- **Build (client):** Vite + `@vitejs/plugin-react`
|
|
- **Styling:** Tailwind v4 (`@tailwindcss/vite`)
|
|
- **Server:** Hono (`hono`)
|
|
- **Router:** React Router v7 (`react-router-dom`)
|
|
- **Storybook:** v8, `@storybook/react-vite`
|
|
- **E2E:** Playwright (config only in this plan; specs in a separate plan)
|
|
- **Tests (Part A):** Vitest, pool `forks`, `provider: 'istanbul'`, 100% coverage
|
|
- **Lint:** Biome
|
|
|
|
---
|
|
|
|
## Pre-flight Checklist
|
|
|
|
Before writing any code, verify:
|
|
|
|
1. `ls /mnt/s/web/genesis/universal/dev/testing-e2e/src/` — confirm the existing module directories.
|
|
2. `cat /mnt/s/web/genesis/universal/dev/testing-e2e/package.json` — note current exports map entries.
|
|
3. `ls /mnt/s/web/genesis/apps/` — confirm `apps/` directory exists (create if absent).
|
|
4. `cat /mnt/s/web/genesis/package.json` — check `workspaces` array and catalog entries; ensure `apps/*` is included.
|
|
|
|
---
|
|
|
|
## Chunk 1 — `@suaveplan/testing-e2e`: 4 New Modules (TDD)
|
|
|
|
All four modules live inside `universal/dev/testing-e2e/src/`. Each follows this cycle:
|
|
|
|
1. Create `<module>/<module>.types.ts` and `<module>/index.ts`
|
|
2. Write failing test file `<module>/<module>.test.ts`
|
|
3. Run `bunx turbo test --filter=@suaveplan/testing-e2e` — confirm failures
|
|
4. Write implementation `<module>/<module>.ts` and doc `<module>/<module>.md`
|
|
5. Run tests again — confirm all pass
|
|
6. After all 4 modules: update `src/index.ts`, update `package.json` exports
|
|
7. Run pre-publish gate
|
|
|
|
### Task 1.1 — `multi-session/` module
|
|
|
|
#### File: `src/multi-session/create-multi-session.types.ts`
|
|
|
|
```typescript
|
|
import type { Browser, BrowserContext, Page } from "@playwright/test";
|
|
|
|
export interface MultiSessionOptions {
|
|
browser: Browser;
|
|
count: number;
|
|
baseUrl: string;
|
|
roles: string[];
|
|
}
|
|
|
|
export interface MultiSessionResult {
|
|
pages: Page[];
|
|
contexts: BrowserContext[];
|
|
teardown: () => Promise<void>;
|
|
}
|
|
```
|
|
|
|
#### File: `src/multi-session/create-multi-session.test.ts`
|
|
|
|
Write BEFORE the implementation. All tests use `{ timeout: 30000 }`.
|
|
|
|
```typescript
|
|
import type { Browser, BrowserContext, Page } from "@playwright/test";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
function makeFakePage(gotoFn = vi.fn(async () => null)): Page {
|
|
return { goto: gotoFn } as unknown as Page;
|
|
}
|
|
|
|
function makeFakeContext(page: Page): BrowserContext {
|
|
return {
|
|
newPage: vi.fn(async () => page),
|
|
close: vi.fn(async () => undefined),
|
|
} as unknown as BrowserContext;
|
|
}
|
|
|
|
function makeFakeBrowser(contexts: BrowserContext[]): Browser {
|
|
let i = 0;
|
|
return {
|
|
newContext: vi.fn(async () => contexts[i++]!),
|
|
} as unknown as Browser;
|
|
}
|
|
|
|
function makeFetch(status: number) {
|
|
return vi.fn(async () => ({ status })) as unknown as typeof fetch;
|
|
}
|
|
|
|
describe("createMultiSession", () => {
|
|
it("creates count contexts and pages, navigates each to baseUrl?role=<role>", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const { createMultiSession } = await import("./create-multi-session.js");
|
|
const pages = [makeFakePage(), makeFakePage(), makeFakePage()];
|
|
const contexts = pages.map(makeFakeContext);
|
|
const browser = makeFakeBrowser(contexts);
|
|
|
|
const result = await createMultiSession({
|
|
browser,
|
|
count: 3,
|
|
baseUrl: "http://localhost:3200/network-webrtc?session=abc",
|
|
roles: ["host", "peer", "peer"],
|
|
});
|
|
|
|
expect(result.pages).toHaveLength(3);
|
|
expect(result.pages[0]).toBe(pages[0]);
|
|
expect((pages[0]!.goto as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
|
"http://localhost:3200/network-webrtc?session=abc&role=host"
|
|
);
|
|
expect((pages[1]!.goto as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
|
"http://localhost:3200/network-webrtc?session=abc&role=peer"
|
|
);
|
|
});
|
|
|
|
it("teardown closes all contexts", { timeout: 30000 }, async () => {
|
|
const { createMultiSession } = await import("./create-multi-session.js");
|
|
const pages = [makeFakePage(), makeFakePage()];
|
|
const contexts = pages.map(makeFakeContext);
|
|
const browser = makeFakeBrowser(contexts);
|
|
|
|
const result = await createMultiSession({
|
|
browser, count: 2,
|
|
baseUrl: "http://localhost:3200/test",
|
|
roles: ["a", "b"],
|
|
});
|
|
|
|
await result.teardown();
|
|
expect(contexts[0]!.close).toHaveBeenCalledOnce();
|
|
expect(contexts[1]!.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("appends role param with & when baseUrl has query string", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const { createMultiSession } = await import("./create-multi-session.js");
|
|
const page = makeFakePage();
|
|
const context = makeFakeContext(page);
|
|
const browser = makeFakeBrowser([context]);
|
|
await createMultiSession({
|
|
browser, count: 1,
|
|
baseUrl: "http://localhost:3200/room?session=xyz",
|
|
roles: ["viewer"],
|
|
});
|
|
expect((page.goto as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
|
"http://localhost:3200/room?session=xyz&role=viewer"
|
|
);
|
|
});
|
|
|
|
it("appends role param with ? when baseUrl has no query string", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const { createMultiSession } = await import("./create-multi-session.js");
|
|
const page = makeFakePage();
|
|
const context = makeFakeContext(page);
|
|
const browser = makeFakeBrowser([context]);
|
|
await createMultiSession({
|
|
browser, count: 1,
|
|
baseUrl: "http://localhost:3200/room",
|
|
roles: ["viewer"],
|
|
});
|
|
expect((page.goto as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
|
"http://localhost:3200/room?role=viewer"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("simulateServerFailure", () => {
|
|
it("sends POST to /api/test-control/disable-route and accepts 204", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const { simulateServerFailure } = await import("./create-multi-session.js");
|
|
const fetchFn = makeFetch(204);
|
|
await simulateServerFailure("http://localhost:3200", "/api/storage", fetchFn);
|
|
expect(fetchFn).toHaveBeenCalledWith(
|
|
"http://localhost:3200/api/test-control/disable-route",
|
|
expect.objectContaining({ method: "POST" })
|
|
);
|
|
});
|
|
|
|
it("throws when response status is not 204", { timeout: 30000 }, async () => {
|
|
const { simulateServerFailure } = await import("./create-multi-session.js");
|
|
const fetchFn = makeFetch(500);
|
|
await expect(
|
|
simulateServerFailure("http://localhost:3200", "/api/storage", fetchFn)
|
|
).rejects.toThrow("simulateServerFailure: expected 204, got 500");
|
|
});
|
|
});
|
|
```
|
|
|
|
#### File: `src/multi-session/create-multi-session.ts`
|
|
|
|
```typescript
|
|
import type { Browser, BrowserContext, Page } from "@playwright/test";
|
|
import type { MultiSessionOptions, MultiSessionResult } from "./create-multi-session.types.js";
|
|
|
|
/**
|
|
* Opens `count` isolated browser contexts against the same base URL.
|
|
* Each context gets a unique role appended as `?role=<value>` or `&role=<value>`.
|
|
*/
|
|
export async function createMultiSession(
|
|
options: MultiSessionOptions
|
|
): Promise<MultiSessionResult> {
|
|
const { browser, count, baseUrl, roles } = options;
|
|
const separator = baseUrl.includes("?") ? "&" : "?";
|
|
const contexts: BrowserContext[] = [];
|
|
const pages: Page[] = [];
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
const role = roles[i] ?? String(i);
|
|
await page.goto(`${baseUrl}${separator}role=${role}`);
|
|
contexts.push(context);
|
|
pages.push(page);
|
|
}
|
|
|
|
const teardown = async (): Promise<void> => {
|
|
await Promise.all(contexts.map((ctx) => ctx.close()));
|
|
};
|
|
|
|
return { pages, contexts, teardown };
|
|
}
|
|
|
|
/**
|
|
* Sends POST /api/test-control/disable-route to the testbed server.
|
|
* Only works when NODE_ENV === "test". Asserts 204 response.
|
|
*/
|
|
export async function simulateServerFailure(
|
|
apiBaseUrl: string,
|
|
route: string,
|
|
fetchFn: typeof fetch = fetch
|
|
): Promise<void> {
|
|
const url = `${apiBaseUrl}/api/test-control/disable-route`;
|
|
const response = await fetchFn(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ route }),
|
|
});
|
|
if (response.status !== 204) {
|
|
throw new Error(`simulateServerFailure: expected 204, got ${response.status}`);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### File: `src/multi-session/create-multi-session.md`
|
|
|
|
```markdown
|
|
# create-multi-session
|
|
|
|
## Purpose
|
|
|
|
`createMultiSession` opens multiple isolated Playwright browser contexts against the same
|
|
base URL, each navigated to a role-specific URL variant. It is the foundation for all
|
|
multi-participant E2E tests — WebRTC mesh, WebSocket rooms, BroadcastChannel cross-tab
|
|
messaging — where each context simulates a distinct user or browser tab.
|
|
|
|
`simulateServerFailure` sends a POST to the Hono testbed server's test-control endpoint
|
|
to disable a named route mid-test, enabling server-failure simulation scenarios.
|
|
|
|
## Features
|
|
|
|
- Opens N isolated browser contexts sequentially, each with its own page
|
|
- Appends `?role=<value>` or `&role=<value>` depending on whether baseUrl has a query string
|
|
- Returns `teardown()` that closes all contexts in parallel via `Promise.all`
|
|
- `simulateServerFailure` accepts injectable `fetchFn` for 100% unit coverage
|
|
- Throws a clear error if the test-control endpoint returns a non-204 status
|
|
|
|
## Usage: Basic Example
|
|
|
|
```typescript
|
|
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
|
|
|
|
const { pages, teardown } = await createMultiSession({
|
|
browser,
|
|
count: 2,
|
|
baseUrl: "http://localhost:3200/network-webrtc?session=abc",
|
|
roles: ["host", "peer"],
|
|
});
|
|
await Promise.all(pages.map(p => p.waitForSelector('[data-status="connected"]')));
|
|
await teardown();
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```typescript
|
|
import { createMultiSession, simulateServerFailure } from "@suaveplan/testing-e2e/multi-session";
|
|
|
|
test("mesh holds after signalling server goes down", async ({ browser }) => {
|
|
const { pages, teardown } = await createMultiSession({
|
|
browser, count: 3,
|
|
baseUrl: "http://localhost:3200/network-webrtc?session=failover-test",
|
|
roles: ["host", "peer", "peer"],
|
|
});
|
|
await Promise.all(pages.map(p => p.waitForSelector('[data-status="connected"]')));
|
|
await simulateServerFailure("http://localhost:3200", "/api/signalling");
|
|
await pages[1]!.waitForSelector('[data-status="connected"]');
|
|
await teardown();
|
|
});
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `createMultiSession(options): Promise<MultiSessionResult>`
|
|
|
|
- `options.browser: Browser` — Playwright Browser instance
|
|
- `options.count: number` — number of contexts/pages to open
|
|
- `options.baseUrl: string` — URL to navigate each page to; role appended automatically
|
|
- `options.roles: string[]` — role labels; `roles[i]` appended as `role=<value>`
|
|
- Returns `MultiSessionResult` with `pages: Page[]`, `contexts: BrowserContext[]`, `teardown: () => Promise<void>`
|
|
|
|
### `simulateServerFailure(apiBaseUrl, route, fetchFn?): Promise<void>`
|
|
|
|
- `apiBaseUrl: string` — e.g. `"http://localhost:3200"`
|
|
- `route: string` — route path to disable, e.g. `"/api/signalling"`
|
|
- `fetchFn?: typeof fetch` — injectable for unit tests; defaults to global `fetch`
|
|
- Throws `Error` if response status is not 204
|
|
|
|
## Implementation Notes
|
|
|
|
Contexts are opened sequentially rather than in parallel to avoid race conditions when
|
|
sharing a Browser instance across many concurrent context creates. Teardown is parallel
|
|
since close operations are independent.
|
|
|
|
```text
|
|
|
|
#### File: `src/multi-session/index.ts`
|
|
|
|
```typescript
|
|
export { createMultiSession, simulateServerFailure } from "./create-multi-session.js";
|
|
export type { MultiSessionOptions, MultiSessionResult } from "./create-multi-session.types.js";
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1.2 — `console-monitor/` module
|
|
|
|
#### File: `src/console-monitor/console-monitor.types.ts`
|
|
|
|
```typescript
|
|
import type { ConsoleMessage } from "@playwright/test";
|
|
|
|
export interface ConsoleMonitorOptions {
|
|
failOn?: Array<"error" | "warning" | "info">;
|
|
ignore?: RegExp[];
|
|
}
|
|
|
|
export interface ConsoleMonitor {
|
|
readonly messages: ConsoleMessage[];
|
|
assertClean(): void;
|
|
}
|
|
```
|
|
|
|
#### File: `src/console-monitor/console-monitor.test.ts`
|
|
|
|
Write BEFORE the implementation.
|
|
|
|
```typescript
|
|
import type { ConsoleMessage, Page } from "@playwright/test";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
function fakeMsg(type: string, text: string): ConsoleMessage {
|
|
return { type: () => type, text: () => text } as unknown as ConsoleMessage;
|
|
}
|
|
|
|
function makeFakePage() {
|
|
const listeners = new Map<string, ((msg: ConsoleMessage) => void)[]>();
|
|
const page = {
|
|
on: vi.fn((event: string, cb: (msg: ConsoleMessage) => void) => {
|
|
if (!listeners.has(event)) listeners.set(event, []);
|
|
listeners.get(event)!.push(cb);
|
|
}),
|
|
} as unknown as Page;
|
|
function emit(msg: ConsoleMessage) {
|
|
for (const cb of listeners.get("console") ?? []) cb(msg);
|
|
}
|
|
return { page, emit };
|
|
}
|
|
|
|
describe("attachConsoleMonitor", () => {
|
|
it("registers console listener on the page", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page } = makeFakePage();
|
|
attachConsoleMonitor(page);
|
|
expect((page.on as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
|
"console",
|
|
expect.any(Function)
|
|
);
|
|
});
|
|
|
|
it("collects console messages emitted on the page", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachConsoleMonitor(page);
|
|
emit(fakeMsg("log", "hello"));
|
|
expect(monitor.messages).toHaveLength(1);
|
|
expect(monitor.messages[0]!.text()).toBe("hello");
|
|
});
|
|
|
|
it("assertClean does not throw when no failOn messages", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
|
|
emit(fakeMsg("log", "safe message"));
|
|
expect(() => monitor.assertClean()).not.toThrow();
|
|
});
|
|
|
|
it("assertClean throws when an error-level message is collected", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
|
|
emit(fakeMsg("error", "uncaught TypeError"));
|
|
expect(() => monitor.assertClean()).toThrow("uncaught TypeError");
|
|
});
|
|
|
|
it("assertClean ignores messages matching ignore regexps", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachConsoleMonitor(page, {
|
|
failOn: ["error"],
|
|
ignore: [/Download the React DevTools/],
|
|
});
|
|
emit(fakeMsg("error", "Download the React DevTools for a better development experience"));
|
|
expect(() => monitor.assertClean()).not.toThrow();
|
|
});
|
|
|
|
it("defaults failOn to [error] when not specified", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachConsoleMonitor(page);
|
|
emit(fakeMsg("error", "default failOn error"));
|
|
expect(() => monitor.assertClean()).toThrow("default failOn error");
|
|
});
|
|
|
|
it("collects warning messages when failOn includes warning", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachConsoleMonitor(page, { failOn: ["warning"] });
|
|
emit(fakeMsg("warning", "deprecation warning"));
|
|
expect(() => monitor.assertClean()).toThrow("deprecation warning");
|
|
});
|
|
|
|
it("normalises warn type to warning", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachConsoleMonitor(page, { failOn: ["warning"] });
|
|
emit(fakeMsg("warn", "normalised warn"));
|
|
expect(() => monitor.assertClean()).toThrow("normalised warn");
|
|
});
|
|
|
|
it("assertClean lists all failing messages in the error text", { timeout: 5000 }, async () => {
|
|
const { attachConsoleMonitor } = await import("./console-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
|
|
emit(fakeMsg("error", "first error"));
|
|
emit(fakeMsg("error", "second error"));
|
|
expect(() => monitor.assertClean()).toThrow(/first error/);
|
|
});
|
|
});
|
|
```
|
|
|
|
#### File: `src/console-monitor/console-monitor.ts`
|
|
|
|
```typescript
|
|
import type { ConsoleMessage, Page } from "@playwright/test";
|
|
import type { ConsoleMonitor, ConsoleMonitorOptions } from "./console-monitor.types.js";
|
|
|
|
const PLAYWRIGHT_TYPE_MAP: Record<string, "error" | "warning" | "info"> = {
|
|
error: "error",
|
|
warning: "warning",
|
|
warn: "warning",
|
|
info: "info",
|
|
log: "info",
|
|
};
|
|
|
|
/**
|
|
* Attaches to a Playwright page and captures all console output.
|
|
* Call `assertClean()` after interactions to fail the test on unexpected messages.
|
|
* Defaults to failing on "error" level messages only.
|
|
*/
|
|
export function attachConsoleMonitor(
|
|
page: Page,
|
|
options: ConsoleMonitorOptions = {}
|
|
): ConsoleMonitor {
|
|
const failOn = options.failOn ?? ["error"];
|
|
const ignore = options.ignore ?? [];
|
|
const messages: ConsoleMessage[] = [];
|
|
|
|
page.on("console", (msg: ConsoleMessage) => {
|
|
messages.push(msg);
|
|
});
|
|
|
|
return {
|
|
get messages() {
|
|
return messages;
|
|
},
|
|
assertClean() {
|
|
const failing = messages.filter((msg) => {
|
|
const level = PLAYWRIGHT_TYPE_MAP[msg.type()] ?? "info";
|
|
if (!failOn.includes(level)) return false;
|
|
const text = msg.text();
|
|
return !ignore.some((re) => re.test(text));
|
|
});
|
|
if (failing.length > 0) {
|
|
const lines = failing.map((m) => ` [${m.type()}] ${m.text()}`).join("\n");
|
|
throw new Error(`ConsoleMonitor: unexpected console messages:\n${lines}`);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
#### File: `src/console-monitor/console-monitor.md`
|
|
|
|
```markdown
|
|
# console-monitor
|
|
|
|
## Purpose
|
|
|
|
`attachConsoleMonitor` attaches to a Playwright `Page` and captures every console message
|
|
emitted during a test. The `assertClean()` method fails the test if any captured message
|
|
matches the configured severity levels — by default only `"error"` — and was not suppressed
|
|
by an `ignore` regexp pattern.
|
|
|
|
This enforces the spec-level invariant that every E2E spec must call `assertClean()` after
|
|
every meaningful interaction. It mirrors what a developer sees in the browser's DevTools
|
|
console, making CI failures easy to reproduce locally.
|
|
|
|
## Features
|
|
|
|
- Registers a single `page.on("console", ...)` listener
|
|
- Configurable `failOn` level list: `"error"`, `"warning"`, `"info"` (default: `["error"]`)
|
|
- Per-message `ignore` regexp list — matches against `msg.text()`
|
|
- Normalises Playwright's `"warn"` type to `"warning"` transparently
|
|
- `assertClean()` throws with a multi-line message listing every failing entry
|
|
- Mutable `messages` array for manual inspection mid-test
|
|
|
|
## Usage: Basic Example
|
|
|
|
```typescript
|
|
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
|
|
|
|
test("crypto page has no console errors", async ({ page }) => {
|
|
const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
|
|
await page.goto("/crypto");
|
|
await page.waitForSelector('[data-testid="page-title"]');
|
|
monitor.assertClean();
|
|
});
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```typescript
|
|
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
|
|
|
|
test("i18n page — no errors, ignore React DevTools prompt", async ({ page }) => {
|
|
const monitor = attachConsoleMonitor(page, {
|
|
failOn: ["error", "warning"],
|
|
ignore: [
|
|
/Download the React DevTools/,
|
|
/ResizeObserver loop limit exceeded/,
|
|
],
|
|
});
|
|
await page.goto("/i18n");
|
|
await page.click('[data-testid="switch-locale-fr"]');
|
|
monitor.assertClean();
|
|
});
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `attachConsoleMonitor(page, options?): ConsoleMonitor`
|
|
|
|
- `page: Page` — Playwright page; must be called before any navigation
|
|
- `options.failOn?: Array<"error" | "warning" | "info">` — levels that trigger assertion failure; default `["error"]`
|
|
- `options.ignore?: RegExp[]` — patterns matched against message text; matching messages excluded from failures
|
|
- Returns `ConsoleMonitor` with `messages: ConsoleMessage[]` and `assertClean(): void`
|
|
|
|
### `ConsoleMonitor.assertClean(): void`
|
|
|
|
Throws `Error` listing all failing messages if any were collected. Safe to call multiple times.
|
|
|
|
## Implementation Notes
|
|
|
|
Playwright's `ConsoleMessage.type()` returns `"warn"` (not `"warning"`) for browser warnings.
|
|
The implementation normalises via `PLAYWRIGHT_TYPE_MAP` so callers always use `"warning"`.
|
|
The `messages` array is live — it accumulates across the full test lifecycle.
|
|
|
|
```text
|
|
|
|
#### File: `src/console-monitor/index.ts`
|
|
|
|
```typescript
|
|
export { attachConsoleMonitor } from "./console-monitor.js";
|
|
export type { ConsoleMonitor, ConsoleMonitorOptions } from "./console-monitor.types.js";
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1.3 — `network-monitor/` module
|
|
|
|
#### File: `src/network-monitor/network-monitor.types.ts`
|
|
|
|
```typescript
|
|
export interface NetworkFailure {
|
|
url: string;
|
|
type: "status" | "failed";
|
|
status?: number;
|
|
errorText?: string;
|
|
}
|
|
|
|
export interface NetworkMonitor {
|
|
readonly failures: NetworkFailure[];
|
|
assertNoFailures(): void;
|
|
}
|
|
```
|
|
|
|
#### File: `src/network-monitor/network-monitor.test.ts`
|
|
|
|
Write BEFORE the implementation.
|
|
|
|
```typescript
|
|
import type { Page, Request, Response } from "@playwright/test";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
type EventCallback = (arg: Request | Response) => void;
|
|
|
|
function makeFakePage() {
|
|
const listeners = new Map<string, EventCallback[]>();
|
|
const page = {
|
|
on: vi.fn((event: string, cb: EventCallback) => {
|
|
if (!listeners.has(event)) listeners.set(event, []);
|
|
listeners.get(event)!.push(cb);
|
|
}),
|
|
} as unknown as Page;
|
|
function emit(event: string, arg: Request | Response) {
|
|
for (const cb of listeners.get(event) ?? []) cb(arg);
|
|
}
|
|
return { page, emit };
|
|
}
|
|
|
|
function fakeResponse(url: string, status: number): Response {
|
|
return {
|
|
url: () => url,
|
|
status: () => status,
|
|
request: () => ({ url: () => url }) as unknown as Request,
|
|
} as unknown as Response;
|
|
}
|
|
|
|
function fakeFailedRequest(url: string, errorText: string): Request {
|
|
return {
|
|
url: () => url,
|
|
failure: () => ({ errorText }),
|
|
} as unknown as Request;
|
|
}
|
|
|
|
describe("attachNetworkMonitor", () => {
|
|
it("registers response and requestfailed listeners", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page } = makeFakePage();
|
|
attachNetworkMonitor(page);
|
|
const onCalls = (page.on as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]);
|
|
expect(onCalls).toContain("response");
|
|
expect(onCalls).toContain("requestfailed");
|
|
});
|
|
|
|
it("records 4xx responses as failures", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachNetworkMonitor(page);
|
|
emit("response", fakeResponse("http://localhost:3200/api/missing", 404));
|
|
expect(monitor.failures).toHaveLength(1);
|
|
expect(monitor.failures[0]!.status).toBe(404);
|
|
expect(monitor.failures[0]!.type).toBe("status");
|
|
});
|
|
|
|
it("records 5xx responses as failures", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachNetworkMonitor(page);
|
|
emit("response", fakeResponse("http://localhost:3200/api/boom", 500));
|
|
expect(monitor.failures).toHaveLength(1);
|
|
expect(monitor.failures[0]!.status).toBe(500);
|
|
});
|
|
|
|
it("does not record 2xx responses as failures", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachNetworkMonitor(page);
|
|
emit("response", fakeResponse("http://localhost:3200/api/ok", 200));
|
|
expect(monitor.failures).toHaveLength(0);
|
|
});
|
|
|
|
it("does not record 3xx responses as failures", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachNetworkMonitor(page);
|
|
emit("response", fakeResponse("http://localhost:3200/redirect", 302));
|
|
expect(monitor.failures).toHaveLength(0);
|
|
});
|
|
|
|
it("records requestfailed events as failures", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachNetworkMonitor(page);
|
|
emit("requestfailed", fakeFailedRequest("http://localhost:3200/api/x", "net::ERR_CONNECTION_REFUSED"));
|
|
expect(monitor.failures).toHaveLength(1);
|
|
expect(monitor.failures[0]!.type).toBe("failed");
|
|
expect(monitor.failures[0]!.errorText).toBe("net::ERR_CONNECTION_REFUSED");
|
|
});
|
|
|
|
it("assertNoFailures does not throw when no failures", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page } = makeFakePage();
|
|
const monitor = attachNetworkMonitor(page);
|
|
expect(() => monitor.assertNoFailures()).not.toThrow();
|
|
});
|
|
|
|
it("assertNoFailures throws when failures exist", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachNetworkMonitor(page);
|
|
emit("response", fakeResponse("http://localhost:3200/api/missing", 404));
|
|
expect(() => monitor.assertNoFailures()).toThrow("404");
|
|
});
|
|
|
|
it("records requestfailed with no failure() object gracefully", { timeout: 5000 }, async () => {
|
|
const { attachNetworkMonitor } = await import("./network-monitor.js");
|
|
const { page, emit } = makeFakePage();
|
|
const monitor = attachNetworkMonitor(page);
|
|
const req = {
|
|
url: () => "http://localhost:3200/img.png",
|
|
failure: () => null,
|
|
} as unknown as Request;
|
|
emit("requestfailed", req);
|
|
expect(monitor.failures).toHaveLength(1);
|
|
expect(monitor.failures[0]!.errorText).toBeUndefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
#### File: `src/network-monitor/network-monitor.ts`
|
|
|
|
```typescript
|
|
import type { Page, Request, Response } from "@playwright/test";
|
|
import type { NetworkFailure, NetworkMonitor } from "./network-monitor.types.js";
|
|
|
|
/**
|
|
* Intercepts all Playwright page requests and responses.
|
|
* Tracks 4xx/5xx HTTP status responses and network-level request failures.
|
|
* Call `assertNoFailures()` after interactions to fail the test on any failure.
|
|
*/
|
|
export function attachNetworkMonitor(page: Page): NetworkMonitor {
|
|
const failures: NetworkFailure[] = [];
|
|
|
|
page.on("response", (response: Response) => {
|
|
const status = response.status();
|
|
if (status >= 400) {
|
|
failures.push({ url: response.url(), type: "status", status });
|
|
}
|
|
});
|
|
|
|
page.on("requestfailed", (request: Request) => {
|
|
const failure = request.failure();
|
|
failures.push({
|
|
url: request.url(),
|
|
type: "failed",
|
|
errorText: failure?.errorText,
|
|
});
|
|
});
|
|
|
|
return {
|
|
get failures() {
|
|
return failures;
|
|
},
|
|
assertNoFailures() {
|
|
if (failures.length === 0) return;
|
|
const lines = failures
|
|
.map((f) =>
|
|
f.type === "status"
|
|
? ` [${f.status}] ${f.url}`
|
|
: ` [FAILED] ${f.url}${f.errorText ? ` -- ${f.errorText}` : ""}`
|
|
)
|
|
.join("\n");
|
|
throw new Error(`NetworkMonitor: unexpected network failures:\n${lines}`);
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
#### File: `src/network-monitor/network-monitor.md`
|
|
|
|
```markdown
|
|
# network-monitor
|
|
|
|
## Purpose
|
|
|
|
`attachNetworkMonitor` intercepts all network activity on a Playwright `Page` and records
|
|
every HTTP 4xx/5xx response and every network-level request failure (connection refused,
|
|
CORS abort, missing assets). The `assertNoFailures()` method fails the test with a
|
|
structured list if any were recorded.
|
|
|
|
This enforces the spec-level invariant that a 200 HTTP response is not proof of correctness.
|
|
A page may load with status 200 while silently failing to fetch fonts, API responses, or
|
|
static assets. The network monitor catches all of these.
|
|
|
|
## Features
|
|
|
|
- Listens to both `response` (HTTP 4xx/5xx) and `requestfailed` (network-layer failures)
|
|
- Records URL, failure type, HTTP status, and error text
|
|
- `assertNoFailures()` throws with a formatted multi-line error listing every failure
|
|
- Live `failures` array accessible mid-test
|
|
- Zero external dependencies beyond `@playwright/test` types
|
|
|
|
## Usage: Basic Example
|
|
|
|
```typescript
|
|
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
|
|
|
|
test("crypto page has no failed network requests", async ({ page }) => {
|
|
const monitor = attachNetworkMonitor(page);
|
|
await page.goto("/crypto");
|
|
await page.waitForSelector('[data-testid="page-title"]');
|
|
monitor.assertNoFailures();
|
|
});
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```typescript
|
|
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
|
|
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
|
|
|
|
test("storage-s3 page -- full health check", async ({ page }) => {
|
|
const consoleMon = attachConsoleMonitor(page, { failOn: ["error"] });
|
|
const networkMon = attachNetworkMonitor(page);
|
|
await page.goto("/storage-s3");
|
|
await page.waitForSelector('[data-testid="output"]:not(:empty)');
|
|
consoleMon.assertClean();
|
|
networkMon.assertNoFailures();
|
|
});
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `attachNetworkMonitor(page): NetworkMonitor`
|
|
|
|
- `page: Page` — must be called before navigation to capture all requests
|
|
- Returns `NetworkMonitor` with `failures: NetworkFailure[]` and `assertNoFailures(): void`
|
|
|
|
### `NetworkMonitor.assertNoFailures(): void`
|
|
|
|
Throws `Error` with multi-line message if any failures were recorded. Safe to call multiple times.
|
|
|
|
### `NetworkFailure`
|
|
|
|
- `url: string` — full request URL
|
|
- `type: "status" | "failed"` — HTTP error vs network-layer failure
|
|
- `status?: number` — HTTP status code (type === "status" only)
|
|
- `errorText?: string` — Playwright error text (type === "failed" only)
|
|
|
|
## Implementation Notes
|
|
|
|
`request.failure()` can return `null` when a request is cancelled rather than outright failed.
|
|
The implementation handles this gracefully by omitting `errorText`. Tests that intentionally
|
|
trigger error responses should inspect `monitor.failures` manually rather than calling
|
|
`assertNoFailures()` for those interactions.
|
|
|
|
```text
|
|
|
|
#### File: `src/network-monitor/index.ts`
|
|
|
|
```typescript
|
|
export { attachNetworkMonitor } from "./network-monitor.js";
|
|
export type { NetworkFailure, NetworkMonitor } from "./network-monitor.types.js";
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1.4 — `extension-fixture/` module
|
|
|
|
#### File: `src/extension-fixture/launch-with-extension.types.ts`
|
|
|
|
```typescript
|
|
import type { BrowserContext, BrowserType } from "@playwright/test";
|
|
|
|
export interface ExtensionFixtureOptions {
|
|
chromium: BrowserType;
|
|
extensionPath: string;
|
|
userDataDir: string;
|
|
}
|
|
|
|
export interface ExtensionFixtureResult {
|
|
context: BrowserContext;
|
|
extensionId: string;
|
|
}
|
|
```
|
|
|
|
#### File: `src/extension-fixture/launch-with-extension.test.ts`
|
|
|
|
Write BEFORE the implementation.
|
|
|
|
```typescript
|
|
import type { BrowserContext, BrowserType, Page } from "@playwright/test";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
function makeFakePage(url: string): Page {
|
|
return { url: () => url } as unknown as Page;
|
|
}
|
|
|
|
function makeFakeContext(backgroundUrl: string) {
|
|
return {
|
|
backgroundPages: vi.fn(() => [makeFakePage(backgroundUrl)]),
|
|
waitForEvent: vi.fn(async () => makeFakePage(backgroundUrl)),
|
|
newPage: vi.fn(async () => makeFakePage("about:blank")),
|
|
} as unknown as BrowserContext;
|
|
}
|
|
|
|
function makeFakeChromium(context: BrowserContext): BrowserType {
|
|
return {
|
|
launchPersistentContext: vi.fn(async () => context),
|
|
} as unknown as BrowserType;
|
|
}
|
|
|
|
describe("launchWithExtension", () => {
|
|
it("calls launchPersistentContext with extension args", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const { launchWithExtension } = await import("./launch-with-extension.js");
|
|
const id = "abcdefghijklmnopqrstuvwxyz012345";
|
|
const context = makeFakeContext(`chrome-extension://${id}/_generated_background_page.html`);
|
|
const chromium = makeFakeChromium(context);
|
|
await launchWithExtension({ chromium, extensionPath: "/tmp/ext", userDataDir: "/tmp/ud" });
|
|
expect(chromium.launchPersistentContext).toHaveBeenCalledWith(
|
|
"/tmp/ud",
|
|
expect.objectContaining({
|
|
args: expect.arrayContaining([
|
|
"--disable-extensions-except=/tmp/ext",
|
|
"--load-extension=/tmp/ext",
|
|
]),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("extracts extensionId from chrome-extension:// background page URL", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const { launchWithExtension } = await import("./launch-with-extension.js");
|
|
const id = "abcdefghijklmnopqrstuvwxyz012345";
|
|
const context = makeFakeContext(`chrome-extension://${id}/_generated_background_page.html`);
|
|
const chromium = makeFakeChromium(context);
|
|
const result = await launchWithExtension({ chromium, extensionPath: "/tmp/ext", userDataDir: "/tmp/ud" });
|
|
expect(result.extensionId).toBe(id);
|
|
expect(result.context).toBe(context);
|
|
});
|
|
|
|
it("falls back to waitForEvent when backgroundPages() is empty", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const { launchWithExtension } = await import("./launch-with-extension.js");
|
|
const id = "zyxwvutsrqponmlkjihgfedcba987654";
|
|
const bgUrl = `chrome-extension://${id}/_generated_background_page.html`;
|
|
const context = {
|
|
backgroundPages: vi.fn(() => []),
|
|
waitForEvent: vi.fn(async () => makeFakePage(bgUrl)),
|
|
newPage: vi.fn(async () => makeFakePage("about:blank")),
|
|
} as unknown as BrowserContext;
|
|
const chromium = makeFakeChromium(context);
|
|
const result = await launchWithExtension({ chromium, extensionPath: "/tmp/ext", userDataDir: "/tmp/ud" });
|
|
expect(context.waitForEvent).toHaveBeenCalledWith("backgroundpage");
|
|
expect(result.extensionId).toBe(id);
|
|
});
|
|
|
|
it("throws when background page URL does not match chrome-extension:// pattern", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const { launchWithExtension } = await import("./launch-with-extension.js");
|
|
const context = {
|
|
backgroundPages: vi.fn(() => [makeFakePage("about:blank")]),
|
|
waitForEvent: vi.fn(async () => makeFakePage("about:blank")),
|
|
} as unknown as BrowserContext;
|
|
const chromium = makeFakeChromium(context);
|
|
await expect(
|
|
launchWithExtension({ chromium, extensionPath: "/tmp/ext", userDataDir: "/tmp/ud" })
|
|
).rejects.toThrow("Could not extract extension ID");
|
|
});
|
|
});
|
|
```
|
|
|
|
#### File: `src/extension-fixture/launch-with-extension.ts`
|
|
|
|
```typescript
|
|
import type { BrowserContext, Page } from "@playwright/test";
|
|
import type { ExtensionFixtureOptions, ExtensionFixtureResult } from "./launch-with-extension.types.js";
|
|
|
|
const EXTENSION_ID_RE = /^chrome-extension:\/\/([a-z0-9]+)\//;
|
|
|
|
/**
|
|
* Launches a Playwright Chromium persistent context with an unpacked extension loaded.
|
|
* Uses `chromium.launchPersistentContext()` with `--load-extension` and
|
|
* `--disable-extensions-except` args. Only works with Chromium (MV3 requirement).
|
|
*
|
|
* Extracts the extension ID from the background service worker page URL.
|
|
*/
|
|
export async function launchWithExtension(
|
|
options: ExtensionFixtureOptions
|
|
): Promise<ExtensionFixtureResult> {
|
|
const { chromium, extensionPath, userDataDir } = options;
|
|
|
|
const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, {
|
|
headless: false,
|
|
args: [
|
|
`--disable-extensions-except=${extensionPath}`,
|
|
`--load-extension=${extensionPath}`,
|
|
],
|
|
});
|
|
|
|
const backgroundPage = await getBackgroundPage(context);
|
|
const match = EXTENSION_ID_RE.exec(backgroundPage.url());
|
|
if (!match?.[1]) {
|
|
throw new Error(
|
|
`Could not extract extension ID from background page URL: ${backgroundPage.url()}`
|
|
);
|
|
}
|
|
|
|
return { context, extensionId: match[1] };
|
|
}
|
|
|
|
async function getBackgroundPage(context: BrowserContext): Promise<Page> {
|
|
const existing = context.backgroundPages();
|
|
if (existing.length > 0) return existing[0]!;
|
|
return context.waitForEvent("backgroundpage") as Promise<Page>;
|
|
}
|
|
```
|
|
|
|
#### File: `src/extension-fixture/launch-with-extension.md`
|
|
|
|
```markdown
|
|
# launch-with-extension
|
|
|
|
## Purpose
|
|
|
|
`launchWithExtension` starts a Playwright Chromium persistent browser context with an
|
|
unpacked browser extension loaded from the local filesystem. It resolves the extension's
|
|
runtime ID by inspecting the background service worker page URL
|
|
(`chrome-extension://<id>/_generated_background_page.html`), which is required to
|
|
construct extension-internal URLs and assert extension behaviour in E2E tests.
|
|
|
|
This helper is the foundation for all browser extension E2E tests in the testbed.
|
|
It is only compatible with Chromium -- Firefox and Safari do not support `--load-extension`
|
|
or MV3 service workers in the same way.
|
|
|
|
## Features
|
|
|
|
- Calls `chromium.launchPersistentContext(userDataDir, { args: [...] })` with correct extension flags
|
|
- Handles both immediate (background page already registered) and async (waitForEvent) cases
|
|
- Extracts extension ID from `chrome-extension://` URL via regex
|
|
- Throws a clear error when the background page URL does not match the expected format
|
|
- Injectable `chromium` for 100% unit coverage without a real browser
|
|
|
|
## Usage: Basic Example
|
|
|
|
```typescript
|
|
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
|
|
import { chromium } from "@playwright/test";
|
|
import { mkdtemp } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
const userDataDir = await mkdtemp(join(tmpdir(), "ext-test-"));
|
|
const { context, extensionId } = await launchWithExtension({
|
|
chromium,
|
|
extensionPath: "/abs/path/to/dist/chrome",
|
|
userDataDir,
|
|
});
|
|
const page = await context.newPage();
|
|
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
|
await context.close();
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```typescript
|
|
test("extension injects UI on /extension-test", async ({ chromium }) => {
|
|
const userDataDir = await mkdtemp(join(tmpdir(), "ext-"));
|
|
const { context, extensionId } = await launchWithExtension({
|
|
chromium,
|
|
extensionPath: resolve(__dirname, "../../dist/chrome"),
|
|
userDataDir,
|
|
});
|
|
const page = await context.newPage();
|
|
await page.goto("http://localhost:3200/extension-test");
|
|
await page.waitForSelector('[data-testid="extension-injected"]');
|
|
const text = await page.textContent('[data-testid="extension-injected"]');
|
|
expect(text).toBeTruthy();
|
|
await context.close();
|
|
});
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `launchWithExtension(options): Promise<ExtensionFixtureResult>`
|
|
|
|
- `options.chromium: BrowserType` — pass `chromium` from `@playwright/test`; injectable for unit tests
|
|
- `options.extensionPath: string` — absolute path to unpacked extension directory
|
|
- `options.userDataDir: string` — path to persistent context data directory; caller creates and cleans up
|
|
- Returns `{ context: BrowserContext; extensionId: string }`
|
|
- Throws `Error` if background page URL does not contain a valid `chrome-extension://` ID
|
|
|
|
## Implementation Notes
|
|
|
|
`headless: false` is required -- Chromium does not load extensions in headless mode. In CI
|
|
without a display, use Xvfb (`DISPLAY=:99`) or check whether the Playwright version in CI
|
|
supports `--headless=new` with extensions. The persistent context approach is the most
|
|
reliable across Playwright versions.
|
|
|
|
`getBackgroundPage` first checks `context.backgroundPages()` synchronously (extension already
|
|
loaded) before falling back to `waitForEvent("backgroundpage")` (extension still loading).
|
|
This avoids a race condition where a slow `waitForEvent` call misses the event if the
|
|
extension loaded before the listener was attached.
|
|
|
|
```text
|
|
|
|
#### File: `src/extension-fixture/index.ts`
|
|
|
|
```typescript
|
|
export { launchWithExtension } from "./launch-with-extension.js";
|
|
export type { ExtensionFixtureOptions, ExtensionFixtureResult } from "./launch-with-extension.types.js";
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1.5 — Update `src/index.ts` barrel
|
|
|
|
Append the following to `/mnt/s/web/genesis/universal/dev/testing-e2e/src/index.ts`
|
|
after the existing `// Page object` export block:
|
|
|
|
```typescript
|
|
// Multi-session
|
|
export { createMultiSession, simulateServerFailure } from "./multi-session/create-multi-session.js";
|
|
export type { MultiSessionOptions, MultiSessionResult } from "./multi-session/create-multi-session.types.js";
|
|
// Console monitor
|
|
export { attachConsoleMonitor } from "./console-monitor/console-monitor.js";
|
|
export type { ConsoleMonitor, ConsoleMonitorOptions } from "./console-monitor/console-monitor.types.js";
|
|
// Network monitor
|
|
export { attachNetworkMonitor } from "./network-monitor/network-monitor.js";
|
|
export type { NetworkFailure, NetworkMonitor } from "./network-monitor/network-monitor.types.js";
|
|
// Extension fixture
|
|
export { launchWithExtension } from "./extension-fixture/launch-with-extension.js";
|
|
export type { ExtensionFixtureOptions, ExtensionFixtureResult } from "./extension-fixture/launch-with-extension.types.js";
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1.6 — Update `package.json` exports map
|
|
|
|
Add these 4 entries to the `exports` object in
|
|
`/mnt/s/web/genesis/universal/dev/testing-e2e/package.json`, after the `"./fixtures"` entry:
|
|
|
|
```json
|
|
"./multi-session": {
|
|
"types": "./dist/multi-session/index.d.ts",
|
|
"default": "./dist/multi-session/index.js"
|
|
},
|
|
"./console-monitor": {
|
|
"types": "./dist/console-monitor/index.d.ts",
|
|
"default": "./dist/console-monitor/index.js"
|
|
},
|
|
"./network-monitor": {
|
|
"types": "./dist/network-monitor/index.d.ts",
|
|
"default": "./dist/network-monitor/index.js"
|
|
},
|
|
"./extension-fixture": {
|
|
"types": "./dist/extension-fixture/index.d.ts",
|
|
"default": "./dist/extension-fixture/index.js"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1.7 — Pre-publish gate for Part A
|
|
|
|
Run in order:
|
|
|
|
```bash
|
|
# Step 1: all tests pass
|
|
bunx turbo test --filter=@suaveplan/testing-e2e
|
|
|
|
# Step 2: 100% coverage on all new modules
|
|
bunx turbo test:coverage --filter=@suaveplan/testing-e2e
|
|
|
|
# Step 3: full gate
|
|
bunx turbo lint typecheck test --filter=@suaveplan/testing-e2e
|
|
```
|
|
|
|
**Expected:** All 4 new modules show 100% Statements/Branches/Functions/Lines in the
|
|
Istanbul coverage report. The thresholds in `vitest.config.ts` are already at 100% --
|
|
the run will fail automatically if any module falls short.
|
|
|
|
**If lint fails:** run `bunx biome check --write --unsafe src/` inside the package directory.
|
|
|
|
---
|
|
|
|
## Chunk 2 — `apps/testbed/` Scaffolding + Server
|
|
|
|
### Task 2.1 — Verify `apps/` workspace inclusion
|
|
|
|
Check `/mnt/s/web/genesis/package.json`. The `workspaces` array must include `"apps/*"`.
|
|
If absent, add it. Also verify `turbo.json` has no exclusions blocking `apps/testbed`.
|
|
|
|
### Task 2.2 — Create directory structure
|
|
|
|
```bash
|
|
mkdir -p /mnt/s/web/genesis/apps/testbed/src/server/routes/api
|
|
mkdir -p /mnt/s/web/genesis/apps/testbed/src/client/components/layout
|
|
mkdir -p /mnt/s/web/genesis/apps/testbed/src/client/components/shared
|
|
mkdir -p /mnt/s/web/genesis/apps/testbed/src/client/pages/home
|
|
mkdir -p /mnt/s/web/genesis/apps/testbed/src/client/styles
|
|
mkdir -p /mnt/s/web/genesis/apps/testbed/.storybook
|
|
mkdir -p /mnt/s/web/genesis/apps/testbed/e2e
|
|
```
|
|
|
|
### Task 2.3 — `package.json`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/package.json`
|
|
|
|
```json
|
|
{
|
|
"name": "@suaveplan/testbed",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"type": "module",
|
|
"scripts": {
|
|
"dev": "bun run src/server/index.ts",
|
|
"build": "vite build",
|
|
"start": "bun run dist/server/index.js",
|
|
"storybook": "storybook dev -p 6042",
|
|
"build-storybook": "storybook build",
|
|
"test:e2e": "playwright test",
|
|
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
|
},
|
|
"dependencies": {
|
|
"@suaveplan/testing-e2e": "workspace:^",
|
|
"hono": "catalog:",
|
|
"react": "catalog:react",
|
|
"react-dom": "catalog:react",
|
|
"react-router-dom": "catalog:react"
|
|
},
|
|
"devDependencies": {
|
|
"@playwright/test": "catalog:testing",
|
|
"@storybook/addon-essentials": "catalog:storybook",
|
|
"@storybook/addon-interactions": "catalog:storybook",
|
|
"@storybook/react-vite": "catalog:storybook",
|
|
"@storybook/test": "catalog:storybook",
|
|
"@tailwindcss/vite": "catalog:build",
|
|
"@types/node": "catalog:",
|
|
"@types/react": "catalog:react",
|
|
"@types/react-dom": "catalog:react",
|
|
"@vitejs/plugin-react": "catalog:build",
|
|
"tailwindcss": "catalog:build",
|
|
"typescript": "catalog:",
|
|
"vite": "catalog:build"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Catalog entries to add if missing** in root `package.json` `workspaces.catalog`:
|
|
|
|
```json
|
|
"hono": "4.7.4",
|
|
"react-router-dom": "7.5.2",
|
|
"@storybook/react-vite": "8.6.12",
|
|
"@storybook/addon-essentials": "8.6.12",
|
|
"@storybook/addon-interactions": "8.6.12",
|
|
"@storybook/test": "8.6.12",
|
|
"storybook": "8.6.12",
|
|
"@tailwindcss/vite": "4.1.5",
|
|
"tailwindcss": "4.1.5",
|
|
"@vitejs/plugin-react": "4.4.1"
|
|
```
|
|
|
|
Run `bun install` from the monorepo root after editing `package.json`.
|
|
|
|
### Task 2.4 — `tsconfig.json`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/tsconfig.json`
|
|
|
|
```json
|
|
{
|
|
"$schema": "https://json.schemastore.org/tsconfig",
|
|
"extends": "../../tsconfig.base.json",
|
|
"compilerOptions": {
|
|
"rootDir": "./src",
|
|
"outDir": "./dist",
|
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
"jsx": "react-jsx",
|
|
"types": ["node"]
|
|
},
|
|
"include": ["src/**/*.ts", "src/**/*.tsx", ".storybook/**/*.ts", "e2e/**/*.ts"],
|
|
"exclude": ["dist", "node_modules", "coverage"]
|
|
}
|
|
```
|
|
|
|
### Task 2.5 — `vite.config.ts`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/vite.config.ts`
|
|
|
|
```typescript
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import { defineConfig } from "vite";
|
|
|
|
export default defineConfig({
|
|
plugins: [react(), tailwindcss()],
|
|
build: {
|
|
outDir: "dist/client",
|
|
emptyOutDir: true,
|
|
sourcemap: true,
|
|
},
|
|
root: ".",
|
|
publicDir: "public",
|
|
});
|
|
```
|
|
|
|
### Task 2.6 — `playwright.config.ts`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/playwright.config.ts`
|
|
|
|
```typescript
|
|
import { defineConfig, devices } from "@playwright/test";
|
|
|
|
export default defineConfig({
|
|
testDir: "./e2e",
|
|
fullyParallel: true,
|
|
retries: process.env["CI"] ? 2 : 0,
|
|
reporter: [["html", { open: "never" }], ["github"]],
|
|
use: {
|
|
baseURL: "http://localhost:3200",
|
|
trace: "on-first-retry",
|
|
screenshot: "only-on-failure",
|
|
video: "on-first-retry",
|
|
},
|
|
projects: [
|
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
|
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
|
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
|
{ name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
|
|
{ name: "mobile-safari", use: { ...devices["iPhone 15"] } },
|
|
{
|
|
name: "chromium-extensions",
|
|
testMatch: "**/extensions/**",
|
|
use: { ...devices["Desktop Chrome"] },
|
|
},
|
|
],
|
|
webServer: {
|
|
command: "bun run dev",
|
|
url: "http://localhost:3200",
|
|
reuseExistingServer: !process.env["CI"],
|
|
timeout: 30_000,
|
|
},
|
|
});
|
|
```
|
|
|
|
### Task 2.7 — `Dockerfile`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/Dockerfile`
|
|
|
|
```dockerfile
|
|
FROM oven/bun:1
|
|
WORKDIR /app
|
|
COPY package.json bun.lockb ./
|
|
RUN bun install --frozen-lockfile
|
|
COPY . .
|
|
RUN bun run build
|
|
EXPOSE 3000
|
|
CMD ["bun", "run", "start"]
|
|
```
|
|
|
|
### Task 2.8 — Server entry point
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/server/index.ts`
|
|
|
|
```typescript
|
|
import { Hono } from "hono";
|
|
import { serveStatic } from "hono/bun";
|
|
import { databaseRoutes } from "./routes/api/database.js";
|
|
import { storageRoutes } from "./routes/api/storage.js";
|
|
|
|
const app = new Hono();
|
|
|
|
app.route("/api/storage", storageRoutes);
|
|
app.route("/api/database", databaseRoutes);
|
|
|
|
if (process.env["NODE_ENV"] === "test") {
|
|
const { testControlRoutes } = await import("./routes/api/test-control.js");
|
|
app.route("/api/test-control", testControlRoutes);
|
|
}
|
|
|
|
app.use("/*", serveStatic({ root: "./dist/client" }));
|
|
app.get("*", serveStatic({ path: "./dist/client/index.html" }));
|
|
|
|
const port = Number(process.env["PORT"] ?? 3200);
|
|
|
|
export default { port, fetch: app.fetch };
|
|
```
|
|
|
|
**Note:** Hono on Bun uses `export default { port, fetch }`. The `serveStatic` import is
|
|
from `"hono/bun"`. If the Hono version does not support this, use `Bun.serve({ port, fetch: app.fetch })`.
|
|
|
|
### Task 2.9 — API route files
|
|
|
|
#### `src/server/routes/api/storage.ts`
|
|
|
|
```typescript
|
|
import { Hono } from "hono";
|
|
|
|
export const storageRoutes = new Hono();
|
|
|
|
storageRoutes.get("/status", (c) => {
|
|
return c.json({
|
|
available: Boolean(
|
|
process.env["GARAGE_S3_ENDPOINT"] && process.env["GARAGE_S3_ACCESS_KEY_ID"]
|
|
),
|
|
});
|
|
});
|
|
```
|
|
|
|
#### `src/server/routes/api/database.ts`
|
|
|
|
```typescript
|
|
import { Hono } from "hono";
|
|
|
|
export const databaseRoutes = new Hono();
|
|
|
|
databaseRoutes.get("/status", (c) => {
|
|
return c.json({ available: Boolean(process.env["SURREALDB_URL"]) });
|
|
});
|
|
```
|
|
|
|
#### `src/server/routes/api/test-control.ts`
|
|
|
|
```typescript
|
|
import { Hono } from "hono";
|
|
|
|
export const disabledRoutes = new Set<string>();
|
|
|
|
export const testControlRoutes = new Hono();
|
|
|
|
/**
|
|
* POST /api/test-control/disable-route
|
|
* Body: { route: string }
|
|
* Response: 204 No Content
|
|
* Only registered when NODE_ENV === "test".
|
|
*/
|
|
testControlRoutes.post("/disable-route", async (c) => {
|
|
const body = await c.req.json<{ route: string }>();
|
|
disabledRoutes.add(body.route);
|
|
return c.body(null, 204);
|
|
});
|
|
|
|
/** POST /api/test-control/reset — clears all disabled routes. */
|
|
testControlRoutes.post("/reset", (c) => {
|
|
disabledRoutes.clear();
|
|
return c.body(null, 204);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3 — Client Shell, Routes, Layout + Shared Components
|
|
|
|
### Task 3.1 — `index.html`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/index.html`
|
|
|
|
```html
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Suaveplan Testbed</title>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="/src/client/main.tsx"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
### Task 3.2 — Styles
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/styles/globals.css`
|
|
|
|
```css
|
|
@import "tailwindcss";
|
|
|
|
:root {
|
|
--color-bg: #ffffff;
|
|
--color-fg: #111111;
|
|
--color-sidebar: #f4f4f5;
|
|
--color-border: #e4e4e7;
|
|
--color-accent: #6366f1;
|
|
}
|
|
|
|
[data-theme="dark"] {
|
|
--color-bg: #09090b;
|
|
--color-fg: #fafafa;
|
|
--color-sidebar: #18181b;
|
|
--color-border: #27272a;
|
|
--color-accent: #818cf8;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
background-color: var(--color-bg);
|
|
color: var(--color-fg);
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: system-ui, sans-serif;
|
|
}
|
|
```
|
|
|
|
### Task 3.3 — Route manifest
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/routes.ts`
|
|
|
|
```typescript
|
|
export interface RouteEntry {
|
|
path: string;
|
|
label: string;
|
|
category: string;
|
|
requiresServices?: boolean;
|
|
}
|
|
|
|
export const routes: RouteEntry[] = [
|
|
{ path: "/crypto", label: "@suaveplan/crypto", category: "crypto" },
|
|
{ path: "/logger", label: "@suaveplan/logger", category: "observability" },
|
|
{ path: "/datetime", label: "@suaveplan/datetime", category: "primitives" },
|
|
{ path: "/i18n", label: "@suaveplan/i18n", category: "primitives" },
|
|
{ path: "/feature-flags", label: "@suaveplan/feature-flags", category: "primitives" },
|
|
{ path: "/storage-s3", label: "@suaveplan/storage-s3", category: "storage", requiresServices: true },
|
|
{ path: "/surrealdb", label: "@suaveplan/db-sqlite", category: "database", requiresServices: true },
|
|
{ path: "/network-webrtc",label: "Network WebRTC", category: "network", requiresServices: true },
|
|
{ path: "/extension-test",label: "Extension Test", category: "extensions" },
|
|
];
|
|
```
|
|
|
|
### Task 3.4 — React entry point
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/main.tsx`
|
|
|
|
```tsx
|
|
import { StrictMode } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import "./styles/globals.css";
|
|
import { App } from "./app.js";
|
|
|
|
const root = document.getElementById("root");
|
|
if (!root) throw new Error("No #root element found");
|
|
|
|
createRoot(root).render(
|
|
<StrictMode>
|
|
<App />
|
|
</StrictMode>
|
|
);
|
|
```
|
|
|
|
### Task 3.5 — App shell
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/app.tsx`
|
|
|
|
```tsx
|
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|
import { Header } from "./components/layout/header.js";
|
|
import { Sidebar } from "./components/layout/sidebar.js";
|
|
import { HomePage } from "./pages/home/home-page.js";
|
|
import { routes } from "./routes.js";
|
|
|
|
function NotFound(): JSX.Element {
|
|
return (
|
|
<main className="flex-1 p-8">
|
|
<h1 data-testid="page-title" className="text-2xl font-semibold">
|
|
404 -- Page not found
|
|
</h1>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function PlaceholderPage({ label }: { label: string }): JSX.Element {
|
|
return (
|
|
<main className="flex-1 p-8">
|
|
<h1 data-testid="page-title" className="text-2xl font-semibold">{label}</h1>
|
|
<p className="mt-2 text-sm text-gray-500">Library demo page -- implementation coming soon.</p>
|
|
<div data-testid="demo-card" className="mt-6 rounded border p-4">
|
|
<div data-testid="output" className="font-mono text-sm" />
|
|
</div>
|
|
<div data-testid="console-panel" className="mt-4" />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export function App(): JSX.Element {
|
|
return (
|
|
<BrowserRouter>
|
|
<div className="flex h-screen flex-col">
|
|
<Header />
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<Sidebar />
|
|
<Routes>
|
|
<Route path="/" element={<HomePage />} />
|
|
{routes.map((route) => (
|
|
<Route
|
|
key={route.path}
|
|
path={route.path}
|
|
element={<PlaceholderPage label={route.label} />}
|
|
/>
|
|
))}
|
|
<Route path="*" element={<NotFound />} />
|
|
</Routes>
|
|
</div>
|
|
</div>
|
|
</BrowserRouter>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Task 3.6 — Layout components
|
|
|
|
Each component: `.tsx` + `.md` + `.stories.tsx`. The barrel `index.ts` is at the end.
|
|
|
|
#### `theme-toggle.tsx`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/layout/theme-toggle.tsx`
|
|
|
|
```tsx
|
|
import { useEffect, useState } from "react";
|
|
|
|
type Theme = "light" | "dark";
|
|
|
|
function getInitialTheme(): Theme {
|
|
if (typeof localStorage === "undefined") return "light";
|
|
const stored = localStorage.getItem("theme");
|
|
if (stored === "light" || stored === "dark") return stored;
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
}
|
|
|
|
export function ThemeToggle(): JSX.Element {
|
|
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
|
|
|
useEffect(() => {
|
|
document.documentElement.setAttribute("data-theme", theme);
|
|
localStorage.setItem("theme", theme);
|
|
}, [theme]);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
|
|
data-testid="theme-toggle"
|
|
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
|
|
className="rounded px-2 py-1 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
>
|
|
{theme === "light" ? "Moon" : "Sun"}
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Note on emoji:** Emojis are avoided per CLAUDE.md. Use text labels "Moon"/"Sun" instead
|
|
of emoji characters. If the user wants emoji, they can add them explicitly.
|
|
|
|
#### `theme-toggle.md`
|
|
|
|
```markdown
|
|
# theme-toggle
|
|
|
|
## Purpose
|
|
|
|
`ThemeToggle` is a button that toggles between `light` and `dark` visual themes.
|
|
It applies the theme by setting a `data-theme` attribute on `<html>` (consumed by CSS
|
|
custom properties and Tailwind dark mode variants) and persists the choice to
|
|
`localStorage["theme"]`. On first render it reads the stored preference, falling back
|
|
to the OS `prefers-color-scheme` media query.
|
|
|
|
Every E2E test for the testbed's theme feature interacts via `data-testid="theme-toggle"`.
|
|
|
|
## Features
|
|
|
|
- Reads initial theme from `localStorage["theme"]` or `prefers-color-scheme`
|
|
- Applies `data-theme` attribute to `document.documentElement` on every toggle
|
|
- Persists selection to `localStorage` so choice survives page reloads
|
|
- Accessible: `aria-label` updates to describe the next action
|
|
- `data-testid="theme-toggle"` for deterministic Playwright targeting
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { ThemeToggle } from "@suaveplan/testbed/components/layout";
|
|
<ThemeToggle />
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
// E2E -- toggle and verify data-theme attribute persists
|
|
await page.click('[data-testid="theme-toggle"]');
|
|
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
|
|
await page.reload();
|
|
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `ThemeToggle(): JSX.Element`
|
|
|
|
No props. Reads and writes `localStorage["theme"]`. Sets `document.documentElement.dataset.theme`.
|
|
|
|
## Implementation Notes
|
|
|
|
The component does not accept a controlled `theme` prop -- the theme is global document state.
|
|
`getInitialTheme` runs synchronously on the first render pass to avoid a flash of the default
|
|
theme before the stored preference is applied.
|
|
|
|
```text
|
|
|
|
#### `theme-toggle.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { ThemeToggle } from "./theme-toggle.js";
|
|
|
|
const meta: Meta<typeof ThemeToggle> = {
|
|
component: ThemeToggle,
|
|
title: "Layout/ThemeToggle",
|
|
tags: ["autodocs"],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof ThemeToggle>;
|
|
|
|
export const Default: Story = {};
|
|
export const Playground: Story = {};
|
|
```
|
|
|
|
### `locale-switcher.tsx`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/layout/locale-switcher.tsx`
|
|
|
|
```tsx
|
|
import { useEffect, useState } from "react";
|
|
|
|
const LOCALES = ["en", "fr", "de", "ja"] as const;
|
|
type Locale = (typeof LOCALES)[number];
|
|
|
|
function getInitialLocale(): Locale {
|
|
if (typeof localStorage === "undefined") return "en";
|
|
const stored = localStorage.getItem("locale");
|
|
return (LOCALES as readonly string[]).includes(stored ?? "") ? (stored as Locale) : "en";
|
|
}
|
|
|
|
export function LocaleSwitcher(): JSX.Element {
|
|
const [locale, setLocale] = useState<Locale>(getInitialLocale);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("locale", locale);
|
|
document.documentElement.setAttribute("lang", locale);
|
|
}, [locale]);
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLSelectElement>): void {
|
|
const value = e.target.value;
|
|
if ((LOCALES as readonly string[]).includes(value)) setLocale(value as Locale);
|
|
}
|
|
|
|
return (
|
|
<select
|
|
aria-label="Select locale"
|
|
data-testid="locale-switcher"
|
|
value={locale}
|
|
onChange={handleChange}
|
|
className="rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
|
|
>
|
|
{LOCALES.map((loc) => (
|
|
<option key={loc} value={loc}>{loc.toUpperCase()}</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### `locale-switcher.md`
|
|
|
|
```markdown
|
|
# locale-switcher
|
|
|
|
## Purpose
|
|
|
|
`LocaleSwitcher` renders a `<select>` allowing the user to switch between the four supported
|
|
locales (`en`, `fr`, `de`, `ja`). The selected locale is persisted to `localStorage["locale"]`
|
|
and applied as a `lang` attribute on `<html>`. On first render it reads the stored preference,
|
|
defaulting to `"en"`.
|
|
|
|
The locale switcher is used by baseline E2E specs to verify every library page renders
|
|
correctly in all four locales. It is placed in the `Header` so it is accessible on every route.
|
|
|
|
## Features
|
|
|
|
- Supports four locales: en, fr, de, ja
|
|
- Persists selection to `localStorage["locale"]`
|
|
- Sets `document.documentElement.lang` on every change
|
|
- `data-testid="locale-switcher"` for Playwright targeting
|
|
- Runtime validation guards against DOM-injected invalid values
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { LocaleSwitcher } from "@suaveplan/testbed/components/layout";
|
|
<LocaleSwitcher />
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
// E2E -- switch to French and verify lang attribute
|
|
await page.selectOption('[data-testid="locale-switcher"]', "fr");
|
|
await page.reload();
|
|
expect(await page.getAttribute("html", "lang")).toBe("fr");
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `LocaleSwitcher(): JSX.Element`
|
|
|
|
No props. Reads and writes `localStorage["locale"]`. Sets `document.documentElement.lang`.
|
|
|
|
## Implementation Notes
|
|
|
|
The `LOCALES` tuple is `as const` to produce a literal union type. Runtime validation in
|
|
`handleChange` checks membership before calling `setLocale`, preventing invalid locale strings
|
|
from reaching `localStorage` or i18n providers.
|
|
|
|
```text
|
|
|
|
#### `locale-switcher.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { LocaleSwitcher } from "./locale-switcher.js";
|
|
|
|
const meta: Meta<typeof LocaleSwitcher> = {
|
|
component: LocaleSwitcher,
|
|
title: "Layout/LocaleSwitcher",
|
|
tags: ["autodocs"],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof LocaleSwitcher>;
|
|
|
|
export const Default: Story = {};
|
|
export const Playground: Story = {};
|
|
```
|
|
|
|
### `console-panel.tsx`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/layout/console-panel.tsx`
|
|
|
|
```tsx
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
interface ConsoleEntry {
|
|
level: "log" | "warn" | "error";
|
|
message: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
const LEVEL_COLORS: Record<ConsoleEntry["level"], string> = {
|
|
log: "text-gray-600 dark:text-gray-400",
|
|
warn: "text-yellow-600 dark:text-yellow-400",
|
|
error: "text-red-600 dark:text-red-400",
|
|
};
|
|
|
|
const LEVEL_BADGES: Record<ConsoleEntry["level"], string> = {
|
|
log: "INFO",
|
|
warn: "WARN",
|
|
error: "ERROR",
|
|
};
|
|
|
|
export function ConsolePanel(): JSX.Element {
|
|
const [entries, setEntries] = useState<ConsoleEntry[]>([]);
|
|
const originals = useRef<{
|
|
log: typeof console.log;
|
|
warn: typeof console.warn;
|
|
error: typeof console.error;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
originals.current = { log: console.log, warn: console.warn, error: console.error };
|
|
|
|
function capture(level: ConsoleEntry["level"]) {
|
|
return (...args: unknown[]): void => {
|
|
const message = args
|
|
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
|
|
.join(" ");
|
|
setEntries((prev) => [...prev, { level, message, timestamp: Date.now() }]);
|
|
originals.current?.[level](...(args as Parameters<typeof console.log>));
|
|
};
|
|
}
|
|
|
|
console.log = capture("log");
|
|
console.warn = capture("warn");
|
|
console.error = capture("error");
|
|
|
|
return () => {
|
|
if (originals.current) {
|
|
console.log = originals.current.log;
|
|
console.warn = originals.current.warn;
|
|
console.error = originals.current.error;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
data-testid="console-panel"
|
|
className="mt-4 max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-900"
|
|
>
|
|
{entries.length === 0 ? (
|
|
<span className="text-gray-400">No console output</span>
|
|
) : (
|
|
entries.map((entry, i) => (
|
|
<div key={`${entry.timestamp}-${i}`} className={LEVEL_COLORS[entry.level]}>
|
|
<span className="mr-2 font-bold">[{LEVEL_BADGES[entry.level]}]</span>
|
|
<span>{entry.message}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### `console-panel.md`
|
|
|
|
```markdown
|
|
# console-panel
|
|
|
|
## Purpose
|
|
|
|
`ConsolePanel` intercepts `window.console.log`, `warn`, and `error` on mount and renders
|
|
each captured message in an in-page log with level badges. It restores the original console
|
|
methods on unmount. The panel is mounted on every library page so developers and Playwright
|
|
tests can see exactly what console output the library produces during a demo interaction.
|
|
|
|
This component mirrors exactly what the Playwright `attachConsoleMonitor` captures, making
|
|
it easy to reproduce CI failures locally.
|
|
|
|
## Features
|
|
|
|
- Intercepts `console.log`, `console.warn`, `console.error` via method replacement
|
|
- Restores originals on React unmount (cleanup effect)
|
|
- Renders each entry with level badge: [INFO], [WARN], [ERROR]
|
|
- Objects serialised via JSON.stringify; primitives via String()
|
|
- `data-testid="console-panel"` for Playwright targeting
|
|
- Scrollable: max-h-48 with overflow-y-auto
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { ConsolePanel } from "@suaveplan/testbed/components/layout";
|
|
<ConsolePanel />
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
// E2E -- trigger demo and verify console panel shows output
|
|
await page.click('[data-testid="encrypt-button"]');
|
|
await page.waitForSelector('[data-testid="console-panel"] >> text=encrypt');
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `ConsolePanel(): JSX.Element`
|
|
|
|
No props. Side-effectful: replaces console methods on mount, restores on unmount.
|
|
`data-testid="console-panel"`.
|
|
|
|
## Implementation Notes
|
|
|
|
Direct method replacement is used rather than a Proxy to avoid infinite recursion
|
|
hazards when the console itself tries to log during render. The `originals` ref captures
|
|
methods at mount time, ensuring remount (React StrictMode double-invocation) always
|
|
restores the methods that were in place when the component first mounted.
|
|
|
|
```text
|
|
|
|
#### `console-panel.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { ConsolePanel } from "./console-panel.js";
|
|
|
|
const meta: Meta<typeof ConsolePanel> = {
|
|
component: ConsolePanel,
|
|
title: "Layout/ConsolePanel",
|
|
tags: ["autodocs"],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof ConsolePanel>;
|
|
|
|
export const Default: Story = {};
|
|
|
|
export const WithMessages: Story = {
|
|
play: async () => {
|
|
console.log("Demo log message");
|
|
console.warn("Demo warning");
|
|
console.error("Demo error");
|
|
},
|
|
};
|
|
|
|
export const Playground: Story = {};
|
|
```
|
|
|
|
### `sidebar.tsx`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/layout/sidebar.tsx`
|
|
|
|
```tsx
|
|
import { NavLink } from "react-router-dom";
|
|
import { routes } from "../../routes.js";
|
|
|
|
const categories = [...new Set(routes.map((r) => r.category))];
|
|
|
|
export function Sidebar(): JSX.Element {
|
|
return (
|
|
<nav
|
|
data-testid="sidebar"
|
|
aria-label="Library navigation"
|
|
className="w-56 shrink-0 overflow-y-auto border-r border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900"
|
|
>
|
|
{categories.map((category) => (
|
|
<div key={category} className="px-3 py-2">
|
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
{category}
|
|
</p>
|
|
{routes
|
|
.filter((r) => r.category === category)
|
|
.map((route) => (
|
|
<NavLink
|
|
key={route.path}
|
|
to={route.path}
|
|
data-testid={`nav-${route.path.replace("/", "")}`}
|
|
className={({ isActive }) =>
|
|
`block truncate rounded px-2 py-1 text-sm ${
|
|
isActive
|
|
? "bg-indigo-100 font-medium text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
|
|
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
}`
|
|
}
|
|
>
|
|
{route.label}
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### `sidebar.md`
|
|
|
|
```markdown
|
|
# sidebar
|
|
|
|
## Purpose
|
|
|
|
`Sidebar` renders the primary navigation for the testbed as a vertically scrollable nav panel.
|
|
Routes are grouped by their `category` field from `routes.ts` and rendered as React Router
|
|
`NavLink` elements. Active links receive distinct styling.
|
|
|
|
Every `data-testid="nav-<path>"` attribute maps directly to a route in the manifest, making
|
|
it trivial to enumerate and test all routes programmatically in E2E navigation specs.
|
|
|
|
## Features
|
|
|
|
- Groups routes by category, preserving insertion order from `routes.ts`
|
|
- Uses React Router `NavLink` with `isActive` callback for active-state styling
|
|
- Each nav item has `data-testid="nav-<route-path-without-slash>"` for Playwright
|
|
- Scrollable: overflow-y-auto handles large numbers of routes
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { Sidebar } from "@suaveplan/testbed/components/layout";
|
|
<Sidebar /> // requires BrowserRouter in tree
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
// E2E -- click nav link and assert page title
|
|
await page.click('[data-testid="nav-crypto"]');
|
|
await page.waitForSelector('[data-testid="page-title"]');
|
|
expect(await page.textContent('[data-testid="page-title"]')).toContain("@suaveplan/crypto");
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `Sidebar(): JSX.Element`
|
|
|
|
No props. Reads `routes` from `routes.ts`. Requires `BrowserRouter` or `MemoryRouter` in tree.
|
|
|
|
## Implementation Notes
|
|
|
|
Categories are derived via `[...new Set(routes.map(r => r.category))]`. ES2015+ Set preserves
|
|
insertion order, so routes appear in the same order as the manifest. Adding routes to
|
|
`routes.ts` automatically updates the sidebar.
|
|
|
|
```text
|
|
|
|
#### `sidebar.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { Sidebar } from "./sidebar.js";
|
|
|
|
const meta: Meta<typeof Sidebar> = {
|
|
component: Sidebar,
|
|
title: "Layout/Sidebar",
|
|
tags: ["autodocs"],
|
|
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof Sidebar>;
|
|
|
|
export const Default: Story = {};
|
|
export const WithActiveCrypto: Story = {
|
|
decorators: [(Story) => <MemoryRouter initialEntries={["/crypto"]}><Story /></MemoryRouter>],
|
|
};
|
|
export const Playground: Story = {};
|
|
```
|
|
|
|
### `header.tsx`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/layout/header.tsx`
|
|
|
|
```tsx
|
|
import { LocaleSwitcher } from "./locale-switcher.js";
|
|
import { ThemeToggle } from "./theme-toggle.js";
|
|
|
|
export function Header(): JSX.Element {
|
|
return (
|
|
<header
|
|
data-testid="header"
|
|
className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900"
|
|
>
|
|
<span className="text-sm font-semibold tracking-tight text-gray-900 dark:text-white">
|
|
Suaveplan Testbed
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<LocaleSwitcher />
|
|
<ThemeToggle />
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### `header.md`
|
|
|
|
```markdown
|
|
# header
|
|
|
|
## Purpose
|
|
|
|
`Header` is the top-level application bar for the testbed. It displays the app title on the
|
|
left and renders `LocaleSwitcher` and `ThemeToggle` on the right. It is mounted above the
|
|
sidebar/main split in `App` so it is visible on every route.
|
|
|
|
The `data-testid="header"` attribute is consumed by E2E tests that verify the presence of
|
|
header controls on every page load.
|
|
|
|
## Features
|
|
|
|
- Fixed height (h-12); does not scroll
|
|
- Title on left, controls (LocaleSwitcher + ThemeToggle) on right
|
|
- Composes control components -- no inline control logic
|
|
- `data-testid="header"` for Playwright
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { Header } from "@suaveplan/testbed/components/layout";
|
|
<Header />
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
// E2E -- verify header present on every route
|
|
for (const route of routes) {
|
|
await page.goto(route.path);
|
|
await expect(page.locator('[data-testid="header"]')).toBeVisible();
|
|
}
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `Header(): JSX.Element`
|
|
|
|
No props.
|
|
|
|
## Implementation Notes
|
|
|
|
`Header` is a pure layout compositor. All state lives in `LocaleSwitcher` and `ThemeToggle`.
|
|
This keeps `Header` trivially testable in Storybook without mock setup.
|
|
|
|
```text
|
|
|
|
#### `header.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { Header } from "./header.js";
|
|
|
|
const meta: Meta<typeof Header> = {
|
|
component: Header,
|
|
title: "Layout/Header",
|
|
tags: ["autodocs"],
|
|
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof Header>;
|
|
|
|
export const Default: Story = {};
|
|
export const Playground: Story = {};
|
|
```
|
|
|
|
### Layout barrel `index.ts`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/layout/index.ts`
|
|
|
|
```typescript
|
|
export { ConsolePanel } from "./console-panel.js";
|
|
export { Header } from "./header.js";
|
|
export { LocaleSwitcher } from "./locale-switcher.js";
|
|
export { Sidebar } from "./sidebar.js";
|
|
export { ThemeToggle } from "./theme-toggle.js";
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.7 — Shared components
|
|
|
|
#### `demo-card.tsx`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/shared/demo-card.tsx`
|
|
|
|
```tsx
|
|
interface DemoCardProps {
|
|
title?: string;
|
|
children: React.ReactNode;
|
|
output?: React.ReactNode;
|
|
}
|
|
|
|
export function DemoCard({ title, children, output }: DemoCardProps): JSX.Element {
|
|
return (
|
|
<div
|
|
data-testid="demo-card"
|
|
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
|
>
|
|
{title && (
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
|
{title}
|
|
</h2>
|
|
)}
|
|
<div className="space-y-3">{children}</div>
|
|
{output !== undefined && (
|
|
<div
|
|
data-testid="output"
|
|
className="mt-3 min-h-6 rounded bg-gray-50 p-2 font-mono text-sm dark:bg-gray-900"
|
|
>
|
|
{output}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### `demo-card.md`
|
|
|
|
```markdown
|
|
# demo-card
|
|
|
|
## Purpose
|
|
|
|
`DemoCard` is the standard wrapper for every interactive library demonstration in the testbed.
|
|
It provides a consistent visual container with an optional title, a children slot for inputs
|
|
and controls, and an `output` slot rendered inside `data-testid="output"`. Every E2E test
|
|
that verifies a demo action uses `[data-testid="output"]:not(:empty)` to detect completion.
|
|
|
|
## Features
|
|
|
|
- `data-testid="demo-card"` wrapper for Playwright targeting
|
|
- Optional `title` prop rendered as a section heading
|
|
- `children` slot for inputs, buttons, and controls
|
|
- `output` slot with `data-testid="output"` -- required for E2E test assertions
|
|
- Light/dark mode compatible via Tailwind classes
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { DemoCard } from "@suaveplan/testbed/components/shared";
|
|
|
|
<DemoCard title="Encrypt" output={ciphertext}>
|
|
<input value={plaintext} onChange={e => setPlaintext(e.target.value)} />
|
|
<button onClick={doEncrypt}>Encrypt</button>
|
|
</DemoCard>
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
<DemoCard
|
|
title="Key Generation"
|
|
output={keypair ? JSON.stringify(keypair, null, 2) : null}
|
|
>
|
|
<select value={algorithm} onChange={e => setAlgorithm(e.target.value)}>
|
|
<option value="ed25519">Ed25519</option>
|
|
<option value="x25519">X25519</option>
|
|
</select>
|
|
<button data-testid="generate-button" onClick={generateKey}>Generate</button>
|
|
</DemoCard>
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `DemoCard(props): JSX.Element`
|
|
|
|
- `props.title?: string` -- optional section heading
|
|
- `props.children: ReactNode` -- controls and inputs
|
|
- `props.output?: ReactNode` -- rendered inside `data-testid="output"`
|
|
|
|
## Implementation Notes
|
|
|
|
`output` accepts `ReactNode` for rich output (formatted JSON, status badges, etc.).
|
|
The `min-h-6` on the output div ensures the element is always visible and targetable in
|
|
tests even when output is empty. E2E tests should wait for the output to contain non-empty
|
|
content rather than testing the empty state.
|
|
|
|
```text
|
|
|
|
#### `demo-card.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { DemoCard } from "./demo-card.js";
|
|
|
|
const meta: Meta<typeof DemoCard> = {
|
|
component: DemoCard,
|
|
title: "Shared/DemoCard",
|
|
tags: ["autodocs"],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof DemoCard>;
|
|
|
|
export const Default: Story = {
|
|
args: {
|
|
title: "Example Demo",
|
|
children: <button type="button">Run</button>,
|
|
output: "result: ok",
|
|
},
|
|
};
|
|
export const NoOutput: Story = {
|
|
args: { title: "Inputs Only", children: <input placeholder="Enter value" /> },
|
|
};
|
|
export const Playground: Story = {
|
|
args: { title: "Playground", children: <span>Edit me</span>, output: "" },
|
|
};
|
|
```
|
|
|
|
### `code-block.tsx`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/shared/code-block.tsx`
|
|
|
|
```tsx
|
|
interface CodeBlockProps {
|
|
code: string;
|
|
language?: string;
|
|
}
|
|
|
|
export function CodeBlock({ code, language }: CodeBlockProps): JSX.Element {
|
|
return (
|
|
<div data-testid="code-block" className="overflow-x-auto rounded bg-gray-900 p-4">
|
|
{language && <span className="mb-2 block text-xs text-gray-400">{language}</span>}
|
|
<pre className="font-mono text-sm text-gray-100"><code>{code}</code></pre>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### `code-block.md`
|
|
|
|
```markdown
|
|
# code-block
|
|
|
|
## Purpose
|
|
|
|
`CodeBlock` renders a preformatted code snippet with monospace styling and an optional
|
|
language label. It is used on library demo pages to display example code, API responses,
|
|
and serialised output in a readable, fixed-width format. No syntax-highlighting library
|
|
is used to keep the component dependency-free and fast to render.
|
|
|
|
## Features
|
|
|
|
- Renders `<pre><code>` block with monospace styling
|
|
- Optional `language` label displayed above the code
|
|
- `data-testid="code-block"` for Playwright targeting
|
|
- Horizontally scrollable for wide code snippets
|
|
- Light text on dark background for readability in both themes
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { CodeBlock } from "@suaveplan/testbed/components/shared";
|
|
<CodeBlock language="typescript" code="const key = await crypto.generateKey();" />
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
<CodeBlock
|
|
language="json"
|
|
code={JSON.stringify({ ciphertext: "...", nonce: "..." }, null, 2)}
|
|
/>
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `CodeBlock(props): JSX.Element`
|
|
|
|
- `props.code: string` -- the code string to display
|
|
- `props.language?: string` -- optional language label
|
|
|
|
## Implementation Notes
|
|
|
|
Syntax highlighting is deliberately omitted. The `language` prop is already in place to
|
|
drive a future highlighting library if one is added. Adding `highlight.js` or `prism`
|
|
would add ~50KB to the bundle, which is not justified for a developer-facing demo app.
|
|
|
|
```text
|
|
|
|
#### `code-block.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { CodeBlock } from "./code-block.js";
|
|
|
|
const meta: Meta<typeof CodeBlock> = {
|
|
component: CodeBlock,
|
|
title: "Shared/CodeBlock",
|
|
tags: ["autodocs"],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof CodeBlock>;
|
|
|
|
export const Default: Story = {
|
|
args: {
|
|
language: "typescript",
|
|
code: 'const result = await crypto.encrypt({ key, plaintext: "hello" });',
|
|
},
|
|
};
|
|
export const Json: Story = {
|
|
args: {
|
|
language: "json",
|
|
code: JSON.stringify({ ciphertext: "abc123", nonce: "xyz789" }, null, 2),
|
|
},
|
|
};
|
|
export const Playground: Story = { args: { code: "// Edit me", language: "typescript" } };
|
|
```
|
|
|
|
### `status-badge.tsx`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/shared/status-badge.tsx`
|
|
|
|
```tsx
|
|
type Status = "ok" | "error" | "pending";
|
|
|
|
interface StatusBadgeProps {
|
|
status: Status;
|
|
label?: string;
|
|
}
|
|
|
|
const STATUS_CLASSES: Record<Status, string> = {
|
|
ok: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
|
pending: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
|
};
|
|
|
|
const STATUS_LABELS: Record<Status, string> = {
|
|
ok: "OK",
|
|
error: "ERROR",
|
|
pending: "PENDING",
|
|
};
|
|
|
|
export function StatusBadge({ status, label }: StatusBadgeProps): JSX.Element {
|
|
return (
|
|
<span
|
|
data-testid="status-badge"
|
|
data-status={status}
|
|
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${STATUS_CLASSES[status]}`}
|
|
>
|
|
{label ?? STATUS_LABELS[status]}
|
|
</span>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### `status-badge.md`
|
|
|
|
```markdown
|
|
# status-badge
|
|
|
|
## Purpose
|
|
|
|
`StatusBadge` renders a colored pill badge indicating `ok`, `error`, or `pending` state.
|
|
It is used on library demo pages to communicate at a glance whether a demo operation
|
|
succeeded, failed, or is in progress. E2E tests use `data-status` to assert outcome
|
|
without relying on text content.
|
|
|
|
## Features
|
|
|
|
- Three states: ok (green), error (red), pending (yellow)
|
|
- Optional `label` prop overrides the default text
|
|
- `data-testid="status-badge"` and `data-status` attribute for Playwright
|
|
- Light/dark mode compatible
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { StatusBadge } from "@suaveplan/testbed/components/shared";
|
|
<StatusBadge status="ok" />
|
|
<StatusBadge status="error" label="Connection failed" />
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
// E2E -- assert operation completed successfully
|
|
await page.waitForSelector('[data-testid="status-badge"][data-status="ok"]');
|
|
expect(await page.getAttribute('[data-testid="status-badge"]', 'data-status')).toBe('ok');
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `StatusBadge(props): JSX.Element`
|
|
|
|
- `props.status: "ok" | "error" | "pending"` -- determines colour and default label
|
|
- `props.label?: string` -- overrides the default status label text
|
|
|
|
## Implementation Notes
|
|
|
|
`data-status` is the authoritative attribute for test assertions. This decouples tests from
|
|
label text that might change with localisation.
|
|
|
|
```text
|
|
|
|
#### `status-badge.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { StatusBadge } from "./status-badge.js";
|
|
|
|
const meta: Meta<typeof StatusBadge> = {
|
|
component: StatusBadge,
|
|
title: "Shared/StatusBadge",
|
|
tags: ["autodocs"],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof StatusBadge>;
|
|
|
|
export const Ok: Story = { args: { status: "ok" } };
|
|
export const Error: Story = { args: { status: "error" } };
|
|
export const Pending: Story = { args: { status: "pending" } };
|
|
export const CustomLabel: Story = { args: { status: "error", label: "Connection failed" } };
|
|
export const Playground: Story = { args: { status: "pending", label: "Processing..." } };
|
|
```
|
|
|
|
### Shared barrel `index.ts`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/components/shared/index.ts`
|
|
|
|
```typescript
|
|
export { CodeBlock } from "./code-block.js";
|
|
export { DemoCard } from "./demo-card.js";
|
|
export { StatusBadge } from "./status-badge.js";
|
|
```
|
|
|
|
### Task 3.8 — Home page
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/pages/home/home-page.tsx`
|
|
|
|
```tsx
|
|
export function HomePage(): JSX.Element {
|
|
return (
|
|
<main className="flex-1 p-8">
|
|
<h1 data-testid="page-title" className="text-2xl font-semibold">
|
|
Suaveplan Testbed
|
|
</h1>
|
|
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
|
Select a library from the sidebar to view its interactive demo.
|
|
</p>
|
|
<div data-testid="demo-card" className="mt-6" />
|
|
<div data-testid="console-panel" className="mt-4" />
|
|
</main>
|
|
);
|
|
}
|
|
```
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/pages/home/index.ts`
|
|
|
|
```typescript
|
|
export { HomePage } from "./home-page.js";
|
|
```
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/pages/home/home-page.md`
|
|
|
|
```markdown
|
|
# home-page
|
|
|
|
## Purpose
|
|
|
|
`HomePage` is the default landing page shown when the testbed app loads at `/`.
|
|
It displays a welcome message directing the user to select a library from the sidebar.
|
|
It contains the required `data-testid` attributes (`page-title`, `demo-card`, `console-panel`)
|
|
so that baseline E2E specs that iterate over all routes do not fail when they visit the root.
|
|
|
|
## Features
|
|
|
|
- `data-testid="page-title"` with the app name
|
|
- Stub `data-testid="demo-card"` and `data-testid="console-panel"` for baseline E2E compatibility
|
|
|
|
## Usage: Basic Example
|
|
|
|
```tsx
|
|
import { HomePage } from "@suaveplan/testbed/pages/home";
|
|
<HomePage />
|
|
```
|
|
|
|
## Usage: Advanced Example
|
|
|
|
```tsx
|
|
// E2E -- home page loads correctly
|
|
await page.goto("/");
|
|
await expect(page.locator('[data-testid="page-title"]')).toContainText("Suaveplan Testbed");
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### `HomePage(): JSX.Element`
|
|
|
|
No props.
|
|
|
|
## Implementation Notes
|
|
|
|
The home page does not import any library package. It is intentionally thin -- it exists to
|
|
satisfy the baseline E2E invariant that every route must render `data-testid="page-title"`.
|
|
|
|
```text
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/src/client/pages/home/home-page.stories.tsx`
|
|
|
|
```tsx
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { HomePage } from "./home-page.js";
|
|
|
|
const meta: Meta<typeof HomePage> = {
|
|
component: HomePage,
|
|
title: "Pages/Home",
|
|
tags: ["autodocs"],
|
|
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
|
|
};
|
|
export default meta;
|
|
type Story = StoryObj<typeof HomePage>;
|
|
|
|
export const Default: Story = {};
|
|
export const Playground: Story = {};
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 4 — Storybook Configuration + Final Wiring
|
|
|
|
### Task 4.1 — Storybook `main.ts`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/.storybook/main.ts`
|
|
|
|
```typescript
|
|
import type { StorybookConfig } from "@storybook/react-vite";
|
|
|
|
const config: StorybookConfig = {
|
|
framework: "@storybook/react-vite",
|
|
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
|
addons: [
|
|
"@storybook/addon-essentials",
|
|
"@storybook/addon-interactions",
|
|
"@storybook/test",
|
|
],
|
|
docs: { autodocs: "tag" },
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
### Task 4.2 — Storybook `preview.ts`
|
|
|
|
File: `/mnt/s/web/genesis/apps/testbed/.storybook/preview.ts`
|
|
|
|
```typescript
|
|
import type { Decorator, Preview } from "@storybook/react";
|
|
import { createElement } from "react";
|
|
|
|
/**
|
|
* ThemeDecorator -- renders each story twice side by side:
|
|
* left: data-theme="light"
|
|
* right: data-theme="dark"
|
|
*/
|
|
const ThemeDecorator: Decorator = (Story) =>
|
|
createElement(
|
|
"div",
|
|
{ style: { display: "flex", gap: "16px" } },
|
|
createElement("div", { "data-theme": "light", style: { flex: 1 } }, createElement(Story)),
|
|
createElement("div", {
|
|
"data-theme": "dark",
|
|
style: { flex: 1, backgroundColor: "#09090b", padding: "8px" },
|
|
}, createElement(Story))
|
|
);
|
|
|
|
/**
|
|
* LocaleDecorator -- wraps story with lang="en" as a lightweight i18n context placeholder
|
|
* until @suaveplan/i18n is wired into the testbed.
|
|
*/
|
|
const LocaleDecorator: Decorator = (Story) =>
|
|
createElement("div", { lang: "en" }, createElement(Story));
|
|
|
|
const preview: Preview = {
|
|
decorators: [ThemeDecorator, LocaleDecorator],
|
|
parameters: {
|
|
controls: {
|
|
matchers: {
|
|
color: /(background|color)$/i,
|
|
date: /Date$/i,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
export default preview;
|
|
```
|
|
|
|
### Task 4.3 — Typecheck gate
|
|
|
|
```bash
|
|
bunx tsc --noEmit -p apps/testbed/tsconfig.json
|
|
```
|
|
|
|
Fix all TypeScript errors before proceeding. Common issues to watch for:
|
|
|
|
- `JSX.Element` not found -- add `"jsx": "react-jsx"` to tsconfig and ensure `@types/react` is installed
|
|
- `MemoryRouter` missing -- ensure `react-router-dom` types are in devDependencies
|
|
- Hono `serveStatic` signature mismatch -- adjust the import if using a different Hono Bun adapter
|
|
|
|
### Task 4.4 — Client build smoke test
|
|
|
|
```bash
|
|
cd /mnt/s/web/genesis/apps/testbed && bun run build
|
|
```
|
|
|
|
Must produce `dist/client/index.html` and `dist/client/assets/`. Fix any Vite/Tailwind errors.
|
|
|
|
### Task 4.5 — Storybook build smoke test
|
|
|
|
```bash
|
|
cd /mnt/s/web/genesis/apps/testbed && bun run build-storybook
|
|
```
|
|
|
|
Must complete without errors and produce `storybook-static/`. Fix any decorator type errors
|
|
(if `createElement` signature complains, use JSX syntax instead with a `.tsx` extension for
|
|
`preview.ts` and rename it to `preview.tsx`).
|
|
|
|
---
|
|
|
|
## Post-completion Verification
|
|
|
|
### Part A
|
|
|
|
```bash
|
|
# All tests pass + 100% coverage
|
|
bunx turbo lint typecheck test --filter=@suaveplan/testing-e2e
|
|
bunx turbo test:coverage --filter=@suaveplan/testing-e2e
|
|
```
|
|
|
|
Expected: green on all 4 new modules, 100% coverage thresholds met.
|
|
|
|
### Part B
|
|
|
|
```bash
|
|
# Typecheck
|
|
bunx turbo typecheck --filter=@suaveplan/testbed
|
|
|
|
# Build
|
|
bunx turbo build --filter=@suaveplan/testbed
|
|
```
|
|
|
|
Expected: TypeScript compiles clean, `dist/client/` produced.
|
|
|
|
---
|
|
|
|
## File Inventory
|
|
|
|
### Part A -- New files in `universal/dev/testing-e2e/src/`
|
|
|
|
```text
|
|
multi-session/
|
|
index.ts
|
|
create-multi-session.ts
|
|
create-multi-session.types.ts
|
|
create-multi-session.md
|
|
create-multi-session.test.ts
|
|
console-monitor/
|
|
index.ts
|
|
console-monitor.ts
|
|
console-monitor.types.ts
|
|
console-monitor.md
|
|
console-monitor.test.ts
|
|
network-monitor/
|
|
index.ts
|
|
network-monitor.ts
|
|
network-monitor.types.ts
|
|
network-monitor.md
|
|
network-monitor.test.ts
|
|
extension-fixture/
|
|
index.ts
|
|
launch-with-extension.ts
|
|
launch-with-extension.types.ts
|
|
launch-with-extension.md
|
|
launch-with-extension.test.ts
|
|
```
|
|
|
|
### Part A -- Modified
|
|
|
|
```text
|
|
src/index.ts <- 4 new export blocks
|
|
package.json <- 4 new exports map entries
|
|
```
|
|
|
|
### Part B -- New files in `apps/testbed/`
|
|
|
|
```text
|
|
package.json
|
|
tsconfig.json
|
|
vite.config.ts
|
|
playwright.config.ts
|
|
Dockerfile
|
|
index.html
|
|
.storybook/
|
|
main.ts
|
|
preview.ts
|
|
src/server/
|
|
index.ts
|
|
routes/api/
|
|
storage.ts
|
|
database.ts
|
|
test-control.ts
|
|
src/client/
|
|
main.tsx
|
|
app.tsx
|
|
routes.ts
|
|
styles/globals.css
|
|
pages/home/
|
|
index.ts
|
|
home-page.tsx
|
|
home-page.md
|
|
home-page.stories.tsx
|
|
components/layout/
|
|
index.ts
|
|
sidebar.tsx + sidebar.md + sidebar.stories.tsx
|
|
header.tsx + header.md + header.stories.tsx
|
|
theme-toggle.tsx + theme-toggle.md + theme-toggle.stories.tsx
|
|
locale-switcher.tsx + locale-switcher.md + locale-switcher.stories.tsx
|
|
console-panel.tsx + console-panel.md + console-panel.stories.tsx
|
|
components/shared/
|
|
index.ts
|
|
demo-card.tsx + demo-card.md + demo-card.stories.tsx
|
|
code-block.tsx + code-block.md + code-block.stories.tsx
|
|
status-badge.tsx + status-badge.md + status-badge.stories.tsx
|
|
```
|
|
|
|
---
|
|
|
|
## Known Constraints and Edge Cases
|
|
|
|
1. **`launchWithExtension` -- `headless: false`:** Chromium does not load extensions in
|
|
headless mode. In CI without a display, use `DISPLAY=:99` with Xvfb. The extension test
|
|
Playwright project runs on Chromium only (MV3 requirement).
|
|
|
|
2. **Tailwind v4 -- no config file:** Tailwind v4 uses `@import "tailwindcss"` in CSS with
|
|
the `@tailwindcss/vite` plugin. No `tailwind.config.js` is required. Custom tokens go
|
|
in CSS via `@theme {}` blocks.
|
|
|
|
3. **Hono `serveStatic` path:** The `root: "./dist/client"` path is relative to the process
|
|
working directory. When running `bun run dev` from `apps/testbed/`, this resolves
|
|
correctly. Add `"cwd": "apps/testbed"` to the turbo task config if cwd is wrong.
|
|
|
|
4. **React Router v7 SPA fallback:** All non-API routes must serve `index.html` for client-
|
|
side routing to work. The `app.get("*", serveStatic({ path: "./dist/client/index.html" }))`
|
|
line in the Hono server handles this.
|
|
|
|
5. **`bun.lockb` update:** After adding `apps/testbed/package.json` and running `bun install`
|
|
from the monorepo root, commit the updated `bun.lockb`. Use `bun install` (not
|
|
`--frozen-lockfile`) when adding new dependencies.
|
|
|
|
6. **Storybook `preview.ts` vs `preview.tsx`:** If TypeScript complains about JSX in
|
|
`preview.ts`, rename to `preview.tsx` and use JSX syntax directly instead of
|
|
`createElement` calls. Update `main.ts` stories pattern if needed.
|
|
|
|
7. **`apps/*` workspace registration:** Bun workspaces must include `"apps/*"` in the root
|
|
`package.json`. If absent, `bun install` will not link `@suaveplan/testbed` as a workspace
|
|
package and the `workspace:^` reference to `@suaveplan/testing-e2e` will fail to resolve.
|