@playfast/reform-proof 0.0.7 → 0.0.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-proof",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.7",
4
+ "version": "0.0.8",
5
5
  "type": "module",
6
6
  "description": "Headless testing toolkit for reform — drive a scene's compositions and assert on the props they compute, with no DOM, timers, or text matching.",
7
7
  "keywords": [
@@ -27,7 +27,7 @@
27
27
  "./*": "./src/*.ts"
28
28
  },
29
29
  "files": [
30
- "dist",
30
+ "src",
31
31
  "README.md"
32
32
  ],
33
33
  "scripts": {
package/src/engine.ts ADDED
@@ -0,0 +1,345 @@
1
+ import { Effect, Layer, ManagedRuntime } from 'effect'
2
+ import { yieldWrapGet } from 'effect/Utils'
3
+ import { isValidElement } from 'react'
4
+ import { UnknownAction, UnknownSlot } from './errors'
5
+ import {
6
+ Bus,
7
+ CaptureSink,
8
+ type CaptureSinkApi,
9
+ Composition,
10
+ type CompositionClass,
11
+ type CompositionService,
12
+ isFeatureBinding,
13
+ type Node,
14
+ publish,
15
+ type RenderEnv,
16
+ type Scene,
17
+ type SlotChild,
18
+ type SlotClass,
19
+ type SlotHost,
20
+ type Trigger,
21
+ type UiCapture,
22
+ type UiContract,
23
+ } from '@playfast/reform'
24
+ import type { Action, DriveResult, Facade, Proof, ProofResult, SlotFacade, StepFrame } from './index'
25
+
26
+ // reform-proof engine — the headless renderer the proof system runs on. It drives
27
+ // the real reduce loop through a capturing FACADE: no DOM, no React reconciler, no
28
+ // text matching. A proof never renders through React — props are recorded by
29
+ // reform's Effect composition logic, and the only view code this engine runs is the
30
+ // pure `(props, slots, events) => Node` body. `executeProof`/`driveProof` are the
31
+ // two seams the `ProofRunner` layer exposes (see ./runner): one runs a proof to
32
+ // completion, the other steps it a frame at a time for the editor's timeline.
33
+
34
+ /** An erased facade — the runtime shape before the contract type is re-attached. */
35
+ type AnyFacade = Facade<UiContract>
36
+ type AnySlotFacade = SlotFacade<UiContract>
37
+
38
+ // The drain loop, channels, RPC, and reducers run on forked fibers, so a
39
+ // dispatched event takes an unknown number of ticks to flow through. Each poll
40
+ // first drains COOPERATIVELY — a burst of `yieldNow` lets those forked fibers
41
+ // cascade (event → channel → procedure → in-memory RPC → fact → reducer) with no
42
+ // real time — so a synchronous flow reaches its final state within one poll. A
43
+ // real-time `sleep` is still paid each poll to remain the correctness authority
44
+ // for a genuinely time-delayed client (the cooperative drain alone could read a
45
+ // flow waiting on a real timer as falsely "stable" and settle early). The render
46
+ // then re-runs until the captured tree stops changing, bounded so a stuck flow
47
+ // fails fast instead of hanging.
48
+ const SETTLE_DRAIN = 30
49
+ const SETTLE_STEP = '1 milli'
50
+ const SETTLE_MAX_RENDERS = 100
51
+
52
+ /** Cooperatively run forked engine fibers to a fixpoint without advancing real time. */
53
+ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
54
+
55
+ // A keyed view backed by a getter — the only place dynamic keys are needed.
56
+ const keyed = <V>(get: (key: string) => V): Record<string, V> => {
57
+ const target: Record<string, V> = Object.create(null)
58
+ return new Proxy(target, { get: (_t, key) => get(String(key)) })
59
+ }
60
+
61
+ const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
62
+
63
+ interface Sink {
64
+ readonly api: CaptureSinkApi
65
+ readonly captures: ReadonlyArray<UiCapture>
66
+ reset(): void
67
+ }
68
+
69
+ const makeSink = (): Sink => {
70
+ // A const holder whose array we swap on reset (no reassigned binding).
71
+ const state: { captures: UiCapture[] } = { captures: [] }
72
+ return {
73
+ api: { record: (capture) => state.captures.push(capture) },
74
+ get captures() {
75
+ return state.captures
76
+ },
77
+ reset: () => {
78
+ state.captures = []
79
+ },
80
+ }
81
+ }
82
+
83
+ type RuntimeServices = CompositionService | SlotChild | Bus
84
+
85
+ /**
86
+ * Build a proof's runtime layer: the scene's closed wiring with the capture sink
87
+ * merged in, so `Ui`'s `serviceOption(CaptureSink)` finds it and records each
88
+ * render (absent in production, the same layer renders to React). The scene's
89
+ * `provide` is typed to the host-read subset (`MountedServices`); a proof also
90
+ * resolves slot-binding tags (`CompositionClass`), which the same closed layer
91
+ * supplies at runtime — restate that broader `RuntimeServices` surface here. This
92
+ * is the one erasure boundary, the same seam the react host crosses when it reads
93
+ * a slot tag with the requirement erased.
94
+ */
95
+ const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
96
+ const sceneLayer = scene.provide.reduce((a, b) =>
97
+ Layer.merge(a, b),
98
+ ) as unknown as Layer.Layer<RuntimeServices, never, never>
99
+ return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
100
+ }
101
+
102
+ /** A composition queued to render, with the props its parent handed it. */
103
+ interface Mounted {
104
+ readonly comp: CompositionClass<unknown>
105
+ readonly props: unknown
106
+ }
107
+
108
+ /**
109
+ * Walk a rendered node tree and invoke any slot components it contains (JSX
110
+ * defers them to the host; headless we drive them ourselves), enqueuing the
111
+ * child each stands for. Slot components are matched by identity, so this never
112
+ * calls — and never needs hooks from — ordinary components.
113
+ */
114
+ const drive = (node: Node, enqueueBy: Map<Function, (props: unknown) => void>): void => {
115
+ if (Array.isArray(node)) {
116
+ for (const child of node) drive(child, enqueueBy)
117
+ return
118
+ }
119
+ if (!isValidElement<{ readonly children?: Node }>(node)) return
120
+ if (node.type instanceof Function) {
121
+ const enqueue = enqueueBy.get(node.type)
122
+ if (enqueue !== undefined) {
123
+ enqueue(node.props)
124
+ return
125
+ }
126
+ }
127
+ drive(node.props.children, enqueueBy)
128
+ }
129
+
130
+ const makeFacade = (
131
+ runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
132
+ root: CompositionClass<unknown>,
133
+ sink: Sink,
134
+ ): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } => {
135
+ // Slot bindings (`provide(slot, composition)`) are static, so resolve the
136
+ // whole child tree once, up front — keeping `renderTree` and slot navigation
137
+ // synchronous (no nested runtime drive).
138
+ const bindings = new Map<SlotClass, CompositionClass<unknown>>()
139
+ // A slot's child is a plain composition or a `FeatureBinding`. A proof drives the
140
+ // composition the feature mounts; for a `default` feature that composition's logic
141
+ // is already live (its eager `.live` was merged into the scene by `provide`), so
142
+ // unwrapping is all that's needed. (Driving a `lazy` feature — load gap +
143
+ // placeholders — is the proof host's Part B increment; until then a scene wires
144
+ // features as `default`, the eagerly-resolvable form.)
145
+ const childComposition = (child: SlotChild): CompositionClass<unknown> =>
146
+ isFeatureBinding(child) ? child.composition : child
147
+ const collect = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
148
+ Effect.gen(function* () {
149
+ for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
150
+ if (bindings.has(slotClass)) continue
151
+ const child = childComposition(yield* slotClass.tag)
152
+ bindings.set(slotClass, child)
153
+ yield* collect(child)
154
+ }
155
+ })
156
+ runtime.runSync(collect(root))
157
+
158
+ // One breadth-first render of the whole tree. Each view reports its
159
+ // `(props, events)` to the sink; each slot it renders enqueues a child (built
160
+ // on the next level), so a single sweep renders everything.
161
+ const renderLevel = (
162
+ frontier: ReadonlyArray<Mounted>,
163
+ ): Effect.Effect<void, never, CompositionService> =>
164
+ Effect.gen(function* () {
165
+ if (frontier.length === 0) return
166
+ const next: Array<Mounted> = []
167
+ for (const { comp, props } of frontier) {
168
+ const service = yield* comp.tag
169
+ // A slot renders to a deferred component (JSX `<slots.Item/>`); map each
170
+ // back to the child it stands for so the tree walk can enqueue it.
171
+ const enqueueBy = new Map<Function, (childProps: unknown) => void>()
172
+ const slots: SlotHost = {
173
+ slot: (name) => {
174
+ const slotClass = comp.manifest.slots?.[name]
175
+ const child = slotClass && bindings.get(slotClass)
176
+ const component: (childProps: unknown) => Node = () => null
177
+ if (child) enqueueBy.set(component, (childProps) => next.push({ comp: child, props: childProps }))
178
+ return component
179
+ },
180
+ }
181
+ const env: RenderEnv = { props, tracker: { add: () => {} }, slots }
182
+ const node = yield* Composition.render(service, env)
183
+ drive(node, enqueueBy)
184
+ }
185
+ yield* renderLevel(next)
186
+ })
187
+
188
+ const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
189
+ sink.reset()
190
+ yield* renderLevel([{ comp: root, props: {} }])
191
+ })
192
+
193
+ const capturesFor = (name: string): ReadonlyArray<UiCapture> =>
194
+ sink.captures.filter((capture) => capture.name === name)
195
+
196
+ // A cheap fingerprint of the rendered tree; when two successive renders match,
197
+ // the engine has stopped producing new state and the read is safe.
198
+ const fingerprint = (): string =>
199
+ JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
200
+
201
+ // Re-render until the tree is stable for two consecutive renders, or give up.
202
+ const settleFrom = (
203
+ previous: string,
204
+ remaining: number,
205
+ ): Effect.Effect<void, never, CompositionService> =>
206
+ Effect.gen(function* () {
207
+ if (remaining === 0) return
208
+ yield* settleDrain
209
+ yield* Effect.sleep(SETTLE_STEP)
210
+ yield* renderTree
211
+ const current = fingerprint()
212
+ if (current === previous) return
213
+ yield* settleFrom(current, remaining - 1)
214
+ })
215
+ const settle: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
216
+ yield* settleDrain
217
+ yield* renderTree
218
+ yield* settleFrom(fingerprint(), SETTLE_MAX_RENDERS)
219
+ })
220
+
221
+ const triggerOf = (name: string, index: number, event: string): Trigger<unknown> => {
222
+ const trigger = capturesFor(name)[index]?.events[event]
223
+ if (trigger === undefined) {
224
+ throw new UnknownAction({ composition: name, action: event, rendered: capturesFor(name).length })
225
+ }
226
+ return trigger
227
+ }
228
+
229
+ const nodeFacade = (name: string, index: number, comp: CompositionClass<unknown>): AnyFacade => ({
230
+ props: Effect.map(renderTree, () => capturesFor(name)[index]?.props),
231
+ // `keyed` resolves event/slot names at runtime; cast the string-keyed proxy
232
+ // to the contract-typed surface. This is the one reflection boundary, the
233
+ // same seam as `definitionClass` — every name the proxy serves is a real
234
+ // contract member, so the cast is sound.
235
+ actions: keyed(
236
+ (event): Action =>
237
+ (payload) =>
238
+ renderTree.pipe(
239
+ Effect.flatMap(() => Effect.sync(() => triggerOf(name, index, event)(payload))),
240
+ Effect.flatMap(() => settle),
241
+ ),
242
+ ) as AnyFacade['actions'],
243
+ slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
244
+ frame: settle,
245
+ })
246
+
247
+ const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
248
+ const slotClass = parent.manifest.slots?.[slotName]
249
+ const child = slotClass && bindings.get(slotClass)
250
+ if (child === undefined) {
251
+ throw new UnknownSlot({ parent: uiNameOf(parent), slot: slotName })
252
+ }
253
+ const childName = uiNameOf(child)
254
+ const at = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
255
+ Effect.as(renderTree, nodeFacade(childName, index, child))
256
+ const first = nodeFacade(childName, 0, child)
257
+ return {
258
+ first: at(0),
259
+ at,
260
+ all: Effect.map(renderTree, () =>
261
+ capturesFor(childName).map((_capture, index) => nodeFacade(childName, index, child)),
262
+ ),
263
+ // Used directly, a slot behaves as its first instance.
264
+ props: first.props,
265
+ actions: first.actions,
266
+ slots: first.slots,
267
+ frame: first.frame,
268
+ }
269
+ }
270
+
271
+ return { facade: nodeFacade(uiNameOf(root), 0, root), settle }
272
+ }
273
+
274
+ /**
275
+ * Run one proof against a fresh runtime, returning its pass/fail result. Each
276
+ * proof gets its own isolated environment, so nothing leaks between proofs. Boots
277
+ * the scene as the host would, settles, then runs the proof body to completion.
278
+ */
279
+ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
280
+ const sink = makeSink()
281
+ const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
282
+ try {
283
+ const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink)
284
+ await runtime.runPromise(
285
+ Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
286
+ Effect.flatMap(() => settle),
287
+ ),
288
+ )
289
+ await runtime.runPromise(Effect.gen(() => proof.body(facade)))
290
+ return { requirement: proof.requirement.manifest.statement, ok: true }
291
+ } catch (error) {
292
+ return {
293
+ requirement: proof.requirement.manifest.statement,
294
+ ok: false,
295
+ error: error instanceof Error ? error.message : String(error),
296
+ }
297
+ } finally {
298
+ await runtime.dispose()
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Drive one proof a step at a time, recording a `StepFrame` after each yielded
304
+ * effect settles. Reuses the same makeFacade runtime/facade/settle as
305
+ * `executeProof`, but instead of handing the body to `Effect.gen` (which runs it
306
+ * to completion), it pumps the generator by hand — `gen.next(value)` yields the
307
+ * next effect, we run it on the live runtime, snapshot the sink, and feed the
308
+ * result back. So the editor gets the per-step timeline with no change to the
309
+ * proof authoring API.
310
+ */
311
+ export const driveProof = async (proof: Proof): Promise<DriveResult> => {
312
+ const sink = makeSink()
313
+ const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
314
+ const frames: Array<StepFrame> = []
315
+ const statement = proof.requirement.manifest.statement
316
+ try {
317
+ const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink)
318
+ await runtime.runPromise(
319
+ Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
320
+ Effect.flatMap(() => settle),
321
+ ),
322
+ )
323
+ frames.push({ index: 0, captures: [...sink.captures] })
324
+ const generator = proof.body(facade)
325
+ // Pump the generator: run each yielded effect on the runtime, snapshot, recur.
326
+ const pump = async (input: unknown, index: number): Promise<void> => {
327
+ const step = generator.next(input)
328
+ if (step.done === true) return
329
+ const value = await runtime.runPromise(yieldWrapGet(step.value))
330
+ frames.push({ index, captures: [...sink.captures] })
331
+ await pump(value, index + 1)
332
+ }
333
+ await pump(undefined, 1)
334
+ return { requirement: statement, frames, ok: true }
335
+ } catch (error) {
336
+ return {
337
+ requirement: statement,
338
+ frames,
339
+ ok: false,
340
+ error: error instanceof Error ? error.message : String(error),
341
+ }
342
+ } finally {
343
+ await runtime.dispose()
344
+ }
345
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { type Cause, Data } from 'effect'
2
+
3
+ /**
4
+ * Tagged errors for the proof harness. A failed assertion or a malformed proof
5
+ * navigation throws one of these — tagged and structured, never a bare `Error` —
6
+ * so a runner can distinguish an assertion failure from a harness misuse.
7
+ */
8
+
9
+ /**
10
+ * The constructor shape `Data.TaggedError(tag)<A>` produces, named so the
11
+ * generated `.d.ts` can describe the `extends` base under `isolatedDeclarations`
12
+ * (which forbids an inferred expression in an extends clause).
13
+ */
14
+ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
15
+ args: A,
16
+ ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
17
+
18
+ const AssertionFailedBase: TaggedErrorClass<
19
+ 'reform-proof/AssertionFailed',
20
+ { readonly detail: string }
21
+ > = Data.TaggedError('reform-proof/AssertionFailed')<{ readonly detail: string }>
22
+
23
+ /** An `expect(...)` matcher did not hold. */
24
+ export class AssertionFailed extends AssertionFailedBase {
25
+ override get message(): string {
26
+ return `reform-proof: assertion failed — ${this.detail}`
27
+ }
28
+ }
29
+
30
+ const UnknownActionBase: TaggedErrorClass<
31
+ 'reform-proof/UnknownAction',
32
+ { readonly composition: string; readonly action: string; readonly rendered: number }
33
+ > = Data.TaggedError('reform-proof/UnknownAction')<{
34
+ readonly composition: string
35
+ readonly action: string
36
+ readonly rendered: number
37
+ }>
38
+
39
+ /** A proof referenced an action the rendered composition never exposed. */
40
+ export class UnknownAction extends UnknownActionBase {
41
+ override get message(): string {
42
+ return `reform-proof: '${this.composition}' has no action '${this.action}' (rendered ${this.rendered} instance(s))`
43
+ }
44
+ }
45
+
46
+ const UnknownSlotBase: TaggedErrorClass<
47
+ 'reform-proof/UnknownSlot',
48
+ { readonly parent: string; readonly slot: string }
49
+ > = Data.TaggedError('reform-proof/UnknownSlot')<{ readonly parent: string; readonly slot: string }>
50
+
51
+ /** A proof navigated into a slot the parent composition does not declare. */
52
+ export class UnknownSlot extends UnknownSlotBase {
53
+ override get message(): string {
54
+ return `reform-proof: '${this.parent}' has no slot '${this.slot}'`
55
+ }
56
+ }