@ripplo/testing 0.0.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/README.md ADDED
@@ -0,0 +1,386 @@
1
+ # @ripplo/testing
2
+
3
+ Typed TypeScript DSL for defining end-to-end tests with [Ripplo](https://ripplo.ai).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @ripplo/testing
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ 1. Run `npx ripplo` to authenticate and scaffold a `.ripplo/` directory in your project
14
+ 2. Define preconditions in `.ripplo/preconditions.ts` — these set up test data (users, projects, etc.)
15
+ 3. Write tests in `.ripplo/tests/` — each file defines one user flow to test
16
+ 4. Run `npx ripplo lint` to validate, `npx ripplo run` to execute
17
+
18
+ Every test gets a clean slate via preconditions — no shared state, no ordering dependencies, fully parallelizable.
19
+
20
+ ## DSL API
21
+
22
+ ### Test Builder
23
+
24
+ ```typescript
25
+ import ripplo from "../ripplo.js";
26
+ import { dataProject } from "../preconditions.js";
27
+
28
+ ripplo
29
+ .test("delete-project")
30
+ .name("Delete a project")
31
+ .requires({ project: dataProject })
32
+ .expectedOutcome("Project deleted and user redirected to connect page")
33
+ .startsAt(({ project }) => `/projects/${project.projectId}/settings`)
34
+ .steps(({ project }) => [
35
+ // steps here
36
+ ]);
37
+ ```
38
+
39
+ **Chain:** `.test(id)` → `.name(display)` → `.requires(preconditions)` → `.expectedOutcome(text)` → `.startsAt(urlFn)` → `.steps(stepsFn)`
40
+
41
+ Use `.notImplemented()` instead of `.startsAt()` + `.steps()` to stub a test during planning.
42
+
43
+ ### Locators
44
+
45
+ Only two locator types are available. ARIA roles are strongly preferred.
46
+
47
+ ```typescript
48
+ import { role, testId } from "@ripplo/testing/locators";
49
+
50
+ role("button", "Save"); // ARIA role + accessible name (preferred)
51
+ role("heading", "Settings"); // role without interaction
52
+ role("textbox", "Email"); // input by role
53
+ role("combobox", "Country"); // select/dropdown by role
54
+ testId("workflow-checkbox"); // data-testid (fallback only)
55
+ ```
56
+
57
+ **Available ARIA roles:** `alert`, `alertdialog`, `button`, `checkbox`, `combobox`, `dialog`, `form`, `grid`, `heading`, `img`, `link`, `list`, `listbox`, `listitem`, `menu`, `menuitem`, `navigation`, `option`, `progressbar`, `radio`, `region`, `row`, `searchbox`, `separator`, `slider`, `spinbutton`, `status`, `switch`, `tab`, `tabpanel`, `textbox`, `toolbar`, `tooltip`, `tree`, `treeitem`
58
+
59
+ **Type-safe constraints:**
60
+
61
+ - `InputLocator` accepts: `role("textbox")`, `role("searchbox")`, `role("combobox")`, `role("spinbutton")`, `testId()`
62
+ - `SelectLocator` accepts: `role("combobox")`, `role("listbox")`, `testId()`
63
+ - `CheckLocator` accepts: `role("checkbox")`, `role("switch")`, `testId()`
64
+
65
+ ### Actions
66
+
67
+ ```typescript
68
+ import {
69
+ click,
70
+ fill,
71
+ select,
72
+ check,
73
+ uncheck,
74
+ hover,
75
+ press,
76
+ navigate,
77
+ dblclick,
78
+ focus,
79
+ clear,
80
+ typeText,
81
+ rightClick,
82
+ scrollIntoView,
83
+ drag,
84
+ upload,
85
+ handleDialog,
86
+ clipboard,
87
+ setPermission,
88
+ setViewport,
89
+ } from "@ripplo/testing/actions";
90
+
91
+ navigate("/settings"); // Go to URL
92
+ click(role("button", "Save")); // Click element
93
+ fill(role("textbox", "Email"), "test@x.com"); // Clear + type into input
94
+ select(role("combobox", "Role"), "admin"); // Select option
95
+ check(role("checkbox", "Terms")); // Check checkbox/switch
96
+ uncheck(role("switch", "Notifications")); // Uncheck
97
+ hover(role("button", "Info")); // Hover
98
+ press("Enter"); // Press key
99
+ focus(role("searchbox", "Search")); // Focus element
100
+ dblclick(role("button", "Edit")); // Double click
101
+ clear(role("textbox", "Search")); // Clear input
102
+ upload(testId("file-input"), "./test.png"); // Upload file
103
+ drag(role("row", "Item 1"), role("row", "Item 2")); // Drag and drop
104
+ ```
105
+
106
+ ### Assertions
107
+
108
+ ```typescript
109
+ import { assert } from "@ripplo/testing/assert";
110
+
111
+ assert.visible(role("heading", "Settings")); // Element visible
112
+ assert.not.visible(role("dialog")); // Element not visible
113
+ assert.text(role("status"), "3 / 5 runs"); // Exact text match
114
+ assert.url("/projects/abc/settings"); // Exact URL match
115
+ assert.enabled(role("button", "Submit")); // Element enabled
116
+ assert.disabled(role("button", "Submit")); // Element disabled
117
+ assert.checked(role("checkbox", "Terms")); // Checked
118
+ assert.not.checked(role("switch", "Dark mode")); // Not checked
119
+ assert.focused(role("textbox", "Search")); // Has focus
120
+ assert.not.focused(role("textbox", "Search")); // No focus
121
+ assert.count(testId("row"), 5); // Element count
122
+ assert.attribute(role("link", "Docs"), "href", "/docs"); // Attribute value
123
+ assert.value(role("textbox", "Email"), "test@x.com"); // Input value
124
+ ```
125
+
126
+ All text/URL assertions use **exact matching only** (`equals` operator). No `contains`, `startsWith`, or regex.
127
+
128
+ ### Variables & Extraction
129
+
130
+ ```typescript
131
+ import { extract, variable } from "@ripplo/testing/control";
132
+
133
+ const token = variable("token");
134
+ extract(testId("token-value"), token).as("capture token");
135
+ // token can be referenced in subsequent steps
136
+ ```
137
+
138
+ ### Step Labels
139
+
140
+ Every step **must** have a `.as("description")` label:
141
+
142
+ ```typescript
143
+ click(role("button", "Save")).as("save the form");
144
+ assert.visible(role("status", "Saved")).as("verify save confirmation");
145
+ ```
146
+
147
+ ## Precondition System
148
+
149
+ Preconditions declare test data requirements with typed contracts:
150
+
151
+ ```typescript
152
+ import ripplo from "./ripplo.js";
153
+
154
+ export const authLoggedIn = ripplo
155
+ .precondition("auth:logged-in")
156
+ .description("Authenticated test user with a valid session")
157
+ .contract<{ userId: string }>();
158
+
159
+ export const dataProject = ripplo
160
+ .precondition("data:project")
161
+ .description("A project exists and the user is an admin member")
162
+ .requires({ auth: authLoggedIn })
163
+ .contract<{ orgId: string; projectId: string }>();
164
+ ```
165
+
166
+ **Chain:** `.precondition(name)` → `.description(text)` → `.requires(deps)` → `.contract<T>()`
167
+
168
+ Use `.notImplemented()` instead of `.contract<T>()` for stubs.
169
+
170
+ ### Data Flow
171
+
172
+ Precondition data flows into tests via destructuring:
173
+
174
+ ```typescript
175
+ ripplo
176
+ .test("delete-project")
177
+ .requires({ project: dataProject })
178
+ .startsAt(({ project }) => `/projects/${project.projectId}/settings`)
179
+ .steps(({ project }) => [
180
+ navigate(`/projects/${project.projectId}/settings`).as("go to settings"),
181
+ // project.projectId, project.orgId available here
182
+ ]);
183
+ ```
184
+
185
+ **Always destructure and use precondition data.** Never hardcode values that come from preconditions — if a precondition implementation changes, the test should not break.
186
+
187
+ ### Precondition Implementation
188
+
189
+ ```typescript
190
+ ripplo.implement(authLoggedIn, {
191
+ setup: async (ctx, deps) => {
192
+ const email = ctx.uniqueEmail();
193
+ // Create user, set cookies via ctx.setCookie()
194
+ return { userId: ctx.fixed("user-123") };
195
+ },
196
+ teardown: async (ctx) => {
197
+ // Clean up using ctx.data
198
+ },
199
+ });
200
+ ```
201
+
202
+ **SetupContext provides:**
203
+
204
+ - `ctx.runId` — unique UUID for this test run
205
+ - `ctx.fixed(value)` — static test value
206
+ - `ctx.uniqueId(prefix)` — generate unique ID (e.g., `ripplo-test-abc123`)
207
+ - `ctx.uniqueEmail()` — generate unique email
208
+ - `ctx.setCookie(name, value, options?)` — inject auth cookies
209
+
210
+ ## Determinism Rules
211
+
212
+ 1. **Use `role()` locators exclusively.** Only use `testId()` when no ARIA role is available.
213
+ 2. **All text assertions use exact matching.** No `contains`, `startsWith`, or regex.
214
+ 3. **Destructure precondition data in `steps()`.** Never hardcode names, IDs, or emails that come from preconditions.
215
+ 4. **Every step must have `.as("description")`.** No unlabeled steps.
216
+ 5. **No duplicate labels** within a test.
217
+ 6. **End with assertions** that verify the `expectedOutcome`.
218
+ 7. **After a test passes, run flake detection** to verify determinism across parallel runs.
219
+
220
+ ## CLI Commands
221
+
222
+ ```bash
223
+ ripplo # Launch interactive dashboard
224
+ ripplo lint [slugs..] # Compile + lint tests (all or specific slugs)
225
+ ripplo run [slugs..] # Run tests in parallel
226
+ ripplo list # List tests with status
227
+ ripplo flake-detect <slug> --runs=10 # Run N times in parallel to detect flakiness
228
+ ```
229
+
230
+ ## Server Setup
231
+
232
+ Your application server must expose the precondition endpoints under a single path prefix (the value you pass to `createRipplo({ preconditionsUrl })`). Pick the adapter that matches your framework — each handles webhook signature verification, cookie forwarding, and request parsing for you. Wrap the mount point behind an env guard (e.g. `ENABLE_RIPPLO_TESTING=true`) so it never ships to production.
233
+
234
+ ### Express
235
+
236
+ ```ts
237
+ import express from "express";
238
+ import { createExpressHandler } from "@ripplo/testing/express";
239
+ import ripplo from "../.ripplo/ripplo.js";
240
+
241
+ const app = express();
242
+ app.use("/api/test/preconditions", createExpressHandler({ ripplo }));
243
+ ```
244
+
245
+ Mounts both `PUT /execute-batch` and `PUT /teardown` under the prefix you choose.
246
+
247
+ ### Fastify
248
+
249
+ ```ts
250
+ import Fastify from "fastify";
251
+ import { registerFastifyHandler } from "@ripplo/testing/fastify";
252
+ import ripplo from "../.ripplo/ripplo.js";
253
+
254
+ const app = Fastify();
255
+ await app.register(registerFastifyHandler({ ripplo }), {
256
+ prefix: "/api/test/preconditions",
257
+ });
258
+ ```
259
+
260
+ ### Next.js (App Router)
261
+
262
+ The Next.js adapter exports a single catch-all handler. Create one dynamic route file:
263
+
264
+ ```ts
265
+ // app/api/test/preconditions/[action]/route.ts
266
+ import { createNextHandler } from "@ripplo/testing/nextjs";
267
+ import ripplo from "@/.ripplo/ripplo";
268
+
269
+ export const PUT = createNextHandler({ ripplo });
270
+ ```
271
+
272
+ The handler dispatches on the last URL segment (`execute-batch` or `teardown`) 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.
273
+
274
+ ### Custom integration (raw engine)
275
+
276
+ 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.
277
+
278
+ ```ts
279
+ import {
280
+ buildSetCookieHeader,
281
+ createEngine,
282
+ serializeCookie,
283
+ verifyWebhookSignature,
284
+ } from "@ripplo/testing";
285
+ import ripplo from "../.ripplo/ripplo.js";
286
+
287
+ const engine = createEngine(ripplo);
288
+ const webhookSecret = ripplo.getConfig().webhookSecret;
289
+
290
+ // PUT /api/test/preconditions/execute-batch
291
+ async function executeBatch(req: Request): Promise<Response> {
292
+ const body = await req.text();
293
+ const verified = verifyWebhookSignature(
294
+ body,
295
+ {
296
+ "webhook-id": req.headers.get("webhook-id") ?? undefined,
297
+ "webhook-signature": req.headers.get("webhook-signature") ?? undefined,
298
+ "webhook-timestamp": req.headers.get("webhook-timestamp") ?? undefined,
299
+ },
300
+ webhookSecret,
301
+ );
302
+ if (!verified) {
303
+ return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 401 });
304
+ }
305
+
306
+ const { preconditions } = JSON.parse(body);
307
+ const appUrl = `${req.headers.get("x-forwarded-proto") ?? "http"}://${req.headers.get("host")}`;
308
+ const result = await engine.executeBatch(preconditions, { appUrl });
309
+
310
+ const headers = new Headers({ "content-type": "application/json" });
311
+ result.cookies.forEach((c) => {
312
+ headers.append("Set-Cookie", buildSetCookieHeader(serializeCookie(c)));
313
+ });
314
+ return new Response(JSON.stringify(result), { headers });
315
+ }
316
+
317
+ // PUT /api/test/preconditions/teardown
318
+ async function teardown(req: Request): Promise<Response> {
319
+ // ... same verify pattern, then:
320
+ // await engine.teardown(parsed.preconditions, parsed.data);
321
+ }
322
+ ```
323
+
324
+ **You're responsible for:**
325
+
326
+ - **Webhook verification.** Always call `verifyWebhookSignature` before invoking the engine.
327
+ - **Routing.** Dispatch the two endpoints (`execute-batch`, `teardown`) however your framework handles routes.
328
+ - **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.
329
+ - **Body parsing.** Use the raw text body for signature verification, then `JSON.parse` for the engine call.
330
+
331
+ ### Config
332
+
333
+ After mounting, point Ripplo at the prefix:
334
+
335
+ ```ts
336
+ // .ripplo/ripplo.ts
337
+ import { createRipplo } from "@ripplo/testing";
338
+
339
+ export default createRipplo({
340
+ appUrl: process.env.APP_URL,
341
+ preconditionsUrl: `${process.env.APP_URL}/api/test/preconditions`,
342
+ projectId: "...",
343
+ webhookSecret: process.env.RIPPLO_WEBHOOK_SECRET,
344
+ });
345
+ ```
346
+
347
+ ## Precondition API Contract
348
+
349
+ The app server exposes two endpoints for test data management:
350
+
351
+ ### Execute (`PUT {preconditionApiPath}/execute`)
352
+
353
+ ```json
354
+ // Request
355
+ { "precondition": "data:project" }
356
+
357
+ // Response
358
+ { "success": true, "data": { "projectId": "cuid-abc", "orgId": "org-xyz" } }
359
+ ```
360
+
361
+ Returns data that flows into test variables. Set-Cookie headers are captured automatically.
362
+
363
+ ### Teardown (`PUT {preconditionApiPath}/teardown`)
364
+
365
+ ```json
366
+ // Request
367
+ { "preconditions": ["auth:logged-in", "data:project"] }
368
+
369
+ // Response
370
+ { "success": true }
371
+ ```
372
+
373
+ ### Parallel Safety
374
+
375
+ - Generate unique names/emails per run using `crypto.randomUUID()` suffixes
376
+ - Return created entity IDs in the `data` response
377
+ - Teardown only deletes that run's data (use session cookies to identify user)
378
+ - Never hardcode entity names or use bulk deletion
379
+
380
+ ### Webhook Signing
381
+
382
+ All requests are signed using Standard Webhooks (HMAC-SHA256). Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`. Verify before executing.
383
+
384
+ ### Environment Guard
385
+
386
+ Wrap all precondition routes behind `ENABLE_RIPPLO_TESTING=true`. Never expose in production.
@@ -0,0 +1,233 @@
1
+ import { U as UnlabeledStep } from './step-DLfkKI3V.js';
2
+ import { CheckLocator, InputLocator, AnyLocator, SelectLocator } from './locators.js';
3
+ import '@ripplo/spec';
4
+
5
+ declare function navigate(url: string): UnlabeledStep<{
6
+ type: "goto";
7
+ url: {
8
+ type: "static";
9
+ value: string;
10
+ };
11
+ }>;
12
+ declare function click(locator: AnyLocator): UnlabeledStep<{
13
+ locator: {
14
+ by: "testId";
15
+ value: string;
16
+ } | {
17
+ by: "role";
18
+ role: string;
19
+ name?: string | undefined;
20
+ };
21
+ type: "click";
22
+ }>;
23
+ declare function fill(locator: InputLocator, value: string): UnlabeledStep<{
24
+ locator: {
25
+ by: "testId";
26
+ value: string;
27
+ } | {
28
+ by: "role";
29
+ role: string;
30
+ name?: string | undefined;
31
+ };
32
+ type: "fill";
33
+ value: {
34
+ type: "static";
35
+ value: string;
36
+ };
37
+ }>;
38
+ declare function select(locator: SelectLocator, value: string): UnlabeledStep<{
39
+ locator: {
40
+ by: "testId";
41
+ value: string;
42
+ } | {
43
+ by: "role";
44
+ role: string;
45
+ name?: string | undefined;
46
+ };
47
+ type: "select";
48
+ value: {
49
+ type: "static";
50
+ value: string;
51
+ };
52
+ }>;
53
+ declare function check(locator: CheckLocator): UnlabeledStep<{
54
+ locator: {
55
+ by: "testId";
56
+ value: string;
57
+ } | {
58
+ by: "role";
59
+ role: string;
60
+ name?: string | undefined;
61
+ };
62
+ type: "check";
63
+ }>;
64
+ declare function uncheck(locator: CheckLocator): UnlabeledStep<{
65
+ locator: {
66
+ by: "testId";
67
+ value: string;
68
+ } | {
69
+ by: "role";
70
+ role: string;
71
+ name?: string | undefined;
72
+ };
73
+ type: "uncheck";
74
+ }>;
75
+ declare function hover(locator: AnyLocator): UnlabeledStep<{
76
+ locator: {
77
+ by: "testId";
78
+ value: string;
79
+ } | {
80
+ by: "role";
81
+ role: string;
82
+ name?: string | undefined;
83
+ };
84
+ type: "hover";
85
+ }>;
86
+ declare function press(key: string): UnlabeledStep<{
87
+ key: string;
88
+ type: "press";
89
+ }>;
90
+ declare function upload(locator: AnyLocator, path: string): UnlabeledStep<{
91
+ files: string[];
92
+ locator: {
93
+ by: "testId";
94
+ value: string;
95
+ } | {
96
+ by: "role";
97
+ role: string;
98
+ name?: string | undefined;
99
+ };
100
+ type: "upload";
101
+ }>;
102
+ declare function dblclick(locator: AnyLocator): UnlabeledStep<{
103
+ locator: {
104
+ by: "testId";
105
+ value: string;
106
+ } | {
107
+ by: "role";
108
+ role: string;
109
+ name?: string | undefined;
110
+ };
111
+ type: "dblclick";
112
+ }>;
113
+ declare function focus(locator: AnyLocator): UnlabeledStep<{
114
+ locator: {
115
+ by: "testId";
116
+ value: string;
117
+ } | {
118
+ by: "role";
119
+ role: string;
120
+ name?: string | undefined;
121
+ };
122
+ type: "focus";
123
+ }>;
124
+ declare function clear(locator: InputLocator): UnlabeledStep<{
125
+ locator: {
126
+ by: "testId";
127
+ value: string;
128
+ } | {
129
+ by: "role";
130
+ role: string;
131
+ name?: string | undefined;
132
+ };
133
+ type: "clear";
134
+ }>;
135
+ declare function typeText(locator: InputLocator, value: string): UnlabeledStep<{
136
+ locator: {
137
+ by: "testId";
138
+ value: string;
139
+ } | {
140
+ by: "role";
141
+ role: string;
142
+ name?: string | undefined;
143
+ };
144
+ type: "type";
145
+ value: {
146
+ type: "static";
147
+ value: string;
148
+ };
149
+ }>;
150
+ declare function rightClick(locator: AnyLocator): UnlabeledStep<{
151
+ locator: {
152
+ by: "testId";
153
+ value: string;
154
+ } | {
155
+ by: "role";
156
+ role: string;
157
+ name?: string | undefined;
158
+ };
159
+ type: "rightClick";
160
+ }>;
161
+ declare function scrollIntoView(locator: AnyLocator): UnlabeledStep<{
162
+ locator: {
163
+ by: "testId";
164
+ value: string;
165
+ } | {
166
+ by: "role";
167
+ role: string;
168
+ name?: string | undefined;
169
+ };
170
+ type: "scrollIntoView";
171
+ }>;
172
+ declare function drag(source: AnyLocator, target: AnyLocator): UnlabeledStep<{
173
+ source: {
174
+ by: "testId";
175
+ value: string;
176
+ } | {
177
+ by: "role";
178
+ role: string;
179
+ name?: string | undefined;
180
+ };
181
+ target: {
182
+ by: "testId";
183
+ value: string;
184
+ } | {
185
+ by: "role";
186
+ role: string;
187
+ name?: string | undefined;
188
+ };
189
+ type: "drag";
190
+ }>;
191
+ interface HandleDialogOptions {
192
+ readonly action: "accept" | "dismiss";
193
+ readonly promptText: string | undefined;
194
+ }
195
+ declare function handleDialog({ action, promptText }: HandleDialogOptions): UnlabeledStep<{
196
+ action: "accept" | "dismiss";
197
+ promptText: string | undefined;
198
+ type: "handleDialog";
199
+ }>;
200
+ interface ClipboardOptions {
201
+ readonly action: "read" | "write";
202
+ readonly value: string | undefined;
203
+ readonly variable: string | undefined;
204
+ }
205
+ declare function clipboard({ action, value, variable }: ClipboardOptions): UnlabeledStep<{
206
+ action: "read" | "write";
207
+ type: "clipboard";
208
+ value: {
209
+ type: "static";
210
+ value: string;
211
+ } | undefined;
212
+ variable: string | undefined;
213
+ }>;
214
+ interface SetPermissionOptions {
215
+ readonly permission: string;
216
+ readonly state: "granted" | "prompt";
217
+ }
218
+ declare function setPermission({ permission, state }: SetPermissionOptions): UnlabeledStep<{
219
+ permission: string;
220
+ state: "granted" | "prompt";
221
+ type: "setPermission";
222
+ }>;
223
+ interface SetViewportOptions {
224
+ readonly height: number;
225
+ readonly width: number;
226
+ }
227
+ declare function setViewport({ height, width }: SetViewportOptions): UnlabeledStep<{
228
+ height: number;
229
+ type: "setViewport";
230
+ width: number;
231
+ }>;
232
+
233
+ export { check, clear, click, clipboard, dblclick, drag, fill, focus, handleDialog, hover, navigate, press, rightClick, scrollIntoView, select, setPermission, setViewport, typeText, uncheck, upload };