@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.
- package/DSL.md +357 -0
- package/LICENSE.md +1 -0
- package/README.md +47 -273
- package/dist/engine-BfvzXgLg.d.ts +1091 -0
- package/dist/express.d.ts +7 -9
- package/dist/express.js +422 -48
- package/dist/index.d.ts +134 -59
- package/dist/index.js +1654 -1126
- package/package.json +31 -113
- package/dist/actions.d.ts +0 -260
- package/dist/actions.js +0 -177
- package/dist/assert.d.ts +0 -188
- package/dist/assert.js +0 -111
- package/dist/builder-SsgqYqSC.d.ts +0 -156
- package/dist/chunk-2YLI7VD4.js +0 -65
- package/dist/chunk-4MGIQFAJ.js +0 -16
- package/dist/chunk-DCJBLS2U.js +0 -26
- package/dist/chunk-MGATMMCZ.js +0 -16
- package/dist/chunk-XO36IU66.js +0 -88
- package/dist/chunk-YFOTJIVF.js +0 -134
- package/dist/chunk-YQAEOH5W.js +0 -111
- package/dist/compiler.d.ts +0 -32
- package/dist/compiler.js +0 -8
- package/dist/control.d.ts +0 -45
- package/dist/control.js +0 -17
- package/dist/elysia.d.ts +0 -78
- package/dist/elysia.js +0 -114
- package/dist/engine-BOqzK_go.d.ts +0 -61
- package/dist/fastify.d.ts +0 -14
- package/dist/fastify.js +0 -79
- package/dist/hono.d.ts +0 -19
- package/dist/hono.js +0 -89
- package/dist/koa.d.ts +0 -14
- package/dist/koa.js +0 -135
- package/dist/locators.d.ts +0 -40
- package/dist/locators.js +0 -11
- package/dist/lockfile.d.ts +0 -722
- package/dist/lockfile.js +0 -707
- package/dist/nestjs.d.ts +0 -17
- package/dist/nestjs.js +0 -139
- package/dist/nextjs.d.ts +0 -14
- package/dist/nextjs.js +0 -137
- package/dist/step-De52hTLd.d.ts +0 -19
- 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).
|