@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/DSL.md ADDED
@@ -0,0 +1,357 @@
1
+ # Ripplo DSL reference
2
+
3
+ The complete primitive catalog for `@ripplo/testing`. The `/ripplo:create` skill covers the workflow and the model; this is the exhaustive "what exists" — every action, locator, predicate, field axis, and assertion with a signature and a one-line example.
4
+
5
+ A project has four layers:
6
+
7
+ - **Entities** (`.ripplo/entities/`) — the state model. Each entity gets a `seed`/`read` impl in the app's engine funnel.
8
+ - **Singletons** (`.ripplo/singletons/`) — client/global state (e.g. localStorage flags).
9
+ - **Worlds** (`.ripplo/worlds/`) — pure builder functions returning a flat record of entity handles (the starting state).
10
+ - **Tests** (`.ripplo/tests/`) — `test("Intent", () => ({ given, steps }))`.
11
+
12
+ Everything is imported from `@ripplo/testing`.
13
+
14
+ ---
15
+
16
+ ## Entities
17
+
18
+ ```ts
19
+ import { entity, field, id, key, v } from "@ripplo/testing";
20
+
21
+ export const Task = entity("task", {
22
+ description: "A task under a project",
23
+ fields: {
24
+ title: field({ value: v.word() }),
25
+ projectId: field({ value: v.id() }), // a foreign key is just an id-valued field
26
+ done: field({ value: v.boolean() }),
27
+ note: field({ optional: true, value: v.word() }), // nullable
28
+ },
29
+ identity: { id: id() },
30
+ source: "backend", // "backend" (DB) or "client" (browser state)
31
+ });
32
+ ```
33
+
34
+ - `entity(name, { description, fields, identity, source })` → an `EntityDef`. `name` is the dispatch key for the engine impl.
35
+
36
+ ### `source` — where the state lives
37
+
38
+ Pick by where the state is persisted, not where it's displayed:
39
+
40
+ - `source: "backend"` — the default for anything long-lived: database rows, server-side session state, anything that survives a page reload because the server has it. Seeded/read by the **server engine** (`createEngine` in your app's `test/engine.ts`).
41
+ - `source: "client"` — state that lives only in the browser: localStorage, IndexedDB, in-memory stores that the app treats as state but never persists server-side. Seeded/read by the **client engine** (`mountClientEngine`, below). Reach for it when a flow's behavior depends on browser-local state you need to arrange or assert.
42
+
43
+ If you're unsure, it's backend. A value that's cached in the browser but owned by the server is backend — model the server's row, not the cache.
44
+
45
+ - A **field** is a free, seedable state dimension. Functionally-derived values (`slug = slugify(name)`) and server-defaulted values (`createdAt`) are **not** fields — drop them.
46
+
47
+ ### `field(options)`
48
+
49
+ ```ts
50
+ field({ value: v.email() }); // required, stable, oracle holds observed == model
51
+ field({ value: v.word(), optional: true }); // nullable; `null` is a valid set/assert value
52
+ field({ value: v.number({ min: 0, max: 100 }), stable: false }); // adopt-only, drift not checked
53
+ ```
54
+
55
+ | option | meaning |
56
+ | ---------- | ----------------------------------------------------------------------------------------------------------- |
57
+ | `value` | the value-space (`v.*`) — required |
58
+ | `optional` | `true` ⇒ the field is nullable (`null` allowed in `of`/`updated`/assertions); oracle treats `null ≡ absent` |
59
+ | `stable` | default `true` (oracle holds `observed == model value`). `false` = adopt-only, drift unchecked |
60
+
61
+ ### Identity: `id()` and `key(options)`
62
+
63
+ ```ts
64
+ identity: {
65
+ id: id();
66
+ } // surrogate key — server-assigned (cuid/autoincrement), adopted
67
+ identity: {
68
+ email: key({ value: v.email() });
69
+ } // natural key — you supply the value
70
+ ```
71
+
72
+ ### Value-spaces (`v.*`)
73
+
74
+ | builder | yields | notes |
75
+ | ------------------------------------------ | ------- | ----------------------------------------------------------- |
76
+ | `v.email()` | string | realistic email |
77
+ | `v.fullName()` | string | person name |
78
+ | `v.companyName()` | string | org name |
79
+ | `v.slug()` | string | url-safe slug |
80
+ | `v.word()` | string | single token |
81
+ | `v.url()` | string | URL |
82
+ | `v.id()` | string | id-shaped string (use for FKs) |
83
+ | `v.number({ min, max })` | number | bounded integer |
84
+ | `v.boolean()` | boolean | |
85
+ | `v.oneOf([...])` | any | enum — one of the listed values |
86
+ | `v.datetime({ offsetDays: { min, max } })` | string | ISO-8601 timestamp, offset days relative to the run's start |
87
+
88
+ String spaces take optional constraints: `v.word({ minLength, maxLength, pattern })`. `v.number` requires `{ min, max }`. `v.oneOf` types narrowly with `as const`-style tuples: `v.oneOf(["PENDING", "ACCEPTED"])`. `v.datetime` values are generated relative to the run's start timestamp (e.g. `{ min: -30, max: 1 }` = up to 30 days past, 1 day future); the oracle compares datetimes by UTC instant, so driver formatting/timezone rendering differences never diverge.
89
+
90
+ ---
91
+
92
+ ## Worlds
93
+
94
+ Pure functions in `.ripplo/worlds/index.ts` returning a flat record of handles. Compose from other worlds.
95
+
96
+ ```ts
97
+ import { arbitrary } from "@ripplo/testing";
98
+ import { Project, Task, User } from "../entities/index.js";
99
+
100
+ export const ownedProject = () => {
101
+ const me = User.of({ email: arbitrary(User.field.email) });
102
+ const project = Project.of({ name: arbitrary(Project.field.name), ownerId: me.id });
103
+ return { me, project };
104
+ };
105
+
106
+ export const projectWithTask = () => {
107
+ const base = ownedProject();
108
+ const task = Task.of({ title: arbitrary(Task.field.title), projectId: base.project.id });
109
+ return { ...base, task };
110
+ };
111
+ ```
112
+
113
+ ### Entity handles in a world
114
+
115
+ | method | meaning |
116
+ | --------------------- | --------------------------------------------------------------------------------------- |
117
+ | `Entity.of(props)` | a row that is **guaranteed** present |
118
+ | `Entity.only(props)` | exactly one such row (singleton in the world) |
119
+ | `Entity.maybe(props)` | optionally present (fuzz covers both branches) |
120
+ | `Entity.none(where)` | asserts **absence** — no row matches (returns an absence handle; include it in `given`) |
121
+
122
+ - `arbitrary(Entity.field.x)` — draws a fresh value for field `x`. A fresh param each call.
123
+ - Wire a foreign key by passing a parent handle's id: `projectId: base.project.id`.
124
+ - `Entity.field.x` is a `FieldHandle` — a reference to the field, used in `arbitrary(...)`.
125
+ - A world MUST return every handle it creates (a dropped const → `no-unused-vars`, or a dangling-ref throw at compile).
126
+
127
+ ---
128
+
129
+ ## Tests
130
+
131
+ ```ts
132
+ import { button, click, fill, goto, test, textbox, visible } from "@ripplo/testing";
133
+ import { Task } from "../entities/index.js";
134
+ import { ownedProject } from "../worlds/index.js";
135
+
136
+ export const createTask = test("Create a task", () => {
137
+ const { me, project } = ownedProject();
138
+ return {
139
+ given: [me, project], // arrange: all the handles this test needs
140
+ steps: [
141
+ // act + assert
142
+ goto`/projects/${project.id}/tasks`.expect(visible(button("New"))),
143
+ click(button("New")).expect(visible(textbox("Title"))),
144
+ fill(textbox("Title"), "Buy milk"),
145
+ click(button("Create")).expect(Task.created({ title: "Buy milk", projectId: project.id })),
146
+ ],
147
+ };
148
+ });
149
+
150
+ export const renameTask = test("Rename a task"); // STUB — no body; gated until implemented
151
+ ```
152
+
153
+ - `test(intent)` (no body) → a **stub**; `test(intent, () => body)` → an implemented test.
154
+ - The test body returns `{ given, steps }`. The arrange/act boundary is `given:` vs `steps:`.
155
+ - Everything a step references must trace to a handle in `given`.
156
+
157
+ ---
158
+
159
+ ## Actions
160
+
161
+ Each returns a step; chain `.expect(...predicates)` to assert after it runs.
162
+
163
+ | action | signature | example |
164
+ | ---------- | ------------------------------ | ---------------------------------------- |
165
+ | `goto` | `` goto`/path/${handle.id}` `` | `` goto`/projects/${p.id}` `` |
166
+ | `click` | `click(locator)` | `click(button("Save"))` |
167
+ | `dblclick` | `dblclick(locator)` | `dblclick(testId\`row-${r.id}\`)` |
168
+ | `fill` | `fill(locator, value)` | `fill(textbox("Name"), "Acme")` |
169
+ | `clear` | `clear(locator)` | `clear(textbox("Name"))` |
170
+ | `select` | `select(locator, value)` | `select(combobox("Plan"), "pro")` |
171
+ | `check` | `check(locator)` | `check(checkbox("Agree"))` |
172
+ | `uncheck` | `uncheck(locator)` | `uncheck(checkbox("Agree"))` |
173
+ | `hover` | `hover(locator)` | `hover(button("More"))` |
174
+ | `upload` | `upload(locator, files)` | `upload(testId\`avatar\`, ["face.png"])` |
175
+ | `press` | `press(key, locator?)` | `press("Enter", textbox("Search"))` |
176
+
177
+ `fill`/`select`/`value`/`text` accept a literal string or a `Binding<string>` (e.g. `arbitrary(...)` or a handle field). `goto`/`testId` are template literals — interpolate handle ids directly.
178
+
179
+ ```ts
180
+ const title = arbitrary(Task.field.title);
181
+ goto`/projects/${project.id}/tasks`.expect(...)
182
+ fill(textbox("Title"), title)
183
+ ```
184
+
185
+ ---
186
+
187
+ ## Locators
188
+
189
+ | locator | signature | resolves to |
190
+ | -------- | --------------------------------------- | ----------------------------------------------- |
191
+ | `role` | `role(roleName, name?)` | any ARIA role, optional accessible name |
192
+ | `inside` | `inside(scope, target)` | target located within scope; nests arbitrarily |
193
+ | `testId` | `` testId\`...\` `` or `testId(string)` | a `data-testid` — only when no ARIA role exists |
194
+
195
+ Named locators — `(name, ...bindings)`, accessible name **required**:
196
+
197
+ `alertdialog` `button` `cell` `checkbox` `columnheader` `combobox` `dialog` `heading` `img` `link` `listitem` `menuitem` `option` `radio` `row` `searchbox` `slider` `spinbutton` `switchControl` (role `switch` — `switch` is a JS keyword) `tab` `textbox` `treeitem`
198
+
199
+ Container/landmark locators — `(name?, ...bindings)`, name optional (often unique per page):
200
+
201
+ `alert` `banner` `complementary` `contentinfo` `form` `grid` `group` `list` `main` `menu` `navigation` `progressbar` `radiogroup` `region` `status` `table` `tablist` `tabpanel` `toolbar`
202
+
203
+ Anything else: `role("menubar", name)` — every ARIA role works through `role()`. Prefer the named sugar above when one exists.
204
+
205
+ Radix/shadcn gotchas: toggles render role `switch` (use `switchControl`, not `checkbox`); confirm dialogs render role `alertdialog` (use `alertdialog`, not `dialog`).
206
+
207
+ Names are matched **exactly** — no `contains`/regex. If the app lacks an accessible name, add one to the app rather than falling back to `testId`. Container rows/dialogs usually need an explicit `aria-label` to be scopable — add it to the app.
208
+
209
+ Every role-locator name accepts a binding or a template literal, same as `testId`:
210
+
211
+ ```ts
212
+ click(button(user.name)); // binding as the accessible name
213
+ click(inside(row(schedule.name), button("Delete"))); // scoped: the Delete in this row
214
+ click(inside(dialog`Edit ${schedule.name}`, button("Save"))); // template-literal name
215
+ click(inside(main(), button("New"))); // disambiguate from a header CTA
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Predicates (UI assertions)
221
+
222
+ Used inside a step's `.expect(...)`.
223
+
224
+ | predicate | signature | asserts |
225
+ | ---------- | --------------------------------- | ----------------------------------------- |
226
+ | `visible` | `visible(locator)` | element is visible |
227
+ | `disabled` | `disabled(locator)` | element is disabled |
228
+ | `enabled` | `enabled(locator)` | element is enabled |
229
+ | `focused` | `focused(locator)` | element has focus |
230
+ | `value` | `value(locator, string\|binding)` | input's value equals |
231
+ | `text` | `text(locator, string\|binding)` | element's text equals |
232
+ | `not` | `not(predicate)` | negation — `not(visible(role("dialog")))` |
233
+ | `and` | `and(...conditions)` | conjunction |
234
+ | `when` | `when(...clauses)` | conditional assertion |
235
+ | `count` | `count(Entity).is(n)` | n rows of the entity exist |
236
+
237
+ ### Browser singletons
238
+
239
+ ```ts
240
+ import { title, url, viewport } from "@ripplo/testing";
241
+ goto`/projects/${p.id}`.expect(url.is`/projects/${p.id}`); // url/title/viewport carry `.is`
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Backend assertions (the oracle)
247
+
248
+ Put these in a step's `.expect(...)` alongside UI predicates. The oracle does a 3-way compare (model-before / predicted-after / observed) and flags divergence — you never poll.
249
+
250
+ ```ts
251
+ import { changed, within } from "@ripplo/testing";
252
+
253
+ Task.created({ title: "Buy milk", projectId: p.id }); // a row with these fields now exists
254
+ Task.updated({ id: task.id }, { title: "Buy bread" }); // the keyed row changed to this
255
+ Task.updated({ id: org.id }, { secret: changed() }); // changed to a server-chosen value (rotation)
256
+ Task.deleted({ id: task.id }); // the keyed row is gone
257
+ ```
258
+
259
+ - `Entity.created(props)` — assert a creation.
260
+ - `Entity.updated(key, changes)` — assert an update on the row matching `key`. Use `changed()` as a value when the new value is server-chosen (assert it _differs_ without pinning it).
261
+ - `Entity.deleted(key)` — assert deletion.
262
+ - Consistency timing is automatic: a field declared `consistency: "eventual"` (or a `changed()` baseline) tolerates propagation lag; default `strict` fails fast on a wrong intermediate value.
263
+
264
+ ### Relational selection (`where` / `within`)
265
+
266
+ For assertions scoped by a subquery — "delete every task in projects this user owns":
267
+
268
+ ```ts
269
+ Task.deleted({ projectId: within(Project.where({ ownerId: me.id }), "id") }); // tasks whose projectId ∈ owned project ids
270
+ ```
271
+
272
+ - `Entity.where(criteria)` — a subquery.
273
+ - `within(selection, "sourceField")` — used as a key value: matches when that column's value is in the `sourceField` values of the subquery's matching rows.
274
+ - Selections nest arbitrarily (a subquery's `where` accepts `within` too).
275
+
276
+ ---
277
+
278
+ ## Singletons (client/global state)
279
+
280
+ ```ts
281
+ import { singleton, v } from "@ripplo/testing";
282
+
283
+ export const onboardingDismissed = singleton("onboardingDismissed", {
284
+ default: false,
285
+ description: "user dismissed the onboarding flow (localStorage)",
286
+ source: "client",
287
+ value: v.boolean(),
288
+ });
289
+
290
+ export const singletons = [onboardingDismissed];
291
+ ```
292
+
293
+ Set in a test's body (`singletons:`) and assert via predicates; the client engine seeds/reads them in the browser.
294
+
295
+ ### The client engine
296
+
297
+ `client`-sourced entities and singletons need a browser-side counterpart to the server engine: a `seed`/`read` impl per declaration, mounted once in your app's client entry. TS exhaustiveness-checks it the same way — every `source: "client"` declaration must have an impl.
298
+
299
+ ```ts
300
+ // app client entry (dev/test builds)
301
+ import { mountClientEngine } from "@ripplo/testing";
302
+ import ripplo from "../../.ripplo/index.js";
303
+
304
+ mountClientEngine(
305
+ ripplo,
306
+ {
307
+ entities: {},
308
+ singletons: {
309
+ onboardingDismissed: {
310
+ read: () => JSON.parse(localStorage.getItem("onboarding-dismissed") ?? "null"),
311
+ seed: (value) => localStorage.setItem("onboarding-dismissed", JSON.stringify(value)),
312
+ },
313
+ },
314
+ },
315
+ // gate with a TARGETED build-time flag (mirrors the server's ENABLE_RIPPLO_TESTING) so the
316
+ // mount stays out of untested production bundles while still allowing runs against prod builds
317
+ // (Vite: VITE_ENABLE_RIPPLO_TESTING; Next.js: NEXT_PUBLIC_ENABLE_RIPPLO_TESTING)
318
+ { enabled: import.meta.env.VITE_ENABLE_RIPPLO_TESTING === "true" },
319
+ );
320
+ ```
321
+
322
+ The runtime injects seeds before the page loads and reads state through the mount during assertions. Backend entities never appear here — they belong to the server engine.
323
+
324
+ ---
325
+
326
+ ---
327
+
328
+ ## Engine impls
329
+
330
+ Each entity needs a `seed`/`read` implementation in the app's engine funnel, exhaustiveness-checked by TS.
331
+
332
+ ```ts
333
+ import { createEngine, type EngineImpls } from "@ripplo/testing";
334
+ import ripplo from "../../.ripplo/index.js";
335
+
336
+ type Impls = EngineImpls<typeof ripplo, "backend">["entities"];
337
+
338
+ const impls: Impls = {
339
+ task: {
340
+ seed: async ({ fields, runId }) => {
341
+ const id = testId(runId, "task"); // run-scoped id for parallel isolation
342
+ await db.task.create({ data: { id, title: fields.title, projectId: fields.projectId } });
343
+ return { row: { id, title: fields.title, projectId: fields.projectId }, session: undefined };
344
+ },
345
+ read: async ({ runId }) =>
346
+ (await db.task.findMany({ where: { projectId: { startsWith: runPrefix(runId) } } })).map(
347
+ (t) => ({ id: t.id, title: t.title, projectId: t.projectId }),
348
+ ),
349
+ },
350
+ };
351
+
352
+ export const engine = createEngine(ripplo, { entities: impls, singletons: {} }, teardown);
353
+ ```
354
+
355
+ - **`seed`** creates one row from `fields`, returns `{ row, session }` (`session` = an auth session for identity entities like `user`/`session`, else `undefined`).
356
+ - **`read`** returns this run's rows of the entity. Scope every query by the run so the oracle only sees this run's data.
357
+ - Isolation lives here, not in the test: run-scoped ids in `seed`, run-scoped queries in `read`. Never `update`/`delete` rows your run didn't create.
package/LICENSE.md ADDED
@@ -0,0 +1 @@
1
+ © Ripplo LLC. All rights reserved. Use is subject to Ripplo's [Terms of Service](https://ripplo.ai/terms).