From ff819a4e15d1dfa34de6aa3da839753c56a37d69 Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Date: Tue, 23 Jun 2026 00:01:24 +0530 Subject: [PATCH] PR1: standardize emulator reset() contract + conformance test Establishes the emulator contract (constructor/start/stop/reset) that the launcher, probe, and forthcoming control plane rely on: - Add reset() to the 6 services that lacked it (cassandra, kafka, mysql, rabbitmq, elasticsearch, supabase). State init moved into reset() and called from the constructor; behavior-preserving (verified via each service's real-driver test suite + live round-trips). - Add test/conformance.test.ts: asserts valid manifests, unique ports, *Server class export, and start/stop/reset across the whole catalog, plus a live boot/health/reset smoke test for a representative sample. - Document the contract in CONTRIBUTING.md + SKILL.md; add CHANGELOG.md. - Add PLAN.md (10-PR roadmap) and SKILL.md (implementation guide). Full suite: 252 files / 5466 tests, green on 3 consecutive runs. --- CHANGELOG.md | 25 ++ CONTRIBUTING.md | 25 +- PLAN.md | 357 +++++++++++++++++++++++++++ SKILL.md | 353 ++++++++++++++++++++++++++ services/cassandra/src/server.js | 8 +- services/elasticsearch/src/server.js | 8 +- services/kafka/src/server.js | 12 +- services/mysql/src/server.js | 10 +- services/rabbitmq/src/server.js | 14 +- services/supabase/src/server.js | 8 +- test/conformance.test.ts | 205 +++++++++++++++ 11 files changed, 1011 insertions(+), 14 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 PLAN.md create mode 100644 SKILL.md create mode 100644 test/conformance.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec0ca8f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to Parlel are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- **Emulator contract conformance test** (`test/conformance.test.ts`). Asserts + across the whole catalog that every service has a valid manifest, a unique port, + a `src/server.js` exporting a `*Server` class, and that the class implements + `start()`, `stop()`, and `reset()`. Guards against convention drift as the + catalog grows. Includes a live boot → `/health` → `reset()` smoke test for a + representative sample of services. +- **`reset()` standardized as part of the emulator contract.** Added `reset()` to + the services that were missing it (`cassandra`, `kafka`, `mysql`, `rabbitmq`, + `elasticsearch`, `supabase`), so every emulator can be returned to a clean state + for per-test isolation and by the forthcoming Parlel control plane. + +### Changed + +- `CONTRIBUTING.md` now documents the full emulator contract (`reset()` required, + all state initialized inside `reset()`), and the PR checklist references the + conformance test. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e458e0d..2da27b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,8 +29,17 @@ A service lives in `services//` and needs three things: ### 2. `services//src/server.js` -Export a class named `Server` with a `constructor(port, options)`, -a `start()` method (returns a promise once listening), and a `stop()` method. +Export a class named `Server` implementing the **emulator contract**: + +- `constructor(port, options)` — store config; call `this.reset()`. No I/O here. +- `start()` — returns a promise that resolves once listening. +- `stop()` — returns a promise that resolves once closed. +- `reset()` — clears **all** in-memory state back to empty. Idempotent, no I/O. + +`reset()` is required: it gives every test a clean slate and is what the Parlel +control plane calls between test cases. Initialize all state inside `reset()` and +call it from the constructor so the two never drift. The conformance test +(`test/conformance.test.ts`) enforces this contract across the whole catalog. ```js import { createServer } from "node:http"; @@ -38,6 +47,14 @@ import { createServer } from "node:http"; export class MyServiceServer { constructor(port = 4900) { this.port = port; + this.server = null; + this.reset(); + } + + // Clears all in-memory state back to empty. Idempotent, no I/O. + reset() { + this.things = new Map(); + this.counter = 0; } start() { @@ -104,9 +121,11 @@ npm run probe # health-check ## PR checklist - [ ] `manifest.json`, `src/server.js`, `test/.test.ts` added. +- [ ] `*Server` implements the contract: `constructor(port, options)`, `start()`, + `stop()`, `reset()` (all state initialized in `reset()`). - [ ] No new runtime dependencies (emulators stay pure Node). - [ ] Port doesn't clash with an existing service. -- [ ] `npm test` passes. +- [ ] `npm test` passes (including `test/conformance.test.ts`). - [ ] Real client library round-trips against the emulator. ## Code of conduct diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..b61118f --- /dev/null +++ b/PLAN.md @@ -0,0 +1,357 @@ +# Parlel — Plan to become the default way devs test locally + +Parlel today is a strong **foundation**: 250+ zero-dependency emulators speaking +real wire protocols, 5,400+ fidelity tests, a collision-safe launcher, per-service +docs, and a health probe. It nails *fidelity* and *contributing*. + +What it is **not yet** is a tool a developer reaches for *by reflex* every time they +write a test or run code locally. The gap is not more services — it's the +**control plane, ergonomics, and integration surface** that turn "a pile of +emulators" into "the obvious local testing default." + +This plan is organized by impact. Each item states the gap, the proposed change, +and why it moves Parlel toward "default." + +--- + +## Guiding principles (do not violate) + +- **Zero runtime dependencies in emulators.** This is Parlel's moat. The control + plane / CLI / MCP layer may use Node built-ins only, same as the emulators. +- **Production drivers connect unmodified.** Any new surface is *additive* (a + separate admin port), never a change to the emulated protocol. +- **Ephemeral by default.** New state features (seed/snapshot) are opt-in. +- **Fast.** Sub-second startup per service must stay sub-second. + +--- + +## Tier 1 — The control plane (the single biggest unlock) + +> Right now each emulator is a black box: you can connect to it, but you cannot +> *ask it anything*. There is no way to list what's running, inspect what calls +> were made, reset state between tests, or seed fixtures. This is the #1 blocker +> to Parlel being a real testing default — test isolation is impossible without +> a reset, and debugging is impossible without inspection. + +### 1.1 Admin / control API (one extra port for the whole fleet) + +Add a single **control-plane HTTP server** (e.g. `localhost:4700`) that the +launcher starts alongside the emulators. Pure Node `node:http`. Endpoints: + +- `GET /services` — list running services: slug, port, protocol, uptime, health. +- `GET /services/:slug/state` — dump current in-memory state (objects, rows, keys). +- `POST /services/:slug/reset` — clear that service's state. **(test isolation)** +- `POST /reset` — reset *all* services at once. **(per-test `beforeEach`)** +- `GET /services/:slug/requests` — request log: every call the emulator received + (method, path, headers, body, response, timestamp). **(debugging)** +- `POST /services/:slug/seed` — load fixture data (see 1.3). +- `GET /healthz` — aggregate health of the whole fleet. + +To support this with zero changes to emulator protocols, define a tiny optional +**emulator contract** the control plane introspects: + +```js +export class StripeServer { + // already have: constructor, start, stop + reset() { /* clear in-memory state */ } // optional, control plane calls it + dump() { return this.state; } // optional, for /state + // request log is captured by a thin wrapper the launcher installs (see 1.2) +} +``` + +Emulators that don't implement `reset`/`dump` degrade gracefully (control plane +reports "not supported"). Many services likely already have a `reset()` — audit +and standardize the signature. + +**Why it matters:** This is what makes Parlel usable *inside a test suite*. +`beforeEach(() => fetch('localhost:4700/reset', {method:'POST'}))` gives every +test a clean slate without restarting containers (which is too slow per-test). + +### 1.2 Universal request recorder + +The launcher wraps each emulator's HTTP handler (and a hook for TCP services) to +record requests into a ring buffer (capped, e.g. last 1,000). Exposed via the +control API. This is the feature that answers the question developers actually +have: **"did my code call the API the way I think it did?"** — the thing mocks +give you and real services don't. + +- Opt-out via env (`PARLEL_RECORD=0`) for max performance. +- `GET /services/:slug/requests?since=` for assertions in tests: + *"assert Stripe received exactly one POST /v1/charges with amount=2000."* + +### 1.3 Seeding & fixtures + +Ephemeral is the right default, but "always empty" is sometimes wrong (you need a +user to exist before testing login). Add: + +- `POST /services/:slug/seed` with a JSON body the emulator loads. +- A declarative `parlel.fixtures.json` at repo root the launcher loads on boot: + ```json + { + "postgres": { "sql": "CREATE TABLE users(...); INSERT ..." }, + "stripe": { "customers": [{ "id": "cus_test", "email": "a@b.com" }] } + } + ``` +- Per-emulator `seed(data)` method (optional, same contract pattern as `reset`). + +### 1.4 Snapshots (stretch) + +`POST /snapshot` → returns an opaque blob of all in-memory state; `POST /restore` +loads it. Enables "set up an expensive scenario once, restore it before each +test" — much faster than re-seeding. Build on `dump()`/`seed()`. + +--- + +## Tier 2 — A real CLI + +> Today the interface is `SERVICES=postgres,redis node src/launch.mjs` and editing +> env vars. That's friction. A first-class CLI is table stakes for a "default" tool. + +Add a `parlel` CLI (the `bin` already exists — expand it). Subcommands: + +- `parlel up postgres redis stripe` — start services (foreground or `-d` detached). +- `parlel down` — stop the detached fleet. +- `parlel status` — table of running services, ports, health, uptime (hits 1.1). +- `parlel logs [slug]` — tail emulator logs / request log. +- `parlel reset [slug]` — wipe state (hits 1.1). +- `parlel ls` — list all 250 available services + their ports (searchable: + `parlel ls payments`). +- `parlel inspect stripe` — show the request log / state for one service. +- `parlel doctor` — preflight: Node version, port conflicts, Docker availability. +- `parlel seed ` — load fixtures. + +Ship as `npx parlel` so the first-run experience is **zero install**: +```bash +npx parlel up postgres stripe +``` + +**Why it matters:** "default tool" means muscle-memory commands. `npx parlel up` +should be as reflexive as `docker compose up`. + +--- + +## Tier 3 — Test-framework integration (make it *the* default in tests) + +> The whole pitch is "test locally." Yet there's no first-class way to wire Parlel +> into a test runner. Developers have to hand-roll `beforeAll`/`afterAll`. Provide +> adapters so adopting Parlel in a test suite is two lines. + +### 3.1 A tiny client library (`@parlel/client`, pure Node, optional) + +A thin wrapper over the control API: +```js +import { parlel } from "@parlel/client"; +const p = await parlel.up(["postgres", "stripe"]); +await p.reset(); // between tests +p.stripe.requests(); // assert calls +await p.down(); +``` + +### 3.2 Vitest / Jest global setup + +```js +// vitest.config.ts +import { parlelSetup } from "@parlel/client/vitest"; +export default { test: { globalSetup: parlelSetup(["postgres", "stripe"]) } }; +``` +Auto-starts the fleet, injects connection env vars, resets between files. + +### 3.3 pytest plugin + +Given the README leads with `psycopg`/Python, a `pytest-parlel` fixture is high +leverage: +```python +def test_charge(parlel): + parlel.up("stripe") + ... + assert parlel.stripe.requests("POST", "/v1/charges") +``` + +### 3.4 Connection-string helper + +A single source of truth that hands back correct connection strings/URLs for +whatever ports were actually bound (important since `up.mjs` remaps busy ports — +right now the remapped port is only *printed*, not *queryable*). Wire this into +the control API (`GET /services/:slug` returns `connection_string`). + +--- + +## Tier 4 — The MCP server (deliver the AI-agent thesis) + +> The README's headline is "a verification layer for AI coding agents" and the +> roadmap lists an MCP server — but it doesn't exist. Without it, the core +> differentiating story is unfulfilled. This is what makes Parlel *the* agent +> testing tool rather than just another LocalStack. + +Build an MCP server (built-ins only) exposing tools an agent calls directly: + +- `parlel_start_services(slugs)` → boots them, returns connection info. +- `parlel_list_services(category?)` → discover what's available. +- `parlel_get_requests(slug)` → agent inspects what its code did (closes the + verify loop: agent writes code → runs it → reads the request log → asserts). +- `parlel_reset(slug?)` → clean slate between agent iterations. +- `parlel_seed(slug, data)` → set up scenarios. +- `parlel_stop_services()`. + +Pair with an **`AGENTS.md`** (currently missing despite the agent positioning) +that teaches an agent the workflow: *"need to test code that calls Stripe? Start +the emulator, point at localhost, run, then read the request log to verify."* + +--- + +## Tier 5 — Observability & DX polish + +- **Web dashboard (optional, stretch):** `localhost:4700/` serves a tiny static + UI over the control API — see running services, live request stream, reset + buttons, state inspector. Zero deps (vanilla HTML/JS). This is a "wow" demo + that drives adoption. +- **Structured logging:** consistent JSON log lines from the launcher with + request IDs, so output is greppable/pipeable. +- **Better remap UX:** the busy-port remap is currently fire-and-forget text. + Surface it in `parlel status` and the control API so tooling can read it. + +--- + +## Tier 6 — Code hygiene & reliability (foundation for contributors) + +The repo has 130K+ lines of hand-written JS with **no linter, no formatter, no +coverage, no typecheck**. For a project whose growth model is "community adds +services," inconsistency will compound. + +- **Add Biome** (single binary, fast, zero-config-ish, fits the zero-dep ethos): + `npm run lint`, `npm run format`. Wire into CI. +- **Coverage:** `vitest run --coverage` with a reporter; track per-service + coverage so gaps (e.g. `apigateway` stub) are visible. +- **Lightweight typecheck:** a `jsconfig.json` + `tsc --noEmit --checkJs` on the + emulators, or at least typecheck the `.test.ts` files. Catches drift early. +- **CI matrix:** test on Node 20 / 22 / 24 (engines say >=20 but CI only runs 24) + and ideally macOS + Linux. +- **Replace `sleep 8` in CI** with a readiness poll against the new control-plane + `/healthz` — removes flakiness and speeds CI. +- **Conformance test:** a single meta-test that asserts every service implements + the emulator contract (`start`/`stop`, and `reset`/`dump` where claimed), + manifest is valid, port is unique, and a test file exists. Prevents the + `apigateway`-style drift. +- **`AGENTS.md` / `CONTRIBUTING` update:** document the control-plane contract so + new emulators implement `reset()`/`dump()` from day one. + +--- + +## Tier 7 — Record / replay (roadmap item, longer horizon) + +Record real upstream responses once, replay them offline. Turns Parlel into a +fidelity-checker for services too complex to fully emulate. Build on the request +recorder (1.2): record mode proxies to the real service and captures; replay mode +serves from the capture. Strictly opt-in, network-gated. + +--- + +## Suggested sequencing + +| Phase | Items | Outcome | +|-------|-------|---------| +| **1** | 1.1 control API, 1.2 recorder, Tier 6 lint/coverage | Test isolation + debugging + clean foundation. The unlock. | +| **2** | Tier 2 CLI, 3.4 connection helper, CI readiness poll | Reflexive UX; `parlel up` / `parlel status`. | +| **3** | 1.3 seeding, 3.1–3.3 test adapters | Two-line adoption in real test suites. | +| **4** | Tier 4 MCP + AGENTS.md | Deliver the agent thesis. | +| **5** | 1.4 snapshots, Tier 5 dashboard, Tier 7 record/replay | Differentiated polish. | + +--- + +## What success looks like + +A developer (or agent) writes code that touches Stripe + Postgres, runs: + +```bash +npx parlel up stripe postgres +``` + +points their unmodified driver at `localhost`, runs their tests with a +`@parlel/client` fixture that resets state between cases, and asserts against the +recorded request log — all locally, free, in under a second of startup, with zero +risk to production. That reflexive `npx parlel up` is the goal. + +--- + +## Delivery — 10 PRs + +Each PR is independently shippable, has its own tests/docs/changelog entry, and +builds on the ones before it. **PRs 1–4 are Tier 1 (ship today).** + +### Tier 1 — Control plane (PRs 1–4, today) + +**PR 1 — Emulator control contract + audit/standardize `reset()`** *(foundation)* +- Define the optional emulator contract the control plane introspects: `reset()`, + `dump()`, `seed(data)`, plus the namespaced `__parlel` admin routes already used + by some services. +- Audit all 250 services: standardize the `reset()` signature, add `POST + /__parlel/reset` where missing on HTTP services, make graceful-degrade explicit + (control plane reports "not supported" when a method is absent). +- A conformance meta-test asserting the contract across services. +- Docs: update `CONTRIBUTING.md` + `SKILL.md` with the contract. Changelog entry. + +**PR 2 — Control-plane HTTP server + `/services` + `/healthz` + reset** *(1.1 core)* +- New `src/control-plane.mjs` (pure Node `node:http`), started by the launcher on + `localhost:4700` (configurable via `PARLEL_CONTROL_PORT`, opt-out env). +- Endpoints: `GET /services`, `GET /healthz`, `POST /reset`, + `POST /services/:slug/reset`, `GET /services/:slug/state` (via `dump()`). +- Launcher registers each started server instance with the control plane. +- Tests against the control API; docs page `docs/control-plane.md`; changelog. + +**PR 3 — Universal request recorder + `/requests`** *(1.2)* +- Launcher installs a thin wrapper around each HTTP emulator's handler to record + `{method, path, headers, body, status, ts}` into a per-service capped ring + buffer (default 1,000). TCP hook stubbed for a later PR. +- `GET /services/:slug/requests?since=` on the control plane. +- Opt-out via `PARLEL_RECORD=0`. Performance check that recording stays sub-ms. +- Tests asserting "service received exactly one POST /v1/charges". Docs + changelog. + +**PR 4 — Seeding & fixtures** *(1.3)* +- `seed(data)` contract method; `POST /services/:slug/seed` on the control plane. +- Declarative `parlel.fixtures.json` loaded on boot by the launcher. +- Implement `seed()` for the high-value services (postgres, stripe, redis, s3) and + document the per-service seed shape; others report "not supported". +- Tests for fixture-on-boot and runtime seed; docs + changelog. +- **End of Tier 1.** + +### Tier 6 — Hygiene foundation (PRs 5–6, fast follow) + +**PR 5 — Biome lint + format + CI wiring** +- Add Biome (single binary), `npm run lint` / `npm run format`, baseline config + matching existing style, wire into CI. Auto-format pass as its own commit. + +**PR 6 — Coverage + CI readiness poll + matrix** +- `vitest run --coverage` with reporter; per-service coverage surfaced. +- Replace CI `sleep 8` with a readiness poll against `/healthz` (depends on PR 2). +- CI matrix: Node 20/22/24. + +### Tier 2 — CLI (PRs 7–8) + +**PR 7 — `parlel` CLI core: `up` / `down` / `status` / `ls`** +- Expand the existing `bin`. `up [-d]`, `down`, `status` (hits control plane), + `ls [filter]`. `npx parlel up postgres stripe` zero-install path. + +**PR 8 — CLI inspect/reset/seed/doctor + connection helper** *(3.4)* +- `inspect `, `reset [slug]`, `seed `, `doctor` preflight. +- Connection-string helper: control plane `GET /services/:slug` returns the actual + bound `connection_string` (resolves the `up.mjs` remap-only-printed gap). + +### Tier 3/4 — Adoption + agents (PRs 9–10) + +**PR 9 — `@parlel/client` + vitest/jest global setup** *(3.1–3.2)* +- Thin pure-Node client over the control API; vitest `globalSetup` helper that + boots the fleet, injects connection env, resets between files. + +**PR 10 — MCP server + `AGENTS.md`** *(Tier 4)* +- MCP server (built-ins only) exposing `start_services`, `list_services`, + `get_requests`, `reset`, `seed`, `stop_services`. +- `AGENTS.md` teaching the agent verify-loop workflow. + +**Later (not in the 10):** 1.4 snapshots, Tier 5 dashboard, Tier 7 record/replay, +pytest plugin (3.3) — tracked as follow-ups. + +### Today's target + +Ship **PRs 1–4** = all of Tier 1. Outcome: every emulator resettable and +inspectable through one control port, request recording for assertions, and +fixture seeding — Parlel becomes usable *inside a test suite*. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..7ca6251 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,353 @@ +# SKILL: Implementing in Parlel + +This is the operating manual for any agent writing code in the Parlel repo. Follow +it end to end: **plan → implement → test → docs → changelog → hygiene**. Treat code +and docs as a single deliverable — a change that updates code without updating its +docs is incomplete. + +Parlel is a collection of **250+ zero-dependency service emulators** that speak +**real wire protocols / REST contracts**, so unmodified production drivers connect +directly. Fidelity is the entire point. Everything below protects that. + +--- + +## 0. Non-negotiable invariants + +Violating any of these fails the change. + +1. **No runtime dependencies in emulators.** `services/**/src/*.js` use **Node + built-ins only** (`node:http`, `node:net`, `node:fs`, `node:crypto`, …). No + `npm install` is ever required to run an emulator. New deps may only be added to + `devDependencies`, and only when they are a **real client library used to verify + fidelity in a test** (e.g. `pg`, `stripe`, an AWS SDK client). +2. **Production drivers connect unmodified.** Never change the emulated protocol to + make implementation easier. The contract is what the real SDK expects. Any admin + surface is *additive* and namespaced (see `__parlel` below), never a protocol change. +3. **Ephemeral by default.** State lives in memory and resets to empty on restart. + No disk persistence, no network egress — "no data ever leaves the machine." +4. **ES Modules.** `"type": "module"`. Use `import`/`export`, never `require`. +5. **Fast.** Per-service startup stays sub-second. No heavy work in the constructor + beyond `this.reset()`. +6. **Match the existing conventions** in neighboring services. When unsure, read a + similar service (`services/stripe`, `services/s3`, `services/openai`) and copy + its shape. Consistency across 130k+ lines matters more than personal taste. + +--- + +## 1. PLAN (before writing any code) + +Do not skip this. Write the plan into the task/PR description. + +1. **Identify the kind of change:** + - **New service emulator** → follow §2 (the common case). + - **Extend an existing emulator** (more routes/fidelity) → §3. + - **Tooling/control-plane/launcher change** (`src/`, `scripts/`) → §4. +2. **Research the real contract.** For a service, find the authoritative API: real + endpoints, auth scheme, request/response shapes, error envelope, pagination, + content types. Use Context7 / official docs. The emulator must mirror this — a + plausible-looking fake that the real SDK rejects is a bug. +3. **Pick the verifying client.** Decide which **real client library** the test will + drive the emulator with. If it's already in `devDependencies`, good. If not, + adding it must be justified (it is the only way to prove fidelity). +4. **Reserve a port.** Grep `.env.example` and `services/*/manifest.json` to pick a + **free, unused port**. Document it. +5. **Scope the surface.** List the operations you will implement now vs. intentionally + return `501 Not Implemented` for. Document the boundary — unimplemented-on-purpose + is fine and must be stated; silent gaps are not. +6. **State the acceptance check:** "real client `X` does round-trip `Y` and asserts `Z`; + `npm test` passes; `npm run probe` shows the service green." + +--- + +## 2. IMPLEMENT — a new service emulator + +A service lives in `services//` and is exactly three files: +`manifest.json`, `src/server.js`, and `test/.test.ts`. + +### 2.1 `services//manifest.json` + +```json +{ + "name": "", + "version": "1.0", + "port": 4900, + "protocol": "http", + "healthcheck": "/health", + "env_vars": { + "_API_KEY": "parlel", + "_BASE_URL": "http://127.0.0.1:4900" + } +} +``` + +- `protocol`: `http`/`https` for REST, `tcp` for wire-protocol databases, + `embedded` for no-network (e.g. sqlite). +- `port` must be unique across the repo. +- `env_vars` are seeded test credentials + base URL; these mirror `.env.example`. + +### 2.2 `services//src/server.js` — the emulator contract + +Export a class named `Server`. The launcher discovers it via the regex +`/Server$/` (`src/launch.mjs`), so the suffix is required. Implement this exact +contract — it is what the launcher, probe, and control plane rely on: + +```js +import { createServer } from "node:http"; + +export class MyServiceServer { + // REQUIRED. Signature is (port, options). Do init via this.reset(). + constructor(port = 4900, options = {}) { + this.port = port; + this.host = options.host || "127.0.0.1"; + this.server = null; + this.reset(); + } + + // REQUIRED by convention. Clears ALL in-memory state back to empty. + // Used for per-test isolation. Idempotent. No I/O. + reset() { + this.things = new Map(); + this.counter = 0; + } + + // REQUIRED. Resolves once listening; rejects on bind error. + start() { + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => { + this.handle(req, res).catch(() => + this.send(res, 500, { error: "internal" }), + ); + }); + this.server.once("error", reject); + this.server.listen(this.port, this.host, () => { + this.server.off("error", reject); + resolve(); + }); + }); + } + + // REQUIRED. Resolves once closed; safe to call when not started. + stop() { + return new Promise((resolve, reject) => { + if (!this.server) return resolve(); + this.server.close((err) => { + this.server = null; + err ? reject(err) : resolve(); + }); + }); + } + + async handle(req, res) { + const url = new URL(req.url || "/", `http://${this.host}:${this.port}`); + // Health check — every service answers this (probe.mjs hits it). + if (req.method === "GET" && url.pathname === "/health") + return this.send(res, 200, { status: "ok" }); + // Parlel control plane — namespaced, additive, never part of the real API. + if (url.pathname.startsWith("/__parlel")) + return this.handleControl(req, res, url); + // ... implement the REAL API contract here ... + this.send(res, 404, { error: "not found" }); + } + + // REQUIRED by convention: the additive admin surface. + handleControl(req, res, url) { + if (req.method === "POST" && url.pathname === "/__parlel/reset") { + this.reset(); + return this.send(res, 200, { ok: true }); + } + return this.send(res, 404, { error: "not found" }); + } + + send(res, status, body) { + res.statusCode = status; + if (body === null || status === 204) return res.end(); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); + } +} +``` + +**The control-plane convention is established and mandatory for new HTTP services:** +- A `reset()` method that returns state to empty. +- A `POST /__parlel/reset` endpoint (routed via `handleControl`) that calls it. +- A `GET /health` endpoint returning `200`. + +This is what makes the emulator usable inside a test suite (clean slate per test). +Look at `services/stripe/src/server.js` for the canonical reference. + +**Fidelity rules:** +- Mirror the **real** request parsing (Stripe is form-encoded with bracket + notation; AWS is often `x-amz-json`; many are JSON). Parse what the real SDK sends. +- Mirror the **real** response shape **and error envelope** exactly — wrong error + format is the #1 reason a real SDK breaks. +- Mirror auth behavior (usually: accept any non-empty token matching the scheme). +- Mirror pagination, content-type headers, and CORS where the SDK depends on them. +- For complex protocols, split into multiple files (see `services/postgres/`, + `services/mongodb/`) — but keep the exported `*Server` class as the entry point. + +### 2.3 Wiring + +- Add the service's `env_vars` (port + seeded creds) to **`.env.example`**. +- `docker-compose.yml` publishes canonical ports — add the port mapping if your + service should be reachable in raw compose mode (match the existing format). +- TCP services need a real-driver round-trip case in `scripts/probe.mjs` if they + use a native driver; HTTP services are probed automatically via `/health`. + +--- + +## 3. IMPLEMENT — extending an existing emulator + +- Read the whole `server.js` (and `docs/.md`) first; match its helpers, + error-envelope function, id-generation, and routing style. +- Add new routes alongside existing ones; do not refactor unrelated code. +- Anything new you implement must be reflected in `reset()` (so test isolation + still wipes it) and in `docs/.md`. + +--- + +## 4. IMPLEMENT — tooling / launcher / scripts + +Files: `src/launch.mjs`, `src/test-helpers.js`, `scripts/up.mjs`, +`scripts/probe.mjs`. Same rule: **Node built-ins only.** These orchestrate the +emulators and must stay dependency-free and fast. If you add a new control-plane +capability, define the emulator-side contract (a method + a `/__parlel/*` route) +and document it here in this skill so future services implement it from day one. + +--- + +## 5. TEST (mandatory — fidelity is proven, not claimed) + +Every change ships with a test in `test/.test.ts` (Vitest, TypeScript). + +- **Drive the emulator with the REAL client library**, not raw fetch where an SDK + exists. The point is to prove the real driver works unmodified. +- Use `getFreePort()` from `src/test-helpers.js` for the port — never hardcode. + Tests must not collide. +- `beforeAll` starts the server; `afterAll` stops it. Reset state between cases + (`beforeEach` calling the instance's `reset()` or `POST /__parlel/reset`) when + tests share an instance. +- Assert **real round-trips**: create → read back → assert shape/values, list + + pagination, and at least one **error path** (the error envelope is fidelity-critical). + +```ts +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { MyServiceServer } from "../services/myservice/src/server.js"; +import { getFreePort } from "../src/test-helpers.js"; + +let server: MyServiceServer; +let port: number; + +beforeAll(async () => { + port = await getFreePort(); + server = new MyServiceServer(port); + await server.start(); +}); +afterAll(() => server.stop()); + +it("round-trips with the real client", async () => { + // ... use the real SDK pointed at http://localhost:${port}, assert the result +}); +``` + +**Run before declaring done:** + +```bash +npm test # full vitest suite (sequential by design) +SERVICES= node src/launch.mjs # boot just your service +npm run probe # health-check; must show your service green +``` + +> Note: `vitest.config.ts` sets `fileParallelism: false` because each emulator binds +> a fixed port — do not "fix" this by enabling parallelism. + +--- + +## 6. DOCS (code and docs go hand in hand — not optional) + +A change is **incomplete** until its documentation matches. For every change: + +1. **`docs/.md`** — the per-service API reference. New/changed service → + create/update it. Required sections (follow `docs/stripe.md` as the template): + - Title + one-line description (dependency-free, in-memory, real-SDK-compatible). + - **Default port.** + - **Quick start**: starting the server + pointing the real client at it (with a + real code snippet). + - **Implemented operations**: every route, its response shape, auth, pagination. + - **Intentionally not implemented**: list what returns `501`/`405` on purpose. +3. **`README.md`** — update the "What's included" category table counts/examples if + you added a service. Update the roadmap if you delivered a roadmap item. +4. **`.env.example`** — the service's port + seeded creds (must already be done in §2.3). +5. **`CONTRIBUTING.md`** — only if you changed the contributor workflow or the + emulator contract. +6. **This `SKILL.md`** — if you changed a repo-wide convention (e.g. the control-plane + contract), update it here so future agents inherit the new rule. + +**Consistency check:** the routes documented in `docs/.md` must exactly match +the routes implemented in `server.js`. If they drift, the docs are wrong. + +--- + +## 7. CHANGELOG + +Maintain `CHANGELOG.md` at the repo root ([Keep a Changelog](https://keepachangelog.com) +format, newest first). If it does not exist yet, create it with an `## [Unreleased]` +section. For every change add an entry under the right heading: + +```markdown +# Changelog + +## [Unreleased] + +### Added +- `` emulator on port `4900` — implements customers, charges, and webhooks; + verified against the real `` client. + +### Changed +- ... + +### Fixed +- ... +``` + +Bump the `version` in `package.json` only when explicitly cutting a release; +otherwise accumulate under `[Unreleased]`. Keep entries user-facing (what a dev +gains), not internal mechanics. + +--- + +## 8. CODE HYGIENE + +- **Style:** match the surrounding file — 2-space indent, ES modules, small focused + methods, helper functions at module scope (see how `stripe/src/server.js` factors + `stripeError`, `parseFormEncoded`, `token`). +- **No dead code, no commented-out blocks, no `console.log` left in emulators.** + The launcher owns logging. +- **Names** describe the real API concept (`customers`, `charges`), not generic + (`data`, `items`). +- **Errors** never crash the server: the top-level `handle().catch()` must return a + well-formed error response in the service's real error envelope. +- **Idempotent `reset()`**, **no shared mutable module state** between instances + (state lives on `this`), so multiple instances on different ports stay isolated. +- **Comment the "why," not the "what"** — especially any deliberate deviation or + `501` boundary, so it isn't mistaken for a bug later. +- **Self-review the diff** before finishing: only intended files changed, no secrets, + no stray formatting churn in untouched code. + +--- + +## 9. Definition of Done — final checklist + +- [ ] **Plan** written (kind of change, real contract researched, port reserved, scope stated). +- [ ] `manifest.json` (unique port, correct protocol/healthcheck/env_vars). +- [ ] `src/server.js` exporting `*Server` with `constructor(port, options)`, + `reset()`, `start()`, `stop()`, `GET /health`, and `POST /__parlel/reset`. +- [ ] **Node built-ins only** in the emulator; no new runtime deps. +- [ ] Real production driver connects **unmodified** and round-trips. +- [ ] `test/.test.ts` drives the **real client**, uses `getFreePort()`, covers + create/read/list/pagination and at least one error path. +- [ ] `npm test` passes (incl. `test/conformance.test.ts`); `npm run probe` shows the service green. +- [ ] `docs/.md` created/updated and **matches the implemented routes**. +- [ ] `.env.example`, `README.md` table (and `docker-compose.yml` ports if applicable) updated. +- [ ] `CHANGELOG.md` entry added under `[Unreleased]`. +- [ ] Diff is clean: no dead code, no `console.log`, no unrelated churn, no secrets. +- [ ] If a repo-wide convention changed, **this SKILL.md was updated** too. diff --git a/services/cassandra/src/server.js b/services/cassandra/src/server.js index 140bd39..e4a080d 100644 --- a/services/cassandra/src/server.js +++ b/services/cassandra/src/server.js @@ -3,9 +3,15 @@ import { createServer } from "node:net"; export class CassandraServer { constructor(port = 9042) { this.port = port; + this.server = null; + this.reset(); + } + + // Clears all in-memory state back to empty. Used for per-test isolation + // and by the Parlel control plane. Idempotent, no I/O. + reset() { this.keyspaces = new Map(); this.tables = new Map(); - this.server = null; } start() { diff --git a/services/elasticsearch/src/server.js b/services/elasticsearch/src/server.js index 24b3288..4d6329d 100644 --- a/services/elasticsearch/src/server.js +++ b/services/elasticsearch/src/server.js @@ -4,8 +4,14 @@ import { randomBytes } from "node:crypto"; export class ElasticsearchServer { constructor(port = 9200) { this.port = port; - this.indices = new Map(); this.server = null; + this.reset(); + } + + // Clears all in-memory state back to empty. Used for per-test isolation + // and by the Parlel control plane. Idempotent, no I/O. + reset() { + this.indices = new Map(); } start() { diff --git a/services/kafka/src/server.js b/services/kafka/src/server.js index 0e8a161..4185a31 100644 --- a/services/kafka/src/server.js +++ b/services/kafka/src/server.js @@ -19,12 +19,18 @@ const K = KafkaProtocol.API_KEYS; export class KafkaServer { constructor(port = 9092) { this.port = port; - // name -> { partitions: [{ records: [{offset, key, value}], offset }] } - this.topics = new Map(); - this.groups = new Map(); // groupId -> { members, assignments } this.server = null; this.brokerId = 1; this.host = "localhost"; + this.reset(); + } + + // Clears all in-memory state back to empty. Used for per-test isolation + // and by the Parlel control plane. Idempotent, no I/O. + reset() { + // name -> { partitions: [{ records: [{offset, key, value}], offset }] } + this.topics = new Map(); + this.groups = new Map(); // groupId -> { members, assignments } } start() { diff --git a/services/mysql/src/server.js b/services/mysql/src/server.js index 9a25b60..b67bf07 100644 --- a/services/mysql/src/server.js +++ b/services/mysql/src/server.js @@ -7,10 +7,16 @@ export class MySQLServer { this.user = options.user || "parlel"; this.password = options.password || "parlel"; this.database = options.database || "parlel"; - this.tables = new Map(); - this.nextId = new Map(); this.server = null; this.sessionId = 1; + this.reset(); + } + + // Clears all in-memory state back to empty. Used for per-test isolation + // and by the Parlel control plane. Idempotent, no I/O. + reset() { + this.tables = new Map(); + this.nextId = new Map(); } start() { diff --git a/services/rabbitmq/src/server.js b/services/rabbitmq/src/server.js index 373658e..5cfc85b 100644 --- a/services/rabbitmq/src/server.js +++ b/services/rabbitmq/src/server.js @@ -26,12 +26,20 @@ const C = { export class RabbitMQServer { constructor(port = 5672) { this.port = port; - this.queues = new Map(); // name -> Array<{ body, props }> - this.exchanges = new Map(); // name -> { type, bindings: [{ queue, key }] } - this.bindings = new Map(); this.server = null; // Per-socket consumer registrations: socket -> Map + // This is live-connection state, not data fixtures, so reset() leaves it alone. this._consumers = new Map(); + this.reset(); + } + + // Clears all in-memory data state (queues, exchanges, bindings) back to empty. + // Used for per-test isolation and by the Parlel control plane. Idempotent, no I/O. + // Live per-socket consumer registrations are intentionally preserved. + reset() { + this.queues = new Map(); // name -> Array<{ body, props }> + this.exchanges = new Map(); // name -> { type, bindings: [{ queue, key }] } + this.bindings = new Map(); this._deliveryTag = 0; } diff --git a/services/supabase/src/server.js b/services/supabase/src/server.js index 8588345..c7edbae 100644 --- a/services/supabase/src/server.js +++ b/services/supabase/src/server.js @@ -16,6 +16,13 @@ function gotrueJwt(payload) { export class SupabaseServer { constructor(port = 54321) { this.port = port; + this.server = null; + this.reset(); + } + + // Clears all in-memory state back to empty. Used for per-test isolation + // and by the Parlel control plane. Idempotent, no I/O. + reset() { this.tables = new Map(); this.users = new Map(); // Auth (GoTrue) state — additive, keyed alongside the existing users map. @@ -23,7 +30,6 @@ export class SupabaseServer { this.authPasswords = new Map(); // auth user id -> password this.authSessions = new Map(); // access_token -> auth user id this.authRefreshTokens = new Map(); // refresh_token -> auth user id - this.server = null; } start() { diff --git a/test/conformance.test.ts b/test/conformance.test.ts new file mode 100644 index 0000000..a7f0c62 --- /dev/null +++ b/test/conformance.test.ts @@ -0,0 +1,205 @@ +// Conformance meta-test — asserts every service implements the Parlel emulator +// contract the launcher, probe, and control plane rely on. This is the guard that +// prevents convention drift as the catalog grows (the apigateway-stub problem). +// +// Contract (see SKILL.md / CONTRIBUTING.md): +// - services//manifest.json is valid (name, port, protocol) and the port +// is unique across the whole catalog. +// - services//src/server.js exports a `Server` class. +// - That class implements `start()`, `stop()`, and `reset()` as functions. +// - `reset()` is callable on a constructed instance without throwing (idempotent, +// no I/O) — this is what makes per-test isolation and the control plane work. +// +// We validate the contract statically (no port binding) for the whole catalog, and +// then do a live boot → /health → reset() smoke test for a representative sample of +// HTTP services to prove the contract holds end to end. + +import { describe, it, expect, beforeAll } from "vitest"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getFreePort } from "../src/test-helpers.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SERVICES_DIR = join(__dirname, "..", "services"); + +// Manifest-only stubs that are intentionally NOT launchable services (no +// src/server.js). Mirrors scripts/probe.mjs NOT_A_SERVICE. The real impls are the +// versioned variants (apigateway-v1 / apigateway-v2). +const NOT_A_SERVICE = new Set(["apigateway"]); + +// HTTP services chosen to span categories/authors for the live smoke test. Kept +// small so the suite stays fast and avoids port churn; the static checks cover all. +const LIVE_SAMPLE = [ + "stripe", + "openai", + "s3", + "sendgrid", + "elasticsearch", + "supabase", + "pinecone", + "github", +]; + +type Manifest = { + name?: string; + port?: number; + protocol?: string; + healthcheck?: string; +}; + +type Svc = { + slug: string; + manifest: Manifest; + hasServer: boolean; +}; + +async function fileExists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +async function loadServices(): Promise { + const entries = await readdir(SERVICES_DIR, { withFileTypes: true }); + const out: Svc[] = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + if (NOT_A_SERVICE.has(e.name)) continue; + const slug = e.name; + const manifestPath = join(SERVICES_DIR, slug, "manifest.json"); + let manifest: Manifest = {}; + try { + manifest = JSON.parse(await readFile(manifestPath, "utf8")); + } catch { + manifest = {}; + } + const hasServer = await fileExists(join(SERVICES_DIR, slug, "src", "server.js")); + out.push({ slug, manifest, hasServer }); + } + return out.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +// Pick the exported server class the same way src/launch.mjs does (/Server$/, else default). +function pickServerClass(mod: Record): unknown { + const candidate = Object.entries(mod).find( + ([key, value]) => typeof value === "function" && /Server$/.test(key), + ); + if (candidate) return candidate[1]; + if (typeof mod.default === "function") return mod.default; + return null; +} + +let services: Svc[]; + +beforeAll(async () => { + services = await loadServices(); +}); + +describe("catalog manifests", () => { + it("loads a non-trivial number of services", () => { + expect(services.length).toBeGreaterThan(200); + }); + + it("every service has a valid manifest (name, port, protocol)", () => { + const bad: string[] = []; + for (const s of services) { + const m = s.manifest; + const ok = + typeof m.name === "string" && + m.name.length > 0 && + typeof m.protocol === "string" && + (m.protocol === "embedded" || typeof m.port === "number"); + if (!ok) bad.push(s.slug); + } + expect(bad, `services with invalid manifest: ${bad.join(", ")}`).toEqual([]); + }); + + it("manifest name matches the directory slug", () => { + const mismatched = services + .filter((s) => s.manifest.name && s.manifest.name !== s.slug) + .map((s) => `${s.slug} (name=${s.manifest.name})`); + expect(mismatched, `slug/name mismatches: ${mismatched.join(", ")}`).toEqual([]); + }); + + it("ports are unique across the whole catalog", () => { + const byPort = new Map(); + for (const s of services) { + const port = s.manifest.port; + if (typeof port !== "number") continue; + const arr = byPort.get(port) || []; + arr.push(s.slug); + byPort.set(port, arr); + } + const collisions = [...byPort.entries()] + .filter(([, slugs]) => slugs.length > 1) + .map(([port, slugs]) => `port ${port}: ${slugs.join(", ")}`); + expect(collisions, `port collisions:\n${collisions.join("\n")}`).toEqual([]); + }); +}); + +describe("emulator contract (static)", () => { + it("every networked service ships src/server.js", () => { + const missing = services + .filter((s) => s.manifest.protocol !== "embedded" && !s.hasServer) + .map((s) => s.slug); + expect(missing, `networked services missing src/server.js: ${missing.join(", ")}`).toEqual([]); + }); + + it("every server.js exports a *Server class with start/stop/reset", async () => { + const problems: string[] = []; + for (const s of services) { + if (!s.hasServer) continue; + const modPath = join(SERVICES_DIR, s.slug, "src", "server.js"); + let mod: Record; + try { + mod = (await import(modPath)) as Record; + } catch (err) { + problems.push(`${s.slug}: import failed (${(err as Error).message})`); + continue; + } + const Ctor = pickServerClass(mod) as { prototype?: Record } | null; + if (typeof Ctor !== "function") { + problems.push(`${s.slug}: no *Server class exported`); + continue; + } + for (const method of ["start", "stop", "reset"]) { + if (typeof Ctor.prototype?.[method] !== "function") { + problems.push(`${s.slug}: missing ${method}()`); + } + } + } + expect(problems, `contract problems:\n${problems.join("\n")}`).toEqual([]); + }); +}); + +describe("emulator contract (live smoke test)", () => { + for (const slug of LIVE_SAMPLE) { + it(`${slug}: boots, answers /health, and reset() is callable`, async () => { + const modPath = join(SERVICES_DIR, slug, "src", "server.js"); + const mod = (await import(modPath)) as Record; + const Ctor = pickServerClass(mod) as new (port: number, options?: object) => { + start(): Promise; + stop(): Promise; + reset(): void; + }; + expect(typeof Ctor).toBe("function"); + + const port = await getFreePort(); + const server = new Ctor(port, {}); + await server.start(); + try { + const res = await fetch(`http://127.0.0.1:${port}/health`); + // Any non-5xx response means the health surface is alive. + expect(res.status).toBeLessThan(500); + // reset() must be callable on a running instance without throwing. + expect(() => server.reset()).not.toThrow(); + } finally { + await server.stop(); + } + }); + } +});