@ripplo/testing 0.1.1 → 0.3.0

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