@ripplo/testing 0.6.1 → 0.7.1

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 (44) hide show
  1. package/DSL.md +357 -0
  2. package/LICENSE.md +1 -0
  3. package/README.md +47 -273
  4. package/dist/engine-BfvzXgLg.d.ts +1091 -0
  5. package/dist/express.d.ts +7 -9
  6. package/dist/express.js +422 -48
  7. package/dist/index.d.ts +134 -59
  8. package/dist/index.js +1654 -1126
  9. package/package.json +31 -113
  10. package/dist/actions.d.ts +0 -260
  11. package/dist/actions.js +0 -177
  12. package/dist/assert.d.ts +0 -188
  13. package/dist/assert.js +0 -111
  14. package/dist/builder-SsgqYqSC.d.ts +0 -156
  15. package/dist/chunk-2YLI7VD4.js +0 -65
  16. package/dist/chunk-4MGIQFAJ.js +0 -16
  17. package/dist/chunk-DCJBLS2U.js +0 -26
  18. package/dist/chunk-MGATMMCZ.js +0 -16
  19. package/dist/chunk-XO36IU66.js +0 -88
  20. package/dist/chunk-YFOTJIVF.js +0 -134
  21. package/dist/chunk-YQAEOH5W.js +0 -111
  22. package/dist/compiler.d.ts +0 -32
  23. package/dist/compiler.js +0 -8
  24. package/dist/control.d.ts +0 -45
  25. package/dist/control.js +0 -17
  26. package/dist/elysia.d.ts +0 -78
  27. package/dist/elysia.js +0 -114
  28. package/dist/engine-BOqzK_go.d.ts +0 -61
  29. package/dist/fastify.d.ts +0 -14
  30. package/dist/fastify.js +0 -79
  31. package/dist/hono.d.ts +0 -19
  32. package/dist/hono.js +0 -89
  33. package/dist/koa.d.ts +0 -14
  34. package/dist/koa.js +0 -135
  35. package/dist/locators.d.ts +0 -40
  36. package/dist/locators.js +0 -11
  37. package/dist/lockfile.d.ts +0 -722
  38. package/dist/lockfile.js +0 -707
  39. package/dist/nestjs.d.ts +0 -17
  40. package/dist/nestjs.js +0 -139
  41. package/dist/nextjs.d.ts +0 -14
  42. package/dist/nextjs.js +0 -137
  43. package/dist/step-De52hTLd.d.ts +0 -19
  44. package/dist/types-BzZrl65Z.d.ts +0 -115
package/README.md CHANGED
@@ -1,301 +1,75 @@
1
1
  # @ripplo/testing
2
2
 
3
- Typed TypeScript DSL for end-to-end tests with real backend state, used by [Ripplo](https://ripplo.ai).
3
+ Typed test DSL for [Ripplo](https://ripplo.ai). You declare your app's state model and user flows in TypeScript; Ripplo compiles them to a lockfile and executes them against your real app with real backend state.
4
4
 
5
- ```sh
6
- npm install @ripplo/testing
7
- ```
8
-
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
-
11
- ## How it fits together
12
-
13
- Tests are split into two halves that the type system stitches together.
5
+ This package is the authoring surface. Execution lives in the [`ripplo` CLI](https://www.npmjs.com/package/ripplo).
14
6
 
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.
7
+ ## Install
16
8
 
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
-
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
-
21
- ## Writing a test
22
-
23
- ```typescript
24
- import { test } from "@ripplo/testing";
25
- import { click, fill } from "@ripplo/testing/actions";
26
- import { assert } from "@ripplo/testing/assert";
27
- import { role } from "@ripplo/testing/locators";
28
- import { dataWorkspace } from "../preconditions/index.js";
29
- import { invitePendingForEmail } from "../observers/index.js";
30
-
31
- export const inviteATeammate = test("invite-a-teammate")
32
- .name("Invite a teammate")
33
- .requires({ workspace: dataWorkspace })
34
- .expectedOutcome("Invite appears in the pending list and an invite record is created")
35
- .startsAt(({ workspace }) => `/workspaces/${workspace.id}/members`)
36
- .steps(({ workspace }) => [
37
- click(role("button", "Invite member")).as("open invite dialog"),
38
- fill(role("textbox", "Email"), "jamie@example.com").as("enter email"),
39
- click(role("button", "Send invite")).as("send"),
40
- assert.visible(role("status", "Invite sent")).as("confirm toast"),
41
- assert
42
- .backend(invitePendingForEmail, { workspaceId: workspace.id, email: "jamie@example.com" })
43
- .as("confirm invite recorded"),
44
- ])
45
- .coverage(
46
- "src/components/members/InviteDialog.tsx#InviteDialog.click[Invite member]",
47
- "src/components/members/InviteDialog.tsx#InviteDialog.click[Send invite]",
48
- );
49
- ```
50
-
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.
54
-
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.
56
-
57
- ## Preconditions
58
-
59
- ```typescript
60
- import { precondition } from "@ripplo/testing";
61
-
62
- export const authLoggedIn = precondition("auth:logged-in")
63
- .description("Authenticated test user with a valid session")
64
- .contract<{ userId: string }>();
65
-
66
- export const dataProject = precondition("data:project")
67
- .description("Project exists; user is admin")
68
- .requires({ auth: authLoggedIn })
69
- .contract<{ orgId: string; projectId: string }>();
70
-
71
- export const preconditions = { authLoggedIn, dataProject };
72
- ```
9
+ `npx ripplo init` (or the [Claude Code plugin](https://github.com/ripplo/claude-plugin)'s `/ripplo:setup`) scaffolds `.ripplo/`, installs this package, and wires env vars.
73
10
 
74
- Contract fields are primitives: `string`, `number`, or `boolean`. Each value is run-scoped.
11
+ ## The model
75
12
 
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.
13
+ A project has four layers, all plain TypeScript under `.ripplo/`:
77
14
 
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.
15
+ - **Entities** the state model. Each entity declares its fields as value-spaces (`v.email()`, `v.number({ min, max })`, ...) and gets a `seed`/`read` implementation in your app's engine.
16
+ - **Singletons** — client/global state (e.g. localStorage flags).
17
+ - **Worlds** — pure functions returning a record of entity handles: the starting state a test runs against.
18
+ - **Tests** — `test("Intent", () => ({ given, steps }))`. `given` arranges; `steps` act and assert.
79
19
 
80
- ## Observers
81
-
82
- ```typescript
83
- import { observer } from "@ripplo/testing";
84
-
85
- export const orgNameIs = observer("org:name-is")
86
- .description("Org has the given name in the DB")
87
- .budget("fast")
88
- .input<{ orgId: string; expectedName: string }>()
89
- .contract();
90
-
91
- export const observers = { orgNameIs };
92
- ```
93
-
94
- `.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).
95
-
96
- `.input<T>()` fields are typed primitives — same rules as precondition contracts. The wire codec preserves the type.
97
-
98
- ## Locators, actions, assertions
99
-
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.
101
-
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.
103
-
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.
105
-
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.
107
-
108
- ### Upload fixtures
109
-
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.
111
-
112
- ### Variables
113
-
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.
115
-
116
- ```typescript
117
- import { extract, variable } from "@ripplo/testing/control";
118
-
119
- const token = variable("token");
120
- extract(testId("token-value"), token).as("capture token");
121
- fill(role("textbox", "Paste here"), token).as("paste token");
122
- ```
123
-
124
- ## Wiring
125
-
126
- ### `.ripplo/index.ts`
127
-
128
- ```typescript
129
- import { createRipplo } from "@ripplo/testing";
130
- import { preconditions } from "./preconditions/index.js";
131
- import { observers } from "./observers/index.js";
132
- import { tests } from "./tests/index.js";
133
-
134
- export default createRipplo({ preconditions, observers, tests });
135
- ```
136
-
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`.
138
-
139
- ### `<app>/src/test/engine.ts`
140
-
141
- ```typescript
142
- import { createEngine, notImplemented } from "@ripplo/testing";
143
- import ripplo from "../../../../.ripplo/index.js";
144
- import { prisma } from "../lib/prisma.js";
145
-
146
- export const engine = createEngine(ripplo, {
147
- preconditions: {
148
- authLoggedIn: {
149
- // Setup receives one item per concurrent run that needs this precondition.
150
- // Issue one bulk write and return results in input order.
151
- setup: async (items) => {
152
- const seeds = items.map(({ ctx }) => ({
153
- id: ctx.uniqueId("user"),
154
- email: ctx.uniqueEmail(),
155
- }));
156
- await prisma.user.createMany({ data: seeds });
157
- return seeds.map(({ id }) => ({ userId: id }));
158
- },
159
- teardown: async (items) => {
160
- await prisma.user.deleteMany({
161
- where: { id: { in: items.map((it) => it.ctx.data.userId) } },
162
- });
163
- },
164
- },
165
- dataProject: notImplemented("awaiting prisma seed helper"),
166
- },
167
- observers: {
168
- orgNameIs: async (ctx, { orgId, expectedName }) => {
169
- const org = await prisma.organization.findUnique({
170
- select: { name: true },
171
- where: { id: orgId },
172
- });
173
- if (org == null) return ctx.retry(`organization "${orgId}" not found yet`);
174
- if (org.name !== expectedName) return ctx.retry(`name is "${org.name}"`);
175
- return ctx.pass();
176
- },
177
- },
20
+ ```ts
21
+ import { button, click, fill, goto, test, textbox, visible } from "@ripplo/testing";
22
+ import { Task } from "../entities/index.js";
23
+ import { ownedProject } from "../worlds/index.js";
24
+
25
+ export const createTask = test("Create a task", () => {
26
+ const { me, project } = ownedProject();
27
+ return {
28
+ given: [me, project],
29
+ steps: [
30
+ goto`/projects/${project.id}/tasks`.expect(visible(button("New"))),
31
+ click(button("New")).expect(visible(textbox("Title"))),
32
+ fill(textbox("Title"), "Buy milk"),
33
+ click(button("Create")).expect(Task.created({ title: "Buy milk", projectId: project.id })),
34
+ ],
35
+ };
178
36
  });
179
37
  ```
180
38
 
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.
182
-
183
- ### Setup context
184
-
185
- Each batched setup item carries a `ctx`:
186
-
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`.
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.
192
-
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.
194
-
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.
196
-
197
- ### Observer context
198
-
199
- The observer impl returns one of three terminal states:
200
-
201
- - `ctx.pass()` — assertion satisfied; stop polling.
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.
204
-
205
- Thrown exceptions count as `fail`.
206
-
207
- ## Observer coverage
208
-
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.
210
-
211
- Steps that genuinely touch no server state opt out with `{ uiOnly: true }`:
212
-
213
- ```typescript
214
- click(role("button", "Cancel"), { uiOnly: true }).as("close dialog");
215
- test("filter-sort", { uiOnly: true }).name("Filter & sort");
216
- ```
217
-
218
- `uiOnly` means zero backend effect. Mutations and optimistic UI need an observer.
39
+ `arbitrary(Entity.field.x)` draws a fresh value per run from the field's value-space, so tests exercise the space instead of one hardcoded value. Backend effects (`created`, `updated`, `deleted`) are checked by an oracle that compares model-before, predicted-after, and observed state.
219
40
 
220
- ## Server adapters
41
+ The complete primitive catalog — every action, locator, predicate, field axis, and assertion — is in [DSL.md](./DSL.md), shipped with the package.
221
42
 
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.
43
+ ## The engine adapter
223
44
 
224
- The recommended setup is Next.js App Router:
45
+ Your app exposes a test engine: `seed`/`read` implementations for each declared entity, mounted behind a signed-webhook endpoint that runs only when explicitly enabled.
225
46
 
226
47
  ```ts
227
- // app/ripplo/[action]/route.ts
228
- import { createNextHandler } from "@ripplo/testing/nextjs";
229
- import { engine } from "@/server/test/engine";
48
+ // src/test/engine.ts
49
+ import { createEngine } from "@ripplo/testing";
50
+ import ripplo from "../../.ripplo/index.js";
51
+ import { impls } from "./impls.js";
230
52
 
231
- export const PUT = createNextHandler({
232
- enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
233
- engine,
234
- });
53
+ export const engine = createEngine(ripplo, { entities: impls, singletons: {} }, teardown);
235
54
  ```
236
55
 
237
- The other adapters follow the same `{ enabled, engine }` shape and mount at a path prefix:
238
-
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", ...)` |
247
-
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`.
249
-
250
- ### Custom adapter
251
-
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.
253
-
254
56
  ```ts
255
- import {
256
- readAdapterWebhookSecret,
257
- toBatchRunResults,
258
- verifyWebhookSignature,
259
- } from "@ripplo/testing";
260
- import { engine } from "./test/engine.js";
261
-
262
- const webhookSecret = readAdapterWebhookSecret();
263
-
264
- async function executePreconditions(req: Request): Promise<Response> {
265
- const body = await req.text();
266
- const verified = verifyWebhookSignature(
267
- body,
268
- {
269
- "webhook-id": req.headers.get("webhook-id") ?? undefined,
270
- "webhook-signature": req.headers.get("webhook-signature") ?? undefined,
271
- "webhook-timestamp": req.headers.get("webhook-timestamp") ?? undefined,
272
- },
273
- webhookSecret,
274
- );
275
- if (!verified) {
276
- return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 401 });
277
- }
278
-
279
- const { batch } = JSON.parse(body);
280
- const appUrl = `${req.headers.get("x-forwarded-proto") ?? "http"}://${req.headers.get("host")}`;
281
- const results = await engine.executePreconditions(
282
- batch.map((b) => ({ runId: b.runId, names: b.preconditions })),
283
- { appUrl },
284
- );
285
- return new Response(JSON.stringify({ results: toBatchRunResults(results) }), {
286
- headers: { "content-type": "application/json" },
287
- });
288
- }
57
+ // Express
58
+ import { createEngineHandler } from "@ripplo/testing/express";
59
+ app.use("/ripplo", createEngineHandler({ enabled: env.ENABLE_RIPPLO_TESTING, engine }));
289
60
  ```
290
61
 
291
- `executeObserver` and `teardownPreconditions` follow the same verify-then-dispatch shape. Request bodies: `{ batch: [{ runId, preconditions, data }] }` for teardown, `{ runId, name, params }` for observers.
62
+ TypeScript enforces one implementation per declared entity a missing or duplicate impl is a compile error.
292
63
 
293
- ## Security and parallelism
64
+ ## Compile, commit, run
294
65
 
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.
66
+ ```sh
67
+ npx ripplo lint # compile + typecheck .ripplo/ → ripplo.lock
68
+ npx ripplo run # execute against your local app via the daemon
69
+ ```
296
70
 
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.
71
+ Commit `.ripplo/ripplo.lock` alongside your test changes. The Ripplo server reads it verbatim on every push.
298
72
 
299
- ## Lockfile
73
+ ## License
300
74
 
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.
75
+ © Ripplo LLC. All rights reserved. Use is subject to Ripplo's [Terms of Service](https://ripplo.ai/terms).