@playfast/reform-proof 0.0.9 → 0.0.11
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/engine.ts +243 -175
- package/src/index.ts +114 -63
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-proof",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.11",
|
|
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": [
|
package/src/engine.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Layer, Match, ManagedRuntime } from 'effect'
|
|
1
|
+
import { Array as Arr, Cause, Effect, Layer, Match, ManagedRuntime, Option, Record as Rec } from 'effect'
|
|
2
2
|
import { yieldWrapGet } from 'effect/Utils'
|
|
3
3
|
import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
|
|
4
4
|
import {
|
|
@@ -54,9 +54,13 @@ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
|
54
54
|
// A keyed view backed by a getter — the only place dynamic keys are needed.
|
|
55
55
|
const keyed = <V>(get: (key: string) => V): Record<string, V> => {
|
|
56
56
|
const target: Record<string, V> = Object.create(null)
|
|
57
|
-
return new Proxy(target, { get: (
|
|
57
|
+
return new Proxy(target, { get: (_target, key) => get(String(key)) })
|
|
58
58
|
}
|
|
59
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
|
+
|
|
60
64
|
const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
|
|
61
65
|
|
|
62
66
|
// The event triggers a `Structure` frame carries (plan 03b). At the erased
|
|
@@ -64,20 +68,27 @@ const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.m
|
|
|
64
68
|
// each value is `never` — assignable to `Trigger<unknown>` without a cast — and an
|
|
65
69
|
// omitted `events` defaults to the empty map. This is the structure-path analog of
|
|
66
70
|
// the view path's `capture.events`.
|
|
67
|
-
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
68
|
-
|
|
71
|
+
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> => ({
|
|
72
|
+
...structure.events,
|
|
73
|
+
})
|
|
69
74
|
|
|
70
|
-
|
|
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 {
|
|
71
78
|
readonly api: CaptureSinkApi
|
|
72
79
|
readonly captures: ReadonlyArray<UiCapture>
|
|
73
80
|
reset(): void
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
const makeSink = (): Sink => {
|
|
83
|
+
export const makeSink = (): Sink => {
|
|
77
84
|
// A const holder whose array we swap on reset (no reassigned binding).
|
|
78
85
|
const state: { captures: UiCapture[] } = { captures: [] }
|
|
79
86
|
return {
|
|
80
|
-
api: {
|
|
87
|
+
api: {
|
|
88
|
+
record: (capture) => {
|
|
89
|
+
state.captures = [...state.captures, capture]
|
|
90
|
+
},
|
|
91
|
+
},
|
|
81
92
|
get captures() {
|
|
82
93
|
return state.captures
|
|
83
94
|
},
|
|
@@ -87,7 +98,7 @@ const makeSink = (): Sink => {
|
|
|
87
98
|
}
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
type RuntimeServices = CompositionService | SlotChild | Bus
|
|
101
|
+
export type RuntimeServices = CompositionService | SlotChild | Bus
|
|
91
102
|
|
|
92
103
|
/**
|
|
93
104
|
* Build a proof's runtime layer: the scene's closed wiring with the capture sink
|
|
@@ -99,10 +110,13 @@ type RuntimeServices = CompositionService | SlotChild | Bus
|
|
|
99
110
|
* is the one erasure boundary, the same seam the react host crosses when it reads
|
|
100
111
|
* a slot tag with the requirement erased.
|
|
101
112
|
*/
|
|
102
|
-
const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
>
|
|
106
120
|
return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
|
|
107
121
|
}
|
|
108
122
|
|
|
@@ -112,10 +126,28 @@ const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, neve
|
|
|
112
126
|
interface Mounted {
|
|
113
127
|
readonly comp: CompositionClass<unknown>
|
|
114
128
|
readonly props: unknown
|
|
115
|
-
readonly key
|
|
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
|
|
116
136
|
}
|
|
117
137
|
|
|
118
|
-
|
|
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 = (
|
|
119
151
|
runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
|
|
120
152
|
root: CompositionClass<unknown>,
|
|
121
153
|
sink: Sink,
|
|
@@ -135,43 +167,59 @@ const makeFacade = (
|
|
|
135
167
|
// features as `default`, the eagerly-resolvable form.)
|
|
136
168
|
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
137
169
|
isFeatureBinding(child) ? child.composition : child
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
)
|
|
146
189
|
})
|
|
147
190
|
runtime.runSync(collect(root))
|
|
148
191
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
173
220
|
}
|
|
174
|
-
yield*
|
|
221
|
+
const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted))
|
|
222
|
+
yield* renderLevel(levels.flat())
|
|
175
223
|
})
|
|
176
224
|
|
|
177
225
|
// Consume a `Structure` frame: record the node's computed props (the structure
|
|
@@ -184,22 +232,20 @@ const makeFacade = (
|
|
|
184
232
|
const driveStructure = (
|
|
185
233
|
comp: CompositionClass<unknown>,
|
|
186
234
|
structure: Structure<UiContract>,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
): void => {
|
|
235
|
+
key: Option.Option<string>,
|
|
236
|
+
): ReadonlyArray<Mounted> => {
|
|
190
237
|
sink.api.record({
|
|
191
238
|
name: uiNameOf(comp),
|
|
192
239
|
props: structure.props,
|
|
193
240
|
events: eventsOf(structure),
|
|
194
|
-
...(key
|
|
241
|
+
...(Option.isSome(key) ? { key: key.value } : {}),
|
|
195
242
|
})
|
|
196
243
|
const fills: Record<string, SlotFill<unknown>> = structure.slots
|
|
197
|
-
|
|
244
|
+
return Rec.toEntries(fills).flatMap(([slotName, fill]) => {
|
|
198
245
|
const slotClass = comp.manifest.slots?.[slotName]
|
|
199
246
|
const child = slotClass && bindings.get(slotClass)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
247
|
+
return child === undefined ? [] : enqueueFill(slotName, fill, child)
|
|
248
|
+
})
|
|
203
249
|
}
|
|
204
250
|
|
|
205
251
|
// Expand one slot fill into the children to render next. `Each` mounts one
|
|
@@ -211,23 +257,25 @@ const makeFacade = (
|
|
|
211
257
|
slotName: string,
|
|
212
258
|
fill: SlotFill<unknown>,
|
|
213
259
|
child: CompositionClass<unknown>,
|
|
214
|
-
|
|
215
|
-
): void => {
|
|
260
|
+
): ReadonlyArray<Mounted> =>
|
|
216
261
|
Match.value(fill).pipe(
|
|
217
|
-
Match.when({ _tag: 'Each' }, (each) =>
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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' }, () => []),
|
|
224
273
|
Match.exhaustive,
|
|
225
274
|
)
|
|
226
|
-
}
|
|
227
275
|
|
|
228
276
|
const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
|
|
229
277
|
sink.reset()
|
|
230
|
-
yield* renderLevel([{ comp: root, props: {} }])
|
|
278
|
+
yield* renderLevel([{ comp: root, props: {}, key: Option.none() }])
|
|
231
279
|
})
|
|
232
280
|
|
|
233
281
|
const capturesFor = (name: string): ReadonlyArray<UiCapture> =>
|
|
@@ -236,73 +284,82 @@ const makeFacade = (
|
|
|
236
284
|
// A cheap fingerprint of the rendered tree; when two successive renders match,
|
|
237
285
|
// the engine has stopped producing new state and the read is safe.
|
|
238
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
|
|
239
288
|
JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
|
|
240
289
|
|
|
241
290
|
// Re-render until the tree is stable for two consecutive renders, or give up.
|
|
242
|
-
const settleFrom =
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
}
|
|
248
298
|
yield* settleDrain
|
|
249
299
|
yield* Effect.sleep(SETTLE_STEP)
|
|
250
300
|
yield* renderTree
|
|
251
301
|
const current = fingerprint()
|
|
252
|
-
if (current === previous)
|
|
253
|
-
|
|
302
|
+
if (current === progress.previous) {
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
yield* settleFrom({ previous: current, remaining: progress.remaining - 1 })
|
|
254
306
|
})
|
|
255
307
|
const settle: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
|
|
256
308
|
yield* settleDrain
|
|
257
309
|
yield* renderTree
|
|
258
|
-
yield* settleFrom(fingerprint(), SETTLE_MAX_RENDERS)
|
|
310
|
+
yield* settleFrom({ previous: fingerprint(), remaining: SETTLE_MAX_RENDERS })
|
|
259
311
|
})
|
|
260
312
|
|
|
261
|
-
const triggerOf = (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
+
})
|
|
268
325
|
|
|
269
|
-
const propsFor = (
|
|
270
|
-
Effect.map(renderTree, () => capturesFor(name)[index]?.props)
|
|
326
|
+
const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
|
|
327
|
+
Effect.map(renderTree, () => capturesFor(ref.name)[ref.index]?.props)
|
|
271
328
|
|
|
272
|
-
const nodeFacade = (
|
|
273
|
-
props: propsFor(
|
|
329
|
+
const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
|
|
330
|
+
props: propsFor(ref),
|
|
274
331
|
// Assert the computed props match a typed subset. Reuses the same partial-match
|
|
275
|
-
// logic as `expect(...).toMatchObject`;
|
|
332
|
+
// logic as `expect(...).toMatchObject`; dies with `AssertionFailed` on mismatch.
|
|
276
333
|
expectProps: (partial) =>
|
|
277
|
-
propsFor(
|
|
334
|
+
propsFor(ref).pipe(
|
|
278
335
|
Effect.flatMap((props) => {
|
|
279
|
-
const mismatch = matchProps(props, partial)
|
|
336
|
+
const mismatch = matchProps({ actual: props, expected: partial })
|
|
280
337
|
return mismatch === undefined
|
|
281
338
|
? Effect.void
|
|
282
|
-
: Effect.
|
|
283
|
-
throw new AssertionFailed({ detail: mismatch })
|
|
284
|
-
})
|
|
339
|
+
: Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
|
|
285
340
|
}),
|
|
286
341
|
),
|
|
287
|
-
// `keyed` resolves event/slot names at runtime;
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
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
|
|
292
347
|
actions: keyed(
|
|
293
348
|
(event): Action =>
|
|
294
349
|
(payload) =>
|
|
295
350
|
renderTree.pipe(
|
|
296
|
-
Effect.flatMap(() =>
|
|
351
|
+
Effect.flatMap(() => triggerOf(ref, event)),
|
|
352
|
+
Effect.flatMap((trigger) =>
|
|
297
353
|
Effect.sync(() => {
|
|
298
354
|
dispatched.add(event)
|
|
299
|
-
|
|
355
|
+
trigger(payload)
|
|
300
356
|
}),
|
|
301
357
|
),
|
|
302
358
|
Effect.flatMap(() => settle),
|
|
303
|
-
Effect.flatMap(() => propsFor(
|
|
359
|
+
Effect.flatMap(() => propsFor(ref)),
|
|
304
360
|
),
|
|
305
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
|
|
306
363
|
slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
|
|
307
364
|
frame: settle,
|
|
308
365
|
})
|
|
@@ -311,38 +368,43 @@ const makeFacade = (
|
|
|
311
368
|
const slotClass = parent.manifest.slots?.[slotName]
|
|
312
369
|
const child = slotClass && bindings.get(slotClass)
|
|
313
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
|
|
314
372
|
throw new UnknownSlot({ parent: uiNameOf(parent), slot: slotName })
|
|
315
373
|
}
|
|
316
374
|
const childName = uiNameOf(child)
|
|
317
|
-
const
|
|
318
|
-
Effect.as(renderTree, nodeFacade(childName, index, child))
|
|
319
|
-
const first = nodeFacade(childName, 0, 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)
|
|
320
378
|
// The render index of the child whose structure-fill `key` matches (plan 06).
|
|
321
379
|
// Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
|
|
322
|
-
// selection is by stable identity, not render order
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const index = capturesFor(childName).findIndex((capture) => capture.key === key)
|
|
326
|
-
if (index < 0) {
|
|
327
|
-
throw new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` })
|
|
328
|
-
}
|
|
329
|
-
return index
|
|
330
|
-
}
|
|
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)
|
|
331
383
|
return {
|
|
332
|
-
first:
|
|
333
|
-
at,
|
|
384
|
+
first: atIndex(0),
|
|
385
|
+
at: atIndex,
|
|
334
386
|
all: Effect.map(renderTree, () =>
|
|
335
|
-
capturesFor(childName).map((_capture, index) => nodeFacade(childName, index, child)),
|
|
387
|
+
capturesFor(childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
|
|
336
388
|
),
|
|
337
389
|
// Select the one child mounted under `key` (the `each` item key). Re-renders
|
|
338
|
-
// first so the fill keys reflect the latest frame, then resolves its facade
|
|
339
|
-
|
|
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
|
+
),
|
|
340
402
|
// Every child whose computed props satisfy `predicate`. Re-renders, then maps
|
|
341
403
|
// the matching captures back to their facades by render index.
|
|
342
404
|
where: (predicate) =>
|
|
343
405
|
Effect.map(renderTree, () =>
|
|
344
406
|
capturesFor(childName).flatMap((capture, index) =>
|
|
345
|
-
predicate(capture.props) ? [nodeFacade(childName, index, child)] : [],
|
|
407
|
+
predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
|
|
346
408
|
),
|
|
347
409
|
),
|
|
348
410
|
// How many children this slot mounted this frame — the fill length.
|
|
@@ -356,15 +418,15 @@ const makeFacade = (
|
|
|
356
418
|
}
|
|
357
419
|
}
|
|
358
420
|
|
|
359
|
-
return { facade: nodeFacade(uiNameOf(root), 0, root), settle }
|
|
421
|
+
return { facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root), settle }
|
|
360
422
|
}
|
|
361
423
|
|
|
362
424
|
/** Declared-but-never-dispatched events for a proof's requirement (coverage). */
|
|
363
|
-
const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
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
|
+
})
|
|
368
430
|
|
|
369
431
|
/**
|
|
370
432
|
* Run one proof against a fresh runtime, returning its pass/fail result. Each
|
|
@@ -375,32 +437,28 @@ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
|
375
437
|
const sink = makeSink()
|
|
376
438
|
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
377
439
|
const dispatched = new Set<string>()
|
|
378
|
-
|
|
440
|
+
const statement = proof.requirement.manifest.statement
|
|
441
|
+
const program = Effect.gen(function* () {
|
|
379
442
|
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
),
|
|
443
|
+
yield* Effect.forEach(
|
|
444
|
+
Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
|
|
445
|
+
(event) => publish('High', event),
|
|
384
446
|
)
|
|
385
|
-
|
|
447
|
+
yield* settle
|
|
448
|
+
yield* Effect.gen(() => proof.body(facade))
|
|
386
449
|
const missing = missingCoverage(proof, dispatched)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
error: error instanceof Error ? error.message : String(error),
|
|
400
|
-
}
|
|
401
|
-
} finally {
|
|
402
|
-
await runtime.dispose()
|
|
403
|
-
}
|
|
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
|
|
404
462
|
}
|
|
405
463
|
|
|
406
464
|
/**
|
|
@@ -415,38 +473,48 @@ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
|
415
473
|
export const driveProof = async (proof: Proof): Promise<DriveResult> => {
|
|
416
474
|
const sink = makeSink()
|
|
417
475
|
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
418
|
-
const frames:
|
|
476
|
+
const collected: { frames: ReadonlyArray<StepFrame> } = { frames: [] }
|
|
419
477
|
const statement = proof.requirement.manifest.statement
|
|
420
478
|
// The driver's per-frame timeline does not enforce coverage; collect into a
|
|
421
479
|
// throwaway set so `makeFacade`'s contract is satisfied.
|
|
422
480
|
const dispatched = new Set<string>()
|
|
423
|
-
|
|
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* () {
|
|
424
498
|
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
),
|
|
499
|
+
yield* Effect.forEach(
|
|
500
|
+
Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
|
|
501
|
+
(event) => publish('High', event),
|
|
429
502
|
)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
error: error instanceof Error ? error.message : String(error),
|
|
448
|
-
}
|
|
449
|
-
} finally {
|
|
450
|
-
await runtime.dispose()
|
|
451
|
-
}
|
|
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
|
|
452
520
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect } from 'effect'
|
|
1
|
+
import { Array as Arr, Effect, Option, Record as Rec } from 'effect'
|
|
2
2
|
import type { YieldWrap } from 'effect/Utils'
|
|
3
3
|
import { AssertionFailed } from './errors'
|
|
4
4
|
import { ProofRunner, type ProofRunnerApi, proofRunnerLayer, withProofRunner } from './runner'
|
|
@@ -26,6 +26,12 @@ import type {
|
|
|
26
26
|
export { ProofRunner, proofRunnerLayer, withProofRunner }
|
|
27
27
|
export type { ProofRunnerApi }
|
|
28
28
|
|
|
29
|
+
// Engine SPI consumed by @playfast/reform-drive — the no-ceremony scene driver builds
|
|
30
|
+
// on the same facade engine. Re-exported from the package index (not the engine
|
|
31
|
+
// subpath) so consumers load the package in its normal init order.
|
|
32
|
+
export { makeFacade, makeSink, proofLayer } from './engine'
|
|
33
|
+
export type { RuntimeServices, Sink } from './engine'
|
|
34
|
+
|
|
29
35
|
// ---------------------------------------------------------------------------
|
|
30
36
|
// Definitions: ProductRequirement + Product
|
|
31
37
|
// ---------------------------------------------------------------------------
|
|
@@ -46,7 +52,7 @@ export interface RequirementManifest<Comp extends AnyComposition, Statement exte
|
|
|
46
52
|
* dispatches a declared event fails, so the requirement and its proof can't
|
|
47
53
|
* silently drift (the definition→implementation link past the statement string).
|
|
48
54
|
*/
|
|
49
|
-
readonly events
|
|
55
|
+
readonly events: Option.Option<ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>>
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export interface RequirementClass<Comp extends AnyComposition, Statement extends string> {
|
|
@@ -71,20 +77,27 @@ export interface ProductClass<Comp extends AnyComposition = AnyComposition> {
|
|
|
71
77
|
* specifies. The composition types the proof facade; the statement's literal
|
|
72
78
|
* type is re-stated (and enforced) at `Proof.implement`.
|
|
73
79
|
*/
|
|
80
|
+
/** Author-facing config for `ProductRequirement.make` — plain JSON the proof author passes. */
|
|
81
|
+
interface MakeRequirementOptionsExternalApi<Comp extends AnyComposition> {
|
|
82
|
+
readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
const makeRequirement = <Comp extends AnyComposition, const Statement extends string>(
|
|
75
86
|
composition: Comp,
|
|
76
87
|
statement: Statement,
|
|
77
|
-
options?:
|
|
78
|
-
): RequirementClass<Comp, Statement> =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
options?: MakeRequirementOptionsExternalApi<Comp>,
|
|
89
|
+
): RequirementClass<Comp, Statement> => {
|
|
90
|
+
const manifest: RequirementManifest<Comp, Statement> = {
|
|
91
|
+
kind: 'ProductRequirement',
|
|
92
|
+
name: statement,
|
|
93
|
+
statement,
|
|
94
|
+
composition,
|
|
95
|
+
events: Option.fromNullable(options?.events),
|
|
96
|
+
}
|
|
97
|
+
return class {
|
|
98
|
+
static readonly manifest = manifest
|
|
99
|
+
}
|
|
100
|
+
}
|
|
88
101
|
|
|
89
102
|
export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
90
103
|
make: makeRequirement,
|
|
@@ -94,18 +107,24 @@ export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
|
94
107
|
* Group a composition with the full list of requirements that specify it. The
|
|
95
108
|
* shared `Comp` type-checks that every requirement targets this composition.
|
|
96
109
|
*/
|
|
110
|
+
interface MakeProductConfig<Comp extends AnyComposition> {
|
|
111
|
+
readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
const makeProduct = <Comp extends AnyComposition>(
|
|
98
115
|
composition: Comp,
|
|
99
|
-
config:
|
|
100
|
-
): ProductClass<Comp> =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
config: MakeProductConfig<Comp>,
|
|
117
|
+
): ProductClass<Comp> => {
|
|
118
|
+
const manifest: ProductManifest<Comp> = {
|
|
119
|
+
kind: 'Product',
|
|
120
|
+
name: composition.manifest.name,
|
|
121
|
+
composition,
|
|
122
|
+
requirements: config.requirements,
|
|
123
|
+
}
|
|
124
|
+
return class {
|
|
125
|
+
static readonly manifest = manifest
|
|
126
|
+
}
|
|
127
|
+
}
|
|
109
128
|
|
|
110
129
|
export const Product: { readonly make: typeof makeProduct } = { make: makeProduct }
|
|
111
130
|
|
|
@@ -113,59 +132,79 @@ export const Product: { readonly make: typeof makeProduct } = { make: makeProduc
|
|
|
113
132
|
// Assertions — Effect-returning matchers; a failed match fails the proof.
|
|
114
133
|
// ---------------------------------------------------------------------------
|
|
115
134
|
|
|
116
|
-
|
|
117
|
-
|
|
135
|
+
/** Two values to deep-compare — one options object, not positional primitives. */
|
|
136
|
+
interface ComparePair {
|
|
137
|
+
readonly left: unknown
|
|
138
|
+
readonly right: unknown
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Display + structural-compare seam: assertion messages and deep-equality operate on
|
|
142
|
+
// arbitrary `unknown` values, which Schema cannot encode — JSON is the right tool here.
|
|
143
|
+
// oxlint-disable-next-line reform-rules/no-json-parse-stringify -- arbitrary unknown assertion values, not Schema-typed data
|
|
144
|
+
const show = (subject: unknown): string => JSON.stringify(subject)
|
|
145
|
+
|
|
146
|
+
const deepEqual = ({ left, right }: ComparePair): boolean =>
|
|
147
|
+
Object.is(left, right) || show(left) === show(right)
|
|
118
148
|
|
|
119
149
|
const fail = (message: string): Effect.Effect<never> =>
|
|
120
|
-
Effect.
|
|
121
|
-
throw new AssertionFailed({ detail: message })
|
|
122
|
-
})
|
|
150
|
+
Effect.dieMessage(new AssertionFailed({ detail: message }).message)
|
|
123
151
|
|
|
124
152
|
export const expect = <A>(actual: A) => ({
|
|
125
153
|
toBe: (expected: A): Effect.Effect<void> =>
|
|
126
154
|
Object.is(actual, expected)
|
|
127
155
|
? Effect.void
|
|
128
|
-
: fail(`expected ${
|
|
156
|
+
: fail(`expected ${show(actual)} to be ${show(expected)}`),
|
|
129
157
|
toEqual: (expected: A): Effect.Effect<void> =>
|
|
130
|
-
deepEqual(actual, expected)
|
|
158
|
+
deepEqual({ left: actual, right: expected })
|
|
131
159
|
? Effect.void
|
|
132
|
-
: fail(`expected ${
|
|
160
|
+
: fail(`expected ${show(actual)} to equal ${show(expected)}`),
|
|
133
161
|
toContain: (expected: A extends ReadonlyArray<infer E> ? E : unknown): Effect.Effect<void> =>
|
|
134
|
-
Array.isArray(actual) && actual.some((
|
|
162
|
+
Array.isArray(actual) && actual.some((element) => deepEqual({ left: element, right: expected }))
|
|
135
163
|
? Effect.void
|
|
136
|
-
: fail(`expected ${
|
|
164
|
+
: fail(`expected ${show(actual)} to contain ${show(expected)}`),
|
|
137
165
|
toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
|
|
138
|
-
const mismatch = matchPartial(actual, expected)
|
|
166
|
+
const mismatch = matchPartial({ actual, expected })
|
|
139
167
|
return mismatch === undefined ? Effect.void : fail(mismatch)
|
|
140
168
|
},
|
|
141
169
|
})
|
|
142
170
|
|
|
171
|
+
/** A non-null object as an indexable record — the narrowing `unknown` doesn't give. */
|
|
172
|
+
const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
173
|
+
typeof candidate === 'object' && candidate !== null
|
|
174
|
+
|
|
175
|
+
/** The pair `matchPartial`/`matchProps` deep-matches: `actual` against the `expected` subset. */
|
|
176
|
+
interface MatchInput {
|
|
177
|
+
readonly actual: unknown
|
|
178
|
+
readonly expected: unknown
|
|
179
|
+
}
|
|
180
|
+
|
|
143
181
|
/**
|
|
144
182
|
* Deep-match every key of `expected` against `actual`; returns an error message
|
|
145
183
|
* naming the first failing key, or `undefined` on a full match. Shared by
|
|
146
184
|
* `expect(...).toMatchObject` and the facade's `expectProps`.
|
|
147
185
|
*/
|
|
148
|
-
const matchPartial = (actual
|
|
149
|
-
if (
|
|
150
|
-
return deepEqual(actual, expected)
|
|
186
|
+
const matchPartial = ({ actual, expected }: MatchInput): string | undefined => {
|
|
187
|
+
if (!isRecord(expected)) {
|
|
188
|
+
return deepEqual({ left: actual, right: expected })
|
|
151
189
|
? undefined
|
|
152
|
-
: `expected ${
|
|
153
|
-
}
|
|
154
|
-
if (typeof actual !== 'object' || actual === null) {
|
|
155
|
-
return `expected ${JSON.stringify(actual)} to be an object matching ${JSON.stringify(expected)}`
|
|
190
|
+
: `expected ${show(actual)} to match ${show(expected)}`
|
|
156
191
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
for (const key of Object.keys(expectedRecord)) {
|
|
160
|
-
if (!deepEqual(actualRecord[key], expectedRecord[key])) {
|
|
161
|
-
return `expected key ${JSON.stringify(key)} to match ${JSON.stringify(expectedRecord[key])}, got ${JSON.stringify(actualRecord[key])}`
|
|
162
|
-
}
|
|
192
|
+
if (!isRecord(actual)) {
|
|
193
|
+
return `expected ${show(actual)} to be an object matching ${show(expected)}`
|
|
163
194
|
}
|
|
164
|
-
|
|
195
|
+
const mismatch = Arr.findFirst(
|
|
196
|
+
Rec.keys(expected),
|
|
197
|
+
(key) => !deepEqual({ left: actual[key], right: expected[key] }),
|
|
198
|
+
)
|
|
199
|
+
return Option.match(mismatch, {
|
|
200
|
+
onNone: () => undefined,
|
|
201
|
+
onSome: (key) =>
|
|
202
|
+
`expected key ${show(key)} to match ${show(expected[key])}, got ${show(actual[key])}`,
|
|
203
|
+
})
|
|
165
204
|
}
|
|
166
205
|
|
|
167
206
|
/** Internal: the facade's `expectProps` reuses the same partial-match logic. */
|
|
168
|
-
export const matchProps: (
|
|
207
|
+
export const matchProps: (input: MatchInput) => string | undefined = matchPartial
|
|
169
208
|
|
|
170
209
|
// ---------------------------------------------------------------------------
|
|
171
210
|
// Facade — a headless view over the live composition tree
|
|
@@ -283,6 +322,7 @@ export interface ProofSuite {
|
|
|
283
322
|
export interface ProofResult {
|
|
284
323
|
readonly requirement: string
|
|
285
324
|
readonly ok: boolean
|
|
325
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- serialized result DTO read as `error ?? …` by the vitest adapter (external); optional kept for wire compat
|
|
286
326
|
readonly error?: string
|
|
287
327
|
}
|
|
288
328
|
|
|
@@ -304,20 +344,27 @@ const implement = <Comp extends AnyComposition>(
|
|
|
304
344
|
requirement: RequirementClass<Comp, string>,
|
|
305
345
|
scene: Scene<ContractOf<Comp>>,
|
|
306
346
|
body: (app: Facade<ContractOf<Comp>>) => ProofGenerator,
|
|
307
|
-
): Proof =>
|
|
347
|
+
): Proof => {
|
|
348
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- contract erasure seam: Facade<C> → AnyFacade is contravariant, sound by construction
|
|
349
|
+
const erased = body as ProofBody
|
|
350
|
+
return { requirement, scene, body: erased }
|
|
351
|
+
}
|
|
308
352
|
|
|
309
|
-
/**
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
): ProofSuite => ({ kind: 'ProofSuite', product, proofs: config.proofs })
|
|
353
|
+
/** The proofs a `Proof.suite` binds to its product. */
|
|
354
|
+
interface MakeSuiteConfig {
|
|
355
|
+
readonly proofs: ReadonlyArray<Proof>
|
|
356
|
+
}
|
|
314
357
|
|
|
315
|
-
|
|
316
|
-
|
|
358
|
+
/** Compose proofs with the product whose requirements they prove. */
|
|
359
|
+
const suite = (product: ProductClass, config: MakeSuiteConfig): ProofSuite => ({
|
|
360
|
+
kind: 'ProofSuite',
|
|
361
|
+
product,
|
|
362
|
+
proofs: config.proofs,
|
|
363
|
+
})
|
|
317
364
|
|
|
318
365
|
/** Duck-type a `ProofSuite` among arbitrary module exports (the adapter's seam). */
|
|
319
|
-
export const isProofSuite = (
|
|
320
|
-
isRecord(
|
|
366
|
+
export const isProofSuite = (candidate: unknown): candidate is ProofSuite =>
|
|
367
|
+
isRecord(candidate) && candidate['kind'] === 'ProofSuite' && Array.isArray(candidate['proofs'])
|
|
321
368
|
|
|
322
369
|
/**
|
|
323
370
|
* Run every proof against a fresh runtime, returning per-requirement results.
|
|
@@ -326,11 +373,14 @@ export const isProofSuite = (value: unknown): value is ProofSuite =>
|
|
|
326
373
|
*/
|
|
327
374
|
const run = (proofSuite: ProofSuite): Promise<SuiteResult> =>
|
|
328
375
|
withProofRunner(async (runner: ProofRunnerApi) => {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
376
|
+
const reports = await Effect.runPromise(
|
|
377
|
+
Effect.forEach(proofSuite.proofs, (proof) => Effect.promise(() => runner.executeProof(proof))),
|
|
378
|
+
)
|
|
379
|
+
return {
|
|
380
|
+
product: proofSuite.product.manifest.name,
|
|
381
|
+
results: reports,
|
|
382
|
+
ok: reports.every((report) => report.ok),
|
|
332
383
|
}
|
|
333
|
-
return { product: proofSuite.product.manifest.name, results, ok: results.every((r) => r.ok) }
|
|
334
384
|
})
|
|
335
385
|
|
|
336
386
|
// ---------------------------------------------------------------------------
|
|
@@ -348,6 +398,7 @@ export interface DriveResult {
|
|
|
348
398
|
readonly requirement: string
|
|
349
399
|
readonly frames: ReadonlyArray<StepFrame>
|
|
350
400
|
readonly ok: boolean
|
|
401
|
+
// 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
|
|
351
402
|
readonly error?: string
|
|
352
403
|
}
|
|
353
404
|
|