@ripplo/testing 0.5.1 → 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 +74 -173
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
85
|
+
Two exceptions:
|
|
87
86
|
|
|
88
|
-
- `upsert` on a row whose primary key
|
|
89
|
-
- Teardown may delete
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
195
|
-
// .ripplo/fixtures/logo.png exists
|
|
196
|
-
upload(testId("logo-input"), fixture("logo.png"));
|
|
137
|
+
### Upload fixtures
|
|
197
138
|
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
186
|
+
// literal: bypasses type-checking
|
|
261
187
|
assert.value(role("textbox", "Table name"), "{{table.name}}").as("name visible");
|
|
262
188
|
|
|
263
|
-
//
|
|
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
|
|
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
|
|
293
|
-
// Issue one bulk write
|
|
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`
|
|
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
|
-
|
|
255
|
+
Each batched setup item exposes `ctx`:
|
|
330
256
|
|
|
331
|
-
- `ctx.runId` —
|
|
332
|
-
- `ctx.fixed
|
|
333
|
-
- `ctx.uniqueId(prefix)`
|
|
334
|
-
- `ctx.uniqueEmail()`
|
|
335
|
-
- `ctx.setCookie(name, value, options?)`
|
|
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
|
|
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)` —
|
|
343
|
-
- `ctx.fail(reason)` —
|
|
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
|
-
|
|
273
|
+
Thrown exceptions are treated as `fail`.
|
|
347
274
|
|
|
348
|
-
|
|
275
|
+
## Observer coverage
|
|
349
276
|
|
|
350
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
+
## Lockfile
|
|
525
428
|
|
|
526
|
-
|
|
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/
|
|
537
|
-
ripplo watch # local executor
|
|
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).
|