@ripplo/testing 0.5.0 → 0.5.2

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,25 +1,24 @@
1
1
  # @ripplo/testing
2
2
 
3
- Typed TypeScript DSL for end-to-end tests with real backend state. Powers [Ripplo](https://ripplo.ai).
4
-
5
- ## Install
3
+ Typed TypeScript DSL for end-to-end tests with real backend state, used by [Ripplo](https://ripplo.ai).
6
4
 
7
5
  ```sh
8
6
  npm install @ripplo/testing
9
7
  ```
10
8
 
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.
9
+ This package ships the DSL and the server adapters. The companion [`ripplo`](https://www.npmjs.com/package/ripplo) CLI scaffolds `.ripplo/`, runs tests, and writes the lockfile.
12
10
 
13
11
  ## Architecture
14
12
 
15
- Two halves, two funnels:
13
+ Definitions and implementations are kept in separate files.
14
+
15
+ `createRipplo({ preconditions, observers, tests })` in `.ripplo/index.ts` collects the handles returned by `precondition()`, `observer()`, and `test()`. These factories have no side effects.
16
16
 
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.
17
+ `createEngine(ripplo, { preconditions, observers })` in your app server wires every handle to its setup, teardown, or observer function. Missing or extra keys are TypeScript errors.
19
18
 
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`.
19
+ Mount the resulting `engine` with one of the adapters (`@ripplo/testing/express`, `/fastify`, `/nextjs`, `/hono`, `/koa`, `/nestjs`, `/elysia`) at a path prefix (default `/ripplo`). The adapter exposes three signed routes: `PUT /execute-preconditions`, `PUT /execute-observer`, `PUT /teardown-preconditions`.
21
20
 
22
- Implementations live server-side where they have DB access. The DSL package never runs that code — it orchestrates execution over signed HTTP.
21
+ Implementations run server-side where they have DB access. The DSL package never invokes them; it dispatches over signed HTTP.
23
22
 
24
23
  ## DSL
25
24
 
@@ -56,7 +55,7 @@ export const inviteATeammate = test("invite-a-teammate")
56
55
 
57
56
  `test(id)` → `.name()` → `.description()?` → `.requires()` → `.expectedOutcome()` → `.startsAt()` → `.steps()` → `.coverage(...ids)`. Use `.notImplemented()` instead of `.startsAt() / .steps() / .coverage()` to stub during planning.
58
57
 
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.
58
+ `.coverage(...)` ids come from the generated `.ripplo/coverage.d.ts`, which augments `CoverageRegistry` so the call autocompletes and breaks the build on stale ids. Implemented tests must list every interaction they exercise; stubs skip it. A pre-commit hook blocks net-new interactions in the diff that no test claims.
60
59
 
61
60
  ### Preconditions
62
61
 
@@ -75,18 +74,18 @@ export const dataProject = precondition("data:project")
75
74
  export const preconditions = { authLoggedIn, dataProject };
76
75
  ```
77
76
 
78
- `precondition(name)` → `.description()` → `.requires()` (optional) → `.contract<T>()`. Each field in `T` must be a primitive `string`, `number`, or `boolean` (run-scoped value).
77
+ `precondition(name)` → `.description()` → `.requires()` (optional) → `.contract<T>()`. Each field in `T` must be a primitive (`string`, `number`, or `boolean`) and is run-scoped.
79
78
 
80
79
  #### Preconditions are create-only
81
80
 
82
- Precondition setups must be **additive**: insert new rows, don't `update` or `delete` existing ones. Concurrent runs share a database, and a `WHERE` clause that looks scoped to the current run can match rows that belong to another run in flight. Even when writes are reliably scoped, mutating a row another precondition produced creates ordering dependencies that break under composition.
81
+ Precondition setups must only insert. No `update`, no `delete`. Parallel runs share a database, so a `WHERE` clause that looks run-scoped can still match another run's rows. And mutating something a parent precondition produced couples them in an order that breaks the moment they're composed differently.
83
82
 
84
- If a test needs a non-default state, the precondition that **creates** the underlying row should accept that state as input — don't seed a default and mutate it from a downstream precondition.
83
+ If a test needs a non-default state, accept that state as input on the precondition that creates the row. Don't seed a default and patch it later.
85
84
 
86
- Allowed exceptions:
85
+ Two exceptions:
87
86
 
88
- - `upsert` on a row whose primary key makes it a 1:1 settings record for the current run (e.g. a per-`(userId, resourceId)` view). Treat it as create-with-default.
89
- - Teardown may delete but only data the precondition itself created.
87
+ - `upsert` on a row whose primary key is per-run (e.g. a `(userId, resourceId)` settings record). Treat it as create-with-default.
88
+ - Teardown may delete, but only rows this precondition created.
90
89
 
91
90
  ### Observers
92
91
 
@@ -102,26 +101,9 @@ export const orgNameIs = observer("org:name-is")
102
101
  export const observers = { orgNameIs };
103
102
  ```
104
103
 
105
- Observer `.input<T>()` and precondition `.contract<T>()` fields can be any primitive — `string`, `number`, or `boolean`. Model each field as the type it actually is; no stringification at the boundary.
106
-
107
- ```typescript
108
- export const orgOverageCapIs = observer("org:overage-cap-is")
109
- .description("Org has the given overageCapCents")
110
- .budget("fast")
111
- .input<{ orgId: string; expectedCapCents: number }>()
112
- .contract();
113
-
114
- export const orgHasLogo = observer("org:has-logo")
115
- .description("Organization logo is set / unset")
116
- .budget("fast")
117
- .input<{ orgId: string; expectLogo: boolean }>()
118
- .contract();
119
-
120
- assert.backend(orgOverageCapIs, { orgId: project.orgId, expectedCapCents: 2500 });
121
- assert.backend(orgHasLogo, { orgId: project.orgId, expectLogo: true });
122
- ```
104
+ Observer `.input<T>()` and precondition `.contract<T>()` fields are typed primitives: `string`, `number`, or `boolean`. The wire codec preserves the type, so a `number` field arrives as a `number` in your impl.
123
105
 
124
- **Budget tiers** (poll behavior, not numeric timeouts):
106
+ `.budget(tier)` controls how long the runtime polls before giving up. The tiers describe poll behavior, not numeric timeouts:
125
107
 
126
108
  | Tier | Window | Backoff | Use for |
127
109
  | ------- | ------ | ---------- | ---------------------------------- |
@@ -131,16 +113,7 @@ assert.backend(orgHasLogo, { orgId: project.orgId, expectLogo: true });
131
113
 
132
114
  ### Locators
133
115
 
134
- ARIA roles strongly preferred; `testId()` is a fallback only.
135
-
136
- ```typescript
137
- import { role, testId } from "@ripplo/testing/locators";
138
-
139
- role("button", "Save");
140
- role("textbox", "Email");
141
- role("combobox", "Country");
142
- testId("workflow-checkbox");
143
- ```
116
+ `role(name, accessibleName)` matches by ARIA role and accessible name. `testId(id)` matches a `data-testid` attribute and exists for elements that genuinely have no semantic role.
144
117
 
145
118
  **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`.
146
119
 
@@ -152,76 +125,33 @@ Type-narrowed locators reject the wrong role at compile time:
152
125
 
153
126
  ### Actions
154
127
 
155
- ```typescript
156
- import {
157
- click,
158
- dblclick,
159
- rightClick,
160
- hover,
161
- focus,
162
- press,
163
- fill,
164
- clear,
165
- typeText,
166
- select,
167
- check,
168
- uncheck,
169
- navigate,
170
- scrollIntoView,
171
- drag,
172
- fixture,
173
- upload,
174
- handleDialog,
175
- clipboard,
176
- setPermission,
177
- setViewport,
178
- } from "@ripplo/testing/actions";
179
-
180
- navigate("/settings");
181
- click(role("button", "Save"));
182
- fill(role("textbox", "Email"), "test@x.com"); // clear + type
183
- select(role("combobox", "Role"), "admin");
184
- check(role("checkbox", "Terms"));
185
- press("Enter");
186
- upload(testId("file-input"), fixture("logo.png"));
187
- drag(role("row", "Item 1"), role("row", "Item 2"));
188
- ```
189
-
190
- ### Upload fixtures
128
+ Imported from `@ripplo/testing/actions`. Every action takes a locator and produces a step:
191
129
 
192
- `upload()` requires a `fixture()` reference. Fixture files live in `.ripplo/fixtures/` (committed). Limits: 10 MB per file, 50 MB total.
130
+ - **Pointer:** `click`, `dblclick`, `rightClick`, `hover`, `focus`
131
+ - **Keyboard:** `press` (single key or chord), `typeText` (no clear), `fill` (clear + type), `clear`
132
+ - **Form controls:** `select`, `check`, `uncheck`
133
+ - **Navigation:** `navigate`, `scrollIntoView`
134
+ - **Composite:** `drag`, `upload`, `handleDialog`, `clipboard`
135
+ - **Browser context:** `setPermission`, `setViewport`
193
136
 
194
- ```ts
195
- // .ripplo/fixtures/logo.png exists
196
- upload(testId("logo-input"), fixture("logo.png"));
137
+ ### Upload fixtures
197
138
 
198
- // Multiple files
199
- upload(testId("attachments"), [fixture("a.pdf"), fixture("b.pdf")]);
200
- ```
139
+ `upload(locator, fixture("name"))` is the only way to attach files. Fixture files live in `.ripplo/fixtures/` and are committed to git so cloud runs see byte-identical inputs. Limits: 10 MB per file, 50 MB total. Pass an array to `upload` for multi-file inputs.
201
140
 
202
141
  ### Assertions
203
142
 
204
- ```typescript
205
- import { assert } from "@ripplo/testing/assert";
206
-
207
- assert.visible(role("heading", "Settings"));
208
- assert.not.visible(role("dialog"));
209
- assert.text(role("status"), "3 / 5 runs"); // exact match
210
- assert.url("/projects/abc/settings");
211
- assert.enabled(role("button", "Submit"));
212
- assert.checked(role("checkbox", "Terms"));
213
- assert.focused(role("textbox", "Search"));
214
- assert.count(testId("row"), 5);
215
- assert.attribute(role("link", "Docs"), "href", "/docs");
216
- assert.value(role("textbox", "Email"), "test@x.com");
217
- assert.backend(observerHandle, { ...params }); // see Observers
218
- ```
143
+ Imported from `@ripplo/testing/assert`. Every assertion is exact-match; there is no `contains`, `startsWith`, or regex.
219
144
 
220
- All text/URL assertions are **exact match only**. No `contains`, `startsWith`, or regex.
145
+ - **Visibility:** `assert.visible(loc)`, `assert.not.visible(loc)`
146
+ - **Content:** `assert.text(loc, exact)`, `assert.value(loc, exact)`, `assert.attribute(loc, name, exact)`
147
+ - **State:** `assert.enabled(loc)`, `assert.checked(loc)`, `assert.focused(loc)`
148
+ - **Counting:** `assert.count(loc, n)`
149
+ - **Routing:** `assert.url(path)`
150
+ - **Backend:** `assert.backend(observer, params)` — see [Observers](#observers).
221
151
 
222
152
  ### Variables
223
153
 
224
- Capture values at runtime, then reuse them:
154
+ `variable(name)` declares a placeholder. `extract(locator, variable)` captures the element's text or value at run time; later steps that take a string accept the variable in its place.
225
155
 
226
156
  ```typescript
227
157
  import { extract, variable } from "@ripplo/testing/control";
@@ -234,15 +164,11 @@ assert.value(role("textbox", "Paste here"), token).as("assert token");
234
164
 
235
165
  ### Step labels
236
166
 
237
- Every step **must** have `.as("description")`. No duplicates within a test.
238
-
239
- ```typescript
240
- click(role("button", "Save")).as("save the form");
241
- ```
167
+ Every step ends with `.as("short description")`. Labels appear in the run UI and in failure detail, and duplicate labels within a test are a compile error. Describe intent ("open invite dialog"), not mechanics ("click button").
242
168
 
243
169
  ## Data flow
244
170
 
245
- Precondition data flows in via destructuring. **Always destructure never hardcode values that come from preconditions:**
171
+ Precondition output reaches steps by destructuring the argument to `.startsAt()` and `.steps()`. Always destructure; never hardcode a value that came from a precondition.
246
172
 
247
173
  ```typescript
248
174
  test("delete-project")
@@ -254,13 +180,13 @@ test("delete-project")
254
180
  ]);
255
181
  ```
256
182
 
257
- **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.
183
+ Never write `"{{namespace.key}}"` as a string literal. The proxy stringifies internally, but writing the literal bypasses the type checker, so typos compile and fail at run time. The `no-literal-template-strings` rule blocks this.
258
184
 
259
185
  ```typescript
260
- // literal no type-checking
186
+ // literal: bypasses type-checking
261
187
  assert.value(role("textbox", "Table name"), "{{table.name}}").as("name visible");
262
188
 
263
- // proxy checked against requires()
189
+ // proxy: checked against requires()
264
190
  assert.value(role("textbox", "Table name"), table.name).as("name visible");
265
191
  ```
266
192
 
@@ -277,7 +203,7 @@ import { tests } from "./tests/index.js";
277
203
  export default createRipplo({ preconditions, observers, tests });
278
204
  ```
279
205
 
280
- 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`.
206
+ 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`.
281
207
 
282
208
  ### `<app>/src/test/engine.ts` — implementations
283
209
 
@@ -289,8 +215,8 @@ import { prisma } from "../lib/prisma.js";
289
215
  export const engine = createEngine(ripplo, {
290
216
  preconditions: {
291
217
  authLoggedIn: {
292
- // setup receives a *batch* of items — one per concurrent run that requires this precondition.
293
- // Issue one bulk write (createMany) and return one result per item, in input order.
218
+ // setup receives one item per concurrent run that needs this precondition.
219
+ // Issue one bulk write and return results in input order.
294
220
  setup: async (items) => {
295
221
  const seeds = items.map(({ ctx }) => ({
296
222
  id: ctx.uniqueId("user"),
@@ -322,61 +248,46 @@ export const engine = createEngine(ripplo, {
322
248
  });
323
249
  ```
324
250
 
325
- `setup` and `teardown` are **batched**: the runtime collects all concurrent runs that need a precondition within a 200ms window and calls the impl once with the full batch. Use `createMany` / `deleteMany` over per-item `create` / `delete` to keep DB load proportional to wall-clock time, not run count. The result array length must equal the input array length and order must be preserved (the engine zips by index back to runs).
251
+ `setup` and `teardown` run in batches. The runtime groups concurrent runs that need the same precondition inside a 200ms window and calls your impl once for the whole batch. Use `createMany` / `deleteMany` so database load scales with wall-clock time rather than run count. Return one result per input item, in input order; the engine zips them back to runs by index.
326
252
 
327
253
  ### Setup context
328
254
 
329
- `item.ctx` available inside each batched setup item:
255
+ Each batched setup item exposes `ctx`:
330
256
 
331
- - `ctx.runId` — unique 12-char id for this run
332
- - `ctx.fixed<T extends string | number | boolean>(value: T)` — static test value (any primitive)
333
- - `ctx.uniqueId(prefix)` `ripplo-test-<prefix>-<runId>`
334
- - `ctx.uniqueEmail()` `ripplo-test-<runId>@test.ripplo.ai`
335
- - `ctx.setCookie(name, value, options?)` applied to the run's browser context before the test starts
257
+ - `ctx.runId` — 12-char run id.
258
+ - `ctx.fixed(value)` — static test value, type-branded so the engine can tell it apart from a raw literal.
259
+ - `ctx.uniqueId(prefix)` returns `ripplo-test-<prefix>-<runId>`.
260
+ - `ctx.uniqueEmail()` returns `ripplo-test-<runId>@test.ripplo.ai`.
261
+ - `ctx.setCookie(name, value, options?)` applies to that run's browser context before the test starts.
336
262
 
337
- Helpers return plain primitives — interpolate, JSON.stringify, or pass through observer params directly. The return type is type-branded, so a hardcoded literal in a `setup` return fails at compile time.
263
+ Helpers return plain primitives, but the return type is branded a bare string literal in a `setup` return fails at compile time, which is what stops tests from accidentally seeding identical-looking data across runs.
338
264
 
339
265
  ### Observer context
340
266
 
267
+ The observer impl returns one of three terminal states:
268
+
341
269
  - `ctx.pass()` — assertion satisfied; stop polling.
342
- - `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.
343
- - `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`.
344
- - Thrown exceptions are treated as `fail`.
270
+ - `ctx.retry(reason)` — the default; use it for anything that might succeed on a later poll (row not yet written, status not yet transitioned, queue not yet drained). The last `reason` is surfaced in failure detail. "Not found" belongs here, not in `fail`.
271
+ - `ctx.fail(reason)` — narrow; only when polling cannot help (invariant violated, contradictory value). Stops immediately.
345
272
 
346
- ## Observer coverage
273
+ Thrown exceptions are treated as `fail`.
347
274
 
348
- Lint enforces backend assertions on mutation flows:
275
+ ## Observer coverage
349
276
 
350
- - **`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**.
351
- - **`observer-params-reference-variables`** — flags assertions whose params are all hardcoded strings while the test declares precondition variables.
277
+ Two lint rules enforce backend assertions on mutation flows. `mutation-without-observer-coverage` flags save / create / update / delete clicks, uploads, and accepted dialogs that aren't followed by an `assert.backend(...)`. `observer-params-reference-variables` flags assertions whose params are all string literals while the test declares precondition variables.
352
278
 
353
- For steps that genuinely don't touch server state (cancel dialogs, client-side sort), pass `{ uiOnly: true }`:
279
+ Steps that genuinely touch no server state (cancel dialogs, client-side sort) opt out with `{ uiOnly: true }`:
354
280
 
355
281
  ```typescript
356
282
  click(role("button", "Cancel"), { uiOnly: true }).as("close dialog");
357
283
  test("filter-sort", { uiOnly: true }).name("Filter & sort");
358
284
  ```
359
285
 
360
- `uiOnly` is for **zero backend effect**. Mutations and optimistic UI need an observer — never use `uiOnly` with a `// TODO: add observer` comment.
361
-
362
- ## Determinism rules
363
-
364
- 1. `role()` locators only; `testId()` only when no role applies.
365
- 2. Exact-match assertions only.
366
- 3. Destructure precondition data; never hardcode names/IDs/emails.
367
- 4. Every step has `.as("description")`. No duplicates.
368
- 5. End with assertions that verify `expectedOutcome`.
369
- 6. Cover backend mutations with observers (or `uiOnly: true`).
286
+ `uiOnly` means zero backend effect. Mutations and optimistic UI need an observer.
370
287
 
371
288
  ## Server adapters
372
289
 
373
- 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:
374
-
375
- ```ts
376
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true";
377
- ```
378
-
379
- When `enabled` is false the adapter mounts a no-op handler.
290
+ Every adapter takes the `engine` from `createEngine(...)` and a required `enabled: boolean`. Bind `enabled` to `process.env.ENABLE_RIPPLO_TESTING === "true"` so the routes can't ship to production; when it's false the adapter mounts a no-op handler.
380
291
 
381
292
  ### Express
382
293
 
@@ -402,7 +313,7 @@ await app.register(registerFastifyHandler({ enabled, engine }), { prefix: "/ripp
402
313
 
403
314
  ### Next.js (App Router)
404
315
 
405
- A single catch-all route runs on Node and Edge.
316
+ A single catch-all route. Works on Node and Edge.
406
317
 
407
318
  ```ts
408
319
  // app/ripplo/[action]/route.ts
@@ -423,7 +334,7 @@ const app = new Hono();
423
334
  app.route("/ripplo", createHonoHandler({ enabled, engine }));
424
335
  ```
425
336
 
426
- Web-standard `Request`/`Response`; runs on Node, Bun, Deno, Cloudflare Workers.
337
+ Web-standard `Request`/`Response`. Runs on Node, Bun, Deno, and Cloudflare Workers.
427
338
 
428
339
  ### Koa
429
340
 
@@ -437,7 +348,7 @@ const app = new Koa();
437
348
  app.use(mount("/ripplo", createKoaHandler({ enabled, engine })));
438
349
  ```
439
350
 
440
- The Koa adapter reads the raw body itself don't mount a body-parser in front of it.
351
+ The Koa adapter reads the raw body itself; don't mount a body-parser in front of it.
441
352
 
442
353
  ### NestJS
443
354
 
@@ -466,7 +377,7 @@ const app = new Elysia().group("/ripplo", (g) => g.use(createElysiaHandler({ ena
466
377
 
467
378
  ### Custom (raw engine)
468
379
 
469
- For unsupported frameworks. The adapters above are thin wrappers over this API.
380
+ For frameworks not listed above. The adapters above are thin wrappers over this API. A custom adapter reads the raw request body, verifies the signature with `verifyWebhookSignature`, dispatches the three routes to `engine.executePreconditions`, `engine.executeObserver`, and `engine.teardownPreconditions`, and shapes responses with `toBatchRunResults` / `toTeardownResults`. Cookies travel inside the JSON response body, not `Set-Cookie` headers; the runtime parses them out and applies them to the browser context.
470
381
 
471
382
  ```ts
472
383
  import {
@@ -493,48 +404,38 @@ async function executePreconditions(req: Request): Promise<Response> {
493
404
  return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 401 });
494
405
  }
495
406
 
496
- // Request body is a batch: { batch: [{ runId, preconditions: [...names] }, ...] }
497
407
  const { batch } = JSON.parse(body);
498
408
  const appUrl = `${req.headers.get("x-forwarded-proto") ?? "http"}://${req.headers.get("host")}`;
499
409
  const results = await engine.executePreconditions(
500
410
  batch.map((b) => ({ runId: b.runId, names: b.preconditions })),
501
411
  { appUrl },
502
412
  );
503
-
504
- // Response body: { results: [{ runId, ok, cookies, data, executed } | { runId, ok: false, error }] }
505
413
  return new Response(JSON.stringify({ results: toBatchRunResults(results) }), {
506
414
  headers: { "content-type": "application/json" },
507
415
  });
508
416
  }
509
- // teardown-preconditions and execute-observer follow the same verify-then-dispatch pattern.
510
- // teardown's request shape is { batch: [{ runId, preconditions, data }, ...] }, response { results: [{ runId, ok, error? }] }.
511
417
  ```
512
418
 
513
- You're responsible for: webhook verification (always before invoking the engine), routing the three endpoints, calling `toBatchRunResults` / `toTeardownResults` to shape the response body, and reading the raw body for signature verification before `JSON.parse`. Cookies travel inside the JSON response body never as `Set-Cookie` headers; the runtime parses them out and applies them to the test browser context.
419
+ `executeObserver` and `teardownPreconditions` follow the same verify-then-dispatch shape. Request bodies: `{ batch: [{ runId, preconditions, data }] }` for teardown, `{ runId, name, params }` for observers.
514
420
 
515
421
  ## Security & parallelism
516
422
 
517
- - All requests signed via Standard Webhooks (HMAC-SHA256). Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`. **Always verify before executing.**
518
- - `ENABLE_RIPPLO_TESTING` gates every adapter. Never expose in production.
519
- - Use `ctx.uniqueId(prefix)` / `ctx.uniqueEmail()` so parallel runs don't collide.
520
- - Return created entity IDs in the data contract; teardown deletes only data this run created. Bulk operations are fine — and encouraged — as long as the `WHERE` is scoped to the batch's own ids (`deleteMany({ where: { id: { in: items.map((it) => it.ctx.data.userId) } } })`). Never write a `WHERE` that could match another run's rows.
423
+ Every request is signed with Standard Webhooks (HMAC-SHA256) over the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers; verify before executing. `ENABLE_RIPPLO_TESTING` gates every adapter and must never be true in production.
521
424
 
522
- ## Lockfile
425
+ For parallel runs, use `ctx.uniqueId(prefix)` and `ctx.uniqueEmail()` to avoid collisions, return the created entity ids in the data contract, and scope teardown's `WHERE` to those ids. `deleteMany` is fine and preferred, as long as the predicate can only match this batch's rows.
523
426
 
524
- `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.
427
+ ## Lockfile
525
428
 
526
- - `ripplo compile` write.
527
- - `ripplo compile --check` — non-zero if stale. Use in pre-commit hooks and CI.
528
- - `ripplo doctor` — surfaces stale lockfiles and missing pre-commit hooks.
429
+ `ripplo compile`, `ripplo lint`, and `ripplo watch` all write `.ripplo/ripplo.lock`. Commit it; the Ripplo server reads it on every push webhook and returns 422 if it's missing or stale. `ripplo compile --check` exits non-zero on a stale lockfile and belongs in pre-commit and CI. `ripplo doctor` surfaces stale lockfiles and missing pre-commit hooks.
529
430
 
530
431
  ## CLI
531
432
 
532
- The CLI lives in [`ripplo`](https://www.npmjs.com/package/ripplo). Most-used commands:
533
-
534
433
  ```bash
535
434
  ripplo auth login # authenticate
536
- ripplo init # scaffold .ripplo/ + write env vars
537
- ripplo watch # local executor run as a standalone background process
435
+ ripplo init # scaffold .ripplo/ and write env vars
436
+ ripplo watch # local executor (run as a background process)
538
437
  ripplo lint [ids..] # compile + lint
539
438
  ripplo run [ids..] # run tests in parallel
540
439
  ```
440
+
441
+ Full command reference at [`ripplo`](https://www.npmjs.com/package/ripplo).
package/dist/actions.d.ts CHANGED
@@ -20,6 +20,7 @@ declare function navigate(url: string): UnlabeledStep<{
20
20
  };
21
21
  }>;
22
22
  interface StepOptions {
23
+ readonly modifier?: "Alt" | "Control" | "Meta" | "Shift";
23
24
  readonly uiOnly?: boolean;
24
25
  }
25
26
  declare function click(locator: AnyLocator, options?: StepOptions): UnlabeledStep<{
@@ -31,6 +32,7 @@ declare function click(locator: AnyLocator, options?: StepOptions): UnlabeledSte
31
32
  role: string;
32
33
  name?: string | undefined;
33
34
  };
35
+ modifier: "Alt" | "Control" | "Meta" | "Shift" | undefined;
34
36
  type: "click";
35
37
  uiOnly: boolean | undefined;
36
38
  }>;
package/dist/actions.js CHANGED
@@ -34,6 +34,7 @@ function navigate(url) {
34
34
  function click(locator, options) {
35
35
  return createStep({
36
36
  locator: toSpecLocator(locator),
37
+ modifier: options?.modifier,
37
38
  type: "click",
38
39
  uiOnly: options?.uiOnly
39
40
  });
@@ -62,6 +62,12 @@ declare const lockfileBodyV2Schema: z.ZodObject<{
62
62
  name: z.ZodOptional<z.ZodString>;
63
63
  role: z.ZodString;
64
64
  }, z.core.$strip>], "by">;
65
+ modifier: z.ZodOptional<z.ZodEnum<{
66
+ Alt: "Alt";
67
+ Control: "Control";
68
+ Meta: "Meta";
69
+ Shift: "Shift";
70
+ }>>;
65
71
  type: z.ZodLiteral<"click">;
66
72
  id: z.ZodString;
67
73
  label: z.ZodOptional<z.ZodString>;
package/dist/lockfile.js CHANGED
@@ -237,7 +237,12 @@ var gotoNode = z7.object({
237
237
  type: z7.literal("goto"),
238
238
  url: stringValueRefSchema
239
239
  });
240
- var clickNode = z7.object({ ...nodeBase, locator: locatorSchema, type: z7.literal("click") });
240
+ var clickNode = z7.object({
241
+ ...nodeBase,
242
+ locator: locatorSchema,
243
+ modifier: z7.enum(["Alt", "Control", "Meta", "Shift"]).optional(),
244
+ type: z7.literal("click")
245
+ });
241
246
  var fillNode = z7.object({
242
247
  ...nodeBase,
243
248
  locator: locatorSchema,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ripplo/testing",
3
3
  "description": "TypeScript DSL for defining and running Ripplo e2e workflow tests",
4
- "version": "0.5.0",
4
+ "version": "0.5.2",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"