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