@ripplo/testing
v0.15.2
Published
Typed test DSL for Ripplo — declare state and user flows, compile to a lockfile
Readme
@ripplo/testing
Ripplo’s typed workflow DSL.
An app defines one Zod state schema. Each top-level key is a source, and each source is read as one validated JSON value. Workflows use handles derived from that schema, so state paths and record fields do not use string keys.
npx ripplo init installs @ripplo/testing and its exact Zod 4 version. For manual setup:
pnpm add -D --save-exact @ripplo/testing [email protected]import {
createRipplo,
defineState,
setup,
source,
} from "@ripplo/testing";
import { z } from "zod/v4";
export const state = defineState(
z.object({
core: source.http(
z.object({
organizations: setup.record(
z.object({
id: setup.generated(z.string()),
name: setup.value(z.string()),
}),
),
users: setup.record(
z.object({
email: setup.value(z.email()),
token: setup.generated(z.string()),
}),
),
),
),
frontend: source.browser(
z.object({
editor: z.object({
dirty: setup.value(z.boolean()),
}),
}),
),
}),
);
export const ripplo = createRipplo({
facts: undefined,
state,
viewports: ["desktop", "mobile"],
workflows,
});Apps that must keep Zod 3 install Zod 4 under an alias:
pnpm add -D --save-exact @ripplo/testing zod4@npm:[email protected]Their state schema imports z from zod4 instead of zod/v4. Do not pass Zod 3 schemas to
Ripplo.
setup.value(schema) marks a value that workflows may provide. setup.generated(schema) marks a
value returned by setup, such as an ID. setup.record(rowSchema) marks a keyed record collection
that can be synthesized.
Workflows
One workflow models one critical user intent through its natural multi-step path.
givendescribes the widest starting-state constraints valid for the whole journey.arbitrary(field)supplies schema-valid workflow inputs. Reuse each binding in later actions and effects.exact(value)is only for concrete values that drive behavior.optional()plus namedwhenbranches cover state-dependent outcomes when intent and path stay the same.- Every mutation declares its visible outcome and complete typed state effects, including cascades.
- Every effect must provably change the state known at that step.
Every source implements one aggregate read():
import { createStateSourceEngine } from "@ripplo/testing/engine";
import { coreStateSchema, state } from "../../../.ripplo/state.js";
export const coreStateEngine = createStateSourceEngine(state.core, {
setup: {
records: {
organizations: ({ input, runId }) => insertOrganization({ input, runId }),
users: ({ input, runId }) => insertUser({ input, runId }),
},
},
teardown: ({ runId }) => clearRunData(runId),
read: ({ runId }) => readCoreState(runId).then((value) => coreStateSchema.parse(value)),
});Browser-owned sources use the same fields and records setup contract. Connect before rendering
so state setup finishes before Ripplo captures the app:
import { connect } from "@ripplo/testing/browser";
import { createStateSourceEngine } from "@ripplo/testing/engine";
const frontendStateEngine = createStateSourceEngine(state.frontend, frontendStateImpl);
const enabled = import.meta.env.VITE_ENABLE_RIPPLO_TESTING === "true";
const connection = enabled ? await connect(frontendStateEngine) : null;
if (connection != null) {
const stopReadySignal = router.subscribe("onResolved", () => {
connection.ready();
stopReadySignal();
});
}
renderApp();connect(engine) mounts and gates browser state setup. connection.ready() separately marks the
page interactive. Ripplo sets up HTTP state and signs in before it invokes the browser engine.
Browser setup may depend on HTTP-created records and may read signed-in application state. Persist
its result in storage that survives navigation. Capture starts from a blank page, and the first
workflow goto performs a hard navigation. Use connect() without an engine when the browser owns
no modeled state.
Server adapters
Mount each HTTP-backed state source and the optional authentication engine behind signed endpoints. For Express:
import { createAuthenticationHandler, createStateSourceHandler } from "@ripplo/testing/express";
const enabled = process.env.ENABLE_RIPPLO_TESTING === "true";
app.use("/ripplo", createStateSourceHandler({ enabled, engine: coreStateEngine }));
app.use("/ripplo", createAuthenticationHandler({ enabled, engines: [userAuthenticationEngine] }));The same createStateSourceHandler() and createAuthenticationHandler() functions are available
from the hono, nextjs, nestjs, koa, and elysia entry points. Fastify exports
registerStateSourceHandler() and registerAuthenticationHandler(). Vite exports
ripploStateSourcePlugin() and ripploAuthenticationPlugin().
See DSL.md for the complete state and workflow surface.
