@ripplo/testing 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ripplo/testing
2
2
 
3
- Typed TypeScript DSL for defining end-to-end tests with [Ripplo](https://ripplo.ai).
3
+ Typed TypeScript DSL for end-to-end tests with real backend state. Powers [Ripplo](https://ripplo.ai).
4
4
 
5
5
  ## Install
6
6
 
@@ -8,57 +8,20 @@ Typed TypeScript DSL for defining end-to-end tests with [Ripplo](https://ripplo.
8
8
  npm install @ripplo/testing
9
9
  ```
10
10
 
11
- ## Quick Start
12
-
13
- 1. Run `npx ripplo` to authenticate and scaffold a `.ripplo/` directory in your project
14
- 2. Define preconditions in `.ripplo/preconditions/index.ts` — export each handle and collect them into a `preconditions` registry
15
- 3. (Optional) Define observers in `.ripplo/observers/index.ts` the same way
16
- 4. Write tests in `.ripplo/tests/` — each file exports a `TestDefinition`; `.ripplo/tests/index.ts` composes them into a `tests` array
17
- 5. Wire everything into `.ripplo/ripplo.ts` by passing the three registries to `createRipplo(config, { preconditions, observers, tests })`
18
- 6. In your app server, call `createEngine(ripplo, { preconditions, observers })` to provide implementations — TypeScript enforces that every handle is implemented exactly once
19
- 7. Run `npx ripplo lint` to validate, `npx ripplo run` to execute
20
- 8. Commit the generated `.ripplo/ripplo.lock` alongside your DSL changes (see Lockfile below)
21
-
22
- Every test gets a clean slate via preconditions — no shared state, no ordering dependencies, fully parallelizable. Observers let tests verify backend side effects (async jobs, DB rows, webhooks) without polling or sleeps in test code.
11
+ This package provides the DSL and engine adapters. The [`ripplo`](https://www.npmjs.com/package/ripplo) CLI scaffolds `.ripplo/`, runs tests, and writes the lockfile.
23
12
 
24
13
  ## Architecture
25
14
 
26
- `@ripplo/testing` has two halves that hand off through two "funnels":
27
-
28
- - **Definitions funnel into `createRipplo`.** The pure top-level factories `precondition(...)`, `observer(...)`, `test(...)` return plain handle/definition values. They have no side effects — no global builder. You gather them into registry objects and pass all three to `createRipplo(config, { preconditions, observers, tests })` in `.ripplo/ripplo.ts`. That call is the single point where the DSL graph is registered.
29
- - **Implementations funnel into `createEngine`.** In your app server, `createEngine(ripplo, { preconditions: {...}, observers: {...} })` wires every handle to its setup/teardown/run function. The impls object is exhaustiveness-checked at compile time: missing a key is a type error, adding an unknown key is a type error.
30
-
31
- The resulting `engine` is what adapters (`@ripplo/testing/express`, `/fastify`, `/nextjs`) mount under a path prefix (default `/ripplo`) — serving three routes: `PUT /execute-preconditions`, `PUT /execute-observer`, `PUT /teardown-preconditions`. Every request is HMAC-signed; the adapter is gated behind `ENABLE_RIPPLO_TESTING` so it can't ship to production by accident.
32
-
33
- Your precondition and observer implementations live in the server package where they have access to your DB/ORM. The DSL package never runs that code — it just orchestrates execution over signed HTTP.
15
+ Two halves, two funnels:
34
16
 
35
- ## Lockfile
36
-
37
- `npx ripplo compile` (and `ripplo lint`, and the dashboard's file watcher) writes `.ripplo/ripplo.lock` — a JSON artifact containing the compiled graph + tests, plus a `lockfileVersion` field. **Commit it.**
38
-
39
- The Ripplo server reads the lockfile on every GitHub push webhook. If the lockfile is missing or out of date, the branch does not sync and the webhook returns 422.
17
+ - **Definitions** → `createRipplo({ preconditions, observers, tests })` in `.ripplo/index.ts`. Pure factories (`precondition()`, `observer()`, `test()`) return plain handles; you collect them into registries and pass all three. No global builder, no side effects.
18
+ - **Implementations** → `createEngine(ripplo, { preconditions, observers })` in your app server. Wires every handle to its setup/teardown/run function. **Exhaustiveness-checked** — missing or extra keys are TypeScript errors.
40
19
 
41
- - `ripplo compile` compile + write (default).
42
- - `ripplo compile --check` — exit non-zero if the lockfile is missing or stale. Use in pre-commit hooks or CI.
43
- - `ripplo doctor` — surfaces stale lockfiles and a missing pre-commit hook.
20
+ The resulting `engine` is mounted by an adapter (`@ripplo/testing/express`, `/fastify`, `/nextjs`, `/hono`, `/koa`, `/nestjs`, `/elysia`) at a path prefix (default `/ripplo`). It exposes three signed routes: `PUT /execute-preconditions`, `PUT /execute-observer`, `PUT /teardown-preconditions`.
44
21
 
45
- ## Project Layout
46
-
47
- ```
48
- .ripplo/
49
- ripplo.ts # createRipplo(config, { preconditions, observers, tests })
50
- index.ts # re-exports the default ripplo instance + registries
51
- preconditions/index.ts # export each handle + a `preconditions` registry
52
- observers/index.ts # export each handle + an `observers` registry
53
- tests/
54
- index.ts # compose all tests into a `tests` array
55
- <test-id>.ts # each file exports a TestDefinition
56
-
57
- <your-server>/src/test/
58
- engine.ts # createEngine(ripplo, { preconditions, observers }) — impls live here
59
- ```
22
+ Implementations live server-side where they have DB access. The DSL package never runs that code — it orchestrates execution over signed HTTP.
60
23
 
61
- ## DSL API
24
+ ## DSL
62
25
 
63
26
  ### Test
64
27
 
@@ -81,17 +44,19 @@ export const inviteATeammate = test("invite-a-teammate")
81
44
  click(role("button", "Send invite")).as("send"),
82
45
  assert.visible(role("status", "Invite sent")).as("confirm toast"),
83
46
  assert
84
- .backend(invitePendingForEmail, {
85
- workspaceId: workspace.id,
86
- email: "jamie@example.com",
87
- })
47
+ .backend(invitePendingForEmail, { workspaceId: workspace.id, email: "jamie@example.com" })
88
48
  .as("confirm invite recorded"),
89
- ]);
49
+ ])
50
+ .coverage(
51
+ "src/components/members/InviteDialog.tsx#InviteDialog.click[Invite member]",
52
+ "src/components/members/InviteDialog.tsx#InviteDialog.input[Email]",
53
+ "src/components/members/InviteDialog.tsx#InviteDialog.click[Send invite]",
54
+ );
90
55
  ```
91
56
 
92
- **Chain:** `test(id)` → `.name(display)` → `.requires(preconditions)` → `.expectedOutcome(text)` → `.startsAt(urlFn)` → `.steps(stepsFn)`
57
+ `test(id)` → `.name()` → `.description()?` → `.requires()` → `.expectedOutcome()` → `.startsAt()` → `.steps()` → `.coverage(...ids)`. Use `.notImplemented()` instead of `.startsAt() / .steps() / .coverage()` to stub during planning.
93
58
 
94
- Use `.notImplemented()` in place of `.startsAt() + .steps()` to stub a test during planning.
59
+ `.coverage(...)` ids come from the generated `.ripplo/coverage.d.ts` and ambiently augment `CoverageRegistry`, so the call autocompletes and type-errors on stale ids. Stubs skip it; implemented tests must include every interaction they exercise. The `stop-enforce` hook blocks on net-new interactions in the diff that no test claims.
95
60
 
96
61
  ### Preconditions
97
62
 
@@ -103,16 +68,14 @@ export const authLoggedIn = precondition("auth:logged-in")
103
68
  .contract<{ userId: string }>();
104
69
 
105
70
  export const dataProject = precondition("data:project")
106
- .description("A project exists and the user is an admin member")
71
+ .description("Project exists; user is admin")
107
72
  .requires({ auth: authLoggedIn })
108
73
  .contract<{ orgId: string; projectId: string }>();
109
74
 
110
75
  export const preconditions = { authLoggedIn, dataProject };
111
76
  ```
112
77
 
113
- **Chain:** `precondition(name)` → `.description(text)` → `.requires(deps)` → `.contract<TData>()`
114
-
115
- The generic on `.contract<T>()` describes the shape of the data the precondition setup must return. Each field must be a `string` (run-scoped test values).
78
+ `precondition(name)` → `.description()` → `.requires()` (optional) → `.contract<T>()`. Each field in `T` must be a `string` (run-scoped value).
116
79
 
117
80
  ### Observers
118
81
 
@@ -120,61 +83,60 @@ The generic on `.contract<T>()` describes the shape of the data the precondition
120
83
  import { observer } from "@ripplo/testing";
121
84
 
122
85
  export const orgNameIs = observer("org:name-is")
123
- .description("Org with the given id has the given name in the DB")
86
+ .description("Org has the given name in the DB")
124
87
  .input<{ orgId: string; expectedName: string }>()
125
- .budget("fast") // optional; "fast" is default
88
+ .budget("fast")
126
89
  .contract();
127
90
 
128
91
  export const observers = { orgNameIs };
129
92
  ```
130
93
 
131
- **Chain:** `observer(name)` → `.description(text)` `.input<TInput>()` `.budget(tier)` (optional) → `.contract()`
132
-
133
- **Budget tiers** (framework-defined, not numeric):
94
+ **Budget tiers** (poll behavior, not numeric timeouts):
134
95
 
135
- - `"fast"` ~5s with 100→1000ms backoff. Default. Synchronous DB reads.
136
- - `"slow"` ~30s with 250→2000ms backoff. Queue drains, replication settling.
137
- - `"async"` ~120s with 5005000ms backoff. Webhooks, queue workers, LLM calls.
96
+ | Tier | Window | Backoff | Use for |
97
+ | ------- | ------ | ---------- | ---------------------------------- |
98
+ | `fast` | ~5s | 1001000ms | Synchronous DB reads (default) |
99
+ | `slow` | ~30s | 250→2000ms | Queue drains, replication settling |
100
+ | `async` | ~120s | 500→5000ms | Webhooks, queue workers, LLM calls |
138
101
 
139
102
  ### Locators
140
103
 
141
- Only two locator types are available. ARIA roles are strongly preferred.
104
+ ARIA roles strongly preferred; `testId()` is a fallback only.
142
105
 
143
106
  ```typescript
144
107
  import { role, testId } from "@ripplo/testing/locators";
145
108
 
146
- role("button", "Save"); // ARIA role + accessible name (preferred)
147
- role("heading", "Settings"); // role without interaction
148
- role("textbox", "Email"); // input by role
149
- role("combobox", "Country"); // select/dropdown by role
150
- testId("workflow-checkbox"); // data-testid (fallback only)
109
+ role("button", "Save");
110
+ role("textbox", "Email");
111
+ role("combobox", "Country");
112
+ testId("workflow-checkbox");
151
113
  ```
152
114
 
153
- **Available ARIA roles:** `alert`, `alertdialog`, `button`, `checkbox`, `combobox`, `dialog`, `form`, `grid`, `heading`, `img`, `link`, `list`, `listbox`, `listitem`, `menu`, `menuitem`, `navigation`, `option`, `progressbar`, `radio`, `region`, `row`, `searchbox`, `separator`, `slider`, `spinbutton`, `status`, `switch`, `tab`, `tabpanel`, `textbox`, `toolbar`, `tooltip`, `tree`, `treeitem`
115
+ **Roles:** `alert`, `alertdialog`, `button`, `checkbox`, `combobox`, `dialog`, `form`, `grid`, `heading`, `img`, `link`, `list`, `listbox`, `listitem`, `menu`, `menuitem`, `navigation`, `option`, `progressbar`, `radio`, `region`, `row`, `searchbox`, `separator`, `slider`, `spinbutton`, `status`, `switch`, `tab`, `tabpanel`, `textbox`, `toolbar`, `tooltip`, `tree`, `treeitem`.
154
116
 
155
- **Type-safe constraints:**
117
+ Type-narrowed locators reject the wrong role at compile time:
156
118
 
157
- - `InputLocator` accepts: `role("textbox")`, `role("searchbox")`, `role("combobox")`, `role("spinbutton")`, `testId()`
158
- - `SelectLocator` accepts: `role("combobox")`, `role("listbox")`, `testId()`
159
- - `CheckLocator` accepts: `role("checkbox")`, `role("switch")`, `testId()`
119
+ - `InputLocator` `textbox`, `searchbox`, `combobox`, `spinbutton`, `testId()`
120
+ - `SelectLocator` `combobox`, `listbox`, `testId()`
121
+ - `CheckLocator` `checkbox`, `switch`, `testId()`
160
122
 
161
123
  ### Actions
162
124
 
163
125
  ```typescript
164
126
  import {
165
127
  click,
128
+ dblclick,
129
+ rightClick,
130
+ hover,
131
+ focus,
132
+ press,
166
133
  fill,
134
+ clear,
135
+ typeText,
167
136
  select,
168
137
  check,
169
138
  uncheck,
170
- hover,
171
- press,
172
139
  navigate,
173
- dblclick,
174
- focus,
175
- clear,
176
- typeText,
177
- rightClick,
178
140
  scrollIntoView,
179
141
  drag,
180
142
  upload,
@@ -184,19 +146,14 @@ import {
184
146
  setViewport,
185
147
  } from "@ripplo/testing/actions";
186
148
 
187
- navigate("/settings"); // Go to URL
188
- click(role("button", "Save")); // Click element
189
- fill(role("textbox", "Email"), "test@x.com"); // Clear + type into input
190
- select(role("combobox", "Role"), "admin"); // Select option
191
- check(role("checkbox", "Terms")); // Check checkbox/switch
192
- uncheck(role("switch", "Notifications")); // Uncheck
193
- hover(role("button", "Info")); // Hover
194
- press("Enter"); // Press key
195
- focus(role("searchbox", "Search")); // Focus element
196
- dblclick(role("button", "Edit")); // Double click
197
- clear(role("textbox", "Search")); // Clear input
198
- upload(testId("file-input"), "./test.png"); // Upload file
199
- drag(role("row", "Item 1"), role("row", "Item 2")); // Drag and drop
149
+ navigate("/settings");
150
+ click(role("button", "Save"));
151
+ fill(role("textbox", "Email"), "test@x.com"); // clear + type
152
+ select(role("combobox", "Role"), "admin");
153
+ check(role("checkbox", "Terms"));
154
+ press("Enter");
155
+ upload(testId("file-input"), "./test.png");
156
+ drag(role("row", "Item 1"), role("row", "Item 2"));
200
157
  ```
201
158
 
202
159
  ### Assertions
@@ -204,100 +161,69 @@ drag(role("row", "Item 1"), role("row", "Item 2")); // Drag and drop
204
161
  ```typescript
205
162
  import { assert } from "@ripplo/testing/assert";
206
163
 
207
- assert.visible(role("heading", "Settings")); // Element visible
208
- assert.not.visible(role("dialog")); // Element not visible
209
- assert.text(role("status"), "3 / 5 runs"); // Exact text match
210
- assert.url("/projects/abc/settings"); // Exact URL match
211
- assert.enabled(role("button", "Submit")); // Element enabled
212
- assert.disabled(role("button", "Submit")); // Element disabled
213
- assert.checked(role("checkbox", "Terms")); // Checked
214
- assert.not.checked(role("switch", "Dark mode")); // Not checked
215
- assert.focused(role("textbox", "Search")); // Has focus
216
- assert.not.focused(role("textbox", "Search")); // No focus
217
- assert.count(testId("row"), 5); // Element count
218
- assert.attribute(role("link", "Docs"), "href", "/docs"); // Attribute value
219
- assert.value(role("textbox", "Email"), "test@x.com"); // Input value
220
- assert.backend(observerHandle, { ... }); // Backend state (see Observers)
164
+ assert.visible(role("heading", "Settings"));
165
+ assert.not.visible(role("dialog"));
166
+ assert.text(role("status"), "3 / 5 runs"); // exact match
167
+ assert.url("/projects/abc/settings");
168
+ assert.enabled(role("button", "Submit"));
169
+ assert.checked(role("checkbox", "Terms"));
170
+ assert.focused(role("textbox", "Search"));
171
+ assert.count(testId("row"), 5);
172
+ assert.attribute(role("link", "Docs"), "href", "/docs");
173
+ assert.value(role("textbox", "Email"), "test@x.com");
174
+ assert.backend(observerHandle, { ...params }); // see Observers
221
175
  ```
222
176
 
223
- All text/URL assertions use **exact matching only** (`equals` operator). No `contains`, `startsWith`, or regex.
177
+ All text/URL assertions are **exact match only**. No `contains`, `startsWith`, or regex.
178
+
179
+ ### Variables
224
180
 
225
- ### Variables & Extraction
181
+ Capture values at runtime, then reuse them:
226
182
 
227
183
  ```typescript
228
184
  import { extract, variable } from "@ripplo/testing/control";
229
185
 
230
186
  const token = variable("token");
231
187
  extract(testId("token-value"), token).as("capture token");
232
- // pass the token anywhere a string value is accepted:
233
188
  fill(role("textbox", "Paste here"), token).as("paste token");
234
189
  assert.value(role("textbox", "Paste here"), token).as("assert token");
235
190
  ```
236
191
 
237
- Never reference a runtime variable with a literal `"{{vars.name}}"` string — always pass the `Variable` token so TypeScript verifies the reference.
192
+ ### Step labels
238
193
 
239
- ### Step Labels
240
-
241
- Every step **must** have a `.as("description")` label:
194
+ Every step **must** have `.as("description")`. No duplicates within a test.
242
195
 
243
196
  ```typescript
244
197
  click(role("button", "Save")).as("save the form");
245
- assert.visible(role("status", "Saved")).as("verify save confirmation");
246
198
  ```
247
199
 
248
- ## Data Flow
200
+ ## Data flow
249
201
 
250
- Precondition data flows into tests via destructuring:
202
+ Precondition data flows in via destructuring. **Always destructure — never hardcode values that come from preconditions:**
251
203
 
252
204
  ```typescript
253
205
  test("delete-project")
254
- .name("Delete project")
255
206
  .requires({ project: dataProject })
256
- .expectedOutcome("project no longer exists")
257
207
  .startsAt(({ project }) => `/projects/${project.projectId}/settings`)
258
208
  .steps(({ project }) => [
259
- // project.projectId, project.orgId available here
260
209
  navigate(`/projects/${project.projectId}/settings`).as("go to settings"),
261
210
  // ...
262
211
  ]);
263
212
  ```
264
213
 
265
- **Always destructure and use precondition data.** Never hardcode values that come from preconditions if a precondition implementation changes, the test should not break.
266
-
267
- **Never write `"{{namespace.key}}"` as a string literal.** Pass the destructured proxy value directly. Internally the proxy stringifies to `{{namespace.key}}`, but writing the literal bypasses TypeScript so typos (`{{tabel.name}}`) silently compile and silently fail at runtime. The `no-literal-template-strings` lint rule enforces this.
214
+ **Never write `"{{namespace.key}}"` as a string literal.** The proxy stringifies internally, but writing the literal bypasses TypeScript so typos compile and fail at runtime. The `no-literal-template-strings` lint rule blocks this.
268
215
 
269
216
  ```typescript
270
- // ❌ WRONGliteral template string, no type-checking
271
- .steps(({ table }) => [
272
- assert.value(role("textbox", "Table name"), "{{table.name}}").as("name visible"),
273
- ])
274
-
275
- // ✅ CORRECT — proxy value, type-checked against requires()
276
- .steps(({ table }) => [
277
- assert.value(role("textbox", "Table name"), table.name).as("name visible"),
278
- ])
279
- ```
217
+ // ❌ literal — no type-checking
218
+ assert.value(role("textbox", "Table name"), "{{table.name}}").as("name visible");
280
219
 
281
- Runtime variables (captured via `clipboard`, `extract`, etc.) follow the same rule. Create a token with `variable("name")` and pass it anywhere a value is accepted:
282
-
283
- ```typescript
284
- import { variable } from "@ripplo/testing/control";
285
-
286
- .steps(() => {
287
- const copied = variable("copied");
288
- return [
289
- click(role("button", "Copy")).as("copy"),
290
- clipboard({ action: "read", target: copied, value: undefined }).as("read clipboard"),
291
- assert.value(role("button", "Copy"), copied).as("match clipboard"),
292
- ];
293
- })
220
+ // proxy checked against requires()
221
+ assert.value(role("textbox", "Table name"), table.name).as("name visible");
294
222
  ```
295
223
 
296
- Never write `"{{vars.copied}}"` as a string literal — use the token.
297
-
298
- ## Wiring it together
224
+ ## Wiring
299
225
 
300
- ### `.ripplo/ripplo.ts` — the definitions funnel
226
+ ### `.ripplo/index.ts` — definitions
301
227
 
302
228
  ```typescript
303
229
  import { createRipplo } from "@ripplo/testing";
@@ -305,25 +231,15 @@ import { preconditions } from "./preconditions/index.js";
305
231
  import { observers } from "./observers/index.js";
306
232
  import { tests } from "./tests/index.js";
307
233
 
308
- const ripplo = createRipplo(
309
- {
310
- appUrl: "https://localhost:3001",
311
- engineUrl: "https://localhost:3001/ripplo",
312
- projectId: "<your-project-id>",
313
- },
314
- { preconditions, observers, tests },
315
- );
316
-
317
- export default ripplo;
234
+ export default createRipplo({ preconditions, observers, tests });
318
235
  ```
319
236
 
320
- `webhookSecret` is read from `RIPPLO_WEBHOOK_SECRET` in `.ripplo/.env` (auto-loaded by the CLI before each compile). You can still pass it explicitly to `createRipplo` if you need to.
237
+ Runtime config (`RIPPLO_APP_URL`, `RIPPLO_ENGINE_URL`, `RIPPLO_WEBHOOK_SECRET`) lives in your host app's env file `ripplo init` writes the initial values. Project id and env-file pointers live in `.ripplo/project.json`.
321
238
 
322
- ### App server the implementations funnel
239
+ ### `<app>/src/test/engine.ts` — implementations
323
240
 
324
241
  ```typescript
325
- // <your-server>/src/test/engine.ts
326
- import { createEngine } from "@ripplo/testing";
242
+ import { createEngine, notImplemented } from "@ripplo/testing";
327
243
  import ripplo from "../../../../.ripplo/index.js";
328
244
  import { prisma } from "../lib/prisma.js";
329
245
 
@@ -338,16 +254,7 @@ export const engine = createEngine(ripplo, {
338
254
  // clean up using ctx.data.userId
339
255
  },
340
256
  },
341
- dataProject: {
342
- setup: async (ctx, { auth }) => {
343
- // use auth.userId to create a project
344
- return {
345
- orgId: ctx.fixed("..."),
346
- projectId: ctx.fixed("..."),
347
- };
348
- },
349
- teardown: async () => {},
350
- },
257
+ dataProject: notImplemented("awaiting prisma seed helper"), // stub for planning
351
258
  },
352
259
  observers: {
353
260
  orgNameIs: async (ctx, { orgId, expectedName }) => {
@@ -356,116 +263,64 @@ export const engine = createEngine(ripplo, {
356
263
  where: { id: orgId },
357
264
  });
358
265
  if (org == null) return ctx.retry(`organization "${orgId}" not found yet`);
359
- if (org.name !== expectedName) {
360
- return ctx.retry(`name is "${org.name}", expected "${expectedName}"`);
361
- }
266
+ if (org.name !== expectedName) return ctx.retry(`name is "${org.name}"`);
362
267
  return ctx.pass();
363
268
  },
364
269
  },
365
270
  });
366
271
  ```
367
272
 
368
- **Exhaustiveness:** the `preconditions` and `observers` keys in this object must exactly match the registries you passed to `createRipplo`. Missing a key is a TypeScript error; adding an unknown key is a TypeScript error.
369
-
370
- **Stubbing an impl:** use the `notImplemented` sentinel to keep planning-mode placeholders while the surrounding code still compiles.
371
-
372
- ```typescript
373
- import { createEngine, notImplemented } from "@ripplo/testing";
374
-
375
- createEngine(ripplo, {
376
- preconditions: {
377
- authLoggedIn: { setup, teardown },
378
- dataProject: notImplemented("awaiting prisma seed helper"),
379
- },
380
- observers: { orgNameIs: notImplemented() },
381
- });
382
- ```
383
-
384
- The engine returns a "not implemented" failure outcome at runtime for any stub, and `ripplo lint` can be configured to block CI on unimplemented entries.
385
-
386
- ### SetupContext
273
+ ### Setup context
387
274
 
388
- `ctx` passed to each precondition `setup` provides:
275
+ `ctx` passed to each precondition `setup`:
389
276
 
390
- - `ctx.runId` — unique 12-char id for this test run
277
+ - `ctx.runId` — unique 12-char id for this run
391
278
  - `ctx.fixed(value)` — static test value
392
- - `ctx.uniqueId(prefix)` — generate unique ID (e.g., `ripplo-test-<prefix>-<runId>`)
393
- - `ctx.uniqueEmail()` — generate unique email (`ripplo-test-<runId>@test.ripplo.ai`)
394
- - `ctx.setCookie(name, value, options?)` — inject auth cookies; forwarded as `Set-Cookie` to the test browser
279
+ - `ctx.uniqueId(prefix)` — `ripplo-test-<prefix>-<runId>`
280
+ - `ctx.uniqueEmail()` — `ripplo-test-<runId>@test.ripplo.ai`
281
+ - `ctx.setCookie(name, value, options?)` — forwarded to the test browser as `Set-Cookie`
395
282
 
396
- ### ObserverContext
283
+ ### Observer context
397
284
 
398
- `ctx` passed to each observer impl provides:
285
+ - `ctx.pass()` assertion satisfied; stop polling.
286
+ - `ctx.retry(reason)` — **default.** Anything that might succeed on a later poll: row not found yet, status not transitioned, queue not drained. Last `reason` surfaces in failure detail.
287
+ - `ctx.fail(reason)` — **narrow.** Only when polling cannot help: invariant violated, contradictory value. Stops immediately. Tempted to use `fail` for "not found"? It's `retry`.
288
+ - Thrown exceptions are treated as `fail`.
399
289
 
400
- - `ctx.pass()` → assertion satisfied; stop polling.
401
- - `ctx.retry(reason)` → **default**. Use for anything that might succeed on a later poll: row not found yet, status not transitioned, queue not drained, side effect still in flight. Runtime polls until the budget expires; the last `reason` surfaces in the failure detail.
402
- - `ctx.fail(reason)` → **narrow**. Only when further polling cannot help — an invariant has been violated (wrong shape, contradictory value, forbidden state). Stops immediately. If you're tempted to use `fail` for "not found" or "not yet X", it should be `retry`.
403
- - Any thrown exception is treated as `fail` with the error message.
290
+ ## Observer coverage
404
291
 
405
- ## Observer Usage in Tests
292
+ Lint enforces backend assertions on mutation flows:
406
293
 
407
- ```typescript
408
- import { orgNameIs } from "../observers/index.js";
294
+ - **`mutation-without-observer-coverage`** — flags save/create/delete/update clicks (and uploads, accepted dialogs) not followed by an `assert.backend(...)`. Fix is always to **add an observer**.
295
+ - **`observer-params-reference-variables`** flags assertions whose params are all hardcoded strings while the test declares precondition variables.
409
296
 
410
- test("update-org-name")
411
- .name("Update org name")
412
- .requires({ project: dataProject })
413
- .expectedOutcome("org name persisted")
414
- .startsAt(({ project }) => `/projects/${project.projectId}/settings`)
415
- .steps(({ project }) => [
416
- fill(role("textbox", "Organization name"), "New Name").as("fill new name"),
417
- click(role("button", "Save")).as("click save"),
418
- assert
419
- .backend(orgNameIs, { orgId: project.orgId, expectedName: "New Name" })
420
- .as("assert org name in db"),
421
- ]);
422
- ```
423
-
424
- ### Observer lint rules
425
-
426
- - **`mutation-without-observer-coverage`** — flags save/create/delete/update/etc. clicks, uploads, and accepted dialogs that are not followed by an `assert.backend(...)` before the next mutation or end of test. The expected fix is always to **add an observer**.
427
- - **`observer-params-reference-variables`** — flags observer assertions whose params are all hardcoded strings while the test declares precondition variables; use precondition data instead of literals.
428
-
429
- ### Opting out (`uiOnly`)
430
-
431
- For steps that genuinely don't touch server state (cancel dialogs, toggle a display-only control, pick a client-side sort option) pass `{ uiOnly: true }` to the step factory:
297
+ For steps that genuinely don't touch server state (cancel dialogs, client-side sort), pass `{ uiOnly: true }`:
432
298
 
433
299
  ```typescript
434
300
  click(role("button", "Cancel"), { uiOnly: true }).as("close dialog");
301
+ test("filter-sort", { uiOnly: true }).name("Filter & sort");
435
302
  ```
436
303
 
437
- For entire presentation-only flows, pass the flag to `test`:
438
-
439
- ```typescript
440
- test("filter-sort-user-flows", { uiOnly: true }).name("Filter & sort");
441
- ```
304
+ `uiOnly` is for **zero backend effect**. Mutations and optimistic UI need an observer — never use `uiOnly` with a `// TODO: add observer` comment.
442
305
 
443
- `uiOnly: true` is only valid for steps with zero backend effect (pure view transitions). If the step triggers a mutation, background job, or optimistic UI update, `uiOnly` is wrong — write an observer. The `mutation-without-observer-coverage` lint rule is a backstop, not a ceiling: authoring a test without backend assertions on a mutation flow is a mistake regardless of whether lint catches this particular click label. **Never use `uiOnly: true` with a `// TODO: add observer` comment as a placeholder.** That's a false green — the observer is in-scope work, not follow-up.
306
+ ## Determinism rules
444
307
 
445
- ## Determinism Rules
308
+ 1. `role()` locators only; `testId()` only when no role applies.
309
+ 2. Exact-match assertions only.
310
+ 3. Destructure precondition data; never hardcode names/IDs/emails.
311
+ 4. Every step has `.as("description")`. No duplicates.
312
+ 5. End with assertions that verify `expectedOutcome`.
313
+ 6. Cover backend mutations with observers (or `uiOnly: true`).
446
314
 
447
- 1. **Use `role()` locators exclusively.** Only use `testId()` when no ARIA role is available.
448
- 2. **All text assertions use exact matching.** No `contains`, `startsWith`, or regex.
449
- 3. **Destructure precondition data in `steps()`.** Never hardcode names, IDs, or emails that come from preconditions.
450
- 4. **Every step must have `.as("description")`.** No unlabeled steps.
451
- 5. **No duplicate labels** within a test.
452
- 6. **End with assertions** that verify the `expectedOutcome`.
453
- 7. **Cover backend mutations with observers.** Any save/create/delete/update click (or upload, or accepted dialog) must be verified with `assert.backend(observerHandle, params)` unless it truly has no server effect — in which case mark the step `{ uiOnly: true }`.
315
+ ## Server adapters
454
316
 
455
- ## CLI Commands
317
+ Every adapter takes the `engine` from `createEngine(...)` and a required `enabled: boolean`. Bind `enabled` to an env var so the routes can't ship to production:
456
318
 
457
- ```bash
458
- ripplo # Launch interactive dashboard
459
- ripplo lint [ids..] # Compile + lint tests (all or specific ids)
460
- ripplo run [ids..] # Run tests in parallel
461
- ripplo flake-detect <id> --runs=10 # Run N times in parallel to detect flakiness
319
+ ```ts
320
+ enabled: process.env.ENABLE_RIPPLO_TESTING === "true";
462
321
  ```
463
322
 
464
- ## Server Setup
465
-
466
- Pick the adapter that matches your framework — each handles webhook signature verification, cookie forwarding, and request parsing for you. Every adapter takes a required `enabled: boolean` flag — bind it to an env var (e.g. `process.env.ENABLE_RIPPLO_TESTING === "true"`) so the endpoints never ship to production. When `enabled` is false the adapter mounts a no-op handler.
467
-
468
- All adapters take the `engine` produced by `createEngine(ripplo, impls)` — not the bare `ripplo` instance.
323
+ When `enabled` is false the adapter mounts a no-op handler.
469
324
 
470
325
  ### Express
471
326
 
@@ -475,13 +330,7 @@ import { createExpressHandler } from "@ripplo/testing/express";
475
330
  import { engine } from "./test/engine.js";
476
331
 
477
332
  const app = express();
478
- app.use(
479
- "/ripplo",
480
- createExpressHandler({
481
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
482
- engine,
483
- }),
484
- );
333
+ app.use("/ripplo", createExpressHandler({ enabled, engine }));
485
334
  ```
486
335
 
487
336
  ### Fastify
@@ -492,32 +341,21 @@ import { registerFastifyHandler } from "@ripplo/testing/fastify";
492
341
  import { engine } from "./test/engine.js";
493
342
 
494
343
  const app = Fastify();
495
- await app.register(
496
- registerFastifyHandler({
497
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
498
- engine,
499
- }),
500
- { prefix: "/ripplo" },
501
- );
344
+ await app.register(registerFastifyHandler({ enabled, engine }), { prefix: "/ripplo" });
502
345
  ```
503
346
 
504
347
  ### Next.js (App Router)
505
348
 
506
- The Next.js adapter exports a single catch-all handler. Create one dynamic route file:
349
+ A single catch-all route runs on Node and Edge.
507
350
 
508
351
  ```ts
509
352
  // app/ripplo/[action]/route.ts
510
353
  import { createNextHandler } from "@ripplo/testing/nextjs";
511
354
  import { engine } from "@/server/test/engine";
512
355
 
513
- export const PUT = createNextHandler({
514
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
515
- engine,
516
- });
356
+ export const PUT = createNextHandler({ enabled, engine });
517
357
  ```
518
358
 
519
- The handler dispatches on the last URL segment (`execute-preconditions`, `execute-observer`, `teardown-preconditions`) and returns 404 for anything else. It depends only on the Web `Request` / `Response` types, so it runs on both the Node and Edge runtimes — no `next` import required.
520
-
521
359
  ### Hono
522
360
 
523
361
  ```ts
@@ -526,16 +364,10 @@ import { createHonoHandler } from "@ripplo/testing/hono";
526
364
  import { engine } from "./test/engine.js";
527
365
 
528
366
  const app = new Hono();
529
- app.route(
530
- "/ripplo",
531
- createHonoHandler({
532
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
533
- engine,
534
- }),
535
- );
367
+ app.route("/ripplo", createHonoHandler({ enabled, engine }));
536
368
  ```
537
369
 
538
- The Hono adapter uses the Web `Request` / `Response` standard and runs on Node, Bun, Deno, and Cloudflare Workers.
370
+ Web-standard `Request`/`Response`; runs on Node, Bun, Deno, Cloudflare Workers.
539
371
 
540
372
  ### Koa
541
373
 
@@ -546,18 +378,10 @@ import { createKoaHandler } from "@ripplo/testing/koa";
546
378
  import { engine } from "./test/engine.js";
547
379
 
548
380
  const app = new Koa();
549
- app.use(
550
- mount(
551
- "/ripplo",
552
- createKoaHandler({
553
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
554
- engine,
555
- }),
556
- ),
557
- );
381
+ app.use(mount("/ripplo", createKoaHandler({ enabled, engine })));
558
382
  ```
559
383
 
560
- The Koa adapter reads the raw request body itself — do not mount a body-parser in front of it. Mount it at any prefix with `koa-mount`.
384
+ The Koa adapter reads the raw body itself — don't mount a body-parser in front of it.
561
385
 
562
386
  ### NestJS
563
387
 
@@ -567,18 +391,12 @@ import { RipploTestingModule } from "@ripplo/testing/nestjs";
567
391
  import { engine } from "./test/engine.js";
568
392
 
569
393
  @Module({
570
- imports: [
571
- RipploTestingModule.forRoot({
572
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
573
- engine,
574
- path: "ripplo",
575
- }),
576
- ],
394
+ imports: [RipploTestingModule.forRoot({ enabled, engine, path: "ripplo" })],
577
395
  })
578
396
  export class AppModule {}
579
397
  ```
580
398
 
581
- Requires the Express platform adapter (`@nestjs/platform-express`) and `reflect-metadata` at the entry point. `path` defaults to `"ripplo"`.
399
+ Requires `@nestjs/platform-express` and `reflect-metadata`. `path` defaults to `"ripplo"`.
582
400
 
583
401
  ### Elysia
584
402
 
@@ -587,19 +405,12 @@ import { Elysia } from "elysia";
587
405
  import { createElysiaHandler } from "@ripplo/testing/elysia";
588
406
  import { engine } from "./test/engine.js";
589
407
 
590
- const app = new Elysia().group("/ripplo", (app) =>
591
- app.use(
592
- createElysiaHandler({
593
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
594
- engine,
595
- }),
596
- ),
597
- );
408
+ const app = new Elysia().group("/ripplo", (g) => g.use(createElysiaHandler({ enabled, engine })));
598
409
  ```
599
410
 
600
- ### Custom integration (raw engine)
411
+ ### Custom (raw engine)
601
412
 
602
- If your framework isn't covered above, use the engine directly. The adapters are thin wrappers over the same API.
413
+ For unsupported frameworks. The adapters above are thin wrappers over this API.
603
414
 
604
415
  ```ts
605
416
  import { buildSetCookieHeader, serializeCookie, verifyWebhookSignature } from "@ripplo/testing";
@@ -607,7 +418,6 @@ import { engine } from "./test/engine.js";
607
418
 
608
419
  const webhookSecret = engine.getConfig().webhookSecret;
609
420
 
610
- // PUT /ripplo/execute-preconditions
611
421
  async function executePreconditions(req: Request): Promise<Response> {
612
422
  const body = await req.text();
613
423
  const verified = verifyWebhookSignature(
@@ -628,34 +438,39 @@ async function executePreconditions(req: Request): Promise<Response> {
628
438
  const result = await engine.executePreconditions(preconditions, { appUrl });
629
439
 
630
440
  const headers = new Headers({ "content-type": "application/json" });
631
- result.cookies.forEach((c) => {
632
- headers.append("Set-Cookie", buildSetCookieHeader(serializeCookie(c)));
633
- });
441
+ result.cookies.forEach((c) =>
442
+ headers.append("Set-Cookie", buildSetCookieHeader(serializeCookie(c))),
443
+ );
634
444
  return new Response(JSON.stringify(result), { headers });
635
445
  }
636
-
637
- // PUT /ripplo/teardown-preconditions — same verify pattern, then engine.teardown(...)
638
- // PUT /ripplo/execute-observer — same verify pattern, then engine.executeObserver(...)
446
+ // teardown-preconditions and execute-observer follow the same verify-then-dispatch pattern.
639
447
  ```
640
448
 
641
- **You're responsible for:**
449
+ You're responsible for: webhook verification (always before invoking the engine), routing the three endpoints, forwarding `result.cookies` to the test browser as `Set-Cookie` headers, and reading the raw body for signature verification before `JSON.parse`.
642
450
 
643
- - **Webhook verification.** Always call `verifyWebhookSignature` before invoking the engine.
644
- - **Routing.** Dispatch the three endpoints (`execute-preconditions`, `execute-observer`, `teardown-preconditions`) however your framework handles routes.
645
- - **Cookie forwarding.** `result.cookies` contains the cookies preconditions set during setup — they must reach the test browser as `Set-Cookie` headers, or login/session preconditions will silently fail.
646
- - **Body parsing.** Use the raw text body for signature verification, then `JSON.parse` for the engine call.
451
+ ## Security & parallelism
647
452
 
648
- ### Webhook Signing
453
+ - All requests signed via Standard Webhooks (HMAC-SHA256). Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`. **Always verify before executing.**
454
+ - `ENABLE_RIPPLO_TESTING` gates every adapter. Never expose in production.
455
+ - Use `ctx.uniqueId(prefix)` / `ctx.uniqueEmail()` so parallel runs don't collide.
456
+ - Return created entity IDs in the data contract; teardown deletes only that run's data using the captured `runId`. Never bulk-delete.
649
457
 
650
- All requests are signed using Standard Webhooks (HMAC-SHA256). Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`. Verify before executing.
458
+ ## Lockfile
459
+
460
+ `ripplo compile` (and `ripplo lint`, and `ripplo watch`) writes `.ripplo/ripplo.lock`. **Commit it.** The Ripplo server reads it on every push webhook; missing or stale returns 422.
651
461
 
652
- ### Environment Guard
462
+ - `ripplo compile` — write.
463
+ - `ripplo compile --check` — non-zero if stale. Use in pre-commit hooks and CI.
464
+ - `ripplo doctor` — surfaces stale lockfiles and missing pre-commit hooks.
653
465
 
654
- Wrap all precondition routes behind `ENABLE_RIPPLO_TESTING=true`. Never expose in production.
466
+ ## CLI
655
467
 
656
- ### Parallel Safety
468
+ The CLI lives in [`ripplo`](https://www.npmjs.com/package/ripplo). Most-used commands:
657
469
 
658
- - Use `ctx.uniqueId(prefix)` / `ctx.uniqueEmail()` in precondition setup so names/emails don't collide across parallel runs
659
- - Return created entity IDs in the data contract so teardown can scope deletion precisely
660
- - Teardown only deletes that run's data (use the `runId` captured at setup)
661
- - Never hardcode entity names or use bulk deletion
470
+ ```bash
471
+ ripplo auth login # authenticate
472
+ ripplo init # scaffold .ripplo/ + write env vars
473
+ ripplo watch # local executor wire into your dev script
474
+ ripplo lint [ids..] # compile + lint
475
+ ripplo run [ids..] # run tests in parallel
476
+ ```