@ripplo/testing 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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>()`
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).
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()`
74
132
 
75
- Use `.notImplemented()` instead of `.startsAt()` + `.steps()` to stub a test during planning.
133
+ **Budget tiers** (framework-defined, not numeric):
134
+
135
+ - `"fast"` — ~5s with 100→1000ms backoff. Default. Synchronous DB reads.
136
+ - `"slow"` — ~30s with 250→2000ms backoff. Queue drains, replication settling.
137
+ - `"async"` — ~120s with 500→5000ms backoff. Webhooks, queue workers, LLM calls.
76
138
 
77
139
  ### Locators
78
140
 
@@ -155,6 +217,7 @@ assert.not.focused(role("textbox", "Search")); // No focus
155
217
  assert.count(testId("row"), 5); // Element count
156
218
  assert.attribute(role("link", "Docs"), "href", "/docs"); // Attribute value
157
219
  assert.value(role("textbox", "Email"), "test@x.com"); // Input value
220
+ assert.backend(observerHandle, { ... }); // Backend state (see Observers)
158
221
  ```
159
222
 
160
223
  All text/URL assertions use **exact matching only** (`equals` operator). No `contains`, `startsWith`, or regex.
@@ -178,125 +241,141 @@ click(role("button", "Save")).as("save the form");
178
241
  assert.visible(role("status", "Saved")).as("verify save confirmation");
179
242
  ```
180
243
 
181
- ## 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: process.env.APP_URL ?? "https://localhost:3001",
276
+ engineUrl: `${process.env.APP_URL ?? "https://localhost:3001"}/ripplo`,
277
+ projectId: "<your-project-id>",
278
+ webhookSecret: process.env.RIPPLO_WEBHOOK_SECRET ?? "",
232
279
  },
233
- });
234
- ```
280
+ { preconditions, observers, tests },
281
+ );
235
282
 
236
- **SetupContext provides:**
283
+ export default ripplo;
284
+ ```
237
285
 
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
286
+ ### App server the implementations funnel
243
287
 
244
- ## Observer System
288
+ ```typescript
289
+ // <your-server>/src/test/engine.ts
290
+ import { createEngine } from "@ripplo/testing";
291
+ import ripplo from "../../../../.ripplo/index.js";
292
+ import { prisma } from "../lib/prisma.js";
293
+
294
+ export const engine = createEngine(ripplo, {
295
+ preconditions: {
296
+ authLoggedIn: {
297
+ setup: async (ctx) => {
298
+ // create user, set cookies via ctx.setCookie()
299
+ return { userId: ctx.uniqueId("user") };
300
+ },
301
+ teardown: async (ctx) => {
302
+ // clean up using ctx.data.userId
303
+ },
304
+ },
305
+ dataProject: {
306
+ setup: async (ctx, { auth }) => {
307
+ // use auth.userId to create a project
308
+ return {
309
+ orgId: ctx.fixed("..."),
310
+ projectId: ctx.fixed("..."),
311
+ };
312
+ },
313
+ teardown: async () => {},
314
+ },
315
+ },
316
+ observers: {
317
+ orgNameIs: async (ctx, { orgId, expectedName }) => {
318
+ const org = await prisma.organization.findUnique({
319
+ select: { name: true },
320
+ where: { id: orgId },
321
+ });
322
+ if (org == null) return ctx.retry(`organization "${orgId}" not found yet`);
323
+ if (org.name !== expectedName) {
324
+ return ctx.retry(`name is "${org.name}", expected "${expectedName}"`);
325
+ }
326
+ return ctx.pass();
327
+ },
328
+ },
329
+ });
330
+ ```
245
331
 
246
- 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.
332
+ **Exhaustiveness:** the `preconditions` and `observers` keys in this object must exactly match the registries you passed to `createRipplo`. Missing a key is a TypeScript error; adding an unknown key is a TypeScript error.
247
333
 
248
- ### Declaration (`.ripplo/observers/*.ts`)
334
+ **Stubbing an impl:** use the `notImplemented` sentinel to keep planning-mode placeholders while the surrounding code still compiles.
249
335
 
250
336
  ```typescript
251
- import type { ObserverHandle } from "@ripplo/testing";
252
- import ripplo from "../ripplo.js";
337
+ import { createEngine, notImplemented } from "@ripplo/testing";
253
338
 
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();
339
+ createEngine(ripplo, {
340
+ preconditions: {
341
+ authLoggedIn: { setup, teardown },
342
+ dataProject: notImplemented("awaiting prisma seed helper"),
343
+ },
344
+ observers: { orgNameIs: notImplemented() },
345
+ });
260
346
  ```
261
347
 
262
- **Chain:** `.observer(name)` `.description(text)` `.input<T>()` `.budget(tier)` (optional) `.contract()`
348
+ The engine returns a "not implemented" failure outcome at runtime for any stub, and `ripplo lint` can be configured to block CI on unimplemented entries.
263
349
 
264
- **Budget tiers** (framework-defined, not numeric):
350
+ ### SetupContext
265
351
 
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.
352
+ `ctx` passed to each precondition `setup` provides:
269
353
 
270
- ### Implementation (server side)
354
+ - `ctx.runId` unique 12-char id for this test run
355
+ - `ctx.fixed(value)` — static test value
356
+ - `ctx.uniqueId(prefix)` — generate unique ID (e.g., `ripplo-test-<prefix>-<runId>`)
357
+ - `ctx.uniqueEmail()` — generate unique email (`ripplo-test-<runId>@test.ripplo.ai`)
358
+ - `ctx.setCookie(name, value, options?)` — inject auth cookies; forwarded as `Set-Cookie` to the test browser
271
359
 
272
- ```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
- ```
360
+ ### ObserverContext
284
361
 
285
- **ObserverContext provides:**
362
+ `ctx` passed to each observer impl provides:
286
363
 
287
364
  - `ctx.pass()` → assertion satisfied; stop polling.
288
365
  - `ctx.retry(reason)` → **default**. Use for anything that might succeed on a later poll: row not found yet, status not transitioned, queue not drained, side effect still in flight. Runtime polls until the budget expires; the last `reason` surfaces in the failure detail.
289
366
  - `ctx.fail(reason)` → **narrow**. Only when further polling cannot help — an invariant has been violated (wrong shape, contradictory value, forbidden state). Stops immediately. If you're tempted to use `fail` for "not found" or "not yet X", it should be `retry`.
290
367
  - Any thrown exception is treated as `fail` with the error message.
291
368
 
292
- ### Usage in tests
369
+ ## Observer Usage in Tests
293
370
 
294
371
  ```typescript
295
372
  import { orgNameIs } from "../observers/index.js";
296
373
 
297
- ripplo
298
- .test("update-org-name")
374
+ test("update-org-name")
375
+ .name("Update org name")
299
376
  .requires({ project: dataProject })
377
+ .expectedOutcome("org name persisted")
378
+ .startsAt(({ project }) => `/projects/${project.projectId}/settings`)
300
379
  .steps(({ project }) => [
301
380
  fill(role("textbox", "Organization name"), "New Name").as("fill new name"),
302
381
  click(role("button", "Save")).as("click save"),
@@ -319,10 +398,10 @@ For steps that genuinely don't touch server state (cancel dialogs, toggle a disp
319
398
  click(role("button", "Cancel"), { uiOnly: true }).as("close dialog");
320
399
  ```
321
400
 
322
- For entire presentation-only flows, pass the flag to `ripplo.test`:
401
+ For entire presentation-only flows, pass the flag to `test`:
323
402
 
324
403
  ```typescript
325
- ripplo.test("filter-sort-user-flows", { uiOnly: true }).name("Filter & sort");
404
+ test("filter-sort-user-flows", { uiOnly: true }).name("Filter & sort");
326
405
  ```
327
406
 
328
407
  Treat `uiOnly` as a narrow escape hatch — default to writing an observer.
@@ -348,34 +427,40 @@ ripplo flake-detect <id> --runs=10 # Run N times in parallel to detect flakines
348
427
 
349
428
  ## Server Setup
350
429
 
351
- 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.
430
+ Pick the adapter that matches your framework — each handles webhook signature verification, cookie forwarding, and request parsing for you. Every adapter takes a required `enabled: boolean` flag — bind it to an env var (e.g. `process.env.ENABLE_RIPPLO_TESTING === "true"`) so the endpoints never ship to production. When `enabled` is false the adapter mounts a no-op handler.
431
+
432
+ All adapters take the `engine` produced by `createEngine(ripplo, impls)` — not the bare `ripplo` instance.
352
433
 
353
434
  ### Express
354
435
 
355
436
  ```ts
356
437
  import express from "express";
357
438
  import { createExpressHandler } from "@ripplo/testing/express";
358
- import ripplo from "<path to .ripplo/ripplo>"; // import the existing instance — never call createRipplo() outside .ripplo/ripplo.ts
439
+ import { engine } from "./test/engine.js";
359
440
 
360
441
  const app = express();
361
442
  app.use(
362
443
  "/ripplo",
363
- createExpressHandler({ enabled: process.env.ENABLE_RIPPLO_TESTING === "true", ripplo }),
444
+ createExpressHandler({
445
+ enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
446
+ engine,
447
+ }),
364
448
  );
365
449
  ```
366
450
 
367
- Mounts both `PUT /execute-preconditions` and `PUT /teardown-preconditions` under the prefix you choose.
368
-
369
451
  ### Fastify
370
452
 
371
453
  ```ts
372
454
  import Fastify from "fastify";
373
455
  import { registerFastifyHandler } from "@ripplo/testing/fastify";
374
- import ripplo from "<path to .ripplo/ripplo>"; // import the existing instance — never call createRipplo() outside .ripplo/ripplo.ts
456
+ import { engine } from "./test/engine.js";
375
457
 
376
458
  const app = Fastify();
377
459
  await app.register(
378
- registerFastifyHandler({ enabled: process.env.ENABLE_RIPPLO_TESTING === "true", ripplo }),
460
+ registerFastifyHandler({
461
+ enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
462
+ engine,
463
+ }),
379
464
  { prefix: "/ripplo" },
380
465
  );
381
466
  ```
@@ -387,34 +472,28 @@ The Next.js adapter exports a single catch-all handler. Create one dynamic route
387
472
  ```ts
388
473
  // app/ripplo/[action]/route.ts
389
474
  import { createNextHandler } from "@ripplo/testing/nextjs";
390
- import ripplo from "@/.ripplo/ripplo";
475
+ import { engine } from "@/server/test/engine";
391
476
 
392
477
  export const PUT = createNextHandler({
393
478
  enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
394
- ripplo,
479
+ engine,
395
480
  });
396
481
  ```
397
482
 
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.
483
+ The handler dispatches on the last URL segment (`execute-preconditions`, `execute-observer`, `teardown-preconditions`) and returns 404 for anything else. It depends only on the Web `Request` / `Response` types, so it runs on both the Node and Edge runtimes — no `next` import required.
399
484
 
400
485
  ### Custom integration (raw engine)
401
486
 
402
- If your framework isn't covered above (Hono, Koa, Bun, Deno, Cloudflare Workers, etc.), use the raw engine directly. The adapters are thin wrappers over the same API.
487
+ If your framework isn't covered above (Hono, Koa, Bun, Deno, Cloudflare Workers, etc.), use the engine directly. The adapters are thin wrappers over the same API.
403
488
 
404
489
  ```ts
405
- import {
406
- 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
490
+ import { buildSetCookieHeader, serializeCookie, verifyWebhookSignature } from "@ripplo/testing";
491
+ import { engine } from "./test/engine.js";
412
492
 
413
- const engine = createEngine(ripplo);
414
- const webhookSecret = ripplo.getConfig().webhookSecret;
493
+ const webhookSecret = engine.getConfig().webhookSecret;
415
494
 
416
495
  // PUT /ripplo/execute-preconditions
417
- async function executeBatch(req: Request): Promise<Response> {
496
+ async function executePreconditions(req: Request): Promise<Response> {
418
497
  const body = await req.text();
419
498
  const verified = verifyWebhookSignature(
420
499
  body,
@@ -431,7 +510,7 @@ async function executeBatch(req: Request): Promise<Response> {
431
510
 
432
511
  const { preconditions } = JSON.parse(body);
433
512
  const appUrl = `${req.headers.get("x-forwarded-proto") ?? "http"}://${req.headers.get("host")}`;
434
- const result = await engine.executeBatch(preconditions, { appUrl });
513
+ const result = await engine.executePreconditions(preconditions, { appUrl });
435
514
 
436
515
  const headers = new Headers({ "content-type": "application/json" });
437
516
  result.cookies.forEach((c) => {
@@ -440,69 +519,17 @@ async function executeBatch(req: Request): Promise<Response> {
440
519
  return new Response(JSON.stringify(result), { headers });
441
520
  }
442
521
 
443
- // PUT /ripplo/teardown
444
- async function teardown(req: Request): Promise<Response> {
445
- // ... same verify pattern, then:
446
- // await engine.teardown(parsed.preconditions, parsed.data);
447
- }
522
+ // PUT /ripplo/teardown-preconditions — same verify pattern, then engine.teardown(...)
523
+ // PUT /ripplo/execute-observer — same verify pattern, then engine.executeObserver(...)
448
524
  ```
449
525
 
450
526
  **You're responsible for:**
451
527
 
452
528
  - **Webhook verification.** Always call `verifyWebhookSignature` before invoking the engine.
453
- - **Routing.** Dispatch the two endpoints (`execute-preconditions`, `teardown-preconditions`) however your framework handles routes.
529
+ - **Routing.** Dispatch the three endpoints (`execute-preconditions`, `execute-observer`, `teardown-preconditions`) however your framework handles routes.
454
530
  - **Cookie forwarding.** `result.cookies` contains the cookies preconditions set during setup — they must reach the test browser as `Set-Cookie` headers, or login/session preconditions will silently fail.
455
531
  - **Body parsing.** Use the raw text body for signature verification, then `JSON.parse` for the engine call.
456
532
 
457
- ### Config
458
-
459
- After mounting, point Ripplo at the prefix:
460
-
461
- ```ts
462
- // .ripplo/ripplo.ts
463
- import { createRipplo } from "@ripplo/testing";
464
-
465
- export default createRipplo({
466
- appUrl: process.env.APP_URL,
467
- engineUrl: `${process.env.APP_URL}/ripplo`,
468
- projectId: "...",
469
- webhookSecret: process.env.RIPPLO_WEBHOOK_SECRET,
470
- });
471
- ```
472
-
473
- ## Precondition API Contract
474
-
475
- The app server exposes two endpoints for test data management:
476
-
477
- ### Execute (`PUT {engineBaseUrl}/execute`)
478
-
479
- ```json
480
- // Request
481
- { "precondition": "data:project" }
482
-
483
- // Response
484
- { "success": true, "data": { "projectId": "cuid-abc", "orgId": "org-xyz" } }
485
- ```
486
-
487
- Returns data that flows into test variables. Set-Cookie headers are captured automatically.
488
-
489
- ### Teardown (`PUT {engineBaseUrl}/teardown`)
490
-
491
- ```json
492
- // Request
493
- { "preconditions": ["auth:logged-in", "data:project"] }
494
-
495
- // Response
496
- { "success": true }
497
- ```
498
-
499
- ### Parallel Safety
500
-
501
- - Generate unique names/emails per run using `crypto.randomUUID()` suffixes
502
- - Return created entity IDs in the `data` response
503
- - Teardown only deletes that run's data (use session cookies to identify user)
504
- - Never hardcode entity names or use bulk deletion
505
-
506
533
  ### Webhook Signing
507
534
 
508
535
  All requests are signed using Standard Webhooks (HMAC-SHA256). Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`. Verify before executing.
@@ -510,3 +537,10 @@ All requests are signed using Standard Webhooks (HMAC-SHA256). Headers: `webhook
510
537
  ### Environment Guard
511
538
 
512
539
  Wrap all precondition routes behind `ENABLE_RIPPLO_TESTING=true`. Never expose in production.
540
+
541
+ ### Parallel Safety
542
+
543
+ - Use `ctx.uniqueId(prefix)` / `ctx.uniqueEmail()` in precondition setup so names/emails don't collide across parallel runs
544
+ - Return created entity IDs in the data contract so teardown can scope deletion precisely
545
+ - Teardown only deletes that run's data (use the `runId` captured at setup)
546
+ - Never hardcode entity names or use bulk deletion
package/dist/assert.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { O as ObserverHandle, a as ObserverInput, b as ObserverBudgetTier } from './types-oYS_Yv4G.js';
1
+ import { O as ObserverHandle, a as ObserverInput, b as ObserverBudgetTier } from './types-yIhY8cwG.js';
2
2
  import { U as UnlabeledStep } from './step-De52hTLd.js';
3
3
  import { CheckLocator, AnyLocator } from './locators.js';
4
4
  import 'zod';
package/dist/assert.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  toSpecLocator
3
3
  } from "./chunk-2VUWFRR5.js";
4
- import {
5
- createStep
6
- } from "./chunk-MGATMMCZ.js";
7
4
  import {
8
5
  readObserverBudget,
9
6
  readObserverName
10
- } from "./chunk-3IL457A7.js";
7
+ } from "./chunk-P4ZI7G5M.js";
8
+ import {
9
+ createStep
10
+ } from "./chunk-MGATMMCZ.js";
11
11
  import "./chunk-DCJBLS2U.js";
12
12
 
13
13
  // src/steps/assert.ts