@playfast/reform-proof 0.1.0 → 1.1.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/package.json +1 -1
- package/src/assertions.ts +65 -0
- package/src/driverTypes.ts +19 -0
- package/src/engine.ts +8 -520
- package/src/errors.ts +0 -14
- package/src/execution.ts +100 -0
- package/src/facade.ts +258 -0
- package/src/index.ts +53 -294
- package/src/product.ts +77 -0
- package/src/runner.ts +1 -17
- package/src/runtimeFacade.ts +269 -0
- package/src/runtimeTreeDispose.ts +21 -0
- package/src/runtimeTreeDriver.ts +285 -0
- package/src/runtimeTreeFacade.ts +134 -0
- package/src/runtimeTreeSettle.ts +34 -0
- package/src/runtimeTreeTypes.ts +99 -0
- package/src/sink.ts +73 -0
- package/src/structure-events.test.ts +0 -11
- package/src/structure-locators.test.ts +0 -23
- package/src/treeBindings.ts +121 -0
- package/src/typed-seams.test.ts +0 -11
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-proof",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.1.1",
|
|
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": [
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Array as Arr, Effect, Option, Record as Rec } from 'effect'
|
|
2
|
+
import { AssertionFailed } from './errors'
|
|
3
|
+
|
|
4
|
+
interface ComparePair {
|
|
5
|
+
readonly left: unknown
|
|
6
|
+
readonly right: unknown
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// oxlint-disable-next-line reform-rules/no-json-parse-stringify -- arbitrary unknown assertion values, not Schema-typed data
|
|
10
|
+
const show = (subject: unknown): string => JSON.stringify(subject)
|
|
11
|
+
|
|
12
|
+
const deepEqual = ({ left, right }: ComparePair): boolean =>
|
|
13
|
+
Object.is(left, right) || show(left) === show(right)
|
|
14
|
+
|
|
15
|
+
const fail = (message: string): Effect.Effect<never> =>
|
|
16
|
+
Effect.dieMessage(new AssertionFailed({ detail: message }).message)
|
|
17
|
+
|
|
18
|
+
export const expect = <A>(actual: A) => ({
|
|
19
|
+
toBe: (expected: A): Effect.Effect<void> =>
|
|
20
|
+
Object.is(actual, expected)
|
|
21
|
+
? Effect.void
|
|
22
|
+
: fail(`expected ${show(actual)} to be ${show(expected)}`),
|
|
23
|
+
toEqual: (expected: A): Effect.Effect<void> =>
|
|
24
|
+
deepEqual({ left: actual, right: expected })
|
|
25
|
+
? Effect.void
|
|
26
|
+
: fail(`expected ${show(actual)} to equal ${show(expected)}`),
|
|
27
|
+
toContain: (expected: A extends ReadonlyArray<infer E> ? E : unknown): Effect.Effect<void> =>
|
|
28
|
+
Array.isArray(actual) && actual.some((element) => deepEqual({ left: element, right: expected }))
|
|
29
|
+
? Effect.void
|
|
30
|
+
: fail(`expected ${show(actual)} to contain ${show(expected)}`),
|
|
31
|
+
toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
|
|
32
|
+
const mismatch = matchPartial({ actual, expected })
|
|
33
|
+
return mismatch === undefined ? Effect.void : fail(mismatch)
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
38
|
+
typeof candidate === 'object' && candidate !== null
|
|
39
|
+
|
|
40
|
+
interface MatchInput {
|
|
41
|
+
readonly actual: unknown
|
|
42
|
+
readonly expected: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const matchPartial = ({ actual, expected }: MatchInput): string | undefined => {
|
|
46
|
+
if (!isRecord(expected)) {
|
|
47
|
+
return deepEqual({ left: actual, right: expected })
|
|
48
|
+
? undefined
|
|
49
|
+
: `expected ${show(actual)} to match ${show(expected)}`
|
|
50
|
+
}
|
|
51
|
+
if (!isRecord(actual)) {
|
|
52
|
+
return `expected ${show(actual)} to be an object matching ${show(expected)}`
|
|
53
|
+
}
|
|
54
|
+
const mismatch = Arr.findFirst(
|
|
55
|
+
Rec.keys(expected),
|
|
56
|
+
(key) => !deepEqual({ left: actual[key], right: expected[key] }),
|
|
57
|
+
)
|
|
58
|
+
return Option.match(mismatch, {
|
|
59
|
+
onNone: () => undefined,
|
|
60
|
+
onSome: (key) =>
|
|
61
|
+
`expected key ${show(key)} to match ${show(expected[key])}, got ${show(actual[key])}`,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const matchProps: (input: MatchInput) => string | undefined = matchPartial
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { UiCapture } from '@playfast/reform'
|
|
2
|
+
|
|
3
|
+
export interface StepFrame {
|
|
4
|
+
readonly index: number
|
|
5
|
+
readonly captures: ReadonlyArray<UiCapture>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DriveResult {
|
|
9
|
+
readonly requirement: string
|
|
10
|
+
readonly frames: ReadonlyArray<StepFrame>
|
|
11
|
+
readonly ok: boolean
|
|
12
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- serialized result DTO read as `error ?? …` by the editor timeline (external); optional kept for wire compat
|
|
13
|
+
readonly error?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProofDriver {
|
|
17
|
+
readonly requirement: string
|
|
18
|
+
readonly run: () => Promise<DriveResult>
|
|
19
|
+
}
|
package/src/engine.ts
CHANGED
|
@@ -1,520 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type CompositionClass,
|
|
10
|
-
type CompositionService,
|
|
11
|
-
isFeatureBinding,
|
|
12
|
-
isStructure,
|
|
13
|
-
publish,
|
|
14
|
-
type RenderEnv,
|
|
15
|
-
type Scene,
|
|
16
|
-
type SlotChild,
|
|
17
|
-
type SlotClass,
|
|
18
|
-
type SlotFill,
|
|
19
|
-
type Structure,
|
|
20
|
-
type Trigger,
|
|
21
|
-
type UiCapture,
|
|
22
|
-
type UiContract,
|
|
23
|
-
} from '@playfast/reform'
|
|
24
|
-
import { matchProps } from './index'
|
|
25
|
-
import type { Action, DriveResult, Facade, Proof, ProofResult, SlotFacade, StepFrame } from './index'
|
|
26
|
-
|
|
27
|
-
// reform-proof engine — the headless renderer the proof system runs on. It drives the
|
|
28
|
-
// real reduce loop through a capturing FACADE: no DOM, no React, no view eval — each
|
|
29
|
-
// composition returns a `Structure`, and the engine reads props/events/fills off the
|
|
30
|
-
// data. `executeProof`/`driveProof` are the two seams the `ProofRunner` layer exposes
|
|
31
|
-
// (see ./runner): run a proof to completion, or step it a frame at a time.
|
|
32
|
-
|
|
33
|
-
/** An erased facade — the runtime shape before the contract type is re-attached. */
|
|
34
|
-
type AnyFacade = Facade<UiContract>
|
|
35
|
-
type AnySlotFacade = SlotFacade<UiContract>
|
|
36
|
-
|
|
37
|
-
// The drain loop, channels, RPC, and reducers run on forked fibers, so a
|
|
38
|
-
// dispatched event takes an unknown number of ticks to flow through. Each poll
|
|
39
|
-
// first drains COOPERATIVELY — a burst of `yieldNow` lets those forked fibers
|
|
40
|
-
// cascade (event → channel → procedure → in-memory RPC → fact → reducer) with no
|
|
41
|
-
// real time — so a synchronous flow reaches its final state within one poll. A
|
|
42
|
-
// real-time `sleep` is still paid each poll to remain the correctness authority
|
|
43
|
-
// for a genuinely time-delayed client (the cooperative drain alone could read a
|
|
44
|
-
// flow waiting on a real timer as falsely "stable" and settle early). The render
|
|
45
|
-
// then re-runs until the captured tree stops changing, bounded so a stuck flow
|
|
46
|
-
// fails fast instead of hanging.
|
|
47
|
-
const SETTLE_DRAIN = 30
|
|
48
|
-
const SETTLE_STEP = '1 milli'
|
|
49
|
-
const SETTLE_MAX_RENDERS = 100
|
|
50
|
-
|
|
51
|
-
/** Cooperatively run forked engine fibers to a fixpoint without advancing real time. */
|
|
52
|
-
const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
53
|
-
|
|
54
|
-
// A keyed view backed by a getter — the only place dynamic keys are needed.
|
|
55
|
-
const keyed = <V>(get: (key: string) => V): Record<string, V> => {
|
|
56
|
-
const target: Record<string, V> = Object.create(null)
|
|
57
|
-
return new Proxy(target, { get: (_target, key) => get(String(key)) })
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Surface an unknown failure/defect as the message string a result DTO carries. */
|
|
61
|
-
const messageOf = (error: unknown): string =>
|
|
62
|
-
error instanceof Error ? error.message : String(error)
|
|
63
|
-
|
|
64
|
-
const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
|
|
65
|
-
|
|
66
|
-
// The event triggers a `Structure` frame carries (plan 03b). At the erased
|
|
67
|
-
// `UiContract` boundary the contract's event map is `Record<never, never>`, so
|
|
68
|
-
// each value is `never` — assignable to `Trigger<unknown>` without a cast — and an
|
|
69
|
-
// omitted `events` defaults to the empty map. This is the structure-path analog of
|
|
70
|
-
// the view path's `capture.events`.
|
|
71
|
-
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> => ({
|
|
72
|
-
...structure.events,
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
// Engine SPI (Sink/makeSink/RuntimeServices/proofLayer/makeFacade) — exported for
|
|
76
|
-
// @playfast/reform-drive, which builds the no-ceremony scene driver on the same core.
|
|
77
|
-
export interface Sink {
|
|
78
|
-
readonly api: CaptureSinkApi
|
|
79
|
-
readonly captures: ReadonlyArray<UiCapture>
|
|
80
|
-
reset(): void
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export const makeSink = (): Sink => {
|
|
84
|
-
// A const holder whose array we swap on reset (no reassigned binding).
|
|
85
|
-
const state: { captures: UiCapture[] } = { captures: [] }
|
|
86
|
-
return {
|
|
87
|
-
api: {
|
|
88
|
-
record: (capture) => {
|
|
89
|
-
state.captures = [...state.captures, capture]
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
get captures() {
|
|
93
|
-
return state.captures
|
|
94
|
-
},
|
|
95
|
-
reset: () => {
|
|
96
|
-
state.captures = []
|
|
97
|
-
},
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export type RuntimeServices = CompositionService | SlotChild | Bus
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Build a proof's runtime layer: the scene's closed wiring with the capture sink
|
|
105
|
-
* merged in, so `Ui`'s `serviceOption(CaptureSink)` finds it and records each
|
|
106
|
-
* render (absent in production, the same layer renders to React). The scene's
|
|
107
|
-
* `provide` is typed to the host-read subset (`MountedServices`); a proof also
|
|
108
|
-
* resolves slot-binding tags (`CompositionClass`), which the same closed layer
|
|
109
|
-
* supplies at runtime — restate that broader `RuntimeServices` surface here. This
|
|
110
|
-
* is the one erasure boundary, the same seam the react host crosses when it reads
|
|
111
|
-
* a slot tag with the requirement erased.
|
|
112
|
-
*/
|
|
113
|
-
export const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
|
|
114
|
-
// oxlint-disable-next-line reform-rules/no-type-assertion -- the one erasure boundary: the scene's host-read `MountedServices` layer widens to the broader `RuntimeServices` a proof resolves
|
|
115
|
-
const sceneLayer = scene.provide.reduce((merged, layer) => Layer.merge(merged, layer)) as unknown as Layer.Layer<
|
|
116
|
-
RuntimeServices,
|
|
117
|
-
never,
|
|
118
|
-
never
|
|
119
|
-
>
|
|
120
|
-
return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** A composition queued to render, with the props its parent handed it. On the
|
|
124
|
-
* structure path it also carries the slot-fill `key` it was mounted under, so the
|
|
125
|
-
* facade can select a child by key (`byKey`); the legacy Node path omits it. */
|
|
126
|
-
interface Mounted {
|
|
127
|
-
readonly comp: CompositionClass<unknown>
|
|
128
|
-
readonly props: unknown
|
|
129
|
-
readonly key: Option.Option<string>
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** A rendered node addressed by its composition ui-name and render index. */
|
|
133
|
-
interface NodeRef {
|
|
134
|
-
readonly name: string
|
|
135
|
-
readonly index: number
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Settle progress: the previous fingerprint and how many renders remain. */
|
|
139
|
-
interface SettleProgress {
|
|
140
|
-
readonly previous: string
|
|
141
|
-
readonly remaining: number
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** One step of the driver's generator pump: the value to feed in and the frame index. */
|
|
145
|
-
interface PumpStep {
|
|
146
|
-
readonly input: unknown
|
|
147
|
-
readonly index: number
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export const makeFacade = (
|
|
151
|
-
runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
|
|
152
|
-
root: CompositionClass<unknown>,
|
|
153
|
-
sink: Sink,
|
|
154
|
-
// Records every event name dispatched through the facade, so a proof's
|
|
155
|
-
// requirement-declared event coverage can be verified after the body runs.
|
|
156
|
-
dispatched: Set<string>,
|
|
157
|
-
): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } => {
|
|
158
|
-
// Slot bindings (`provide(slot, composition)`) are static, so resolve the
|
|
159
|
-
// whole child tree once, up front — keeping `renderTree` and slot navigation
|
|
160
|
-
// synchronous (no nested runtime drive).
|
|
161
|
-
const bindings = new Map<SlotClass, CompositionClass<unknown>>()
|
|
162
|
-
// A slot's child is a plain composition or a `FeatureBinding`. A proof drives the
|
|
163
|
-
// composition the feature mounts; for a `default` feature that composition's logic
|
|
164
|
-
// is already live (its eager `.live` was merged into the scene by `provide`), so
|
|
165
|
-
// unwrapping is all that's needed. (Driving a `lazy` feature — load gap +
|
|
166
|
-
// placeholders — is the proof host's Part B increment; until then a scene wires
|
|
167
|
-
// features as `default`, the eagerly-resolvable form.)
|
|
168
|
-
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
169
|
-
isFeatureBinding(child) ? child.composition : child
|
|
170
|
-
const slotClassesOf = (comp: CompositionClass<unknown>): ReadonlyArray<SlotClass> =>
|
|
171
|
-
Option.fromNullable(comp.manifest.slots).pipe(
|
|
172
|
-
Option.map((slots) => Object.values(slots)),
|
|
173
|
-
Option.getOrElse(() => []),
|
|
174
|
-
)
|
|
175
|
-
const bindChild = Effect.fn('bindChild')(function* (
|
|
176
|
-
slotClass: SlotClass,
|
|
177
|
-
): Effect.fn.Return<void, never, SlotChild> {
|
|
178
|
-
const child = childComposition(yield* slotClass.tag)
|
|
179
|
-
bindings.set(slotClass, child)
|
|
180
|
-
yield* collect(child)
|
|
181
|
-
})
|
|
182
|
-
const collect: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> =
|
|
183
|
-
Effect.fn('collect')(function* (
|
|
184
|
-
comp: CompositionClass<unknown>,
|
|
185
|
-
): Effect.fn.Return<void, never, SlotChild> {
|
|
186
|
-
yield* Effect.forEach(slotClassesOf(comp), (slotClass) =>
|
|
187
|
-
bindings.has(slotClass) ? Effect.void : bindChild(slotClass),
|
|
188
|
-
)
|
|
189
|
-
})
|
|
190
|
-
runtime.runSync(collect(root))
|
|
191
|
-
|
|
192
|
-
// Render one mounted composition, returning the children its fills enqueue. Every
|
|
193
|
-
// composition returns a `Structure` (no view is ever executed — the proof is
|
|
194
|
-
// view-free / React-free): read its computed props straight off the value, record
|
|
195
|
-
// them + the events it carries into the sink (so the facade, `triggerOf`, and
|
|
196
|
-
// navigation work uniformly), and walk its slot FILLS to enqueue children.
|
|
197
|
-
const renderMounted = Effect.fn('renderMounted')(function* (
|
|
198
|
-
mounted: Mounted,
|
|
199
|
-
): Effect.fn.Return<ReadonlyArray<Mounted>, never, CompositionService> {
|
|
200
|
-
const service = yield* mounted.comp.tag
|
|
201
|
-
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
202
|
-
const frame = yield* Composition.render(service, env)
|
|
203
|
-
if (!isStructure(frame)) {
|
|
204
|
-
return yield* Effect.dieMessage(
|
|
205
|
-
`reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
|
|
206
|
-
)
|
|
207
|
-
}
|
|
208
|
-
return driveStructure(mounted.comp, frame, mounted.key)
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
// One breadth-first render of a whole level. Each mounted node reports its
|
|
212
|
-
// `(props, events)` to the sink and yields the next level of children, so a
|
|
213
|
-
// single recursive sweep renders everything.
|
|
214
|
-
const renderLevel: (frontier: ReadonlyArray<Mounted>) => Effect.Effect<void, never, CompositionService> =
|
|
215
|
-
Effect.fn('renderLevel')(function* (
|
|
216
|
-
frontier: ReadonlyArray<Mounted>,
|
|
217
|
-
): Effect.fn.Return<void, never, CompositionService> {
|
|
218
|
-
if (frontier.length === 0) {
|
|
219
|
-
return
|
|
220
|
-
}
|
|
221
|
-
const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted))
|
|
222
|
-
yield* renderLevel(levels.flat())
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
// Consume a `Structure` frame: record the node's computed props (the structure
|
|
226
|
-
// value already holds them — the proof never derives them from a view) AND the
|
|
227
|
-
// event triggers it carries on `structure.events` (plan 03b), then enqueue one
|
|
228
|
-
// child per slot fill with the per-item props the fill carries. Recording the
|
|
229
|
-
// events into the capture the same way the view path does means `triggerOf` /
|
|
230
|
-
// `app.actions` resolve identically for Structure frames; the index-keyed enqueue
|
|
231
|
-
// order matches the legacy node walk.
|
|
232
|
-
const driveStructure = (
|
|
233
|
-
comp: CompositionClass<unknown>,
|
|
234
|
-
structure: Structure<UiContract>,
|
|
235
|
-
key: Option.Option<string>,
|
|
236
|
-
): ReadonlyArray<Mounted> => {
|
|
237
|
-
sink.api.record({
|
|
238
|
-
name: uiNameOf(comp),
|
|
239
|
-
props: structure.props,
|
|
240
|
-
events: eventsOf(structure),
|
|
241
|
-
...(Option.isSome(key) ? { key: key.value } : {}),
|
|
242
|
-
})
|
|
243
|
-
const fills: Record<string, SlotFill<unknown>> = structure.slots
|
|
244
|
-
return Rec.toEntries(fills).flatMap(([slotName, fill]) => {
|
|
245
|
-
const slotClass = comp.manifest.slots?.[slotName]
|
|
246
|
-
const child = slotClass && bindings.get(slotClass)
|
|
247
|
-
return child === undefined ? [] : enqueueFill(slotName, fill, child)
|
|
248
|
-
})
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Expand one slot fill into the children to render next. `Each` mounts one
|
|
252
|
-
// child per keyed item with that item's props and key; `One` mounts a single
|
|
253
|
-
// child under a synthesized singleton key; `Absent` mounts nothing (the
|
|
254
|
-
// data-driven `cond && …`). The key rides each `Mounted` so the child's capture
|
|
255
|
-
// can record it, letting the facade select by key (`byKey`).
|
|
256
|
-
const enqueueFill = (
|
|
257
|
-
slotName: string,
|
|
258
|
-
fill: SlotFill<unknown>,
|
|
259
|
-
child: CompositionClass<unknown>,
|
|
260
|
-
): ReadonlyArray<Mounted> =>
|
|
261
|
-
Match.value(fill).pipe(
|
|
262
|
-
Match.when({ _tag: 'Each' }, (each) =>
|
|
263
|
-
each.items.map((entry) => ({
|
|
264
|
-
comp: child,
|
|
265
|
-
props: entry.props,
|
|
266
|
-
key: Option.some(entry.key),
|
|
267
|
-
})),
|
|
268
|
-
),
|
|
269
|
-
Match.when({ _tag: 'One' }, (oneFill) => [
|
|
270
|
-
{ comp: child, props: oneFill.props, key: Option.some(`${slotName}.0`) },
|
|
271
|
-
]),
|
|
272
|
-
Match.when({ _tag: 'Absent' }, () => []),
|
|
273
|
-
Match.exhaustive,
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
|
|
277
|
-
sink.reset()
|
|
278
|
-
yield* renderLevel([{ comp: root, props: {}, key: Option.none() }])
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
const capturesFor = (name: string): ReadonlyArray<UiCapture> =>
|
|
282
|
-
sink.captures.filter((capture) => capture.name === name)
|
|
283
|
-
|
|
284
|
-
// A cheap fingerprint of the rendered tree; when two successive renders match,
|
|
285
|
-
// the engine has stopped producing new state and the read is safe.
|
|
286
|
-
const fingerprint = (): string =>
|
|
287
|
-
// oxlint-disable-next-line reform-rules/no-json-parse-stringify -- structural fixpoint fingerprint of the captured tree; not Schema-typed data
|
|
288
|
-
JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
|
|
289
|
-
|
|
290
|
-
// Re-render until the tree is stable for two consecutive renders, or give up.
|
|
291
|
-
const settleFrom: (progress: SettleProgress) => Effect.Effect<void, never, CompositionService> =
|
|
292
|
-
Effect.fn('settleFrom')(function* (
|
|
293
|
-
progress: SettleProgress,
|
|
294
|
-
): Effect.fn.Return<void, never, CompositionService> {
|
|
295
|
-
if (progress.remaining === 0) {
|
|
296
|
-
return
|
|
297
|
-
}
|
|
298
|
-
yield* settleDrain
|
|
299
|
-
yield* Effect.sleep(SETTLE_STEP)
|
|
300
|
-
yield* renderTree
|
|
301
|
-
const current = fingerprint()
|
|
302
|
-
if (current === progress.previous) {
|
|
303
|
-
return
|
|
304
|
-
}
|
|
305
|
-
yield* settleFrom({ previous: current, remaining: progress.remaining - 1 })
|
|
306
|
-
})
|
|
307
|
-
const settle: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
|
|
308
|
-
yield* settleDrain
|
|
309
|
-
yield* renderTree
|
|
310
|
-
yield* settleFrom({ previous: fingerprint(), remaining: SETTLE_MAX_RENDERS })
|
|
311
|
-
})
|
|
312
|
-
|
|
313
|
-
const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>> =>
|
|
314
|
-
Option.match(Option.fromNullable(capturesFor(ref.name)[ref.index]?.events[event]), {
|
|
315
|
-
onNone: () =>
|
|
316
|
-
Effect.dieMessage(
|
|
317
|
-
new UnknownAction({
|
|
318
|
-
composition: ref.name,
|
|
319
|
-
action: event,
|
|
320
|
-
rendered: capturesFor(ref.name).length,
|
|
321
|
-
}).message,
|
|
322
|
-
),
|
|
323
|
-
onSome: Effect.succeed,
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
|
|
327
|
-
Effect.map(renderTree, () => capturesFor(ref.name)[ref.index]?.props)
|
|
328
|
-
|
|
329
|
-
const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
|
|
330
|
-
props: propsFor(ref),
|
|
331
|
-
// Assert the computed props match a typed subset. Reuses the same partial-match
|
|
332
|
-
// logic as `expect(...).toMatchObject`; dies with `AssertionFailed` on mismatch.
|
|
333
|
-
expectProps: (partial) =>
|
|
334
|
-
propsFor(ref).pipe(
|
|
335
|
-
Effect.flatMap((props) => {
|
|
336
|
-
const mismatch = matchProps({ actual: props, expected: partial })
|
|
337
|
-
return mismatch === undefined
|
|
338
|
-
? Effect.void
|
|
339
|
-
: Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
|
|
340
|
-
}),
|
|
341
|
-
),
|
|
342
|
-
// `keyed` resolves event/slot names at runtime; the string-keyed proxy is the one
|
|
343
|
-
// reflection boundary — every name it serves is a real contract member, so reading
|
|
344
|
-
// it as the contract-typed surface is sound. The action resolves to the props this
|
|
345
|
-
// node computed once the dispatch settled (the typed "result" of the event).
|
|
346
|
-
// oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: the string-keyed proxy serves only real contract event names
|
|
347
|
-
actions: keyed(
|
|
348
|
-
(event): Action =>
|
|
349
|
-
(payload) =>
|
|
350
|
-
renderTree.pipe(
|
|
351
|
-
Effect.flatMap(() => triggerOf(ref, event)),
|
|
352
|
-
Effect.flatMap((trigger) =>
|
|
353
|
-
Effect.sync(() => {
|
|
354
|
-
dispatched.add(event)
|
|
355
|
-
trigger(payload)
|
|
356
|
-
}),
|
|
357
|
-
),
|
|
358
|
-
Effect.flatMap(() => settle),
|
|
359
|
-
Effect.flatMap(() => propsFor(ref)),
|
|
360
|
-
),
|
|
361
|
-
) as AnyFacade['actions'],
|
|
362
|
-
// oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: the string-keyed proxy serves only real contract slot names
|
|
363
|
-
slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
|
|
364
|
-
frame: settle,
|
|
365
|
-
})
|
|
366
|
-
|
|
367
|
-
const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
|
|
368
|
-
const slotClass = parent.manifest.slots?.[slotName]
|
|
369
|
-
const child = slotClass && bindings.get(slotClass)
|
|
370
|
-
if (child === undefined) {
|
|
371
|
-
// oxlint-disable-next-line reform-rules/no-throw -- proof navigated to an undeclared slot: programmer error surfaced as a defect during generator-time proxy access
|
|
372
|
-
throw new UnknownSlot({ parent: uiNameOf(parent), slot: slotName })
|
|
373
|
-
}
|
|
374
|
-
const childName = uiNameOf(child)
|
|
375
|
-
const atIndex = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
|
|
376
|
-
Effect.as(renderTree, nodeFacade({ name: childName, index }, child))
|
|
377
|
-
const first = nodeFacade({ name: childName, index: 0 }, child)
|
|
378
|
-
// The render index of the child whose structure-fill `key` matches (plan 06).
|
|
379
|
-
// Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
|
|
380
|
-
// selection is by stable identity, not render order — `none` when no fill carries it.
|
|
381
|
-
const indexOfKey = (key: string): Option.Option<number> =>
|
|
382
|
-
Arr.findFirstIndex(capturesFor(childName), (capture) => capture.key === key)
|
|
383
|
-
return {
|
|
384
|
-
first: atIndex(0),
|
|
385
|
-
at: atIndex,
|
|
386
|
-
all: Effect.map(renderTree, () =>
|
|
387
|
-
capturesFor(childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
|
|
388
|
-
),
|
|
389
|
-
// Select the one child mounted under `key` (the `each` item key). Re-renders
|
|
390
|
-
// first so the fill keys reflect the latest frame, then resolves its facade;
|
|
391
|
-
// dies with `UnknownSlot` (key in the slot position) when no fill carries it.
|
|
392
|
-
byKey: (key) =>
|
|
393
|
-
Effect.flatMap(renderTree, () =>
|
|
394
|
-
Option.match(indexOfKey(key), {
|
|
395
|
-
onNone: () =>
|
|
396
|
-
Effect.dieMessage(
|
|
397
|
-
new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` }).message,
|
|
398
|
-
),
|
|
399
|
-
onSome: (index) => Effect.succeed(nodeFacade({ name: childName, index }, child)),
|
|
400
|
-
}),
|
|
401
|
-
),
|
|
402
|
-
// Every child whose computed props satisfy `predicate`. Re-renders, then maps
|
|
403
|
-
// the matching captures back to their facades by render index.
|
|
404
|
-
where: (predicate) =>
|
|
405
|
-
Effect.map(renderTree, () =>
|
|
406
|
-
capturesFor(childName).flatMap((capture, index) =>
|
|
407
|
-
predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
|
|
408
|
-
),
|
|
409
|
-
),
|
|
410
|
-
// How many children this slot mounted this frame — the fill length.
|
|
411
|
-
count: Effect.map(renderTree, () => capturesFor(childName).length),
|
|
412
|
-
// Used directly, a slot behaves as its first instance.
|
|
413
|
-
props: first.props,
|
|
414
|
-
expectProps: first.expectProps,
|
|
415
|
-
actions: first.actions,
|
|
416
|
-
slots: first.slots,
|
|
417
|
-
frame: first.frame,
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
return { facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root), settle }
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/** Declared-but-never-dispatched events for a proof's requirement (coverage). */
|
|
425
|
-
const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
|
|
426
|
-
Option.match(proof.requirement.manifest.events, {
|
|
427
|
-
onNone: () => [],
|
|
428
|
-
onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* Run one proof against a fresh runtime, returning its pass/fail result. Each
|
|
433
|
-
* proof gets its own isolated environment, so nothing leaks between proofs. Boots
|
|
434
|
-
* the scene as the host would, settles, then runs the proof body to completion.
|
|
435
|
-
*/
|
|
436
|
-
export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
437
|
-
const sink = makeSink()
|
|
438
|
-
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
439
|
-
const dispatched = new Set<string>()
|
|
440
|
-
const statement = proof.requirement.manifest.statement
|
|
441
|
-
const program = Effect.gen(function* () {
|
|
442
|
-
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
443
|
-
yield* Effect.forEach(
|
|
444
|
-
Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
|
|
445
|
-
(event) => publish('High', event),
|
|
446
|
-
)
|
|
447
|
-
yield* settle
|
|
448
|
-
yield* Effect.gen(() => proof.body(facade))
|
|
449
|
-
const missing = missingCoverage(proof, dispatched)
|
|
450
|
-
const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
|
|
451
|
-
return missing.length > 0
|
|
452
|
-
? { requirement: statement, ok: false, error: coverageError }
|
|
453
|
-
: { requirement: statement, ok: true }
|
|
454
|
-
}).pipe(
|
|
455
|
-
Effect.catchAllCause((cause) =>
|
|
456
|
-
Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
|
|
457
|
-
),
|
|
458
|
-
)
|
|
459
|
-
const report = await runtime.runPromise(program)
|
|
460
|
-
await runtime.dispose()
|
|
461
|
-
return report
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Drive one proof a step at a time, recording a `StepFrame` after each yielded
|
|
466
|
-
* effect settles. Reuses the same makeFacade runtime/facade/settle as
|
|
467
|
-
* `executeProof`, but instead of handing the body to `Effect.gen` (which runs it
|
|
468
|
-
* to completion), it pumps the generator by hand — `gen.next(value)` yields the
|
|
469
|
-
* next effect, we run it on the live runtime, snapshot the sink, and feed the
|
|
470
|
-
* result back. So the editor gets the per-step timeline with no change to the
|
|
471
|
-
* proof authoring API.
|
|
472
|
-
*/
|
|
473
|
-
export const driveProof = async (proof: Proof): Promise<DriveResult> => {
|
|
474
|
-
const sink = makeSink()
|
|
475
|
-
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
476
|
-
const collected: { frames: ReadonlyArray<StepFrame> } = { frames: [] }
|
|
477
|
-
const statement = proof.requirement.manifest.statement
|
|
478
|
-
// The driver's per-frame timeline does not enforce coverage; collect into a
|
|
479
|
-
// throwaway set so `makeFacade`'s contract is satisfied.
|
|
480
|
-
const dispatched = new Set<string>()
|
|
481
|
-
const record = (index: number): void => {
|
|
482
|
-
collected.frames = [...collected.frames, { index, captures: [...sink.captures] }]
|
|
483
|
-
}
|
|
484
|
-
// Pump the generator: run each yielded effect on the runtime, snapshot, recur.
|
|
485
|
-
const pump = Effect.fn('pump')(function* (
|
|
486
|
-
generator: ReturnType<Proof['body']>,
|
|
487
|
-
step: PumpStep,
|
|
488
|
-
): Effect.fn.Return<void, unknown, CompositionService> {
|
|
489
|
-
const next = generator.next(step.input)
|
|
490
|
-
if (next.done === true) {
|
|
491
|
-
return
|
|
492
|
-
}
|
|
493
|
-
const output = yield* yieldWrapGet(next.value)
|
|
494
|
-
yield* Effect.sync(() => record(step.index))
|
|
495
|
-
yield* pump(generator, { input: output, index: step.index + 1 })
|
|
496
|
-
})
|
|
497
|
-
const program = Effect.gen(function* () {
|
|
498
|
-
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
499
|
-
yield* Effect.forEach(
|
|
500
|
-
Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
|
|
501
|
-
(event) => publish('High', event),
|
|
502
|
-
)
|
|
503
|
-
yield* settle
|
|
504
|
-
yield* Effect.sync(() => record(0))
|
|
505
|
-
yield* pump(proof.body(facade), { input: undefined, index: 1 })
|
|
506
|
-
return { requirement: statement, frames: collected.frames, ok: true }
|
|
507
|
-
}).pipe(
|
|
508
|
-
Effect.catchAllCause((cause) =>
|
|
509
|
-
Effect.succeed({
|
|
510
|
-
requirement: statement,
|
|
511
|
-
frames: collected.frames,
|
|
512
|
-
ok: false,
|
|
513
|
-
error: messageOf(Cause.squash(cause)),
|
|
514
|
-
}),
|
|
515
|
-
),
|
|
516
|
-
)
|
|
517
|
-
const timeline = await runtime.runPromise(program)
|
|
518
|
-
await runtime.dispose()
|
|
519
|
-
return timeline
|
|
520
|
-
}
|
|
1
|
+
export { makeFacade, makeFacadeEffect } from './facade'
|
|
2
|
+
export { makeRuntimeHandleFacade } from './runtimeFacade'
|
|
3
|
+
export type { MountedFacade, MountedSlotFacade } from './runtimeFacade'
|
|
4
|
+
export { makeRuntimeTreeFacade } from './runtimeTreeFacade'
|
|
5
|
+
export type { RuntimeTreeFacade } from './runtimeTreeTypes'
|
|
6
|
+
export { makeSink, proofLayer } from './sink'
|
|
7
|
+
export type { RuntimeServices, Sink } from './sink'
|
|
8
|
+
export { driveProof, executeProof } from './execution'
|
package/src/errors.ts
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
import { type Cause, Data } from 'effect'
|
|
2
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
3
|
type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
|
|
15
4
|
args: A,
|
|
16
5
|
) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
|
|
@@ -20,7 +9,6 @@ const AssertionFailedBase: TaggedErrorClass<
|
|
|
20
9
|
{ readonly detail: string }
|
|
21
10
|
> = Data.TaggedError('reform-proof/AssertionFailed')<{ readonly detail: string }>
|
|
22
11
|
|
|
23
|
-
/** An `expect(...)` matcher did not hold. */
|
|
24
12
|
export class AssertionFailed extends AssertionFailedBase {
|
|
25
13
|
override get message(): string {
|
|
26
14
|
return `reform-proof: assertion failed — ${this.detail}`
|
|
@@ -36,7 +24,6 @@ const UnknownActionBase: TaggedErrorClass<
|
|
|
36
24
|
readonly rendered: number
|
|
37
25
|
}>
|
|
38
26
|
|
|
39
|
-
/** A proof referenced an action the rendered composition never exposed. */
|
|
40
27
|
export class UnknownAction extends UnknownActionBase {
|
|
41
28
|
override get message(): string {
|
|
42
29
|
return `reform-proof: '${this.composition}' has no action '${this.action}' (rendered ${this.rendered} instance(s))`
|
|
@@ -48,7 +35,6 @@ const UnknownSlotBase: TaggedErrorClass<
|
|
|
48
35
|
{ readonly parent: string; readonly slot: string }
|
|
49
36
|
> = Data.TaggedError('reform-proof/UnknownSlot')<{ readonly parent: string; readonly slot: string }>
|
|
50
37
|
|
|
51
|
-
/** A proof navigated into a slot the parent composition does not declare. */
|
|
52
38
|
export class UnknownSlot extends UnknownSlotBase {
|
|
53
39
|
override get message(): string {
|
|
54
40
|
return `reform-proof: '${this.parent}' has no slot '${this.slot}'`
|