49 KiB
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.
- 100% test coverage —
provider: 'istanbul'on every package. No exceptions. - No mocks of real services — unit tests use constructor-injected fakes for the Docker layer only; integration tests hit live containers.
- Every implementation
.ts/.tsxfile 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, barrelindex.ts,constants/index.ts. - Every feature directory has a barrel
index.ts— re-exports the public surface of that feature only. - Package root
index.ts— re-exports every feature barrel. - No category-level
README.mdfiles insidesrc/— oneREADME.mdat the package root only. - Biome for lint and format — no ESLint, no Prettier.
- Vitest for tests — pool
forks,provider: 'istanbul', always{ timeout: 5000 }on unit tests,{ timeout: 30000 }on async/integration. bunx turbo lint typecheck testmust pass green before any package is considered shippable.skipIfUnavailableguard on every integration test suite — Docker absence skips, never fails.- Plugin packages (
testing-integration-*) depend on@suaveplan/testing-integrationfor theServiceFixtureinterface. They never import from the runtime library they serve. Plugin packages return plain config objects (strings, numbers, URLs) only. - Port assignments follow
../suaveplan/README.mdranges:3001–3999applications,6001–6999Storybook,7001–7999test services,8001–8999backend services. - Update
CLAUDE.md— when any new package in this spec ships, add it to the packages listing inCLAUDE.mdand theREADME.mdtable. Also addapps/testbed/anddocker/toCLAUDE.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:
-
The package ships a static
src/setup/global-setup.tsthat reads fixtures from a module-levelMap(named_fixtureRegistry) and callsnew TestEnvironment(registry).setup()/.teardown(). -
createIntegrationSetup(fixtures)writes each fixture into_fixtureRegistry(a module-levelMap<string, ServiceFixture>exported fromcreate-integration-setup.ts), then returns:globalSetup: the absolute file path to the compiledglobal-setup.jsinside the package'sdist/directory (resolved viaimport.meta.resolveorrequire.resolve).inject: a typed wrapper around Vitest'sinject()that reads from"__integration_configs__"and casts to the correct config type usingInferConfig.
-
The consumer's
vitest.config.tssetstest.globalSetupto the returned path. Vitest runsglobal-setup.tsin the main process, which callsTestEnvironment.setup(), which calls Vitest'sprovide(). Test worker processes then call the typedinject()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 firsttrue; retries onfalse; throws on timeout; injectablesleepremoves 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 toglobal-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:
garage status— capture output, extract the node ID from the first data row (format:<nodeId> <status> <addr>).garage layout assign -z default -c 1G <nodeId>— assign the node to zonedefaultwith 1 GB capacity.garage layout apply --version 1— apply the layout.garage bucket create <bucket>— create the bucket (bucket name comes fromGarageS3Options.bucket).garage key create test-key— create an access key namedtest-key.garage bucket allow --read --write <bucket> --key test-key— grant the key full access.garage key info test-key— parse output to extractKey ID:andSecret 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 theenlocale.
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:
- Attach a
ConsoleMonitorbefore any navigation and callmonitor.assertClean()after every meaningful interaction. - Attach a
NetworkMonitorbefore any navigation and callmonitor.assertNoFailures()after every meaningful interaction. - Locate elements by
data-testidattributes — never by CSS class or visible text content. - Assert that
data-testid="page-title"contains the expected library name. - 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:
- Purpose — at least two sentences explaining what this module does and why it exists.
- Features — bulleted list of capabilities.
- Usage: Basic Example — the simplest correct usage with real imports.
- Usage: Advanced Example — a more complex scenario showing configuration or composition.
- API Reference — every exported symbol: parameters, return types, thrown errors.
- 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-testidcontract 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-routeworks and when it is available (NODE_ENV=testonly) - The env-bridge variables and how they interact with testcontainers
- The
routes.tsmanifest 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 |