Files
suaveplan/docs/superpowers/specs/2026-04-24-enterprise-testbed-design.md
2026-05-27 23:29:53 +01:00

49 KiB
Raw Blame History

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

@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

src/
  fixture/
    index.ts
    service-fixture.ts          ← ServiceFixture<TConfig> 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<TConfig> interface

// src/fixture/service-fixture.ts

export interface ServiceFixture<TConfig extends Record<string, unknown>> {
  /** 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<TConfig>;

  /**
   * Stops the container. No-op when env-var override was used.
   */
  stop(): Promise<void>;

  /**
   * 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<boolean>;
}

/** Extracts the TConfig type from a ServiceFixture implementation. */
export type InferConfig<F> = F extends ServiceFixture<infer C> ? C : never;

TestEnvironment class

// src/environment/test-environment.ts

export class TestEnvironment {
  constructor(
    private readonly fixtures: Record<string, ServiceFixture<Record<string, unknown>>>,
    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<string, Record<string, unknown>> — 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<void>;

  /**
   * Stops all fixtures in reverse start order.
   */
  async teardown(): Promise<void>;
}

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<string, ServiceFixture> 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.

// src/setup/create-integration-setup.ts

/** Module-level registry — populated before globalSetup runs. */
export const _fixtureRegistry = new Map<string, ServiceFixture<Record<string, unknown>>>();

/**
 * 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<string, ServiceFixture<Record<string, unknown>>>
>(fixtures: TFixtures): {
  globalSetup: string;
  inject: <K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]>;
};
// 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.

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

// 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<Record<string, unknown>>): Promise<void>;
}

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<void>;
}

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)

src/
  fixture/
    index.ts
    <service>-fixture.ts
    <service>-fixture.types.ts
    <service>-fixture.md
    <service>-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:

export interface GarageS3Config {
  endpoint: string;           // "http://localhost:<s3Port>"
  adminEndpoint: string;      // "http://localhost:<adminPort>"
  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: <nodeId> <status> <addr>).
  2. garage layout assign -z default -c 1G <nodeId> — assign the node to zone default with 1 GB capacity.
  3. garage layout apply --version 1 — apply the layout.
  4. garage bucket create <bucket> — 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 <bucket> --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:<adminPort>/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:

export interface SurrealDBConfig {
  url: string;                // "ws://localhost:<port>"
  namespace: string;
  database: string;
  username: string;           // "root"
  password: string;           // generated per run via crypto.randomUUID()
}

Container command: start --auth --user root --pass <generated>

Health check: HTTP GET http://localhost:<httpPort>/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:

export interface RedisConfig {
  url: string;                // "redis://localhost:<port>"
  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:

export interface PostgresConfig {
  url: string;                // "postgresql://test:test@localhost:<port>/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:

export interface MailpitConfig {
  smtpUrl: string;            // "smtp://localhost:<smtpPort>"
  apiUrl: string;             // "http://localhost:<apiPort>"
}

Container ports: Two host ports — SMTP (container port 1025) and web/API (container port 8025).

Health check: HTTP GET <apiUrl>/api/v1/messages → 200.

Extended source layout (Mailpit adds a helpers/ directory beyond the base plugin template):

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:

// 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<void>;

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:

export interface TypesenseConfig {
  url: string;                // "http://localhost:<port>"
  apiKey: string;             // generated per run via crypto.randomUUID()
}

Container command: --data-dir /data --api-key=<generated> --enable-cors

Health check: HTTP GET <url>/health → { ok: true }.


5. Sub-project 2 — Docker Compose Infrastructure

5.1 Workspace

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:

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

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.

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.

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

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

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 <div data-theme="light"> and a <div data-theme="dark"> 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

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.

// 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 /<package-name> (without the @suaveplan/ prefix). The sidebar groups routes by category matching the monorepo's universal/<domain>/ structure.

6.7 Standard page anatomy

Every library page follows an identical visual contract. Tests rely on this contract.

┌─ 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" <h1> 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 <html>. 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=<value>&session=<id> 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/

src/multi-session/
  index.ts
  create-multi-session.ts
  create-multi-session.types.ts
  create-multi-session.md
  create-multi-session.test.ts
/**
 * Opens n isolated browser contexts against the same base URL.
 * Each context gets a unique role appended as ?role=<value>.
 *
 * @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<MultiSessionResult>;

Also exports simulateServerFailure(apiBaseUrl: string, route: string) — sends POST /api/test-control/disable-route and asserts 204.

console-monitor/

src/console-monitor/
  index.ts
  console-monitor.ts
  console-monitor.types.ts
  console-monitor.md
  console-monitor.test.ts
/**
 * 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/

src/network-monitor/
  index.ts
  network-monitor.ts
  network-monitor.types.ts
  network-monitor.md
  network-monitor.test.ts
/**
 * 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/

src/extension-fixture/
  index.ts
  launch-with-extension.ts
  launch-with-extension.types.ts
  launch-with-extension.md
  launch-with-extension.test.ts
/**
 * 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://<id>/_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<ExtensionFixtureResult>;

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/

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

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)

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

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