# Enterprise Testbed Design **Date:** 2026-04-24 **Status:** Approved — ready for implementation planning **Branch:** genesis --- ## 1. Purpose The genesis monorepo contains ~356 packages spanning cryptography, databases, storage, networking, React components, WebRTC mesh, and browser extensions. Today every package uses fake clients, in-memory stubs, or no integration tests at all. This spec defines the complete testbed: real-service integration fixtures, a live showcase application, and a comprehensive Playwright E2E suite that tests every library against the real infrastructure it runs in production. An AI agent reading this document cold must be able to implement every package described here without asking clarifying questions. All decisions are final. --- ## 2. Scope This spec covers four sub-projects, delivered in dependency order: | # | Sub-project | Output | |---|---|---| | 1 | Integration fixture packages | `@suaveplan/testing-integration` + six service fixture packages | | 2 | Docker Compose infrastructure | `docker/` workspace — dev, test, and staging compose files | | 3 | Showcase application | `apps/testbed/` — Bun + Hono + React 19 + Tailwind | | 4 | E2E enhancements + test suite | `@suaveplan/testing-e2e` additions + `apps/testbed/e2e/` specs | Sub-projects 1 and 2 are independent and build in parallel. Sub-project 3 depends on sub-project 2. Sub-project 4 depends on sub-projects 1, 2, and 3. --- ## 3. Constraints and Non-Negotiables These rules apply to every package in this spec. They derive from `CLAUDE.md` and the user's stated requirements. 1. **100% test coverage** — `provider: 'istanbul'` on every package. No exceptions. 2. **No mocks of real services** — unit tests use constructor-injected fakes for the Docker layer only; integration tests hit live containers. 3. **Every implementation `.ts`/`.tsx` file has a sibling `.md`** — purpose, features, basic example, advanced example, API reference, implementation notes. Minimum 200 words excluding code blocks. Exclusions per CLAUDE.md: `.test.ts`, `.test.tsx`, `.spec.ts`, `.types.ts` (inside a feature dir), `.stories.tsx`, barrel `index.ts`, `constants/index.ts`. 4. **Every feature directory has a barrel `index.ts`** — re-exports the public surface of that feature only. 5. **Package root `index.ts`** — re-exports every feature barrel. 6. **No category-level `README.md` files inside `src/`** — one `README.md` at the package root only. 7. **Biome** for lint and format — no ESLint, no Prettier. 8. **Vitest** for tests — pool `forks`, `provider: 'istanbul'`, always `{ timeout: 5000 }` on unit tests, `{ timeout: 30000 }` on async/integration. 9. **`bunx turbo lint typecheck test`** must pass green before any package is considered shippable. 10. **`skipIfUnavailable`** guard on every integration test suite — Docker absence skips, never fails. 11. Plugin packages (`testing-integration-*`) depend on `@suaveplan/testing-integration` for the `ServiceFixture` interface. They never import from the runtime library they serve. Plugin packages return plain config objects (strings, numbers, URLs) only. 12. Port assignments follow `../suaveplan/README.md` ranges: `3001–3999` applications, `6001–6999` Storybook, `7001–7999` test services, `8001–8999` backend services. 13. **Update `CLAUDE.md`** — when any new package in this spec ships, add it to the packages listing in `CLAUDE.md` and the `README.md` table. Also add `apps/testbed/` and `docker/` to `CLAUDE.md`. --- ## 4. Sub-project 1 — Integration Fixture Packages ### 4.1 Package list All packages live under `universal/dev/` — the same domain used by `testing`, `testing-dom`, and `testing-e2e`. | Package name | Path | Purpose | |---|---|---| | `@suaveplan/testing-integration` | `universal/dev/testing-integration/` | Core: `ServiceFixture` interface, `TestEnvironment`, `createIntegrationSetup()` factory, `skipIfUnavailable()` helper, Docker health poller | | `@suaveplan/testing-integration-garage-s3` | `universal/dev/testing-integration-garage-s3/` | Garage S3 v2.0.0 fixture | | `@suaveplan/testing-integration-surrealdb` | `universal/dev/testing-integration-surrealdb/` | SurrealDB v3.0.4 fixture | | `@suaveplan/testing-integration-redis` | `universal/dev/testing-integration-redis/` | Redis 7 fixture | | `@suaveplan/testing-integration-postgres` | `universal/dev/testing-integration-postgres/` | PostgreSQL 16 fixture | | `@suaveplan/testing-integration-mailpit` | `universal/dev/testing-integration-mailpit/` | Mailpit (local SMTP trap) fixture | | `@suaveplan/testing-integration-typesense` | `universal/dev/testing-integration-typesense/` | Typesense 27 fixture | ### 4.2 Dependency graph ```text @suaveplan/testing-integration-garage-s3 @suaveplan/testing-integration-surrealdb @suaveplan/testing-integration-redis @suaveplan/testing-integration-postgres @suaveplan/testing-integration-mailpit @suaveplan/testing-integration-typesense └── @suaveplan/testing-integration (workspace:^) └── testcontainers (external, catalog) └── @suaveplan/types (workspace:^ — permitted exception) ``` **Note on `@suaveplan/types` exception:** CLAUDE.md Rule 8 states the `@suaveplan/types` exception applies because `@suaveplan/types` ships zero runtime code and therefore cannot introduce the circular testing-of-testing hazard. This reasoning applies equally to `@suaveplan/testing-integration` — the exception is based on the nature of the `types` package, not on which testing packages are named in Rule 8. The exception is valid here. Runtime packages (e.g. `@suaveplan/storage-s3`, `@suaveplan/surrealdb`) add fixture packages as `devDependencies` only. ### 4.3 `@suaveplan/testing-integration` — core package #### Source layout ```text src/ fixture/ index.ts service-fixture.ts ← ServiceFixture interface + InferConfig utility type service-fixture.types.ts service-fixture.md service-fixture.test.ts environment/ index.ts test-environment.ts ← TestEnvironment class test-environment.types.ts test-environment.md test-environment.test.ts setup/ index.ts create-integration-setup.ts ← factory + globalSetup module + typed inject wrapper create-integration-setup.types.ts create-integration-setup.md create-integration-setup.test.ts global-setup.ts ← static module shipped with the package (see mechanism below) helpers/ index.ts ← barrel: re-exports skip-if-unavailable + health-poller skip-if-unavailable.ts skip-if-unavailable.md skip-if-unavailable.test.ts health-poller.ts health-poller.md health-poller.test.ts index.ts ``` #### `ServiceFixture` interface ```typescript // src/fixture/service-fixture.ts export interface ServiceFixture> { /** Unique name used in log output and skip messages. e.g. "garage-s3" */ readonly name: string; /** * Starts the service. If a matching env-var override is present, * uses the already-running service instead of spinning a container. * Resolves with the config once the service passes healthCheck(). * In env-bridge passthrough mode, healthCheck() is NOT called — * the service is assumed healthy by virtue of env vars being set. */ start(): Promise; /** * Stops the container. No-op when env-var override was used. */ stop(): Promise; /** * Returns true when the service is ready to accept connections. * Only called by HealthPoller when spinning a new container. * Never called in env-bridge passthrough mode. */ healthCheck(): Promise; } /** Extracts the TConfig type from a ServiceFixture implementation. */ export type InferConfig = F extends ServiceFixture ? C : never; ``` #### `TestEnvironment` class ```typescript // src/environment/test-environment.ts export class TestEnvironment { constructor( private readonly fixtures: Record>>, private readonly options: TestEnvironmentOptions = {} ) {} /** * Starts all fixtures in parallel. * On any failure, stops already-started fixtures before throwing. * Calls Vitest's provide() with: * - "__integration_configs__": Record> — the collected configs * - "__integration_failed__": string[] — names of fixtures that failed to start * Both keys are used by skipIfUnavailable() and inject() in test workers. */ async setup(): Promise; /** * Stops all fixtures in reverse start order. */ async teardown(): Promise; } ``` Behaviour details: - Default timeout: 120 seconds per fixture. - Health poll interval: 1 second. - On partial failure: stops all successfully started fixtures before re-throwing. - Failed fixture names are recorded and provided via `provide("__integration_failed__", [...])`. #### `createIntegrationSetup()` factory — mechanism `createIntegrationSetup()` works as follows: 1. The package ships a static `src/setup/global-setup.ts` that reads fixtures from a module-level `Map` (named `_fixtureRegistry`) and calls `new TestEnvironment(registry).setup()` / `.teardown()`. 2. `createIntegrationSetup(fixtures)` writes each fixture into `_fixtureRegistry` (a module-level `Map` exported from `create-integration-setup.ts`), then returns: - `globalSetup`: the **absolute file path** to the compiled `global-setup.js` inside the package's `dist/` directory (resolved via `import.meta.resolve` or `require.resolve`). - `inject`: a typed wrapper around Vitest's `inject()` that reads from `"__integration_configs__"` and casts to the correct config type using `InferConfig`. 3. The consumer's `vitest.config.ts` sets `test.globalSetup` to the returned path. Vitest runs `global-setup.ts` in the main process, which calls `TestEnvironment.setup()`, which calls Vitest's `provide()`. Test worker processes then call the typed `inject()` wrapper. **Why module-level registry works across config and globalSetup:** Vitest evaluates `vitest.config.ts` and loads `globalSetup` in the same main Node.js process. Both the vitest config (which calls `createIntegrationSetup()` via `@suaveplan/testing-integration`) and `global-setup.js` (which imports `./create-integration-setup.js` via relative path) ultimately resolve to the same filesystem path: `dist/setup/create-integration-setup.js`. Node.js/Bun ESM caches modules by resolved URL — one instance is shared. Mutations made during config evaluation (`_fixtureRegistry.set(...)`) are visible when `global-setup.ts` reads `_fixtureRegistry` during Vitest startup. This is **not** the same situation as test workers (which run in separate forked processes and receive data only via `inject()`). An implementer note: if the package is not yet built (`dist/` absent), the monorepo's Turborepo dependency graph ensures `@suaveplan/testing-integration` is built before any consumer package's vitest runs. The `globalSetup` path must therefore be resolved via `import.meta.resolve("@suaveplan/testing-integration/setup/global-setup")` or an equivalent resolver that traverses the package's `exports` map — never hardcoded. ```typescript // src/setup/create-integration-setup.ts /** Module-level registry — populated before globalSetup runs. */ export const _fixtureRegistry = new Map>>(); /** * Registers fixtures and returns a Vitest globalSetup path + typed inject wrapper. * * @example * // vitest.config.ts * import { createIntegrationSetup } from "@suaveplan/testing-integration"; * import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3"; * * const { globalSetup, inject } = createIntegrationSetup({ * s3: new GarageS3Fixture({ bucket: "test-bucket" }), * }); * export { inject }; * export default defineConfig({ test: { globalSetup } }); * * // a test file * import { inject } from "../vitest.config.js"; * const { endpoint, bucket } = inject("s3"); */ export function createIntegrationSetup< TFixtures extends Record>> >(fixtures: TFixtures): { globalSetup: string; inject: (key: K) => InferConfig; }; ``` ```typescript // src/setup/global-setup.ts — static module shipped with the package import { _fixtureRegistry } from "./create-integration-setup.js"; import { TestEnvironment } from "../environment/test-environment.js"; let env: TestEnvironment; export async function setup() { const fixtures = Object.fromEntries(_fixtureRegistry); env = new TestEnvironment(fixtures); await env.setup(); } export async function teardown() { await env?.teardown(); } ``` #### `skipIfUnavailable()` helper `skipIfUnavailable` reads from Vitest's `inject("__integration_failed__")` to determine whether the named fixture failed to start. It returns a `beforeAll`-compatible callback that calls `ctx.skip()` on the Vitest suite context — the only documented API for skipping a suite from within `beforeAll`. ```typescript // src/helpers/skip-if-unavailable.ts import { inject } from "vitest"; /** * Returns a beforeAll callback that skips the suite when the named fixture failed to start. * Uses the Vitest SuiteContext.skip() API — the only documented way to skip a suite * from within beforeAll(). Do NOT call as a plain function; always pass to beforeAll(). * * @example * describe("S3 integration", () => { * beforeAll(skipIfUnavailable("garage-s3")); * ... * }); */ export function skipIfUnavailable(fixtureName: string): (ctx: { skip: () => void }) => void { return ({ skip }) => { const failed: string[] = inject("__integration_failed__") ?? []; if (failed.includes(fixtureName)) { skip(); } }; } ``` **Important:** `skipIfUnavailable()` returns a callback — it does not call `skip()` itself. Callers must pass the returned function to `beforeAll(...)`. Calling it as `beforeAll(() => skipIfUnavailable("garage-s3"))` is wrong because the inner callback never receives the suite context. Calling it as `beforeAll(skipIfUnavailable("garage-s3"))` is correct. #### `HealthPoller` ```typescript // src/helpers/health-poller.ts /** * Polls fixture.healthCheck() every intervalMs until it returns true * or timeoutMs elapses. Throws TestEnvironmentError on timeout. * Uses an injectable timer function for 100% unit-test coverage without real delays. */ export class HealthPoller { constructor(options: HealthPollerOptions); poll(fixture: ServiceFixture>): Promise; } export interface HealthPollerOptions { timeoutMs?: number; // default: 120_000 intervalMs?: number; // default: 1_000 /** Injectable sleep function — defaults to real setTimeout, overridden in tests. */ sleep?: (ms: number) => Promise; } ``` #### Environment variable bridge Every fixture checks for env-var overrides **before** starting a container. When overrides are present, the fixture constructs config from env vars, marks itself as a passthrough (so `stop()` is a no-op), and **skips `healthCheck()` entirely** — the service is assumed healthy by virtue of the env vars being explicitly set by the developer. Override variable names per fixture: | Fixture | Env vars checked | |---|---| | `garage-s3` | `GARAGE_S3_ENDPOINT`, `GARAGE_S3_ADMIN_ENDPOINT`, `GARAGE_S3_ACCESS_KEY_ID`, `GARAGE_S3_SECRET_ACCESS_KEY` | | `surrealdb` | `SURREALDB_URL`, `SURREALDB_USERNAME`, `SURREALDB_PASSWORD` | | `redis` | `REDIS_URL` | | `postgres` | `POSTGRES_URL` | | `mailpit` | `MAILPIT_SMTP_URL`, `MAILPIT_API_URL` | | `typesense` | `TYPESENSE_URL`, `TYPESENSE_API_KEY` | A fixture enters passthrough mode only when **all required env vars** for that fixture are set and non-empty. A partial set (e.g. `GARAGE_S3_ENDPOINT` set but `GARAGE_S3_ACCESS_KEY_ID` missing) causes the fixture to fall through to container startup. #### Testing strategy The core package achieves 100% coverage through unit tests only — no Docker required. Unit tests cover: - `HealthPoller`: resolves on first `true`; retries on `false`; throws on timeout; injectable `sleep` removes real delays. - `TestEnvironment`: parallel start; failure stops already-started fixtures; teardown in reverse order; `provide()` called with configs and failed names. - `createIntegrationSetup()`: populates `_fixtureRegistry`; returns path to `global-setup.js`; `inject()` wrapper reads from `"__integration_configs__"`. - `skipIfUnavailable()`: skips suite when fixture name appears in `"__integration_failed__"`; does not skip when absent. ### 4.4 Plugin package structure (all six share this shape) ```text src/ fixture/ index.ts -fixture.ts -fixture.types.ts -fixture.md -fixture.test.ts constants/ index.ts ← re-exports all constants; no .md required (constants/index.ts exemption per CLAUDE.md) index.ts ``` The Mailpit plugin extends this with a `helpers/` directory (see Section 4.9). ### 4.5 `@suaveplan/testing-integration-garage-s3` **Image:** `ghcr.io/deuxfleurs/garage:v2.0.0` **Config shape:** ```typescript export interface GarageS3Config { endpoint: string; // "http://localhost:" adminEndpoint: string; // "http://localhost:" region: string; // always "garage" bucket: string; accessKeyId: string; secretAccessKey: string; } ``` **Container ports:** Two host ports are bound — one for the S3 API (container port `3900`) and one for the admin API (container port `3902`). Both are assigned random host ports by testcontainers. **Container setup sequence (exact commands):** After the container starts and passes its health check, the fixture runs the following commands inside the container using the testcontainers container command execution API: 1. `garage status` — capture output, extract the node ID from the first data row (format: ` `). 2. `garage layout assign -z default -c 1G ` — assign the node to zone `default` with 1 GB capacity. 3. `garage layout apply --version 1` — apply the layout. 4. `garage bucket create ` — create the bucket (bucket name comes from `GarageS3Options.bucket`). 5. `garage key create test-key` — create an access key named `test-key`. 6. `garage bucket allow --read --write --key test-key` — grant the key full access. 7. `garage key info test-key` — parse output to extract `Key ID:` and `Secret key:` lines. **Health check:** `HTTP GET http://localhost:/health` → `200`. Note: the Garage S3 API port (`3900`) does NOT expose `/health`. Only the admin API port (`3902`) does. The health poller targets `adminEndpoint`, not `endpoint`. **Tests:** constructor-injected `GenericContainer` fake covers all lifecycle paths; one Docker integration test (guarded by `skipIfUnavailable`) verifies a real bucket operation end-to-end. ### 4.6 `@suaveplan/testing-integration-surrealdb` **Image:** `surrealdb/surrealdb:v3.0.4` **Config shape:** ```typescript export interface SurrealDBConfig { url: string; // "ws://localhost:" namespace: string; database: string; username: string; // "root" password: string; // generated per run via crypto.randomUUID() } ``` **Container command:** `start --auth --user root --pass ` **Health check:** `HTTP GET http://localhost:/health` → `200`. The HTTP port is the same container port as the WebSocket port (`8000`). The `url` in config uses `ws://` protocol; the health check uses `http://` on the same mapped host port. ### 4.7 `@suaveplan/testing-integration-redis` **Image:** `redis:7-alpine` **Config shape:** ```typescript export interface RedisConfig { url: string; // "redis://localhost:" host: string; port: number; } ``` **Health check:** TCP connect to the mapped host port; send `PING\r\n`; expect response starting with `+PONG`. ### 4.8 `@suaveplan/testing-integration-postgres` **Image:** `postgres:16-alpine` **Config shape:** ```typescript export interface PostgresConfig { url: string; // "postgresql://test:test@localhost:/test" host: string; port: number; database: string; username: string; password: string; } ``` **Health check:** Run `pg_isready -U postgres` inside the container using the testcontainers container command execution API. Returns `true` when exit code is `0`. ### 4.9 `@suaveplan/testing-integration-mailpit` **Image:** `axllent/mailpit:latest` **Config shape:** ```typescript export interface MailpitConfig { smtpUrl: string; // "smtp://localhost:" apiUrl: string; // "http://localhost:" } ``` **Container ports:** Two host ports — SMTP (container port `1025`) and web/API (container port `8025`). **Health check:** `HTTP GET /api/v1/messages` → `200`. **Extended source layout** (Mailpit adds a `helpers/` directory beyond the base plugin template): ```text src/ fixture/ index.ts mailpit-fixture.ts mailpit-fixture.types.ts mailpit-fixture.md mailpit-fixture.test.ts helpers/ index.ts ← barrel: re-exports assert-mail assert-mail.ts assert-mail.md assert-mail.test.ts ← uses constructor-injected fetch fake; no live Mailpit needed constants/ index.ts ← re-exports all constants; no .md required (constants/index.ts exemption) index.ts ``` **`assertMail()` helper:** ```typescript // src/helpers/assert-mail.ts /** * Queries the Mailpit REST API to assert that a specific email was received. * Throws AssertionError with details if no matching message is found. * * @example * await assertMail(config.apiUrl, { to: "user@example.com", subject: "Welcome" }); */ export async function assertMail( apiUrl: string, criteria: { to: string; subject: string }, options?: { timeoutMs?: number; fetchFn?: typeof fetch } ): Promise; ``` The `fetchFn` parameter enables 100% unit-test coverage without a live Mailpit instance. ### 4.10 `@suaveplan/testing-integration-typesense` **Image:** `typesense/typesense:27.1` **Config shape:** ```typescript export interface TypesenseConfig { url: string; // "http://localhost:" apiKey: string; // generated per run via crypto.randomUUID() } ``` **Container command:** `--data-dir /data --api-key= --enable-cors` **Health check:** `HTTP GET /health` → `{ ok: true }`. --- ## 5. Sub-project 2 — Docker Compose Infrastructure ### 5.1 Workspace ```text docker/ package.json ← name: "@suaveplan/docker", private: true, no src docker-compose.dev.yml docker-compose.test.yml ← mirrors dev compose with ports substituted (7004–7011), no volumes docker-compose.testbed.yml Caddyfile ← reverse proxy config for testbed staging .env.example README.md ``` `package.json` exposes turbo-compatible scripts: ```json { "name": "@suaveplan/docker", "private": true, "scripts": { "dev:up": "docker compose -f docker-compose.dev.yml up -d", "dev:down": "docker compose -f docker-compose.dev.yml down", "test:up": "docker compose -f docker-compose.test.yml up -d", "test:down": "docker compose -f docker-compose.test.yml down", "testbed:up": "docker compose -f docker-compose.testbed.yml up --build -d", "testbed:down": "docker compose -f docker-compose.testbed.yml down" } } ``` ### 5.2 Port allocations All ports follow `../suaveplan/README.md` ranges. **Dev stack — `8001–8999` (backend services, persistent volumes):** | Port | Service | |---|---| | 8001 | SurrealDB | | 8002 | Redis | | 8003 | PostgreSQL | | 8004 | Mailpit SMTP | | 8005 | Mailpit Web UI | | 8006 | Typesense | | 8900 | Garage S3 API | | 8902 | Garage S3 Admin | **Test stack — `7004–7999` (7001–7003 already allocated, ephemeral, no volumes):** | Port | Service | |---|---| | 7004 | SurrealDB | | 7005 | Redis | | 7006 | PostgreSQL | | 7007 | Mailpit SMTP | | 7008 | Mailpit Web UI | | 7009 | Typesense | | 7010 | Garage S3 API | | 7011 | Garage S3 Admin | **Testbed app:** | Port | Service | |---|---| | 3200 | `apps/testbed/` HTTP | | 6042 | Storybook (next available slot after 6041) | ### 5.3 `docker-compose.dev.yml` Persistent local development stack. Volumes survive restarts. Ports match env-bridge defaults. ```yaml services: surrealdb: image: surrealdb/surrealdb:v3.0.4 command: start --auth --user root --pass root ports: ["8001:8000"] volumes: ["surrealdb-data:/data"] healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 5s timeout: 3s retries: 5 redis: image: redis:7-alpine ports: ["8002:6379"] volumes: ["redis-data:/data"] postgres: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres ports: ["8003:5432"] volumes: ["postgres-data:/var/lib/postgresql/data"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 5s timeout: 3s retries: 5 mailpit: image: axllent/mailpit:latest ports: - "8004:1025" - "8005:8025" typesense: image: typesense/typesense:27.1 environment: TYPESENSE_DATA_DIR: /data TYPESENSE_API_KEY: test ports: ["8006:8108"] volumes: ["typesense-data:/data"] garage-s3: image: ghcr.io/deuxfleurs/garage:v2.0.0 ports: - "8900:3900" - "8902:3902" volumes: ["garage-data:/data"] environment: GARAGE_ALLOW_WORLD_READABLE_CONFIG: "true" volumes: surrealdb-data: redis-data: postgres-data: typesense-data: garage-data: ``` ### 5.4 `docker-compose.test.yml` Identical structure to `docker-compose.dev.yml` with two differences: ports use the `7004–7011` range (substituted mechanically from the dev port table), and no `volumes:` block — all containers run with ephemeral storage. This file is used by CI and by `bun run test:up` for local integration test runs outside of testcontainers. ### 5.5 `docker-compose.testbed.yml` Full staging environment for `apps/testbed/`. Adds the testbed app container, a Caddy reverse proxy, and a shared internal Docker network. All services communicate by service name on the internal network. Mailpit is included so email-related library pages work. ```yaml networks: testbed-net: services: surrealdb: image: surrealdb/surrealdb:v3.0.4 command: start --auth --user root --pass root networks: [testbed-net] redis: image: redis:7-alpine networks: [testbed-net] postgres: image: postgres:16-alpine environment: { POSTGRES_PASSWORD: postgres } networks: [testbed-net] mailpit: image: axllent/mailpit:latest networks: [testbed-net] garage-s3: image: ghcr.io/deuxfleurs/garage:v2.0.0 networks: [testbed-net] typesense: image: typesense/typesense:27.1 environment: { TYPESENSE_API_KEY: test } networks: [testbed-net] testbed: build: context: ../apps/testbed dockerfile: Dockerfile environment: SURREALDB_URL: ws://surrealdb:8000 GARAGE_S3_ENDPOINT: http://garage-s3:3900 GARAGE_S3_ADMIN_ENDPOINT: http://garage-s3:3902 REDIS_URL: redis://redis:6379 POSTGRES_URL: postgresql://postgres:postgres@postgres:5432/postgres TYPESENSE_URL: http://typesense:8108 TYPESENSE_API_KEY: test MAILPIT_SMTP_URL: smtp://mailpit:1025 MAILPIT_API_URL: http://mailpit:8025 networks: [testbed-net] depends_on: [surrealdb, redis, postgres, garage-s3, typesense, mailpit] caddy: image: caddy:2-alpine ports: ["3200:80", "3201:443"] volumes: ["./Caddyfile:/etc/caddy/Caddyfile"] networks: [testbed-net] ``` ### 5.6 `.env.example` Documents every env-bridge override. Developers copy this to `.env` and populate it to point integration tests at their live dev stack. When all vars for a fixture are set, testcontainers skips container startup for that fixture. ```bash # Set these to use your already-running local services instead of spinning containers. # A fixture only enters passthrough mode when ALL its required vars are set and non-empty. SURREALDB_URL=ws://localhost:8001 SURREALDB_USERNAME=root SURREALDB_PASSWORD=root REDIS_URL=redis://localhost:8002 POSTGRES_URL=postgresql://postgres:postgres@localhost:8003/postgres MAILPIT_SMTP_URL=smtp://localhost:8004 MAILPIT_API_URL=http://localhost:8005 TYPESENSE_URL=http://localhost:8006 TYPESENSE_API_KEY=test GARAGE_S3_ENDPOINT=http://localhost:8900 GARAGE_S3_ADMIN_ENDPOINT=http://localhost:8902 GARAGE_S3_ACCESS_KEY_ID= GARAGE_S3_SECRET_ACCESS_KEY= ``` ### 5.7 Port table update When this sub-project ships, add the following rows to the Active Test Ports table in `../suaveplan/README.md`: ```text | 7004–7011 | Integration test services | docker/docker-compose.test.yml | | 8001–8006, 8900, 8902 | Dev services | docker/docker-compose.dev.yml | | 3200 | apps/testbed | docker/docker-compose.testbed.yml | | 6042 | apps/testbed Storybook | apps/testbed/.storybook | ``` --- ## 6. Sub-project 3 — `apps/testbed/` Showcase Application ### 6.1 Purpose `apps/testbed/` is both a live demonstration of every library and the sole target for all Playwright E2E tests. Every library page drives real library code against real services — nothing is mocked or faked. ### 6.2 Technology stack | Concern | Choice | |---|---| | Runtime | Bun | | Server | Hono | | Client | React 19 | | Styling | Tailwind v4 | | Bundler (client) | Vite | | Component isolation | Storybook 8 (framework: `@storybook/react-vite`, port 6042) | | E2E | Playwright | ### 6.3 Storybook 8 configuration `.storybook/main.ts` content: ```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; ``` `.storybook/preview.ts` registers two global decorators: - `ThemeDecorator` — wraps each story in a `
` and a `
` side-by-side. - `LocaleDecorator` — wraps each story with an i18n provider seeded with the `en` locale. Required packages: `@storybook/react-vite`, `@storybook/addon-essentials`, `@storybook/addon-interactions`, `@storybook/test`. ### 6.4 Workspace structure ```text apps/testbed/ package.json vite.config.ts playwright.config.ts Dockerfile .storybook/ main.ts preview.ts e2e/ ← all Playwright specs (see sub-project 4) src/ server/ index.ts ← Hono entry point; serves Vite SPA + API routes routes/ api/ storage.ts ← proxy routes for storage-s3 demo database.ts ← proxy routes for surrealdb demo test-control.ts ← enables/disables routes for E2E failure simulation client/ main.tsx app.tsx ← React Router v7 + top-level layout routes.ts ← ROUTE MANIFEST — exported array of { path, label, category } pages/ home/ index.ts home-page.tsx home-page.md home-page.stories.tsx crypto/ index.ts crypto-page.tsx crypto-page.md crypto-page.stories.tsx # one folder per library; every .tsx has a sibling .md and a .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 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 styles/ globals.css ``` ### 6.5 Route manifest `src/client/routes.ts` exports a typed array of every library page. Baseline E2E specs import this array to enumerate pages dynamically — no hardcoded route lists in test files. ```typescript // src/client/routes.ts export interface RouteEntry { path: string; // e.g. "/crypto" label: string; // e.g. "@suaveplan/crypto" category: string; // e.g. "crypto" /** When true, this route requires Docker services to function correctly. */ requiresServices?: boolean; } export const routes: RouteEntry[] = [ { path: "/crypto", label: "@suaveplan/crypto", category: "crypto" }, { path: "/crypto-hash", label: "@suaveplan/crypto-hash", category: "crypto" }, { path: "/logger", label: "@suaveplan/logger", category: "observability" }, // ... one entry per library page ... ]; ``` Baseline specs iterate `routes` to load every page and assert zero console errors / zero network failures. ### 6.6 Route structure Every genesis library gets a route at `/` (without the `@suaveplan/` prefix). The sidebar groups routes by category matching the monorepo's `universal//` structure. ### 6.7 Standard page anatomy Every library page follows an identical visual contract. Tests rely on this contract. ```text ┌─ sidebar ─────────────┬─ main ─────────────────────────────────────────┐ │ ▾ crypto │ @suaveplan/crypto │ │ crypto-hash │ │ │ crypto-curve │ [purpose sentence] [source link] │ │ crypto-cipher │ │ │ ▾ database │ ┌─ demo-card ─────────────────────────────┐ │ │ surrealdb │ │ [interactive inputs] │ │ │ db-sqlite │ │ [live output — data-testid="output"] │ │ │ ▾ storage │ └─────────────────────────────────────────┘ │ │ storage-s3 │ │ │ storage │ ┌─ console-panel ─────────────────────────┐ │ │ ... │ │ [INFO] encrypt() called │ │ │ │ │ [INFO] result: { ... } │ │ └───────────────────────┴──┴─────────────────────────────────────────┴───┘ ``` **Required `data-testid` attributes on every page:** | Attribute | Element | |---|---| | `data-testid="page-title"` | `

` with the package name | | `data-testid="demo-card"` | Outer wrapper of each demo card | | `data-testid="output"` | Live output display inside demo card | | `data-testid="console-panel"` | Console capture panel | Playwright tests locate elements exclusively by these `data-testid` attributes — never by CSS class or visible text content. ### 6.8 Console panel `console-panel.tsx` intercepts `window.console.log`, `warn`, and `error` on mount (restoring originals on unmount) and appends each message to an in-component log displayed below each demo. This mirrors exactly what Playwright's console monitor captures. ### 6.9 Theme and locale **Theme:** `light` / `dark`. Toggled by the header toggle button. State persisted to `localStorage` under key `theme`. Applied as a `data-theme` attribute on ``. Tailwind uses this attribute for dark mode variants. **Locale:** Minimum four locales: `en`, `fr`, `de`, `ja`. Wired to `@suaveplan/i18n`. Switched by the locale switcher in the header. State persisted to `localStorage` under key `locale`. Every string in the UI routes through the i18n layer — no hardcoded English strings outside the `en` message catalog. ### 6.10 Multi-session pages Library pages that exercise networking (WebRTC, WebSocket, BroadcastChannel) expose `?role=&session=` query parameters. Playwright opens multiple browser contexts against the same route with different roles. The Hono server exposes a `POST /api/test-control/disable-route` endpoint. This endpoint is only registered when `NODE_ENV === "test"` — it is absent in production builds. E2E tests call this endpoint to simulate server failure during multi-session WebRTC tests, then assert that peer-to-peer connectivity holds without the signalling server. ### 6.11 Extension test page `/extension-test` contains: - A login form: `data-testid="login-form"`, `data-testid="username-field"`, `data-testid="password-field"` - A standalone password field: `data-testid="password-field-standalone"` - An injection target: `data-testid="extension-injected"` — the extension content script must populate this element --- ## 7. Sub-project 4 — Playwright E2E Strategy ### 7.1 Guiding rule A 200 response proves nothing. Every test must verify rendered DOM content, zero unexpected console errors, and zero failed network requests. ### 7.2 Enhancements to `@suaveplan/testing-e2e` Four new modules added to the existing package at `universal/dev/testing-e2e/src/`: #### `multi-session/` ```text src/multi-session/ index.ts create-multi-session.ts create-multi-session.types.ts create-multi-session.md create-multi-session.test.ts ``` ```typescript /** * Opens n isolated browser contexts against the same base URL. * Each context gets a unique role appended as ?role=. * * @example * const { pages, teardown } = await createMultiSession({ * browser, * count: 3, * baseUrl: "http://localhost:3200/network-webrtc?session=abc", * roles: ["host", "peer", "peer"], * }); * await Promise.all(pages.map(p => p.waitForSelector('[data-status="connected"]'))); * await teardown(); */ export async function createMultiSession(options: MultiSessionOptions): Promise; ``` Also exports `simulateServerFailure(apiBaseUrl: string, route: string)` — sends `POST /api/test-control/disable-route` and asserts 204. #### `console-monitor/` ```text src/console-monitor/ index.ts console-monitor.ts console-monitor.types.ts console-monitor.md console-monitor.test.ts ``` ```typescript /** * Attaches to a Playwright page and captures all console output. * Fails the test on any message at the "error" level or above. * * @example * const monitor = attachConsoleMonitor(page, { * failOn: ["error"], * ignore: [/Download the React DevTools/], * }); * // interactions here * await monitor.assertClean(); */ export function attachConsoleMonitor(page: Page, options?: ConsoleMonitorOptions): ConsoleMonitor; ``` #### `network-monitor/` ```text src/network-monitor/ index.ts network-monitor.ts network-monitor.types.ts network-monitor.md network-monitor.test.ts ``` ```typescript /** * Intercepts all Playwright page requests and responses. * Tracks 4xx/5xx responses, failed requests, CORS errors, and missing assets. * * @example * const monitor = attachNetworkMonitor(page); * await page.goto("/crypto"); * await monitor.assertNoFailures(); */ export function attachNetworkMonitor(page: Page): NetworkMonitor; ``` #### `extension-fixture/` ```text src/extension-fixture/ index.ts launch-with-extension.ts launch-with-extension.types.ts launch-with-extension.md launch-with-extension.test.ts ``` ```typescript /** * Launches a Playwright Chromium browser with an unpacked extension loaded. * Uses chromium.launchPersistentContext() with --load-extension and * --disable-extensions-except args. Only works with chromium (MV3 requirement). * * Returns the BrowserContext (not Browser) and the resolved extension ID * (extracted from chrome-extension:///_generated_background_page.html). * * @example * const { context, extensionId } = await launchWithExtension({ * chromium, * extensionPath: resolve(__dirname, "../../dist/chrome"), * userDataDir: await mkdtemp(join(tmpdir(), "ext-")), * }); * const page = await context.newPage(); * await page.goto("http://localhost:3200/extension-test"); */ export async function launchWithExtension(options: ExtensionFixtureOptions): Promise; export interface ExtensionFixtureOptions { chromium: BrowserType; // pass chromium from @playwright/test extensionPath: string; // absolute path to unpacked extension directory userDataDir: string; // temp directory for persistent context (caller creates + cleans up) } export interface ExtensionFixtureResult { context: BrowserContext; extensionId: string; // e.g. "abcdefghijklmnopqrstuvwxyz012345" } ``` ### 7.3 E2E test suite structure inside `apps/testbed/e2e/` ```text e2e/ layout/ navigation.spec.ts ← every sidebar link navigates; page title matches route theme.spec.ts ← toggle switches theme; persists across reload locale.spec.ts ← EN→FR→DE→JA; UI strings change on every switch console-baseline.spec.ts ← iterates routes array; zero console errors per page network-baseline.spec.ts ← iterates routes array; zero failed requests per page libraries/ crypto.spec.ts crypto-hash.spec.ts logger.spec.ts datetime.spec.ts surrealdb.spec.ts ← requires SurrealDB fixture storage-s3.spec.ts ← requires Garage S3 fixture i18n.spec.ts feature-flags.spec.ts # one spec per library page, growing as pages are added multi-session/ webrtc-mesh.spec.ts ← 3 contexts; mesh forms; messages flow bidirectionally webrtc-server-failover.spec.ts ← signalling route disabled; P2P holds websocket-gateway.spec.ts ← 2 contexts; room join, broadcast, leave broadcast-channel.spec.ts ← 2 contexts same origin; cross-tab messaging verified extensions/ content-script.spec.ts ← extension injects UI on /extension-test autofill-detection.spec.ts ← form-detection heuristics fire on login form ``` **Baseline spec pattern** (both `console-baseline.spec.ts` and `network-baseline.spec.ts`): ```typescript import { routes } from "../../src/client/routes.js"; for (const route of routes) { test(`${route.label} — no errors`, async ({ page }) => { const monitor = attachConsoleMonitor(page, { failOn: ["error"] }); await page.goto(route.path); await page.waitForSelector('[data-testid="page-title"]'); await monitor.assertClean(); }); } ``` ### 7.4 Invariants every spec must enforce Every spec — without exception — must: 1. Attach a `ConsoleMonitor` before any navigation and call `monitor.assertClean()` after every meaningful interaction. 2. Attach a `NetworkMonitor` before any navigation and call `monitor.assertNoFailures()` after every meaningful interaction. 3. Locate elements by `data-testid` attributes — never by CSS class or visible text content. 4. Assert that `data-testid="page-title"` contains the expected library name. 5. Assert that `data-testid="output"` contains non-empty content after triggering a demo action. ### 7.5 Playwright configuration (`apps/testbed/playwright.config.ts`) ```typescript 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"] } }, // Extension tests use launchWithExtension() from @suaveplan/testing-e2e // which calls chromium.launchPersistentContext() with --load-extension args. // These specs do NOT use the standard browser fixture — they manage their // own context via the fixture helper. Run on chromium only (MV3 requirement). { 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, }, }); ``` ### 7.6 CI matrix ```text Every pull request: - bunx turbo lint typecheck test (all packages, no Docker) - storybook build + storybook test Nightly (Docker available in CI runner): - bunx turbo test:integration (testcontainers fixtures) - playwright test (chromium, firefox, webkit, mobile-chrome, mobile-safari) - playwright test --project=chromium-extensions ``` --- ## 8. Documentation Standards These standards apply to every file in every package in this spec. An AI agent implementing any part of this spec must apply all of them. ### 8.1 Co-located `.md` per implementation file Every `.ts` and `.tsx` implementation file must have a sibling `.md`. **Exclusions** (no `.md` required): `.test.ts`, `.test.tsx`, `.spec.ts`, `.types.ts` (inside a feature dir), `.stories.tsx`, barrel `index.ts`, `constants/index.ts`. Required sections: 1. **Purpose** — at least two sentences explaining what this module does and why it exists. 2. **Features** — bulleted list of capabilities. 3. **Usage: Basic Example** — the simplest correct usage with real imports. 4. **Usage: Advanced Example** — a more complex scenario showing configuration or composition. 5. **API Reference** — every exported symbol: parameters, return types, thrown errors. 6. **Implementation Notes** — design decisions, performance characteristics, known limitations. Minimum 200 words excluding code blocks. No placeholder text. No marketing language. ### 8.2 README at package root One `README.md` per package at the root. It links to each co-located module `.md`. No `README.md` files inside `src/` subdirectories. ### 8.3 `apps/testbed/README.md` The testbed `README.md` must document all of the following so that an AI agent reading it cold can operate any part of the testbed correctly: - How to start the dev server (`bun run dev`, port 3200) - How to start the dev services stack (`bun run --filter=@suaveplan/docker dev:up`) - How to run Storybook (`bun run storybook`, port 6042) - How to run the full E2E suite (`bun run test:e2e`) - How to run multi-session tests and which Docker services they require - How to load the browser extension under test into Playwright (via `launchWithExtension()`) - The complete port allocation table (3200, 6042, and all service ports) - The `data-testid` contract every library page must implement - How the console panel works — what it intercepts and what it renders - How `?role=&session=` query params work for multi-session pages - How `POST /api/test-control/disable-route` works and when it is available (`NODE_ENV=test` only) - The env-bridge variables and how they interact with testcontainers - The `routes.ts` manifest and how to add a new library page --- ## 9. Glossary | Term | Meaning | |---|---| | **Fixture** | A `ServiceFixture` implementation that manages one Docker container | | **TestEnvironment** | The orchestrator that starts and stops an array of fixtures | | **Env-bridge** | The mechanism by which a fixture detects env-var overrides and skips container startup | | **Passthrough mode** | Fixture state when all env-var overrides are set — `healthCheck()` and `stop()` are both no-ops | | **Multi-session** | A Playwright test that opens two or more independent browser contexts simultaneously | | **Console monitor** | `attachConsoleMonitor()` — captures `window.console` output; fails tests on errors | | **Network monitor** | `attachNetworkMonitor()` — intercepts requests; fails tests on 4xx/5xx | | **Extension fixture** | `launchWithExtension()` — starts Chromium with an unpacked extension via `launchPersistentContext` | | **Testbed** | `apps/testbed/` — the showcase app that every E2E test runs against | | **Route manifest** | `src/client/routes.ts` — the exported array of all library page routes; used by baseline specs | | **Dev stack** | `docker-compose.dev.yml` — persistent local services on ports 8001–8902 | | **Test stack** | `docker-compose.test.yml` — ephemeral CI services on ports 7004–7011 | | **Staging** | `docker-compose.testbed.yml` — full environment including testbed app and Caddy |