@ripplo/testing 0.6.0 → 0.7.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/DSL.md +355 -0
- package/LICENSE.md +1 -0
- package/README.md +47 -273
- package/dist/engine-BT7hUouB.d.ts +1095 -0
- package/dist/express.d.ts +7 -9
- package/dist/express.js +422 -48
- package/dist/index.d.ts +122 -59
- package/dist/index.js +1630 -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/README.md
CHANGED
|
@@ -1,301 +1,75 @@
|
|
|
1
1
|
# @ripplo/testing
|
|
2
2
|
|
|
3
|
-
Typed
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
## Install
|
|
16
8
|
|
|
17
|
-
`
|
|
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
|
-
|
|
11
|
+
## The model
|
|
75
12
|
|
|
76
|
-
|
|
13
|
+
A project has four layers, all plain TypeScript under `.ripplo/`:
|
|
77
14
|
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
import {
|
|
84
|
-
|
|
85
|
-
export const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
`.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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
## The engine adapter
|
|
223
44
|
|
|
224
|
-
|
|
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
|
-
//
|
|
228
|
-
import {
|
|
229
|
-
import
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
62
|
+
TypeScript enforces one implementation per declared entity — a missing or duplicate impl is a compile error.
|
|
292
63
|
|
|
293
|
-
##
|
|
64
|
+
## Compile, commit, run
|
|
294
65
|
|
|
295
|
-
|
|
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
|
-
|
|
71
|
+
Commit `.ripplo/ripplo.lock` alongside your test changes. The Ripplo server reads it verbatim on every push.
|
|
298
72
|
|
|
299
|
-
##
|
|
73
|
+
## License
|
|
300
74
|
|
|
301
|
-
|
|
75
|
+
© Ripplo LLC. All rights reserved. Use is subject to Ripplo's [Terms of Service](https://ripplo.ai/terms).
|