@playfast/reform-proof
v1.0.1
Published
Headless testing toolkit for reform — drive a scene's compositions and assert on the props they compute, with no DOM, timers, or text matching.
Maintainers
Readme
@playfast/reform-proof
A headless testing toolkit for reform. Drive a scene's compositions and assert on what they compute — deterministically, with no DOM, no timers, and no text matching.
A reform scene is renderer-neutral, so it can be exercised without a host. @playfast/reform-proof runs that same scene against a capturing UI: it dispatches typed events, settles the engine, and lets you assert on the props a composition computed — pure data, never rendered markup. The scene you prove is the exact scene @playfast/reform-react renders, so there is no seam between a passing test and production behavior.
Install
bun add -d @playfast/reform-proof @playfast/reform effect reactPeers: effect, react (^19), and @playfast/reform.
Quick start
A proof binds a human-readable requirement to a composition, then implements it as a generator that drives the scene's app facade:
import { Layer } from 'effect'
import { scene } from '@playfast/reform'
import { Product, Proof, ProductRequirement, expect } from '@playfast/reform-proof'
import { Counter } from './counter.compose'
import { CounterLive } from './counter.layer'
const CounterScene = scene(Counter, { provide: [CounterLive] })
class Bumps extends ProductRequirement.make(Counter, 'bumps the count') {}
const bumps = Proof.implement(Bumps, CounterScene, function* (app) {
yield* app.actions.bump({}) // dispatch a contract event, settle
const props = yield* app.props // read what the composition computed
yield* expect(props.count).toBe(1)
})
const product = Product.make(Counter, { requirements: [Bumps] })
const suite = Proof.suite(product, { proofs: [bumps] })
const result = await Proof.run(suite) // { product, results, ok }The workflow
| step | API |
| --- | --- |
| Declare a requirement | ProductRequirement.make(composition, statement) |
| Group requirements | Product.make(composition, { requirements }) |
| Implement a proof | Proof.implement(requirement, scene, function* (app) { … }) |
| Compose a suite | Proof.suite(product, { proofs }) |
| Run it | Proof.run(suite) → Promise<SuiteResult> |
| Step it (for tooling) | Proof.driver(suite) → ProofDriver[] |
The statement is a string-literal type, matched at Proof.implement so a requirement's name and its proof can never drift apart.
The facade
Inside a proof body, app is a typed handle derived from the composition's UI contract:
yield* app.actions.<event>(payload)— dispatch a contract event and settle.yield* app.props— re-render and return the latest computed props struct.yield* app.slots.<name>.first/.at(i)/.all— descend into child composition facades; a slot used directly defaults to its first instance.yield* app.frame— force a settle (re-render and wait for engine stability).
Assertions are Effects: expect(actual).toBe(…), .toEqual(…), .toContain(…). Mis-targeting a contract surfaces as a tagged failure — UnknownAction, UnknownSlot, or AssertionFailed — rather than an undefined read.
Why it's deterministic
There is no real clock. settle cooperatively drains forked fibers and loops until the render tree reaches a two-frame fixpoint, so genuinely async flows (queries, procedures, remote state) resolve without flakiness from timing races. Proof.driver exposes the same run one frame at a time, capturing what each step saw — which is what powers the editor's test-play timeline.
The reform family
Core @playfast/reform · hosts @playfast/reform-react / @playfast/reform-react-native · forms @playfast/reform-forms + @playfast/reform-forms-react
License
MIT
