@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.
Files changed (3) hide show
  1. package/README.md +64 -206
  2. package/dist/lockfile.js +18 -3
  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
- This package ships the DSL and the server adapters. The companion [`ripplo`](https://www.npmjs.com/package/ripplo) CLI scaffolds `.ripplo/`, runs tests, and writes the lockfile.
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
- ## Architecture
11
+ ## How it fits together
12
12
 
13
- Definitions and implementations are kept in separate files.
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 the handles returned by `precondition()`, `observer()`, and `test()`. These factories have no side effects.
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
- Mount the resulting `engine` with one of the adapters (`@ripplo/testing/express`, `/fastify`, `/nextjs`, `/hono`, `/koa`, `/nestjs`, `/elysia`) at a path prefix (default `/ripplo`). The adapter exposes three signed routes: `PUT /execute-preconditions`, `PUT /execute-observer`, `PUT /teardown-preconditions`.
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
- Implementations run server-side where they have DB access. The DSL package never invokes them; it dispatches over signed HTTP.
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)` `.name()` `.description()?` `.requires()` `.expectedOutcome()` `.startsAt()` `.steps()` `.coverage(...ids)`. Use `.notImplemented()` instead of `.startsAt() / .steps() / .coverage()` to stub during planning.
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
- `.coverage(...)` ids come from the generated `.ripplo/coverage.d.ts`, which augments `CoverageRegistry` so the call autocompletes and breaks the build on stale ids. Implemented tests must list every interaction they exercise; stubs skip it. A pre-commit hook blocks net-new interactions in the diff that no test claims.
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
- ### Preconditions
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
- `precondition(name)` `.description()` `.requires()` (optional) → `.contract<T>()`. Each field in `T` must be a primitive (`string`, `number`, or `boolean`) and is run-scoped.
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 a non-default state, accept that state as input on the precondition that creates the row. Don't seed a default and patch it later.
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 exceptions:
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
- - `upsert` on a row whose primary key is per-run (e.g. a `(userId, resourceId)` settings record). Treat it as create-with-default.
88
- - Teardown may delete, but only rows this precondition created.
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
- Observer `.input<T>()` and precondition `.contract<T>()` fields are typed primitives: `string`, `number`, or `boolean`. The wire codec preserves the type, so a `number` field arrives as a `number` in your impl.
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
- `role(name, accessibleName)` matches by ARIA role and accessible name. `testId(id)` matches a `data-testid` attribute and exists for elements that genuinely have no semantic role.
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
- **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`.
98
+ ## Locators, actions, assertions
119
99
 
120
- Type-narrowed locators reject the wrong role at compile time:
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` `textbox`, `searchbox`, `combobox`, `spinbutton`, `testId()`
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
- ### Actions
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
- Imported from `@ripplo/testing/actions`. Every action takes a locator and produces a step:
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 files live in `.ripplo/fixtures/` and are committed to git so cloud runs see byte-identical inputs. Limits: 10 MB per file, 50 MB total. Pass an array to `upload` for multi-file inputs.
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. `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.
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` — definitions
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 host app's env file; `ripplo init` writes the initial values. Project id and env-file pointers live in `.ripplo/project.json`.
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` — implementations
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
- // setup receives one item per concurrent run that needs this precondition.
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"), // stub for planning
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
- `setup` and `teardown` run in batches. The runtime groups concurrent runs that need the same precondition inside a 200ms window and calls your impl once for the whole batch. Use `createMany` / `deleteMany` so database load scales with wall-clock time rather than run count. Return one result per input item, in input order; the engine zips them back to runs by index.
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 exposes `ctx`:
185
+ Each batched setup item carries a `ctx`:
256
186
 
257
- - `ctx.runId` 12-char run id.
258
- - `ctx.fixed(value)` static test value, type-branded so the engine can tell it apart from a raw literal.
259
- - `ctx.uniqueId(prefix)` returns `ripplo-test-<prefix>-<runId>-<n>`, where `n` is a per-call counter scoped to the run. Calling it repeatedly yields distinct values.
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
- Helpers return plain primitives, but the return type is branded a bare string literal in a `setup` return fails at compile time, which is what stops tests from accidentally seeding identical-looking data across runs.
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
- The shared prefix (`ripplo-test-`) is also exported as `TEST_ID_PREFIX` from `@ripplo/testing`, so teardown / cleanup logic that scopes `WHERE` clauses by `startsWith(TEST_ID_PREFIX)` doesn't have to hardcode the string.
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)` — the default; use it for anything that might succeed on a later poll (row not yet written, status not yet transitioned, queue not yet drained). The last `reason` is surfaced in failure detail. "Not found" belongs here, not in `fail`.
273
- - `ctx.fail(reason)` — narrow; only when polling cannot help (invariant violated, contradictory value). Stops immediately.
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 are treated as `fail`.
205
+ Thrown exceptions count as `fail`.
276
206
 
277
207
  ## Observer coverage
278
208
 
279
- Two lint rules enforce backend assertions on mutation flows. `mutation-without-observer-coverage` flags save / create / update / delete clicks, uploads, and accepted dialogs that aren't followed by an `assert.backend(...)`. `observer-params-reference-variables` flags assertions whose params are all string literals while the test declares precondition variables.
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 (cancel dialogs, client-side sort) opt out with `{ uiOnly: true }`:
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 the `engine` from `createEngine(...)` and a required `enabled: boolean`. Bind `enabled` to `process.env.ENABLE_RIPPLO_TESTING === "true"` so the routes can't ship to production; when it's false the adapter mounts a no-op handler.
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
- ### Express
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({ enabled, engine });
326
- ```
327
-
328
- ### Hono
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
- Requires `@nestjs/platform-express` and `reflect-metadata`. `path` defaults to `"ripplo"`.
237
+ The other adapters follow the same `{ enabled, engine }` shape and mount at a path prefix:
369
238
 
370
- ### Elysia
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
- ```ts
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 (raw engine)
250
+ ### Custom adapter
381
251
 
382
- For frameworks not listed above. The adapters above are thin wrappers over this API. A custom adapter reads the raw request body, verifies the signature with `verifyWebhookSignature`, dispatches the three routes to `engine.executePreconditions`, `engine.executeObserver`, and `engine.teardownPreconditions`, and shapes responses with `toBatchRunResults` / `toTeardownResults`. Cookies travel inside the JSON response body, not `Set-Cookie` headers; the runtime parses them out and applies them to the browser context.
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 & parallelism
293
+ ## Security and parallelism
424
294
 
425
- Every request is signed with Standard Webhooks (HMAC-SHA256) over the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers; verify before executing. `ENABLE_RIPPLO_TESTING` gates every adapter and must never be true in production.
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; the Ripplo server reads it on every push webhook and returns 422 if it's missing or stale. `ripplo compile --check` exits non-zero on a stale lockfile and belongs in pre-commit and CI. `ripplo doctor` surfaces stale lockfiles and missing pre-commit hooks.
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.values(test.spec.nodes).forEach((node) => {
638
- if (node.type === "upload") {
639
- node.files.forEach((name) => names.add(name));
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.5.5",
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/spec": "^0.0.0",
103
- "@ripplo/eslint-config": "0.0.0"
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",