1277 lines
49 KiB
Markdown
1277 lines
49 KiB
Markdown
# 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
|
||
|
||
```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<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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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.
|
||
|
||
```typescript
|
||
// 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]>;
|
||
};
|
||
```
|
||
|
||
```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<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)
|
||
|
||
```text
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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):
|
||
|
||
```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<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:**
|
||
|
||
```typescript
|
||
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
|
||
|
||
```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 `<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
|
||
|
||
```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 `/<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.
|
||
|
||
```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"` | `<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/`
|
||
|
||
```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=<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/`
|
||
|
||
```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://<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/`
|
||
|
||
```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<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 |
|