@ripplo/testing 0.1.1 → 0.3.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 +206 -171
- package/dist/assert.d.ts +1 -1
- package/dist/assert.js +4 -4
- package/dist/{builder-c7tXey03.d.ts → builder-CiAO8dEG.d.ts} +29 -29
- package/dist/{chunk-3IL457A7.js → chunk-76BU4M6E.js} +26 -3
- package/dist/chunk-TO3T2D2Y.js +84 -0
- package/dist/compiler.d.ts +2 -2
- package/dist/engine-L0JWPRkj.d.ts +47 -0
- package/dist/express.d.ts +5 -4
- package/dist/express.js +4 -7
- package/dist/fastify.d.ts +5 -4
- package/dist/fastify.js +4 -7
- package/dist/index.d.ts +5 -28
- package/dist/index.js +529 -270
- package/dist/lockfile.d.ts +2 -2
- package/dist/nextjs.d.ts +5 -4
- package/dist/nextjs.js +6 -9
- package/dist/{types-oYS_Yv4G.d.ts → types-Degkxs1f.d.ts} +10 -1
- package/package.json +1 -1
- package/dist/chunk-CD3M7H5A.js +0 -332
package/README.md
CHANGED
|
@@ -11,20 +11,24 @@ npm install @ripplo/testing
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
13
|
1. Run `npx ripplo` to authenticate and scaffold a `.ripplo/` directory in your project
|
|
14
|
-
2. Define preconditions in `.ripplo/preconditions
|
|
15
|
-
3. (Optional) Define observers in `.ripplo/observers
|
|
16
|
-
4. Write tests in `.ripplo/tests
|
|
17
|
-
5.
|
|
18
|
-
6.
|
|
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)
|
|
19
21
|
|
|
20
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.
|
|
21
23
|
|
|
22
24
|
## Architecture
|
|
23
25
|
|
|
24
|
-
`@ripplo/testing` has two halves:
|
|
26
|
+
`@ripplo/testing` has two halves that hand off through two "funnels":
|
|
25
27
|
|
|
26
|
-
- **
|
|
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.
|
|
28
32
|
|
|
29
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.
|
|
30
34
|
|
|
@@ -38,20 +42,35 @@ The Ripplo server reads the lockfile on every GitHub push webhook. If the lockfi
|
|
|
38
42
|
- `ripplo compile --check` — exit non-zero if the lockfile is missing or stale. Use in pre-commit hooks or CI.
|
|
39
43
|
- `ripplo doctor` — surfaces stale lockfiles and a missing pre-commit hook.
|
|
40
44
|
|
|
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
|
+
```
|
|
60
|
+
|
|
41
61
|
## DSL API
|
|
42
62
|
|
|
43
|
-
### Test
|
|
63
|
+
### Test
|
|
44
64
|
|
|
45
65
|
```typescript
|
|
46
|
-
import
|
|
47
|
-
import { dataWorkspace } from "../preconditions/index.js";
|
|
48
|
-
import { invitePendingForEmail } from "../observers/index.js";
|
|
66
|
+
import { test } from "@ripplo/testing";
|
|
49
67
|
import { click, fill } from "@ripplo/testing/actions";
|
|
50
68
|
import { assert } from "@ripplo/testing/assert";
|
|
51
69
|
import { role } from "@ripplo/testing/locators";
|
|
70
|
+
import { dataWorkspace } from "../preconditions/index.js";
|
|
71
|
+
import { invitePendingForEmail } from "../observers/index.js";
|
|
52
72
|
|
|
53
|
-
|
|
54
|
-
.test("invite-a-teammate")
|
|
73
|
+
export const inviteATeammate = test("invite-a-teammate")
|
|
55
74
|
.name("Invite a teammate")
|
|
56
75
|
.requires({ workspace: dataWorkspace })
|
|
57
76
|
.expectedOutcome("Invite appears in the pending list and an invite record is created")
|
|
@@ -70,9 +89,52 @@ ripplo
|
|
|
70
89
|
]);
|
|
71
90
|
```
|
|
72
91
|
|
|
73
|
-
**Chain:**
|
|
92
|
+
**Chain:** `test(id)` → `.name(display)` → `.requires(preconditions)` → `.expectedOutcome(text)` → `.startsAt(urlFn)` → `.steps(stepsFn)`
|
|
93
|
+
|
|
94
|
+
Use `.notImplemented()` in place of `.startsAt() + .steps()` to stub a test during planning.
|
|
95
|
+
|
|
96
|
+
### Preconditions
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { precondition } from "@ripplo/testing";
|
|
100
|
+
|
|
101
|
+
export const authLoggedIn = precondition("auth:logged-in")
|
|
102
|
+
.description("Authenticated test user with a valid session")
|
|
103
|
+
.contract<{ userId: string }>();
|
|
104
|
+
|
|
105
|
+
export const dataProject = precondition("data:project")
|
|
106
|
+
.description("A project exists and the user is an admin member")
|
|
107
|
+
.requires({ auth: authLoggedIn })
|
|
108
|
+
.contract<{ orgId: string; projectId: string }>();
|
|
109
|
+
|
|
110
|
+
export const preconditions = { authLoggedIn, dataProject };
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Chain:** `precondition(name)` → `.description(text)` → `.requires(deps)` → `.contract<TData>()`
|
|
74
114
|
|
|
75
|
-
|
|
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).
|
|
116
|
+
|
|
117
|
+
### Observers
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import { observer } from "@ripplo/testing";
|
|
121
|
+
|
|
122
|
+
export const orgNameIs = observer("org:name-is")
|
|
123
|
+
.description("Org with the given id has the given name in the DB")
|
|
124
|
+
.input<{ orgId: string; expectedName: string }>()
|
|
125
|
+
.budget("fast") // optional; "fast" is default
|
|
126
|
+
.contract();
|
|
127
|
+
|
|
128
|
+
export const observers = { orgNameIs };
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Chain:** `observer(name)` → `.description(text)` → `.input<TInput>()` → `.budget(tier)` (optional) → `.contract()`
|
|
132
|
+
|
|
133
|
+
**Budget tiers** (framework-defined, not numeric):
|
|
134
|
+
|
|
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 500→5000ms backoff. Webhooks, queue workers, LLM calls.
|
|
76
138
|
|
|
77
139
|
### Locators
|
|
78
140
|
|
|
@@ -155,6 +217,7 @@ assert.not.focused(role("textbox", "Search")); // No focus
|
|
|
155
217
|
assert.count(testId("row"), 5); // Element count
|
|
156
218
|
assert.attribute(role("link", "Docs"), "href", "/docs"); // Attribute value
|
|
157
219
|
assert.value(role("textbox", "Email"), "test@x.com"); // Input value
|
|
220
|
+
assert.backend(observerHandle, { ... }); // Backend state (see Observers)
|
|
158
221
|
```
|
|
159
222
|
|
|
160
223
|
All text/URL assertions use **exact matching only** (`equals` operator). No `contains`, `startsWith`, or regex.
|
|
@@ -178,125 +241,142 @@ click(role("button", "Save")).as("save the form");
|
|
|
178
241
|
assert.visible(role("status", "Saved")).as("verify save confirmation");
|
|
179
242
|
```
|
|
180
243
|
|
|
181
|
-
##
|
|
182
|
-
|
|
183
|
-
Preconditions declare test data requirements with typed contracts:
|
|
184
|
-
|
|
185
|
-
```typescript
|
|
186
|
-
import ripplo from "./ripplo.js";
|
|
187
|
-
|
|
188
|
-
export const authLoggedIn = ripplo
|
|
189
|
-
.precondition("auth:logged-in")
|
|
190
|
-
.description("Authenticated test user with a valid session")
|
|
191
|
-
.contract<{ userId: string }>();
|
|
192
|
-
|
|
193
|
-
export const dataProject = ripplo
|
|
194
|
-
.precondition("data:project")
|
|
195
|
-
.description("A project exists and the user is an admin member")
|
|
196
|
-
.requires({ auth: authLoggedIn })
|
|
197
|
-
.contract<{ orgId: string; projectId: string }>();
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
**Chain:** `.precondition(name)` → `.description(text)` → `.requires(deps)` → `.contract<T>()`
|
|
201
|
-
|
|
202
|
-
Use `.notImplemented()` instead of `.contract<T>()` for stubs.
|
|
203
|
-
|
|
204
|
-
### Data Flow
|
|
244
|
+
## Data Flow
|
|
205
245
|
|
|
206
246
|
Precondition data flows into tests via destructuring:
|
|
207
247
|
|
|
208
248
|
```typescript
|
|
209
|
-
|
|
210
|
-
.
|
|
249
|
+
test("delete-project")
|
|
250
|
+
.name("Delete project")
|
|
211
251
|
.requires({ project: dataProject })
|
|
252
|
+
.expectedOutcome("project no longer exists")
|
|
212
253
|
.startsAt(({ project }) => `/projects/${project.projectId}/settings`)
|
|
213
254
|
.steps(({ project }) => [
|
|
214
|
-
navigate(`/projects/${project.projectId}/settings`).as("go to settings"),
|
|
215
255
|
// project.projectId, project.orgId available here
|
|
256
|
+
navigate(`/projects/${project.projectId}/settings`).as("go to settings"),
|
|
257
|
+
// ...
|
|
216
258
|
]);
|
|
217
259
|
```
|
|
218
260
|
|
|
219
261
|
**Always destructure and use precondition data.** Never hardcode values that come from preconditions — if a precondition implementation changes, the test should not break.
|
|
220
262
|
|
|
221
|
-
|
|
263
|
+
## Wiring it together
|
|
264
|
+
|
|
265
|
+
### `.ripplo/ripplo.ts` — the definitions funnel
|
|
222
266
|
|
|
223
267
|
```typescript
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
268
|
+
import { createRipplo } from "@ripplo/testing";
|
|
269
|
+
import { preconditions } from "./preconditions/index.js";
|
|
270
|
+
import { observers } from "./observers/index.js";
|
|
271
|
+
import { tests } from "./tests/index.js";
|
|
272
|
+
|
|
273
|
+
const ripplo = createRipplo(
|
|
274
|
+
{
|
|
275
|
+
appUrl: "https://localhost:3001",
|
|
276
|
+
engineUrl: "https://localhost:3001/ripplo",
|
|
277
|
+
projectId: "<your-project-id>",
|
|
232
278
|
},
|
|
233
|
-
}
|
|
279
|
+
{ preconditions, observers, tests },
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
export default ripplo;
|
|
234
283
|
```
|
|
235
284
|
|
|
236
|
-
|
|
285
|
+
`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
286
|
|
|
238
|
-
|
|
239
|
-
- `ctx.fixed(value)` — static test value
|
|
240
|
-
- `ctx.uniqueId(prefix)` — generate unique ID (e.g., `ripplo-test-abc123`)
|
|
241
|
-
- `ctx.uniqueEmail()` — generate unique email
|
|
242
|
-
- `ctx.setCookie(name, value, options?)` — inject auth cookies
|
|
287
|
+
### App server — the implementations funnel
|
|
243
288
|
|
|
244
|
-
|
|
289
|
+
```typescript
|
|
290
|
+
// <your-server>/src/test/engine.ts
|
|
291
|
+
import { createEngine } from "@ripplo/testing";
|
|
292
|
+
import ripplo from "../../../../.ripplo/index.js";
|
|
293
|
+
import { prisma } from "../lib/prisma.js";
|
|
294
|
+
|
|
295
|
+
export const engine = createEngine(ripplo, {
|
|
296
|
+
preconditions: {
|
|
297
|
+
authLoggedIn: {
|
|
298
|
+
setup: async (ctx) => {
|
|
299
|
+
// create user, set cookies via ctx.setCookie()
|
|
300
|
+
return { userId: ctx.uniqueId("user") };
|
|
301
|
+
},
|
|
302
|
+
teardown: async (ctx) => {
|
|
303
|
+
// clean up using ctx.data.userId
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
dataProject: {
|
|
307
|
+
setup: async (ctx, { auth }) => {
|
|
308
|
+
// use auth.userId to create a project
|
|
309
|
+
return {
|
|
310
|
+
orgId: ctx.fixed("..."),
|
|
311
|
+
projectId: ctx.fixed("..."),
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
teardown: async () => {},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
observers: {
|
|
318
|
+
orgNameIs: async (ctx, { orgId, expectedName }) => {
|
|
319
|
+
const org = await prisma.organization.findUnique({
|
|
320
|
+
select: { name: true },
|
|
321
|
+
where: { id: orgId },
|
|
322
|
+
});
|
|
323
|
+
if (org == null) return ctx.retry(`organization "${orgId}" not found yet`);
|
|
324
|
+
if (org.name !== expectedName) {
|
|
325
|
+
return ctx.retry(`name is "${org.name}", expected "${expectedName}"`);
|
|
326
|
+
}
|
|
327
|
+
return ctx.pass();
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
```
|
|
245
332
|
|
|
246
|
-
|
|
333
|
+
**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.
|
|
247
334
|
|
|
248
|
-
|
|
335
|
+
**Stubbing an impl:** use the `notImplemented` sentinel to keep planning-mode placeholders while the surrounding code still compiles.
|
|
249
336
|
|
|
250
337
|
```typescript
|
|
251
|
-
import
|
|
252
|
-
import ripplo from "../ripplo.js";
|
|
338
|
+
import { createEngine, notImplemented } from "@ripplo/testing";
|
|
253
339
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
340
|
+
createEngine(ripplo, {
|
|
341
|
+
preconditions: {
|
|
342
|
+
authLoggedIn: { setup, teardown },
|
|
343
|
+
dataProject: notImplemented("awaiting prisma seed helper"),
|
|
344
|
+
},
|
|
345
|
+
observers: { orgNameIs: notImplemented() },
|
|
346
|
+
});
|
|
260
347
|
```
|
|
261
348
|
|
|
262
|
-
|
|
349
|
+
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.
|
|
263
350
|
|
|
264
|
-
|
|
351
|
+
### SetupContext
|
|
265
352
|
|
|
266
|
-
|
|
267
|
-
- `"slow"` — ~30s with 250→2000ms backoff. Queue drains, replication settling.
|
|
268
|
-
- `"async"` — ~120s with 500→5000ms backoff. Webhooks, queue workers, LLM calls.
|
|
353
|
+
`ctx` passed to each precondition `setup` provides:
|
|
269
354
|
|
|
270
|
-
|
|
355
|
+
- `ctx.runId` — unique 12-char id for this test run
|
|
356
|
+
- `ctx.fixed(value)` — static test value
|
|
357
|
+
- `ctx.uniqueId(prefix)` — generate unique ID (e.g., `ripplo-test-<prefix>-<runId>`)
|
|
358
|
+
- `ctx.uniqueEmail()` — generate unique email (`ripplo-test-<runId>@test.ripplo.ai`)
|
|
359
|
+
- `ctx.setCookie(name, value, options?)` — inject auth cookies; forwarded as `Set-Cookie` to the test browser
|
|
271
360
|
|
|
272
|
-
|
|
273
|
-
ripplo.implementObserver(orgNameIs, async (ctx, { orgId, expectedName }) => {
|
|
274
|
-
const org = await prisma.organization.findUnique({
|
|
275
|
-
select: { name: true },
|
|
276
|
-
where: { id: orgId },
|
|
277
|
-
});
|
|
278
|
-
if (org == null) return ctx.retry(`organization "${orgId}" not found yet`);
|
|
279
|
-
if (org.name !== expectedName)
|
|
280
|
-
return ctx.retry(`name is "${org.name}", expected "${expectedName}"`);
|
|
281
|
-
return ctx.pass();
|
|
282
|
-
});
|
|
283
|
-
```
|
|
361
|
+
### ObserverContext
|
|
284
362
|
|
|
285
|
-
|
|
363
|
+
`ctx` passed to each observer impl provides:
|
|
286
364
|
|
|
287
365
|
- `ctx.pass()` → assertion satisfied; stop polling.
|
|
288
366
|
- `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.
|
|
289
367
|
- `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`.
|
|
290
368
|
- Any thrown exception is treated as `fail` with the error message.
|
|
291
369
|
|
|
292
|
-
|
|
370
|
+
## Observer Usage in Tests
|
|
293
371
|
|
|
294
372
|
```typescript
|
|
295
373
|
import { orgNameIs } from "../observers/index.js";
|
|
296
374
|
|
|
297
|
-
|
|
298
|
-
.
|
|
375
|
+
test("update-org-name")
|
|
376
|
+
.name("Update org name")
|
|
299
377
|
.requires({ project: dataProject })
|
|
378
|
+
.expectedOutcome("org name persisted")
|
|
379
|
+
.startsAt(({ project }) => `/projects/${project.projectId}/settings`)
|
|
300
380
|
.steps(({ project }) => [
|
|
301
381
|
fill(role("textbox", "Organization name"), "New Name").as("fill new name"),
|
|
302
382
|
click(role("button", "Save")).as("click save"),
|
|
@@ -319,10 +399,10 @@ For steps that genuinely don't touch server state (cancel dialogs, toggle a disp
|
|
|
319
399
|
click(role("button", "Cancel"), { uiOnly: true }).as("close dialog");
|
|
320
400
|
```
|
|
321
401
|
|
|
322
|
-
For entire presentation-only flows, pass the flag to `
|
|
402
|
+
For entire presentation-only flows, pass the flag to `test`:
|
|
323
403
|
|
|
324
404
|
```typescript
|
|
325
|
-
|
|
405
|
+
test("filter-sort-user-flows", { uiOnly: true }).name("Filter & sort");
|
|
326
406
|
```
|
|
327
407
|
|
|
328
408
|
Treat `uiOnly` as a narrow escape hatch — default to writing an observer.
|
|
@@ -348,34 +428,40 @@ ripplo flake-detect <id> --runs=10 # Run N times in parallel to detect flakines
|
|
|
348
428
|
|
|
349
429
|
## Server Setup
|
|
350
430
|
|
|
351
|
-
|
|
431
|
+
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.
|
|
432
|
+
|
|
433
|
+
All adapters take the `engine` produced by `createEngine(ripplo, impls)` — not the bare `ripplo` instance.
|
|
352
434
|
|
|
353
435
|
### Express
|
|
354
436
|
|
|
355
437
|
```ts
|
|
356
438
|
import express from "express";
|
|
357
439
|
import { createExpressHandler } from "@ripplo/testing/express";
|
|
358
|
-
import
|
|
440
|
+
import { engine } from "./test/engine.js";
|
|
359
441
|
|
|
360
442
|
const app = express();
|
|
361
443
|
app.use(
|
|
362
444
|
"/ripplo",
|
|
363
|
-
createExpressHandler({
|
|
445
|
+
createExpressHandler({
|
|
446
|
+
enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
|
|
447
|
+
engine,
|
|
448
|
+
}),
|
|
364
449
|
);
|
|
365
450
|
```
|
|
366
451
|
|
|
367
|
-
Mounts both `PUT /execute-preconditions` and `PUT /teardown-preconditions` under the prefix you choose.
|
|
368
|
-
|
|
369
452
|
### Fastify
|
|
370
453
|
|
|
371
454
|
```ts
|
|
372
455
|
import Fastify from "fastify";
|
|
373
456
|
import { registerFastifyHandler } from "@ripplo/testing/fastify";
|
|
374
|
-
import
|
|
457
|
+
import { engine } from "./test/engine.js";
|
|
375
458
|
|
|
376
459
|
const app = Fastify();
|
|
377
460
|
await app.register(
|
|
378
|
-
registerFastifyHandler({
|
|
461
|
+
registerFastifyHandler({
|
|
462
|
+
enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
|
|
463
|
+
engine,
|
|
464
|
+
}),
|
|
379
465
|
{ prefix: "/ripplo" },
|
|
380
466
|
);
|
|
381
467
|
```
|
|
@@ -387,34 +473,28 @@ The Next.js adapter exports a single catch-all handler. Create one dynamic route
|
|
|
387
473
|
```ts
|
|
388
474
|
// app/ripplo/[action]/route.ts
|
|
389
475
|
import { createNextHandler } from "@ripplo/testing/nextjs";
|
|
390
|
-
import
|
|
476
|
+
import { engine } from "@/server/test/engine";
|
|
391
477
|
|
|
392
478
|
export const PUT = createNextHandler({
|
|
393
479
|
enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
|
|
394
|
-
|
|
480
|
+
engine,
|
|
395
481
|
});
|
|
396
482
|
```
|
|
397
483
|
|
|
398
|
-
The handler dispatches on the last URL segment (`execute-preconditions`
|
|
484
|
+
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.
|
|
399
485
|
|
|
400
486
|
### Custom integration (raw engine)
|
|
401
487
|
|
|
402
|
-
If your framework isn't covered above (Hono, Koa, Bun, Deno, Cloudflare Workers, etc.), use the
|
|
488
|
+
If your framework isn't covered above (Hono, Koa, Bun, Deno, Cloudflare Workers, etc.), use the engine directly. The adapters are thin wrappers over the same API.
|
|
403
489
|
|
|
404
490
|
```ts
|
|
405
|
-
import {
|
|
406
|
-
|
|
407
|
-
createEngine,
|
|
408
|
-
serializeCookie,
|
|
409
|
-
verifyWebhookSignature,
|
|
410
|
-
} from "@ripplo/testing";
|
|
411
|
-
import ripplo from "<path to .ripplo/ripplo>"; // import the existing instance — never call createRipplo() outside .ripplo/ripplo.ts
|
|
491
|
+
import { buildSetCookieHeader, serializeCookie, verifyWebhookSignature } from "@ripplo/testing";
|
|
492
|
+
import { engine } from "./test/engine.js";
|
|
412
493
|
|
|
413
|
-
const
|
|
414
|
-
const webhookSecret = ripplo.getConfig().webhookSecret;
|
|
494
|
+
const webhookSecret = engine.getConfig().webhookSecret;
|
|
415
495
|
|
|
416
496
|
// PUT /ripplo/execute-preconditions
|
|
417
|
-
async function
|
|
497
|
+
async function executePreconditions(req: Request): Promise<Response> {
|
|
418
498
|
const body = await req.text();
|
|
419
499
|
const verified = verifyWebhookSignature(
|
|
420
500
|
body,
|
|
@@ -431,7 +511,7 @@ async function executeBatch(req: Request): Promise<Response> {
|
|
|
431
511
|
|
|
432
512
|
const { preconditions } = JSON.parse(body);
|
|
433
513
|
const appUrl = `${req.headers.get("x-forwarded-proto") ?? "http"}://${req.headers.get("host")}`;
|
|
434
|
-
const result = await engine.
|
|
514
|
+
const result = await engine.executePreconditions(preconditions, { appUrl });
|
|
435
515
|
|
|
436
516
|
const headers = new Headers({ "content-type": "application/json" });
|
|
437
517
|
result.cookies.forEach((c) => {
|
|
@@ -440,69 +520,17 @@ async function executeBatch(req: Request): Promise<Response> {
|
|
|
440
520
|
return new Response(JSON.stringify(result), { headers });
|
|
441
521
|
}
|
|
442
522
|
|
|
443
|
-
// PUT /ripplo/teardown
|
|
444
|
-
|
|
445
|
-
// ... same verify pattern, then:
|
|
446
|
-
// await engine.teardown(parsed.preconditions, parsed.data);
|
|
447
|
-
}
|
|
523
|
+
// PUT /ripplo/teardown-preconditions — same verify pattern, then engine.teardown(...)
|
|
524
|
+
// PUT /ripplo/execute-observer — same verify pattern, then engine.executeObserver(...)
|
|
448
525
|
```
|
|
449
526
|
|
|
450
527
|
**You're responsible for:**
|
|
451
528
|
|
|
452
529
|
- **Webhook verification.** Always call `verifyWebhookSignature` before invoking the engine.
|
|
453
|
-
- **Routing.** Dispatch the
|
|
530
|
+
- **Routing.** Dispatch the three endpoints (`execute-preconditions`, `execute-observer`, `teardown-preconditions`) however your framework handles routes.
|
|
454
531
|
- **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.
|
|
455
532
|
- **Body parsing.** Use the raw text body for signature verification, then `JSON.parse` for the engine call.
|
|
456
533
|
|
|
457
|
-
### Config
|
|
458
|
-
|
|
459
|
-
After mounting, point Ripplo at the prefix:
|
|
460
|
-
|
|
461
|
-
```ts
|
|
462
|
-
// .ripplo/ripplo.ts
|
|
463
|
-
import { createRipplo } from "@ripplo/testing";
|
|
464
|
-
|
|
465
|
-
export default createRipplo({
|
|
466
|
-
appUrl: process.env.APP_URL,
|
|
467
|
-
engineUrl: `${process.env.APP_URL}/ripplo`,
|
|
468
|
-
projectId: "...",
|
|
469
|
-
webhookSecret: process.env.RIPPLO_WEBHOOK_SECRET,
|
|
470
|
-
});
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
## Precondition API Contract
|
|
474
|
-
|
|
475
|
-
The app server exposes two endpoints for test data management:
|
|
476
|
-
|
|
477
|
-
### Execute (`PUT {engineBaseUrl}/execute`)
|
|
478
|
-
|
|
479
|
-
```json
|
|
480
|
-
// Request
|
|
481
|
-
{ "precondition": "data:project" }
|
|
482
|
-
|
|
483
|
-
// Response
|
|
484
|
-
{ "success": true, "data": { "projectId": "cuid-abc", "orgId": "org-xyz" } }
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
Returns data that flows into test variables. Set-Cookie headers are captured automatically.
|
|
488
|
-
|
|
489
|
-
### Teardown (`PUT {engineBaseUrl}/teardown`)
|
|
490
|
-
|
|
491
|
-
```json
|
|
492
|
-
// Request
|
|
493
|
-
{ "preconditions": ["auth:logged-in", "data:project"] }
|
|
494
|
-
|
|
495
|
-
// Response
|
|
496
|
-
{ "success": true }
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
### Parallel Safety
|
|
500
|
-
|
|
501
|
-
- Generate unique names/emails per run using `crypto.randomUUID()` suffixes
|
|
502
|
-
- Return created entity IDs in the `data` response
|
|
503
|
-
- Teardown only deletes that run's data (use session cookies to identify user)
|
|
504
|
-
- Never hardcode entity names or use bulk deletion
|
|
505
|
-
|
|
506
534
|
### Webhook Signing
|
|
507
535
|
|
|
508
536
|
All requests are signed using Standard Webhooks (HMAC-SHA256). Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`. Verify before executing.
|
|
@@ -510,3 +538,10 @@ All requests are signed using Standard Webhooks (HMAC-SHA256). Headers: `webhook
|
|
|
510
538
|
### Environment Guard
|
|
511
539
|
|
|
512
540
|
Wrap all precondition routes behind `ENABLE_RIPPLO_TESTING=true`. Never expose in production.
|
|
541
|
+
|
|
542
|
+
### Parallel Safety
|
|
543
|
+
|
|
544
|
+
- Use `ctx.uniqueId(prefix)` / `ctx.uniqueEmail()` in precondition setup so names/emails don't collide across parallel runs
|
|
545
|
+
- Return created entity IDs in the data contract so teardown can scope deletion precisely
|
|
546
|
+
- Teardown only deletes that run's data (use the `runId` captured at setup)
|
|
547
|
+
- Never hardcode entity names or use bulk deletion
|
package/dist/assert.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { O as ObserverHandle, a as ObserverInput, b as ObserverBudgetTier } from './types-
|
|
1
|
+
import { O as ObserverHandle, a as ObserverInput, b as ObserverBudgetTier } from './types-Degkxs1f.js';
|
|
2
2
|
import { U as UnlabeledStep } from './step-De52hTLd.js';
|
|
3
3
|
import { CheckLocator, AnyLocator } from './locators.js';
|
|
4
4
|
import 'zod';
|
package/dist/assert.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
toSpecLocator
|
|
3
3
|
} from "./chunk-2VUWFRR5.js";
|
|
4
|
-
import {
|
|
5
|
-
createStep
|
|
6
|
-
} from "./chunk-MGATMMCZ.js";
|
|
7
4
|
import {
|
|
8
5
|
readObserverBudget,
|
|
9
6
|
readObserverName
|
|
10
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-76BU4M6E.js";
|
|
8
|
+
import {
|
|
9
|
+
createStep
|
|
10
|
+
} from "./chunk-MGATMMCZ.js";
|
|
11
11
|
import "./chunk-DCJBLS2U.js";
|
|
12
12
|
|
|
13
13
|
// src/steps/assert.ts
|