npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ripplo/testing

v0.6.1

Published

TypeScript DSL for defining and running Ripplo e2e workflow tests

Readme

@ripplo/testing

Typed TypeScript DSL for end-to-end tests with real backend state, used by Ripplo.

npm install @ripplo/testing

The companion ripplo CLI scaffolds .ripplo/, runs tests, and writes the lockfile. This package ships the DSL and the server adapters.

How it fits together

Tests are split into two halves that the type system stitches together.

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.

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.

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.

Writing a test

import { test } from "@ripplo/testing";
import { click, fill } from "@ripplo/testing/actions";
import { assert } from "@ripplo/testing/assert";
import { role } from "@ripplo/testing/locators";
import { dataWorkspace } from "../preconditions/index.js";
import { invitePendingForEmail } from "../observers/index.js";

export const inviteATeammate = test("invite-a-teammate")
  .name("Invite a teammate")
  .requires({ workspace: dataWorkspace })
  .expectedOutcome("Invite appears in the pending list and an invite record is created")
  .startsAt(({ workspace }) => `/workspaces/${workspace.id}/members`)
  .steps(({ workspace }) => [
    click(role("button", "Invite member")).as("open invite dialog"),
    fill(role("textbox", "Email"), "[email protected]").as("enter email"),
    click(role("button", "Send invite")).as("send"),
    assert.visible(role("status", "Invite sent")).as("confirm toast"),
    assert
      .backend(invitePendingForEmail, { workspaceId: workspace.id, email: "[email protected]" })
      .as("confirm invite recorded"),
  ])
  .coverage(
    "src/components/members/InviteDialog.tsx#InviteDialog.click[Invite member]",
    "src/components/members/InviteDialog.tsx#InviteDialog.click[Send invite]",
  );

The chain is: test(id), .name(), optional .description(), .requires(), .expectedOutcome(), .startsAt(), .steps(), .coverage(). While planning, swap .startsAt() / .steps() / .coverage() for .notImplemented() to stub.

.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.

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.

Preconditions

import { precondition } from "@ripplo/testing";

export const authLoggedIn = precondition("auth:logged-in")
  .description("Authenticated test user with a valid session")
  .contract<{ userId: string }>();

export const dataProject = precondition("data:project")
  .description("Project exists; user is admin")
  .requires({ auth: authLoggedIn })
  .contract<{ orgId: string; projectId: string }>();

export const preconditions = { authLoggedIn, dataProject };

Contract fields are primitives: string, number, or boolean. Each value is run-scoped.

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.

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.

Observers

import { observer } from "@ripplo/testing";

export const orgNameIs = observer("org:name-is")
  .description("Org has the given name in the DB")
  .budget("fast")
  .input<{ orgId: string; expectedName: string }>()
  .contract();

export const observers = { orgNameIs };

.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).

.input<T>() fields are typed primitives — same rules as precondition contracts. The wire codec preserves the type.

Locators, actions, assertions

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.

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.

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.

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.

Upload fixtures

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.

Variables

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.

import { extract, variable } from "@ripplo/testing/control";

const token = variable("token");
extract(testId("token-value"), token).as("capture token");
fill(role("textbox", "Paste here"), token).as("paste token");

Wiring

.ripplo/index.ts

import { createRipplo } from "@ripplo/testing";
import { preconditions } from "./preconditions/index.js";
import { observers } from "./observers/index.js";
import { tests } from "./tests/index.js";

export default createRipplo({ preconditions, observers, tests });

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.

<app>/src/test/engine.ts

import { createEngine, notImplemented } from "@ripplo/testing";
import ripplo from "../../../../.ripplo/index.js";
import { prisma } from "../lib/prisma.js";

export const engine = createEngine(ripplo, {
  preconditions: {
    authLoggedIn: {
      // Setup receives one item per concurrent run that needs this precondition.
      // Issue one bulk write and return results in input order.
      setup: async (items) => {
        const seeds = items.map(({ ctx }) => ({
          id: ctx.uniqueId("user"),
          email: ctx.uniqueEmail(),
        }));
        await prisma.user.createMany({ data: seeds });
        return seeds.map(({ id }) => ({ userId: id }));
      },
      teardown: async (items) => {
        await prisma.user.deleteMany({
          where: { id: { in: items.map((it) => it.ctx.data.userId) } },
        });
      },
    },
    dataProject: notImplemented("awaiting prisma seed helper"),
  },
  observers: {
    orgNameIs: async (ctx, { orgId, expectedName }) => {
      const org = await prisma.organization.findUnique({
        select: { name: true },
        where: { id: orgId },
      });
      if (org == null) return ctx.retry(`organization "${orgId}" not found yet`);
      if (org.name !== expectedName) return ctx.retry(`name is "${org.name}"`);
      return ctx.pass();
    },
  },
});

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.

Setup context

Each batched setup item carries a ctx:

  • ctx.runId is a 12-char run id.
  • ctx.uniqueId(prefix) returns ripplo-test-<prefix>-<runId>-<n> and increments per call.
  • ctx.uniqueEmail() returns ripplo-test-<runId>-<n>@test.ripplo.ai.
  • ctx.setCookie(name, value, options?) applies to that run's browser context before the test starts.
  • ctx.fixed(value) brands a static value so the engine can tell it apart from a raw literal.

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.

TEST_ID_PREFIX is exported so teardown logic that scopes WHERE clauses by startsWith(TEST_ID_PREFIX) doesn't have to hardcode the string.

Observer context

The observer impl returns one of three terminal states:

  • ctx.pass() — assertion satisfied; stop polling.
  • 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.
  • ctx.fail(reason) — give up immediately. Reserve this for invariant violations where polling cannot help.

Thrown exceptions count as fail.

Observer coverage

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.

Steps that genuinely touch no server state opt out with { uiOnly: true }:

click(role("button", "Cancel"), { uiOnly: true }).as("close dialog");
test("filter-sort", { uiOnly: true }).name("Filter & sort");

uiOnly means zero backend effect. Mutations and optimistic UI need an observer.

Server adapters

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.

The recommended setup is Next.js App Router:

// app/ripplo/[action]/route.ts
import { createNextHandler } from "@ripplo/testing/nextjs";
import { engine } from "@/server/test/engine";

export const PUT = createNextHandler({
  enabled: process.env.ENABLE_RIPPLO_TESTING === "true",
  engine,
});

The other adapters follow the same { enabled, engine } shape and mount at a path prefix:

| Framework | Import | Mount | | --------- | -------------------------------------------- | ------------------------------------ | | Express | createExpressHandler from /express | app.use("/ripplo", handler) | | Fastify | registerFastifyHandler from /fastify | app.register(handler, { prefix }) | | Hono | createHonoHandler from /hono | app.route("/ripplo", handler) | | Koa | createKoaHandler from /koa | koa-mount("/ripplo", handler) | | NestJS | RipploTestingModule.forRoot from /nestjs | Import in your AppModule | | Elysia | createElysiaHandler from /elysia | new Elysia().group("/ripplo", ...) |

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.

Custom adapter

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.

import {
  readAdapterWebhookSecret,
  toBatchRunResults,
  verifyWebhookSignature,
} from "@ripplo/testing";
import { engine } from "./test/engine.js";

const webhookSecret = readAdapterWebhookSecret();

async function executePreconditions(req: Request): Promise<Response> {
  const body = await req.text();
  const verified = verifyWebhookSignature(
    body,
    {
      "webhook-id": req.headers.get("webhook-id") ?? undefined,
      "webhook-signature": req.headers.get("webhook-signature") ?? undefined,
      "webhook-timestamp": req.headers.get("webhook-timestamp") ?? undefined,
    },
    webhookSecret,
  );
  if (!verified) {
    return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 401 });
  }

  const { batch } = JSON.parse(body);
  const appUrl = `${req.headers.get("x-forwarded-proto") ?? "http"}://${req.headers.get("host")}`;
  const results = await engine.executePreconditions(
    batch.map((b) => ({ runId: b.runId, names: b.preconditions })),
    { appUrl },
  );
  return new Response(JSON.stringify({ results: toBatchRunResults(results) }), {
    headers: { "content-type": "application/json" },
  });
}

executeObserver and teardownPreconditions follow the same verify-then-dispatch shape. Request bodies: { batch: [{ runId, preconditions, data }] } for teardown, { runId, name, params } for observers.

Security and parallelism

Every request is signed with Standard Webhooks (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.

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.

Lockfile

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.