@playfast/reform-proof 1.1.1 → 1.2.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/README.md CHANGED
@@ -13,7 +13,7 @@ Drive a scene's compositions and assert on what they compute — deterministical
13
13
 
14
14
  ---
15
15
 
16
- A reform *scene* is renderer-neutral, so it can be exercised without a host. `@playfast/reform-proof` runs that same scene against a capturing UI: it dispatches typed events, settles the engine, and lets you assert on the **props a composition computed** — pure data, never rendered markup. The scene you prove is the exact scene `@playfast/reform-react` renders, so there is no seam between a passing test and production behavior.
16
+ A reform _scene_ is renderer-neutral, so it can be exercised without a host. `@playfast/reform-proof` runs that same scene against a capturing UI: it dispatches typed events, settles the engine, and lets you assert on the **props a composition computed** — pure data, never rendered markup. The scene you prove is the exact scene `@playfast/reform-react` renders, so there is no seam between a passing test and production behavior.
17
17
 
18
18
  ## Install
19
19
 
@@ -39,27 +39,27 @@ const CounterScene = scene(Counter, { provide: [CounterLive] })
39
39
  class Bumps extends ProductRequirement.make(Counter, 'bumps the count') {}
40
40
 
41
41
  const bumps = Proof.implement(Bumps, CounterScene, function* (app) {
42
- yield* app.actions.bump({}) // dispatch a contract event, settle
43
- const props = yield* app.props // read what the composition computed
42
+ yield* app.actions.bump({}) // dispatch a contract event, settle
43
+ const props = yield* app.props // read what the composition computed
44
44
  yield* expect(props.count).toBe(1)
45
45
  })
46
46
 
47
47
  const product = Product.make(Counter, { requirements: [Bumps] })
48
48
  const suite = Proof.suite(product, { proofs: [bumps] })
49
49
 
50
- const result = await Proof.run(suite) // { product, results, ok }
50
+ const result = await Proof.run(suite) // { product, results, ok }
51
51
  ```
52
52
 
53
53
  ## The workflow
54
54
 
55
- | step | API |
56
- | --- | --- |
57
- | Declare a requirement | `ProductRequirement.make(composition, statement)` |
58
- | Group requirements | `Product.make(composition, { requirements })` |
59
- | Implement a proof | `Proof.implement(requirement, scene, function* (app) { … })` |
60
- | Compose a suite | `Proof.suite(product, { proofs })` |
61
- | Run it | `Proof.run(suite) → Promise<SuiteResult>` |
62
- | Step it (for tooling) | `Proof.driver(suite) → ProofDriver[]` |
55
+ | step | API |
56
+ | --------------------- | ------------------------------------------------------------ |
57
+ | Declare a requirement | `ProductRequirement.make(composition, statement)` |
58
+ | Group requirements | `Product.make(composition, { requirements })` |
59
+ | Implement a proof | `Proof.implement(requirement, scene, function* (app) { … })` |
60
+ | Compose a suite | `Proof.suite(product, { proofs })` |
61
+ | Run it | `Proof.run(suite) → Promise<SuiteResult>` |
62
+ | Step it (for tooling) | `Proof.driver(suite) → ProofDriver[]` |
63
63
 
64
64
  The `statement` is a string-literal type, matched at `Proof.implement` so a requirement's name and its proof can never drift apart.
65
65
 
package/package.json CHANGED
@@ -1,35 +1,37 @@
1
1
  {
2
2
  "name": "@playfast/reform-proof",
3
- "playbook": "./playbook",
4
- "version": "1.1.1",
5
- "type": "module",
3
+ "version": "1.2.1",
6
4
  "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
5
  "keywords": [
8
- "reform",
9
6
  "effect",
10
- "testing",
11
7
  "headless",
12
- "proof"
8
+ "proof",
9
+ "reform",
10
+ "testing"
13
11
  ],
12
+ "bugs": {
13
+ "url": "https://github.com/playfast/reform/issues"
14
+ },
14
15
  "license": "MIT",
15
16
  "repository": {
16
17
  "type": "git",
17
18
  "url": "https://github.com/playfast/reform.git",
18
19
  "directory": "packages/proof"
19
20
  },
20
- "bugs": {
21
- "url": "https://github.com/playfast/reform/issues"
22
- },
21
+ "files": [
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "type": "module",
23
26
  "sideEffects": false,
24
27
  "exports": {
25
28
  "./package.json": "./package.json",
26
29
  ".": "./src/index.ts",
27
30
  "./*": "./src/*.ts"
28
31
  },
29
- "files": [
30
- "src",
31
- "README.md"
32
- ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
33
35
  "scripts": {
34
36
  "clean": "rm -rf dist .tsbuildinfo",
35
37
  "check": "tsc --noEmit",
@@ -41,11 +43,9 @@
41
43
  "lint:fix": "oxlint --fix src"
42
44
  },
43
45
  "peerDependencies": {
46
+ "@playfast/reform": "*",
44
47
  "effect": "*",
45
- "react": "^19.0.0",
46
- "@playfast/reform": "*"
48
+ "react": "^19.0.0"
47
49
  },
48
- "publishConfig": {
49
- "access": "public"
50
- }
50
+ "playbook": "./playbook"
51
51
  }
@@ -1,13 +1,13 @@
1
1
  import { Array as Arr, Effect, Option, Record as Rec } from 'effect'
2
2
  import { AssertionFailed } from './errors'
3
+ import { formatFingerprint } from './fingerprint'
3
4
 
4
5
  interface ComparePair {
5
6
  readonly left: unknown
6
7
  readonly right: unknown
7
8
  }
8
9
 
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)
10
+ const show = (subject: unknown): string => formatFingerprint(subject)
11
11
 
12
12
  const deepEqual = ({ left, right }: ComparePair): boolean =>
13
13
  Object.is(left, right) || show(left) === show(right)
@@ -34,7 +34,7 @@ export const expect = <A>(actual: A) => ({
34
34
  },
35
35
  })
36
36
 
37
- const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
37
+ export const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
38
38
  typeof candidate === 'object' && candidate !== null
39
39
 
40
40
  interface MatchInput {
package/src/engine.ts CHANGED
@@ -1,8 +1,285 @@
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'
1
+ import { Cause, Effect, Option, Record as Rec, Ref } from 'effect'
2
+ import { yieldWrapGet } from 'effect/Utils'
3
+ import {
4
+ type CapturedScene,
5
+ type CompositionClass,
6
+ makeAppRuntime,
7
+ sceneInstrumentation,
8
+ type UiContract,
9
+ } from '@playfast/reform'
10
+ import { type AnyComposition, type AnyScene } from '@playfast/reform/internal'
11
+ import {
12
+ invalidCompositionProps,
13
+ type HostRuntime,
14
+ makeSink,
15
+ messageOf,
16
+ type RenderRuntime,
17
+ type Sink,
18
+ } from './engineSink'
19
+ import { childComposition, readRuntimeSlotChild, slotsOf } from './engineTree'
20
+ import type { RuntimeTreeFacade } from './engineTreeTypes'
21
+ import { makeRuntimeTreeFacade } from './engineTreeFacade'
22
+ import type { ProofCase, ProofResult } from './proofCase'
23
+ import type { PumpStep } from './engineTree'
24
+ import type { DriveResult, StepFrame } from './index'
25
+ import type { ProductComposition } from './product'
26
+ import type { Proof } from './index'
27
+
28
+ export { makeSink } from './engineSink'
29
+ export type { Sink } from './engineSink'
30
+ export { proofLayer } from './engineTree'
31
+ export { makeFacade, makeProofFacade, makeRuntimeHandleFacade } from './engineMounted'
32
+ export type {
33
+ MountedFacade,
34
+ MountedSlotFacade,
35
+ MountedSlotFacadesOf,
36
+ } from './engineSink'
37
+ export { makeRuntimeTreeFacade } from './engineTreeFacade'
38
+ export type { RuntimeTreeFacade } from './engineTreeTypes'
39
+
40
+ const proofRootProps = <
41
+ P,
42
+ C extends UiContract,
43
+ S extends ReadonlyArray<unknown>,
44
+ N extends string,
45
+ Identity,
46
+ >(
47
+ composition: CompositionClass<P, C, S, N, Identity>,
48
+ ): Effect.Effect<P, never, never> =>
49
+ Option.match(composition.parseProps({}), {
50
+ onNone: () => Effect.dieMessage(invalidCompositionProps(composition)),
51
+ onSome: Effect.succeed,
52
+ })
53
+
54
+ const withProofRuntimeTree = <
55
+ A,
56
+ E,
57
+ C extends UiContract,
58
+ S extends ReadonlyArray<unknown>,
59
+ Services,
60
+ P,
61
+ N extends string,
62
+ Identity,
63
+ >(
64
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
65
+ sink: Sink,
66
+ dispatched: Set<string>,
67
+ use: (runtime: HostRuntime, tree: RuntimeTreeFacade<C>) => Effect.Effect<A, E, never>,
68
+ ): Effect.Effect<A, E, never> =>
69
+ Effect.acquireUseRelease(
70
+ Effect.sync(() => makeAppRuntime(scene)),
71
+ (runtime) =>
72
+ Effect.acquireUseRelease(
73
+ Effect.gen(function* () {
74
+ const rootProps = yield* proofRootProps(scene.composition)
75
+ yield* Effect.sync(() => runtime.boot())
76
+ return makeRuntimeTreeFacade(runtime, scene.composition, rootProps, sink, dispatched)
77
+ }),
78
+ (tree) => use(runtime, tree),
79
+ (tree) => Effect.sync(tree.dispose),
80
+ ),
81
+ (runtime) => Effect.sync(runtime.dispose),
82
+ )
83
+
84
+ const missingCoverage = (
85
+ proof: Pick<Proof, 'requirement'>,
86
+ dispatched: Set<string>,
87
+ ): ReadonlyArray<string> =>
88
+ Option.match(proof.requirement.manifest.events, {
89
+ onNone: () => [],
90
+ onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
91
+ })
92
+
93
+ interface ProofPlacement {
94
+ readonly requirement: Proof['requirement']
95
+ readonly placement: 'Root' | 'Slot'
96
+ }
97
+
98
+ const compositionIdentity = (composition: AnyComposition): symbol =>
99
+ composition.capture<symbol>((exact) => exact.identity)
100
+
101
+ const verifyProofPlacement = (
102
+ proof: ProofPlacement,
103
+ scene: AnyScene,
104
+ runtime: RenderRuntime,
105
+ ): Effect.Effect<void, never, never> => {
106
+ const expected = proof.requirement.manifest.composition
107
+ if (
108
+ proof.placement === 'Root' &&
109
+ Object.is(compositionIdentity(scene.composition), compositionIdentity(expected))
110
+ ) {
111
+ return Effect.void
112
+ }
113
+ if (proof.placement === 'Slot') {
114
+ const matches = Rec.values(slotsOf(scene.composition)).some((slotClass) => {
115
+ const child = childComposition(
116
+ readRuntimeSlotChild({
117
+ runtime,
118
+ slotDefinition: slotClass,
119
+ parent: scene.composition,
120
+ }),
121
+ )
122
+ return Object.is(compositionIdentity(child), compositionIdentity(expected))
123
+ })
124
+ if (matches) {
125
+ return Effect.void
126
+ }
127
+ }
128
+ const location = proof.placement === 'Root' ? 'scene root' : 'direct scene slot'
129
+ return Effect.dieMessage(
130
+ `reform-proof: requirement composition ${expected.manifest.name} is not the ${location} by definition identity`,
131
+ )
132
+ }
133
+
134
+ const executeProofWithSink = Effect.fn('executeProofWithSink')(function* <
135
+ Comp extends ProductComposition,
136
+ C extends UiContract,
137
+ S extends ReadonlyArray<unknown>,
138
+ Services,
139
+ P,
140
+ N extends string,
141
+ Identity,
142
+ >(
143
+ proof: ProofCase<Comp, C, S, Services, P, N, Identity>,
144
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
145
+ sink: Sink,
146
+ ): Effect.fn.Return<ProofResult, never, never> {
147
+ const dispatched = new Set<string>()
148
+ const statement = proof.requirement.manifest.statement
149
+ const runGenerator = Effect.fn('runProofGenerator')(function* (
150
+ generator: ReturnType<typeof proof.body>,
151
+ input: unknown,
152
+ settleEffect: Effect.Effect<void, never, never>,
153
+ ): Effect.fn.Return<void, unknown, never> {
154
+ const next = generator.next(input)
155
+ if (next.done === true) {
156
+ return
157
+ }
158
+ const output = yield* yieldWrapGet(next.value)
159
+ yield* settleEffect
160
+ yield* runGenerator(generator, output, settleEffect)
161
+ })
162
+ return yield* withProofRuntimeTree(scene, sink, dispatched, (runtime, tree) =>
163
+ Effect.gen(function* () {
164
+ yield* verifyProofPlacement(proof, scene, runtime)
165
+ yield* tree.settle
166
+ yield* runGenerator(proof.body(tree.facade), undefined, tree.settle)
167
+ const missing = missingCoverage(proof, dispatched)
168
+ const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
169
+ return missing.length > 0
170
+ ? { requirement: statement, ok: false, error: coverageError }
171
+ : { requirement: statement, ok: true, error: undefined }
172
+ }),
173
+ ).pipe(
174
+ Effect.catchAllCause((cause) =>
175
+ Effect.succeed({
176
+ requirement: statement,
177
+ ok: false,
178
+ error: messageOf(Cause.squash(cause)),
179
+ }),
180
+ ),
181
+ )
182
+ })
183
+
184
+ export const executeProofEffect = (proof: Proof): Effect.Effect<ProofResult, never, never> =>
185
+ proof.capture(
186
+ <
187
+ Comp extends ProductComposition,
188
+ C extends UiContract,
189
+ S extends ReadonlyArray<unknown>,
190
+ Services,
191
+ P,
192
+ N extends string,
193
+ Identity,
194
+ >(
195
+ exact: ProofCase<Comp, C, S, Services, P, N, Identity>,
196
+ ): Effect.Effect<ProofResult, never, never> => {
197
+ const sink = makeSink(sceneInstrumentation(exact.scene))
198
+ return executeProofWithSink<Comp, C, S, Services, P, N, Identity>(exact, exact.scene, sink)
199
+ },
200
+ )
201
+
202
+ export const executeProof = (proof: Proof): Promise<ProofResult> =>
203
+ Effect.runPromise(executeProofEffect(proof))
204
+
205
+ const driveProofWithSink = Effect.fn('driveProofWithSink')(function* <
206
+ Comp extends ProductComposition,
207
+ C extends UiContract,
208
+ S extends ReadonlyArray<unknown>,
209
+ Services,
210
+ P,
211
+ N extends string,
212
+ Identity,
213
+ >(
214
+ proof: ProofCase<Comp, C, S, Services, P, N, Identity>,
215
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
216
+ sink: Sink,
217
+ ): Effect.fn.Return<DriveResult, never, never> {
218
+ const frames = yield* Ref.make<ReadonlyArray<StepFrame>>([])
219
+ const statement = proof.requirement.manifest.statement
220
+ // Driving tracks dispatches for the facade without enforcing coverage.
221
+ const dispatched = new Set<string>()
222
+ const record = (index: number): Effect.Effect<void> =>
223
+ Ref.update(frames, (current) => [...current, { index, captures: [...sink.captures] }])
224
+ const pump = Effect.fn('pump')(function* (
225
+ generator: ReturnType<typeof proof.body>,
226
+ step: PumpStep,
227
+ settleEffect: Effect.Effect<void, never, never>,
228
+ ): Effect.fn.Return<void, unknown, never> {
229
+ const next = generator.next(step.input)
230
+ if (next.done === true) {
231
+ return
232
+ }
233
+ const output = yield* yieldWrapGet(next.value)
234
+ yield* settleEffect
235
+ yield* record(step.index)
236
+ yield* pump(generator, { input: output, index: step.index + 1 }, settleEffect)
237
+ })
238
+ return yield* withProofRuntimeTree(scene, sink, dispatched, (runtime, tree) =>
239
+ Effect.gen(function* () {
240
+ yield* verifyProofPlacement(proof, scene, runtime)
241
+ yield* tree.settle
242
+ yield* record(0)
243
+ yield* pump(proof.body(tree.facade), { input: undefined, index: 1 }, tree.settle)
244
+ return {
245
+ requirement: statement,
246
+ frames: yield* Ref.get(frames),
247
+ ok: true,
248
+ error: undefined,
249
+ }
250
+ }),
251
+ ).pipe(
252
+ Effect.catchAllCause((cause) =>
253
+ Ref.get(frames).pipe(
254
+ Effect.map((collected) => ({
255
+ requirement: statement,
256
+ frames: collected,
257
+ ok: false,
258
+ error: messageOf(Cause.squash(cause)),
259
+ })),
260
+ ),
261
+ ),
262
+ )
263
+ })
264
+
265
+ export const driveProofEffect = (proof: Proof): Effect.Effect<DriveResult, never, never> =>
266
+ proof.capture(
267
+ <
268
+ Comp extends ProductComposition,
269
+ C extends UiContract,
270
+ S extends ReadonlyArray<unknown>,
271
+ Services,
272
+ P,
273
+ N extends string,
274
+ Identity,
275
+ >(
276
+ exact: ProofCase<Comp, C, S, Services, P, N, Identity>,
277
+ ): Effect.Effect<DriveResult, never, never> => {
278
+ const sink = makeSink(sceneInstrumentation(exact.scene))
279
+ return driveProofWithSink<Comp, C, S, Services, P, N, Identity>(exact, exact.scene, sink)
280
+ },
281
+ )
282
+
283
+ export const driveProof = (proof: Proof): Promise<DriveResult> =>
284
+ Effect.runPromise(driveProofEffect(proof))
285
+