@ripplo/testing 0.1.0 → 0.2.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 +209 -175
- package/dist/assert.d.ts +1 -1
- package/dist/assert.js +4 -4
- package/dist/{builder-c7tXey03.d.ts → builder-dqXTFZ4j.d.ts} +29 -29
- package/dist/{chunk-3IL457A7.js → chunk-P4ZI7G5M.js} +22 -2
- package/dist/chunk-TO3T2D2Y.js +84 -0
- package/dist/compiler.d.ts +2 -2
- package/dist/engine-CphCJ1ZS.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 +522 -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-yIhY8cwG.d.ts} +1 -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,141 @@ 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: process.env.APP_URL ?? "https://localhost:3001",
|
|
276
|
+
engineUrl: `${process.env.APP_URL ?? "https://localhost:3001"}/ripplo`,
|
|
277
|
+
projectId: "<your-project-id>",
|
|
278
|
+
webhookSecret: process.env.RIPPLO_WEBHOOK_SECRET ?? "",
|
|
232
279
|
},
|
|
233
|
-
}
|
|
234
|
-
|
|
280
|
+
{ preconditions, observers, tests },
|
|
281
|
+
);
|
|
235
282
|
|
|
236
|
-
|
|
283
|
+
export default ripplo;
|
|
284
|
+
```
|
|
237
285
|
|
|
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
|
|
286
|
+
### App server — the implementations funnel
|
|
243
287
|
|
|
244
|
-
|
|
288
|
+
```typescript
|
|
289
|
+
// <your-server>/src/test/engine.ts
|
|
290
|
+
import { createEngine } from "@ripplo/testing";
|
|
291
|
+
import ripplo from "../../../../.ripplo/index.js";
|
|
292
|
+
import { prisma } from "../lib/prisma.js";
|
|
293
|
+
|
|
294
|
+
export const engine = createEngine(ripplo, {
|
|
295
|
+
preconditions: {
|
|
296
|
+
authLoggedIn: {
|
|
297
|
+
setup: async (ctx) => {
|
|
298
|
+
// create user, set cookies via ctx.setCookie()
|
|
299
|
+
return { userId: ctx.uniqueId("user") };
|
|
300
|
+
},
|
|
301
|
+
teardown: async (ctx) => {
|
|
302
|
+
// clean up using ctx.data.userId
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
dataProject: {
|
|
306
|
+
setup: async (ctx, { auth }) => {
|
|
307
|
+
// use auth.userId to create a project
|
|
308
|
+
return {
|
|
309
|
+
orgId: ctx.fixed("..."),
|
|
310
|
+
projectId: ctx.fixed("..."),
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
teardown: async () => {},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
observers: {
|
|
317
|
+
orgNameIs: async (ctx, { orgId, expectedName }) => {
|
|
318
|
+
const org = await prisma.organization.findUnique({
|
|
319
|
+
select: { name: true },
|
|
320
|
+
where: { id: orgId },
|
|
321
|
+
});
|
|
322
|
+
if (org == null) return ctx.retry(`organization "${orgId}" not found yet`);
|
|
323
|
+
if (org.name !== expectedName) {
|
|
324
|
+
return ctx.retry(`name is "${org.name}", expected "${expectedName}"`);
|
|
325
|
+
}
|
|
326
|
+
return ctx.pass();
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
```
|
|
245
331
|
|
|
246
|
-
|
|
332
|
+
**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
333
|
|
|
248
|
-
|
|
334
|
+
**Stubbing an impl:** use the `notImplemented` sentinel to keep planning-mode placeholders while the surrounding code still compiles.
|
|
249
335
|
|
|
250
336
|
```typescript
|
|
251
|
-
import
|
|
252
|
-
import ripplo from "../ripplo.js";
|
|
337
|
+
import { createEngine, notImplemented } from "@ripplo/testing";
|
|
253
338
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
339
|
+
createEngine(ripplo, {
|
|
340
|
+
preconditions: {
|
|
341
|
+
authLoggedIn: { setup, teardown },
|
|
342
|
+
dataProject: notImplemented("awaiting prisma seed helper"),
|
|
343
|
+
},
|
|
344
|
+
observers: { orgNameIs: notImplemented() },
|
|
345
|
+
});
|
|
260
346
|
```
|
|
261
347
|
|
|
262
|
-
|
|
348
|
+
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
349
|
|
|
264
|
-
|
|
350
|
+
### SetupContext
|
|
265
351
|
|
|
266
|
-
|
|
267
|
-
- `"slow"` — ~30s with 250→2000ms backoff. Queue drains, replication settling.
|
|
268
|
-
- `"async"` — ~120s with 500→5000ms backoff. Webhooks, queue workers, LLM calls.
|
|
352
|
+
`ctx` passed to each precondition `setup` provides:
|
|
269
353
|
|
|
270
|
-
|
|
354
|
+
- `ctx.runId` — unique 12-char id for this test run
|
|
355
|
+
- `ctx.fixed(value)` — static test value
|
|
356
|
+
- `ctx.uniqueId(prefix)` — generate unique ID (e.g., `ripplo-test-<prefix>-<runId>`)
|
|
357
|
+
- `ctx.uniqueEmail()` — generate unique email (`ripplo-test-<runId>@test.ripplo.ai`)
|
|
358
|
+
- `ctx.setCookie(name, value, options?)` — inject auth cookies; forwarded as `Set-Cookie` to the test browser
|
|
271
359
|
|
|
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
|
-
```
|
|
360
|
+
### ObserverContext
|
|
284
361
|
|
|
285
|
-
|
|
362
|
+
`ctx` passed to each observer impl provides:
|
|
286
363
|
|
|
287
|
-
- `ctx.pass()` → assertion satisfied
|
|
288
|
-
- `ctx.retry(reason)` →
|
|
289
|
-
- `ctx.fail(reason)` → invariant violated
|
|
364
|
+
- `ctx.pass()` → assertion satisfied; stop polling.
|
|
365
|
+
- `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.
|
|
366
|
+
- `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
367
|
- Any thrown exception is treated as `fail` with the error message.
|
|
291
368
|
|
|
292
|
-
|
|
369
|
+
## Observer Usage in Tests
|
|
293
370
|
|
|
294
371
|
```typescript
|
|
295
372
|
import { orgNameIs } from "../observers/index.js";
|
|
296
373
|
|
|
297
|
-
|
|
298
|
-
.
|
|
374
|
+
test("update-org-name")
|
|
375
|
+
.name("Update org name")
|
|
299
376
|
.requires({ project: dataProject })
|
|
377
|
+
.expectedOutcome("org name persisted")
|
|
378
|
+
.startsAt(({ project }) => `/projects/${project.projectId}/settings`)
|
|
300
379
|
.steps(({ project }) => [
|
|
301
380
|
fill(role("textbox", "Organization name"), "New Name").as("fill new name"),
|
|
302
381
|
click(role("button", "Save")).as("click save"),
|
|
@@ -319,10 +398,10 @@ For steps that genuinely don't touch server state (cancel dialogs, toggle a disp
|
|
|
319
398
|
click(role("button", "Cancel"), { uiOnly: true }).as("close dialog");
|
|
320
399
|
```
|
|
321
400
|
|
|
322
|
-
For entire presentation-only flows, pass the flag to `
|
|
401
|
+
For entire presentation-only flows, pass the flag to `test`:
|
|
323
402
|
|
|
324
403
|
```typescript
|
|
325
|
-
|
|
404
|
+
test("filter-sort-user-flows", { uiOnly: true }).name("Filter & sort");
|
|
326
405
|
```
|
|
327
406
|
|
|
328
407
|
Treat `uiOnly` as a narrow escape hatch — default to writing an observer.
|
|
@@ -348,34 +427,40 @@ ripplo flake-detect <id> --runs=10 # Run N times in parallel to detect flakines
|
|
|
348
427
|
|
|
349
428
|
## Server Setup
|
|
350
429
|
|
|
351
|
-
|
|
430
|
+
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.
|
|
431
|
+
|
|
432
|
+
All adapters take the `engine` produced by `createEngine(ripplo, impls)` — not the bare `ripplo` instance.
|
|
352
433
|
|
|
353
434
|
### Express
|
|
354
435
|
|
|
355
436
|
```ts
|
|
356
437
|
import express from "express";
|
|
357
438
|
import { createExpressHandler } from "@ripplo/testing/express";
|
|
358
|
-
import
|
|
439
|
+
import { engine } from "./test/engine.js";
|
|
359
440
|
|
|
360
441
|
const app = express();
|
|
361
442
|
app.use(
|
|
362
443
|
"/ripplo",
|
|
363
|
-
createExpressHandler({
|
|
444
|
+
createExpressHandler({
|
|
445
|
+
enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
|
|
446
|
+
engine,
|
|
447
|
+
}),
|
|
364
448
|
);
|
|
365
449
|
```
|
|
366
450
|
|
|
367
|
-
Mounts both `PUT /execute-preconditions` and `PUT /teardown-preconditions` under the prefix you choose.
|
|
368
|
-
|
|
369
451
|
### Fastify
|
|
370
452
|
|
|
371
453
|
```ts
|
|
372
454
|
import Fastify from "fastify";
|
|
373
455
|
import { registerFastifyHandler } from "@ripplo/testing/fastify";
|
|
374
|
-
import
|
|
456
|
+
import { engine } from "./test/engine.js";
|
|
375
457
|
|
|
376
458
|
const app = Fastify();
|
|
377
459
|
await app.register(
|
|
378
|
-
registerFastifyHandler({
|
|
460
|
+
registerFastifyHandler({
|
|
461
|
+
enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
|
|
462
|
+
engine,
|
|
463
|
+
}),
|
|
379
464
|
{ prefix: "/ripplo" },
|
|
380
465
|
);
|
|
381
466
|
```
|
|
@@ -387,34 +472,28 @@ The Next.js adapter exports a single catch-all handler. Create one dynamic route
|
|
|
387
472
|
```ts
|
|
388
473
|
// app/ripplo/[action]/route.ts
|
|
389
474
|
import { createNextHandler } from "@ripplo/testing/nextjs";
|
|
390
|
-
import
|
|
475
|
+
import { engine } from "@/server/test/engine";
|
|
391
476
|
|
|
392
477
|
export const PUT = createNextHandler({
|
|
393
478
|
enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
|
|
394
|
-
|
|
479
|
+
engine,
|
|
395
480
|
});
|
|
396
481
|
```
|
|
397
482
|
|
|
398
|
-
The handler dispatches on the last URL segment (`execute-preconditions`
|
|
483
|
+
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
484
|
|
|
400
485
|
### Custom integration (raw engine)
|
|
401
486
|
|
|
402
|
-
If your framework isn't covered above (Hono, Koa, Bun, Deno, Cloudflare Workers, etc.), use the
|
|
487
|
+
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
488
|
|
|
404
489
|
```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
|
|
490
|
+
import { buildSetCookieHeader, serializeCookie, verifyWebhookSignature } from "@ripplo/testing";
|
|
491
|
+
import { engine } from "./test/engine.js";
|
|
412
492
|
|
|
413
|
-
const
|
|
414
|
-
const webhookSecret = ripplo.getConfig().webhookSecret;
|
|
493
|
+
const webhookSecret = engine.getConfig().webhookSecret;
|
|
415
494
|
|
|
416
495
|
// PUT /ripplo/execute-preconditions
|
|
417
|
-
async function
|
|
496
|
+
async function executePreconditions(req: Request): Promise<Response> {
|
|
418
497
|
const body = await req.text();
|
|
419
498
|
const verified = verifyWebhookSignature(
|
|
420
499
|
body,
|
|
@@ -431,7 +510,7 @@ async function executeBatch(req: Request): Promise<Response> {
|
|
|
431
510
|
|
|
432
511
|
const { preconditions } = JSON.parse(body);
|
|
433
512
|
const appUrl = `${req.headers.get("x-forwarded-proto") ?? "http"}://${req.headers.get("host")}`;
|
|
434
|
-
const result = await engine.
|
|
513
|
+
const result = await engine.executePreconditions(preconditions, { appUrl });
|
|
435
514
|
|
|
436
515
|
const headers = new Headers({ "content-type": "application/json" });
|
|
437
516
|
result.cookies.forEach((c) => {
|
|
@@ -440,69 +519,17 @@ async function executeBatch(req: Request): Promise<Response> {
|
|
|
440
519
|
return new Response(JSON.stringify(result), { headers });
|
|
441
520
|
}
|
|
442
521
|
|
|
443
|
-
// PUT /ripplo/teardown
|
|
444
|
-
|
|
445
|
-
// ... same verify pattern, then:
|
|
446
|
-
// await engine.teardown(parsed.preconditions, parsed.data);
|
|
447
|
-
}
|
|
522
|
+
// PUT /ripplo/teardown-preconditions — same verify pattern, then engine.teardown(...)
|
|
523
|
+
// PUT /ripplo/execute-observer — same verify pattern, then engine.executeObserver(...)
|
|
448
524
|
```
|
|
449
525
|
|
|
450
526
|
**You're responsible for:**
|
|
451
527
|
|
|
452
528
|
- **Webhook verification.** Always call `verifyWebhookSignature` before invoking the engine.
|
|
453
|
-
- **Routing.** Dispatch the
|
|
529
|
+
- **Routing.** Dispatch the three endpoints (`execute-preconditions`, `execute-observer`, `teardown-preconditions`) however your framework handles routes.
|
|
454
530
|
- **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
531
|
- **Body parsing.** Use the raw text body for signature verification, then `JSON.parse` for the engine call.
|
|
456
532
|
|
|
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
533
|
### Webhook Signing
|
|
507
534
|
|
|
508
535
|
All requests are signed using Standard Webhooks (HMAC-SHA256). Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`. Verify before executing.
|
|
@@ -510,3 +537,10 @@ All requests are signed using Standard Webhooks (HMAC-SHA256). Headers: `webhook
|
|
|
510
537
|
### Environment Guard
|
|
511
538
|
|
|
512
539
|
Wrap all precondition routes behind `ENABLE_RIPPLO_TESTING=true`. Never expose in production.
|
|
540
|
+
|
|
541
|
+
### Parallel Safety
|
|
542
|
+
|
|
543
|
+
- Use `ctx.uniqueId(prefix)` / `ctx.uniqueEmail()` in precondition setup so names/emails don't collide across parallel runs
|
|
544
|
+
- Return created entity IDs in the data contract so teardown can scope deletion precisely
|
|
545
|
+
- Teardown only deletes that run's data (use the `runId` captured at setup)
|
|
546
|
+
- 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-yIhY8cwG.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-P4ZI7G5M.js";
|
|
8
|
+
import {
|
|
9
|
+
createStep
|
|
10
|
+
} from "./chunk-MGATMMCZ.js";
|
|
11
11
|
import "./chunk-DCJBLS2U.js";
|
|
12
12
|
|
|
13
13
|
// src/steps/assert.ts
|