@ripplo/testing 0.5.5 → 0.6.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 +64 -206
- package/dist/lockfile.js +18 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -6,23 +6,19 @@ Typed TypeScript DSL for end-to-end tests with real backend state, used by [Ripp
|
|
|
6
6
|
npm install @ripplo/testing
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
The companion [`ripplo`](https://www.npmjs.com/package/ripplo) CLI scaffolds `.ripplo/`, runs tests, and writes the lockfile. This package ships the DSL and the server adapters.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## How it fits together
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Tests are split into two halves that the type system stitches together.
|
|
14
14
|
|
|
15
|
-
`createRipplo({ preconditions, observers, tests })` in `.ripplo/index.ts` collects
|
|
15
|
+
`createRipplo({ preconditions, observers, tests })` in `.ripplo/index.ts` collects handles returned by `precondition()`, `observer()`, and `test()`. These are pure factories — they describe shape, not behavior.
|
|
16
16
|
|
|
17
|
-
`createEngine(ripplo, { preconditions, observers })` in your app server wires every handle to its setup, teardown, or observer function. Missing or extra keys are TypeScript errors.
|
|
17
|
+
`createEngine(ripplo, { preconditions, observers })` in your app server wires every handle to its setup, teardown, or observer function. Missing or extra keys are TypeScript errors. The engine runs server-side where it has DB access; the DSL package never invokes it directly. Everything goes over signed HTTP.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
You mount the engine with one of the adapters (`@ripplo/testing/express`, `/fastify`, `/nextjs`, `/hono`, `/koa`, `/nestjs`, `/elysia`) at a path prefix, default `/ripplo`. The adapter exposes three signed routes: `PUT /execute-preconditions`, `PUT /execute-observer`, `PUT /teardown-preconditions`.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
## DSL
|
|
24
|
-
|
|
25
|
-
### Test
|
|
21
|
+
## Writing a test
|
|
26
22
|
|
|
27
23
|
```typescript
|
|
28
24
|
import { test } from "@ripplo/testing";
|
|
@@ -48,16 +44,17 @@ export const inviteATeammate = test("invite-a-teammate")
|
|
|
48
44
|
])
|
|
49
45
|
.coverage(
|
|
50
46
|
"src/components/members/InviteDialog.tsx#InviteDialog.click[Invite member]",
|
|
51
|
-
"src/components/members/InviteDialog.tsx#InviteDialog.input[Email]",
|
|
52
47
|
"src/components/members/InviteDialog.tsx#InviteDialog.click[Send invite]",
|
|
53
48
|
);
|
|
54
49
|
```
|
|
55
50
|
|
|
56
|
-
`test(id)
|
|
51
|
+
The chain is: `test(id)`, `.name()`, optional `.description()`, `.requires()`, `.expectedOutcome()`, `.startsAt()`, `.steps()`, `.coverage()`. While planning, swap `.startsAt() / .steps() / .coverage()` for `.notImplemented()` to stub.
|
|
52
|
+
|
|
53
|
+
`.coverage(...)` ids come from a generated `.ripplo/coverage.d.ts` that augments `CoverageRegistry`, so they autocomplete and stale ids break the build. Implemented tests must list every interaction they exercise. A pre-commit hook blocks net-new interactions in the diff that no test claims.
|
|
57
54
|
|
|
58
|
-
|
|
55
|
+
Every step ends with `.as("short description")`. Labels appear in the run UI and in failure detail. Duplicates within a test are a compile error. Describe intent, not mechanics.
|
|
59
56
|
|
|
60
|
-
|
|
57
|
+
## Preconditions
|
|
61
58
|
|
|
62
59
|
```typescript
|
|
63
60
|
import { precondition } from "@ripplo/testing";
|
|
@@ -74,20 +71,13 @@ export const dataProject = precondition("data:project")
|
|
|
74
71
|
export const preconditions = { authLoggedIn, dataProject };
|
|
75
72
|
```
|
|
76
73
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
#### Preconditions are create-only
|
|
80
|
-
|
|
81
|
-
Precondition setups must only insert. No `update`, no `delete`. Parallel runs share a database, so a `WHERE` clause that looks run-scoped can still match another run's rows. And mutating something a parent precondition produced couples them in an order that breaks the moment they're composed differently.
|
|
74
|
+
Contract fields are primitives: `string`, `number`, or `boolean`. Each value is run-scoped.
|
|
82
75
|
|
|
83
|
-
If a test needs
|
|
76
|
+
**Setups insert; they don't update or delete.** Parallel runs share a database, so a `WHERE` clause that looks run-scoped can still match another run's rows, and mutating something a parent precondition produced couples them in an order that breaks the moment they're composed differently. If a test needs non-default state, accept that state as input on the precondition that creates the row. Don't seed a default and patch it later.
|
|
84
77
|
|
|
85
|
-
Two
|
|
78
|
+
Two carve-outs: `upsert` on a row whose primary key is per-run (treat it as create-with-default), and teardown, which may delete rows this precondition created.
|
|
86
79
|
|
|
87
|
-
|
|
88
|
-
- Teardown may delete, but only rows this precondition created.
|
|
89
|
-
|
|
90
|
-
### Observers
|
|
80
|
+
## Observers
|
|
91
81
|
|
|
92
82
|
```typescript
|
|
93
83
|
import { observer } from "@ripplo/testing";
|
|
@@ -101,57 +91,27 @@ export const orgNameIs = observer("org:name-is")
|
|
|
101
91
|
export const observers = { orgNameIs };
|
|
102
92
|
```
|
|
103
93
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
`.budget(tier)` controls how long the runtime polls before giving up. The tiers describe poll behavior, not numeric timeouts:
|
|
107
|
-
|
|
108
|
-
| Tier | Window | Backoff | Use for |
|
|
109
|
-
| ------- | ------ | ---------- | ---------------------------------- |
|
|
110
|
-
| `fast` | ~5s | 100→1000ms | Synchronous DB reads (default) |
|
|
111
|
-
| `slow` | ~30s | 250→2000ms | Queue drains, replication settling |
|
|
112
|
-
| `async` | ~120s | 500→5000ms | Webhooks, queue workers, LLM calls |
|
|
113
|
-
|
|
114
|
-
### Locators
|
|
94
|
+
`.input<T>()` fields are typed primitives — same rules as precondition contracts. The wire codec preserves the type.
|
|
115
95
|
|
|
116
|
-
|
|
96
|
+
`.budget(tier)` controls how long the runtime polls. Use `fast` for synchronous DB reads (default, ~5s), `slow` for queue drains and replication (~30s), `async` for webhooks and LLM calls (~120s).
|
|
117
97
|
|
|
118
|
-
|
|
98
|
+
## Locators, actions, assertions
|
|
119
99
|
|
|
120
|
-
|
|
100
|
+
`role(name, accessibleName)` matches by ARIA role and accessible name and is the right tool for almost everything. `testId(id)` matches `data-testid` and exists for elements with no semantic role.
|
|
121
101
|
|
|
122
|
-
- `InputLocator`
|
|
123
|
-
- `SelectLocator` — `combobox`, `listbox`, `testId()`
|
|
124
|
-
- `CheckLocator` — `checkbox`, `switch`, `testId()`
|
|
102
|
+
Locators are type-narrowed by what you can do with them: `InputLocator` accepts `textbox`, `searchbox`, `combobox`, `spinbutton`, or `testId()`; `SelectLocator` accepts `combobox`, `listbox`, or `testId()`; `CheckLocator` accepts `checkbox`, `switch`, or `testId()`. Passing a `button` to `fill()` is a compile error.
|
|
125
103
|
|
|
126
|
-
|
|
104
|
+
Actions live in `@ripplo/testing/actions`. Pointer (`click`, `dblclick`, `hover`, ...), keyboard (`press`, `typeText`, `fill`, `clear`), form controls (`select`, `check`, `uncheck`), navigation (`navigate`, `scrollIntoView`), and composites like `drag`, `upload`, `handleDialog`, `clipboard`, `setPermission`, `setViewport`. Each takes a locator and returns a step.
|
|
127
105
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
- **Pointer:** `click`, `dblclick`, `rightClick`, `hover`, `focus`
|
|
131
|
-
- **Keyboard:** `press` (single key or chord), `typeText` (no clear), `fill` (clear + type), `clear`
|
|
132
|
-
- **Form controls:** `select`, `check`, `uncheck`
|
|
133
|
-
- **Navigation:** `navigate`, `scrollIntoView`
|
|
134
|
-
- **Composite:** `drag`, `upload`, `handleDialog`, `clipboard`
|
|
135
|
-
- **Browser context:** `setPermission`, `setViewport`
|
|
106
|
+
Assertions live in `@ripplo/testing/assert` and are exact-match. There is no `contains`, no `startsWith`, no regex. `assert.visible / .text / .value / .attribute / .enabled / .checked / .focused / .count / .url`, plus `assert.backend(observer, params)` for server-state checks.
|
|
136
107
|
|
|
137
108
|
### Upload fixtures
|
|
138
109
|
|
|
139
|
-
`upload(locator, fixture("name"))` is the only way to attach files. Fixture
|
|
140
|
-
|
|
141
|
-
### Assertions
|
|
142
|
-
|
|
143
|
-
Imported from `@ripplo/testing/assert`. Every assertion is exact-match; there is no `contains`, `startsWith`, or regex.
|
|
144
|
-
|
|
145
|
-
- **Visibility:** `assert.visible(loc)`, `assert.not.visible(loc)`
|
|
146
|
-
- **Content:** `assert.text(loc, exact)`, `assert.value(loc, exact)`, `assert.attribute(loc, name, exact)`
|
|
147
|
-
- **State:** `assert.enabled(loc)`, `assert.checked(loc)`, `assert.focused(loc)`
|
|
148
|
-
- **Counting:** `assert.count(loc, n)`
|
|
149
|
-
- **Routing:** `assert.url(path)`
|
|
150
|
-
- **Backend:** `assert.backend(observer, params)` — see [Observers](#observers).
|
|
110
|
+
`upload(locator, fixture("name"))` is the only way to attach files. Fixture bytes live in `.ripplo/fixtures/` and are committed to git so cloud runs see byte-identical inputs. Caps: 10 MB per file, 50 MB total. Pass an array for multi-file inputs.
|
|
151
111
|
|
|
152
112
|
### Variables
|
|
153
113
|
|
|
154
|
-
`variable(name)` declares a placeholder
|
|
114
|
+
`variable(name)` declares a placeholder; `extract(locator, variable)` captures the element's text or value at run time; later steps that take a string accept the variable in its place.
|
|
155
115
|
|
|
156
116
|
```typescript
|
|
157
117
|
import { extract, variable } from "@ripplo/testing/control";
|
|
@@ -159,40 +119,11 @@ import { extract, variable } from "@ripplo/testing/control";
|
|
|
159
119
|
const token = variable("token");
|
|
160
120
|
extract(testId("token-value"), token).as("capture token");
|
|
161
121
|
fill(role("textbox", "Paste here"), token).as("paste token");
|
|
162
|
-
assert.value(role("textbox", "Paste here"), token).as("assert token");
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### Step labels
|
|
166
|
-
|
|
167
|
-
Every step ends with `.as("short description")`. Labels appear in the run UI and in failure detail, and duplicate labels within a test are a compile error. Describe intent ("open invite dialog"), not mechanics ("click button").
|
|
168
|
-
|
|
169
|
-
## Data flow
|
|
170
|
-
|
|
171
|
-
Precondition output reaches steps by destructuring the argument to `.startsAt()` and `.steps()`. Always destructure; never hardcode a value that came from a precondition.
|
|
172
|
-
|
|
173
|
-
```typescript
|
|
174
|
-
test("delete-project")
|
|
175
|
-
.requires({ project: dataProject })
|
|
176
|
-
.startsAt(({ project }) => `/projects/${project.projectId}/settings`)
|
|
177
|
-
.steps(({ project }) => [
|
|
178
|
-
navigate(`/projects/${project.projectId}/settings`).as("go to settings"),
|
|
179
|
-
// ...
|
|
180
|
-
]);
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
Never write `"{{namespace.key}}"` as a string literal. The proxy stringifies internally, but writing the literal bypasses the type checker, so typos compile and fail at run time. The `no-literal-template-strings` rule blocks this.
|
|
184
|
-
|
|
185
|
-
```typescript
|
|
186
|
-
// literal: bypasses type-checking
|
|
187
|
-
assert.value(role("textbox", "Table name"), "{{table.name}}").as("name visible");
|
|
188
|
-
|
|
189
|
-
// proxy: checked against requires()
|
|
190
|
-
assert.value(role("textbox", "Table name"), table.name).as("name visible");
|
|
191
122
|
```
|
|
192
123
|
|
|
193
124
|
## Wiring
|
|
194
125
|
|
|
195
|
-
### `.ripplo/index.ts`
|
|
126
|
+
### `.ripplo/index.ts`
|
|
196
127
|
|
|
197
128
|
```typescript
|
|
198
129
|
import { createRipplo } from "@ripplo/testing";
|
|
@@ -203,9 +134,9 @@ import { tests } from "./tests/index.js";
|
|
|
203
134
|
export default createRipplo({ preconditions, observers, tests });
|
|
204
135
|
```
|
|
205
136
|
|
|
206
|
-
Runtime config (`RIPPLO_APP_URL`, `RIPPLO_ENGINE_URL`, `RIPPLO_WEBHOOK_SECRET`) lives in your
|
|
137
|
+
Runtime config (`RIPPLO_APP_URL`, `RIPPLO_ENGINE_URL`, `RIPPLO_WEBHOOK_SECRET`) lives in your app's env file; `ripplo init` writes the initial values. Project id and env-file pointers live in `.ripplo/project.json`.
|
|
207
138
|
|
|
208
|
-
### `<app>/src/test/engine.ts`
|
|
139
|
+
### `<app>/src/test/engine.ts`
|
|
209
140
|
|
|
210
141
|
```typescript
|
|
211
142
|
import { createEngine, notImplemented } from "@ripplo/testing";
|
|
@@ -215,7 +146,7 @@ import { prisma } from "../lib/prisma.js";
|
|
|
215
146
|
export const engine = createEngine(ripplo, {
|
|
216
147
|
preconditions: {
|
|
217
148
|
authLoggedIn: {
|
|
218
|
-
//
|
|
149
|
+
// Setup receives one item per concurrent run that needs this precondition.
|
|
219
150
|
// Issue one bulk write and return results in input order.
|
|
220
151
|
setup: async (items) => {
|
|
221
152
|
const seeds = items.map(({ ctx }) => ({
|
|
@@ -223,7 +154,6 @@ export const engine = createEngine(ripplo, {
|
|
|
223
154
|
email: ctx.uniqueEmail(),
|
|
224
155
|
}));
|
|
225
156
|
await prisma.user.createMany({ data: seeds });
|
|
226
|
-
// each item.ctx.setCookie(...) flows back to that run's browser
|
|
227
157
|
return seeds.map(({ id }) => ({ userId: id }));
|
|
228
158
|
},
|
|
229
159
|
teardown: async (items) => {
|
|
@@ -232,7 +162,7 @@ export const engine = createEngine(ripplo, {
|
|
|
232
162
|
});
|
|
233
163
|
},
|
|
234
164
|
},
|
|
235
|
-
dataProject: notImplemented("awaiting prisma seed helper"),
|
|
165
|
+
dataProject: notImplemented("awaiting prisma seed helper"),
|
|
236
166
|
},
|
|
237
167
|
observers: {
|
|
238
168
|
orgNameIs: async (ctx, { orgId, expectedName }) => {
|
|
@@ -248,37 +178,37 @@ export const engine = createEngine(ripplo, {
|
|
|
248
178
|
});
|
|
249
179
|
```
|
|
250
180
|
|
|
251
|
-
|
|
181
|
+
The runtime batches concurrent runs that need the same precondition inside a 200ms window and calls your impl once for the batch. Use `createMany` and `deleteMany` so DB load scales with wall-clock time, not run count. Return one result per input item, in input order.
|
|
252
182
|
|
|
253
183
|
### Setup context
|
|
254
184
|
|
|
255
|
-
Each batched setup item
|
|
185
|
+
Each batched setup item carries a `ctx`:
|
|
256
186
|
|
|
257
|
-
- `ctx.runId`
|
|
258
|
-
- `ctx.
|
|
259
|
-
- `ctx.
|
|
260
|
-
- `ctx.uniqueEmail()` returns `ripplo-test-<runId>-<n>@test.ripplo.ai`, same per-call counter.
|
|
187
|
+
- `ctx.runId` is a 12-char run id.
|
|
188
|
+
- `ctx.uniqueId(prefix)` returns `ripplo-test-<prefix>-<runId>-<n>` and increments per call.
|
|
189
|
+
- `ctx.uniqueEmail()` returns `ripplo-test-<runId>-<n>@test.ripplo.ai`.
|
|
261
190
|
- `ctx.setCookie(name, value, options?)` applies to that run's browser context before the test starts.
|
|
191
|
+
- `ctx.fixed(value)` brands a static value so the engine can tell it apart from a raw literal.
|
|
262
192
|
|
|
263
|
-
|
|
193
|
+
The branding matters: helpers return plain primitives, but their return type is branded so a bare string literal in a `setup` return fails to compile. That's what stops two parallel runs from accidentally seeding identical-looking data.
|
|
264
194
|
|
|
265
|
-
|
|
195
|
+
`TEST_ID_PREFIX` is exported so teardown logic that scopes `WHERE` clauses by `startsWith(TEST_ID_PREFIX)` doesn't have to hardcode the string.
|
|
266
196
|
|
|
267
197
|
### Observer context
|
|
268
198
|
|
|
269
199
|
The observer impl returns one of three terminal states:
|
|
270
200
|
|
|
271
201
|
- `ctx.pass()` — assertion satisfied; stop polling.
|
|
272
|
-
- `ctx.retry(reason)` —
|
|
273
|
-
- `ctx.fail(reason)` —
|
|
202
|
+
- `ctx.retry(reason)` — try again later. The default. Anything that might succeed on a future poll belongs here, including "not found" — rows often arrive late. The last `reason` shows up in failure detail.
|
|
203
|
+
- `ctx.fail(reason)` — give up immediately. Reserve this for invariant violations where polling cannot help.
|
|
274
204
|
|
|
275
|
-
Thrown exceptions
|
|
205
|
+
Thrown exceptions count as `fail`.
|
|
276
206
|
|
|
277
207
|
## Observer coverage
|
|
278
208
|
|
|
279
|
-
Two lint rules
|
|
209
|
+
Two lint rules push backend assertions onto mutation flows. `mutation-without-observer-coverage` flags save / create / update / delete clicks, uploads, and accepted dialogs that aren't followed by an `assert.backend(...)`. `observer-params-reference-variables` flags assertions whose params are all string literals while the test declares precondition variables.
|
|
280
210
|
|
|
281
|
-
Steps that genuinely touch no server state
|
|
211
|
+
Steps that genuinely touch no server state opt out with `{ uiOnly: true }`:
|
|
282
212
|
|
|
283
213
|
```typescript
|
|
284
214
|
click(role("button", "Cancel"), { uiOnly: true }).as("close dialog");
|
|
@@ -289,97 +219,37 @@ test("filter-sort", { uiOnly: true }).name("Filter & sort");
|
|
|
289
219
|
|
|
290
220
|
## Server adapters
|
|
291
221
|
|
|
292
|
-
Every adapter takes
|
|
222
|
+
Every adapter takes `engine` from `createEngine(...)` and a required `enabled: boolean`. Bind `enabled` to `process.env.ENABLE_RIPPLO_TESTING === "true"` so the routes can't ship to production. When `enabled` is false the adapter mounts a no-op handler.
|
|
293
223
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
```ts
|
|
297
|
-
import express from "express";
|
|
298
|
-
import { createExpressHandler } from "@ripplo/testing/express";
|
|
299
|
-
import { engine } from "./test/engine.js";
|
|
300
|
-
|
|
301
|
-
const app = express();
|
|
302
|
-
app.use("/ripplo", createExpressHandler({ enabled, engine }));
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
### Fastify
|
|
306
|
-
|
|
307
|
-
```ts
|
|
308
|
-
import Fastify from "fastify";
|
|
309
|
-
import { registerFastifyHandler } from "@ripplo/testing/fastify";
|
|
310
|
-
import { engine } from "./test/engine.js";
|
|
311
|
-
|
|
312
|
-
const app = Fastify();
|
|
313
|
-
await app.register(registerFastifyHandler({ enabled, engine }), { prefix: "/ripplo" });
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
### Next.js (App Router)
|
|
317
|
-
|
|
318
|
-
A single catch-all route. Works on Node and Edge.
|
|
224
|
+
The recommended setup is Next.js App Router:
|
|
319
225
|
|
|
320
226
|
```ts
|
|
321
227
|
// app/ripplo/[action]/route.ts
|
|
322
228
|
import { createNextHandler } from "@ripplo/testing/nextjs";
|
|
323
229
|
import { engine } from "@/server/test/engine";
|
|
324
230
|
|
|
325
|
-
export const PUT = createNextHandler({
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
```ts
|
|
331
|
-
import { Hono } from "hono";
|
|
332
|
-
import { createHonoHandler } from "@ripplo/testing/hono";
|
|
333
|
-
import { engine } from "./test/engine.js";
|
|
334
|
-
|
|
335
|
-
const app = new Hono();
|
|
336
|
-
app.route("/ripplo", createHonoHandler({ enabled, engine }));
|
|
337
|
-
```
|
|
338
|
-
|
|
339
|
-
Web-standard `Request`/`Response`. Runs on Node, Bun, Deno, and Cloudflare Workers.
|
|
340
|
-
|
|
341
|
-
### Koa
|
|
342
|
-
|
|
343
|
-
```ts
|
|
344
|
-
import Koa from "koa";
|
|
345
|
-
import mount from "koa-mount";
|
|
346
|
-
import { createKoaHandler } from "@ripplo/testing/koa";
|
|
347
|
-
import { engine } from "./test/engine.js";
|
|
348
|
-
|
|
349
|
-
const app = new Koa();
|
|
350
|
-
app.use(mount("/ripplo", createKoaHandler({ enabled, engine })));
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
The Koa adapter reads the raw body itself; don't mount a body-parser in front of it.
|
|
354
|
-
|
|
355
|
-
### NestJS
|
|
356
|
-
|
|
357
|
-
```ts
|
|
358
|
-
import { Module } from "@nestjs/common";
|
|
359
|
-
import { RipploTestingModule } from "@ripplo/testing/nestjs";
|
|
360
|
-
import { engine } from "./test/engine.js";
|
|
361
|
-
|
|
362
|
-
@Module({
|
|
363
|
-
imports: [RipploTestingModule.forRoot({ enabled, engine, path: "ripplo" })],
|
|
364
|
-
})
|
|
365
|
-
export class AppModule {}
|
|
231
|
+
export const PUT = createNextHandler({
|
|
232
|
+
enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
|
|
233
|
+
engine,
|
|
234
|
+
});
|
|
366
235
|
```
|
|
367
236
|
|
|
368
|
-
|
|
237
|
+
The other adapters follow the same `{ enabled, engine }` shape and mount at a path prefix:
|
|
369
238
|
|
|
370
|
-
|
|
239
|
+
| Framework | Import | Mount |
|
|
240
|
+
| --------- | -------------------------------------------- | ------------------------------------ |
|
|
241
|
+
| Express | `createExpressHandler` from `/express` | `app.use("/ripplo", handler)` |
|
|
242
|
+
| Fastify | `registerFastifyHandler` from `/fastify` | `app.register(handler, { prefix })` |
|
|
243
|
+
| Hono | `createHonoHandler` from `/hono` | `app.route("/ripplo", handler)` |
|
|
244
|
+
| Koa | `createKoaHandler` from `/koa` | `koa-mount("/ripplo", handler)` |
|
|
245
|
+
| NestJS | `RipploTestingModule.forRoot` from `/nestjs` | Import in your `AppModule` |
|
|
246
|
+
| Elysia | `createElysiaHandler` from `/elysia` | `new Elysia().group("/ripplo", ...)` |
|
|
371
247
|
|
|
372
|
-
|
|
373
|
-
import { Elysia } from "elysia";
|
|
374
|
-
import { createElysiaHandler } from "@ripplo/testing/elysia";
|
|
375
|
-
import { engine } from "./test/engine.js";
|
|
376
|
-
|
|
377
|
-
const app = new Elysia().group("/ripplo", (g) => g.use(createElysiaHandler({ enabled, engine })));
|
|
378
|
-
```
|
|
248
|
+
Two notes worth knowing. The Koa adapter reads the raw body itself, so don't mount a body-parser in front of it. NestJS requires `@nestjs/platform-express` and `reflect-metadata`.
|
|
379
249
|
|
|
380
|
-
### Custom
|
|
250
|
+
### Custom adapter
|
|
381
251
|
|
|
382
|
-
|
|
252
|
+
The wrappers above are thin. If you're on a framework not listed, read the raw request body, verify the signature with `verifyWebhookSignature`, dispatch the three routes to `engine.executePreconditions`, `engine.executeObserver`, and `engine.teardownPreconditions`, and shape responses with `toBatchRunResults` / `toTeardownResults`. Cookies travel inside the JSON response body, not `Set-Cookie` headers; the runtime parses them out and applies them to the browser context.
|
|
383
253
|
|
|
384
254
|
```ts
|
|
385
255
|
import {
|
|
@@ -420,24 +290,12 @@ async function executePreconditions(req: Request): Promise<Response> {
|
|
|
420
290
|
|
|
421
291
|
`executeObserver` and `teardownPreconditions` follow the same verify-then-dispatch shape. Request bodies: `{ batch: [{ runId, preconditions, data }] }` for teardown, `{ runId, name, params }` for observers.
|
|
422
292
|
|
|
423
|
-
## Security
|
|
293
|
+
## Security and parallelism
|
|
424
294
|
|
|
425
|
-
Every request is signed with Standard Webhooks (HMAC-SHA256) over
|
|
295
|
+
Every request is signed with [Standard Webhooks](https://www.standardwebhooks.com/) (HMAC-SHA256) over `webhook-id`, `webhook-timestamp`, and `webhook-signature`. Verify before executing. `ENABLE_RIPPLO_TESTING` gates every adapter and must never be true in production.
|
|
426
296
|
|
|
427
297
|
For parallel runs, use `ctx.uniqueId(prefix)` and `ctx.uniqueEmail()` to avoid collisions, return the created entity ids in the data contract, and scope teardown's `WHERE` to those ids. `deleteMany` is fine and preferred, as long as the predicate can only match this batch's rows.
|
|
428
298
|
|
|
429
299
|
## Lockfile
|
|
430
300
|
|
|
431
|
-
`ripplo compile`, `ripplo lint`, and `ripplo watch` all write `.ripplo/ripplo.lock`. Commit it
|
|
432
|
-
|
|
433
|
-
## CLI
|
|
434
|
-
|
|
435
|
-
```bash
|
|
436
|
-
ripplo auth login # authenticate
|
|
437
|
-
ripplo init # scaffold .ripplo/ and write env vars
|
|
438
|
-
ripplo watch # local executor (run as a background process)
|
|
439
|
-
ripplo lint [ids..] # compile + lint
|
|
440
|
-
ripplo run [ids..] # run tests in parallel
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
Full command reference at [`ripplo`](https://www.npmjs.com/package/ripplo).
|
|
301
|
+
`ripplo compile`, `ripplo lint`, and `ripplo watch` all write `.ripplo/ripplo.lock`. Commit it. The Ripplo server reads it on every push webhook and returns 422 if it's missing or stale. Run `ripplo compile --check` in pre-commit and CI; `ripplo doctor` surfaces stale lockfiles and missing pre-commit hooks.
|
package/dist/lockfile.js
CHANGED
|
@@ -611,6 +611,12 @@ async function hashFixturesIntoCompileResult({
|
|
|
611
611
|
return { ...result, fixtures: Object.fromEntries(hashed) };
|
|
612
612
|
}
|
|
613
613
|
async function hashOneFixture({ fixturesRoot, name }) {
|
|
614
|
+
const rawName = name;
|
|
615
|
+
if (typeof rawName !== "string" || rawName.length === 0) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`Internal error: upload step produced a non-string fixture name (got ${rawName === null ? "null" : typeof rawName}). This usually means a test passed a non-Fixture value to upload() \u2014 wrap the filename with fixture("file.png").`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
614
620
|
if (name.includes("..") || path.isAbsolute(name)) {
|
|
615
621
|
throw new Error(`Invalid fixture name "${name}": must be a path under .ripplo/fixtures/`);
|
|
616
622
|
}
|
|
@@ -634,10 +640,19 @@ async function hashOneFixture({ fixturesRoot, name }) {
|
|
|
634
640
|
function collectFixtureReferences(result) {
|
|
635
641
|
const names = /* @__PURE__ */ new Set();
|
|
636
642
|
result.tests.forEach((test) => {
|
|
637
|
-
Object.
|
|
638
|
-
if (node.type
|
|
639
|
-
|
|
643
|
+
Object.entries(test.spec.nodes).forEach(([stepId, node]) => {
|
|
644
|
+
if (node.type !== "upload") {
|
|
645
|
+
return;
|
|
640
646
|
}
|
|
647
|
+
node.files.forEach((name, i) => {
|
|
648
|
+
const raw = name;
|
|
649
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`Test "${test.slug}" step "${stepId}" upload files[${String(i)}] is not a non-empty string (got ${raw === null ? "null" : typeof raw}). Wrap filenames with fixture(): upload(loc, fixture("file.png")).`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
names.add(raw);
|
|
655
|
+
});
|
|
641
656
|
});
|
|
642
657
|
});
|
|
643
658
|
return names;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ripplo/testing",
|
|
3
3
|
"description": "TypeScript DSL for defining and running Ripplo e2e workflow tests",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist"
|
|
@@ -99,8 +99,8 @@
|
|
|
99
99
|
"tsup": "^8.5.1",
|
|
100
100
|
"typescript": "catalog:",
|
|
101
101
|
"vitest": "^4.1.4",
|
|
102
|
-
"@ripplo/
|
|
103
|
-
"@ripplo/
|
|
102
|
+
"@ripplo/eslint-config": "0.0.0",
|
|
103
|
+
"@ripplo/spec": "^0.0.0"
|
|
104
104
|
},
|
|
105
105
|
"peerDependencies": {
|
|
106
106
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|