@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 +12 -12
- package/package.json +18 -18
- package/src/{assertions.ts → assert.ts} +3 -3
- package/src/engine.ts +285 -8
- package/src/engineMounted.ts +271 -0
- package/src/engineMounts.ts +165 -0
- package/src/engineRender.ts +136 -0
- package/src/engineSink.ts +195 -0
- package/src/engineTree.ts +298 -0
- package/src/engineTreeDriver.ts +247 -0
- package/src/engineTreeFacade.ts +183 -0
- package/src/{runtimeTreeTypes.ts → engineTreeTypes.ts} +26 -22
- package/src/errors.ts +19 -0
- package/src/facade.ts +71 -251
- package/src/fingerprint.test.ts +24 -0
- package/src/fingerprint.ts +73 -0
- package/src/index.ts +165 -115
- package/src/nested-feature.test.ts +174 -0
- package/src/product.ts +50 -19
- package/src/proofCase.ts +46 -0
- package/src/runner.ts +5 -1
- package/src/structure-events.test.ts +9 -3
- package/src/structure-locators.test.ts +19 -9
- package/src/test-clock.test.ts +135 -0
- package/src/typed-seams.test.ts +6 -3
- package/src/driverTypes.ts +0 -19
- package/src/execution.ts +0 -100
- package/src/runtimeFacade.ts +0 -269
- package/src/runtimeTreeDispose.ts +0 -21
- package/src/runtimeTreeDriver.ts +0 -285
- package/src/runtimeTreeFacade.ts +0 -134
- package/src/runtimeTreeSettle.ts +0 -34
- package/src/sink.ts +0 -73
- package/src/treeBindings.ts +0 -121
package/src/facade.ts
CHANGED
|
@@ -1,258 +1,78 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
isStructure,
|
|
7
|
-
type RenderEnv,
|
|
8
|
-
type SlotChild,
|
|
9
|
-
type SlotClass,
|
|
10
|
-
type Trigger,
|
|
11
|
-
type UiCapture,
|
|
12
|
-
type UiContract,
|
|
1
|
+
import type { Duration, Effect } from 'effect'
|
|
2
|
+
import type {
|
|
3
|
+
SlotContract as ReformSlotContract,
|
|
4
|
+
Trigger,
|
|
5
|
+
UiContract,
|
|
13
6
|
} from '@playfast/reform'
|
|
14
|
-
import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
|
|
15
|
-
import { matchProps } from './assertions'
|
|
16
|
-
import type { Action, Facade, SlotFacade } from './index'
|
|
17
|
-
import {
|
|
18
|
-
keyed,
|
|
19
|
-
SETTLE_MAX_RENDERS,
|
|
20
|
-
SETTLE_STEP,
|
|
21
|
-
settleDrain,
|
|
22
|
-
type RuntimeServices,
|
|
23
|
-
type Sink,
|
|
24
|
-
uiNameOf,
|
|
25
|
-
} from './sink'
|
|
26
|
-
import {
|
|
27
|
-
collectBindings,
|
|
28
|
-
driveStructure,
|
|
29
|
-
type Mounted,
|
|
30
|
-
type NodeRef,
|
|
31
|
-
type SettleProgress,
|
|
32
|
-
slotsOf,
|
|
33
|
-
} from './treeBindings'
|
|
34
7
|
|
|
35
|
-
type
|
|
36
|
-
|
|
37
|
-
|
|
8
|
+
export type EventsOf<C extends UiContract> = C extends {
|
|
9
|
+
readonly events: infer E extends NonNullable<UiContract['events']>
|
|
10
|
+
}
|
|
11
|
+
? E
|
|
12
|
+
: Readonly<Record<PropertyKey, never>>
|
|
13
|
+
export type EventNamesOf<C extends UiContract> = C extends {
|
|
14
|
+
readonly events: NonNullable<UiContract['events']>
|
|
15
|
+
}
|
|
16
|
+
? keyof EventsOf<C>
|
|
17
|
+
: never
|
|
18
|
+
export type SlotsOf<C extends UiContract> = C extends {
|
|
19
|
+
readonly slots: infer S extends NonNullable<UiContract['slots']>
|
|
20
|
+
}
|
|
21
|
+
? S
|
|
22
|
+
: Readonly<Record<PropertyKey, never>>
|
|
23
|
+
export type PayloadOf<T> = T extends Trigger<infer P> ? P : never
|
|
24
|
+
export type ContractOf<Comp> = Comp extends {
|
|
25
|
+
readonly Contract: infer C extends UiContract
|
|
26
|
+
}
|
|
27
|
+
? C
|
|
28
|
+
: never
|
|
29
|
+
export type ContractOfSlot<S> = ReformSlotContract<S>
|
|
38
30
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(mounted.comp))
|
|
47
|
-
const frame = yield* Composition.render(service, env)
|
|
48
|
-
endRenderSpan()
|
|
49
|
-
if (!isStructure(frame)) {
|
|
50
|
-
return yield* Effect.dieMessage(
|
|
51
|
-
`reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
return driveStructure(mounted.comp, frame, mounted.key, sink, bindings)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
const renderLevel: (
|
|
58
|
-
frontier: ReadonlyArray<Mounted>,
|
|
59
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
60
|
-
sink: Sink,
|
|
61
|
-
) => Effect.Effect<void, never, CompositionService> = Effect.fn('renderLevel')(function* (
|
|
62
|
-
frontier: ReadonlyArray<Mounted>,
|
|
63
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
64
|
-
sink: Sink,
|
|
65
|
-
): Effect.fn.Return<void, never, CompositionService> {
|
|
66
|
-
if (frontier.length === 0) {
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
const levels = yield* Effect.forEach(frontier, (mounted) =>
|
|
70
|
-
renderMounted(mounted, bindings, sink),
|
|
71
|
-
)
|
|
72
|
-
yield* renderLevel(levels.flat(), bindings, sink)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
const renderTree = Effect.fn('renderTree')(function* (
|
|
76
|
-
root: CompositionClass<unknown>,
|
|
77
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
78
|
-
sink: Sink,
|
|
79
|
-
): Effect.fn.Return<void, never, CompositionService> {
|
|
80
|
-
sink.reset()
|
|
81
|
-
yield* renderLevel([{ comp: root, props: {}, key: Option.none() }], bindings, sink)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
export const capturesFor = (sink: Sink, name: string): ReadonlyArray<UiCapture> =>
|
|
85
|
-
sink.captures.filter((capture) => capture.name === name)
|
|
86
|
-
|
|
87
|
-
export const fingerprint = (sink: Sink): string =>
|
|
88
|
-
encodeUnknown(sink.captures.map((capture) => [capture.name, capture.props]))
|
|
89
|
-
|
|
90
|
-
const settleTree = Effect.fn('settleTree')(function* (
|
|
91
|
-
root: CompositionClass<unknown>,
|
|
92
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
93
|
-
sink: Sink,
|
|
94
|
-
): Effect.fn.Return<void, never, CompositionService> {
|
|
95
|
-
yield* settleDrain
|
|
96
|
-
yield* renderTree(root, bindings, sink)
|
|
97
|
-
yield* Effect.iterate(
|
|
98
|
-
{
|
|
99
|
-
previous: fingerprint(sink),
|
|
100
|
-
remaining: SETTLE_MAX_RENDERS,
|
|
101
|
-
stable: false,
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
|
|
105
|
-
body: (progress) =>
|
|
106
|
-
Effect.gen(function* () {
|
|
107
|
-
yield* settleDrain
|
|
108
|
-
yield* Effect.sleep(SETTLE_STEP)
|
|
109
|
-
yield* renderTree(root, bindings, sink)
|
|
110
|
-
const current = fingerprint(sink)
|
|
111
|
-
return {
|
|
112
|
-
previous: current,
|
|
113
|
-
remaining: progress.remaining - 1,
|
|
114
|
-
stable: current === progress.previous,
|
|
115
|
-
}
|
|
116
|
-
}),
|
|
117
|
-
},
|
|
118
|
-
)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
export const makeFacadeEffect: (
|
|
122
|
-
root: CompositionClass<unknown>,
|
|
123
|
-
sink: Sink,
|
|
124
|
-
dispatched: Set<string>,
|
|
125
|
-
) => Effect.Effect<
|
|
126
|
-
{
|
|
127
|
-
readonly facade: AnyFacade
|
|
128
|
-
readonly settle: Effect.Effect<void, never, CompositionService>
|
|
129
|
-
},
|
|
130
|
-
never,
|
|
131
|
-
SlotChild
|
|
132
|
-
> = Effect.fn('makeFacadeEffect')(function* (
|
|
133
|
-
root: CompositionClass<unknown>,
|
|
134
|
-
sink: Sink,
|
|
135
|
-
dispatched: Set<string>,
|
|
136
|
-
): Effect.fn.Return<
|
|
137
|
-
{
|
|
138
|
-
readonly facade: AnyFacade
|
|
139
|
-
readonly settle: Effect.Effect<void, never, CompositionService>
|
|
140
|
-
},
|
|
141
|
-
never,
|
|
142
|
-
SlotChild
|
|
143
|
-
> {
|
|
144
|
-
const bindings = yield* collectBindings(root)
|
|
145
|
-
const settle = settleTree(root, bindings, sink)
|
|
146
|
-
const rerender = renderTree(root, bindings, sink)
|
|
147
|
-
|
|
148
|
-
const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>> =>
|
|
149
|
-
Option.match(Option.fromNullable(capturesFor(sink, ref.name)[ref.index]?.events[event]), {
|
|
150
|
-
onNone: () =>
|
|
151
|
-
Effect.dieMessage(
|
|
152
|
-
new UnknownAction({
|
|
153
|
-
composition: ref.name,
|
|
154
|
-
action: event,
|
|
155
|
-
rendered: capturesFor(sink, ref.name).length,
|
|
156
|
-
}).message,
|
|
157
|
-
),
|
|
158
|
-
onSome: Effect.succeed,
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
|
|
162
|
-
Effect.map(rerender, () => capturesFor(sink, ref.name)[ref.index]?.props)
|
|
163
|
-
|
|
164
|
-
const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
|
|
165
|
-
props: propsFor(ref),
|
|
166
|
-
expectProps: (partial) =>
|
|
167
|
-
propsFor(ref).pipe(
|
|
168
|
-
Effect.flatMap((props) => {
|
|
169
|
-
const mismatch = matchProps({ actual: props, expected: partial })
|
|
170
|
-
return mismatch === undefined
|
|
171
|
-
? Effect.void
|
|
172
|
-
: Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
|
|
173
|
-
}),
|
|
174
|
-
),
|
|
175
|
-
actions: keyed(
|
|
176
|
-
(event): Action =>
|
|
177
|
-
(payload) =>
|
|
178
|
-
rerender.pipe(
|
|
179
|
-
Effect.flatMap(() => triggerOf(ref, event)),
|
|
180
|
-
Effect.flatMap((trigger) =>
|
|
181
|
-
Effect.sync(() => {
|
|
182
|
-
dispatched.add(event)
|
|
183
|
-
trigger(payload)
|
|
184
|
-
}),
|
|
185
|
-
),
|
|
186
|
-
Effect.flatMap(() => settle),
|
|
187
|
-
Effect.flatMap(() => propsFor(ref)),
|
|
188
|
-
),
|
|
189
|
-
),
|
|
190
|
-
slots: keyed((slotName) => slotFacade(comp, slotName)),
|
|
191
|
-
frame: settle,
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
|
|
195
|
-
const slotClass = slotsOf(parent)[slotName]
|
|
196
|
-
const child = slotClass && bindings.get(slotClass)
|
|
197
|
-
if (child === undefined) {
|
|
198
|
-
return Effect.runSync(
|
|
199
|
-
Effect.dieMessage(new UnknownSlot({ parent: uiNameOf(parent), slot: slotName }).message),
|
|
200
|
-
)
|
|
31
|
+
export type ActionsOf<C extends UiContract, R = never> = C extends {
|
|
32
|
+
readonly events: NonNullable<UiContract['events']>
|
|
33
|
+
}
|
|
34
|
+
? {
|
|
35
|
+
readonly [K in keyof EventsOf<C>]: (
|
|
36
|
+
payload: PayloadOf<EventsOf<C>[K]>,
|
|
37
|
+
) => Effect.Effect<C['props'], never, R>
|
|
201
38
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return {
|
|
209
|
-
first: atIndex(0),
|
|
210
|
-
at: atIndex,
|
|
211
|
-
all: Effect.map(rerender, () =>
|
|
212
|
-
capturesFor(sink, childName).map((_capture, index) =>
|
|
213
|
-
nodeFacade({ name: childName, index }, child),
|
|
214
|
-
),
|
|
215
|
-
),
|
|
216
|
-
byKey: (key) =>
|
|
217
|
-
Effect.flatMap(rerender, () =>
|
|
218
|
-
Option.match(indexOfKey(key), {
|
|
219
|
-
onNone: () =>
|
|
220
|
-
Effect.dieMessage(
|
|
221
|
-
new UnknownSlot({
|
|
222
|
-
parent: uiNameOf(parent),
|
|
223
|
-
slot: `${slotName}[key=${key}]`,
|
|
224
|
-
}).message,
|
|
225
|
-
),
|
|
226
|
-
onSome: (index) => Effect.succeed(nodeFacade({ name: childName, index }, child)),
|
|
227
|
-
}),
|
|
228
|
-
),
|
|
229
|
-
where: (predicate) =>
|
|
230
|
-
Effect.map(rerender, () =>
|
|
231
|
-
capturesFor(sink, childName).flatMap((capture, index) =>
|
|
232
|
-
predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
|
|
233
|
-
),
|
|
234
|
-
),
|
|
235
|
-
count: Effect.map(rerender, () => capturesFor(sink, childName).length),
|
|
236
|
-
props: first.props,
|
|
237
|
-
expectProps: first.expectProps,
|
|
238
|
-
actions: first.actions,
|
|
239
|
-
slots: first.slots,
|
|
240
|
-
frame: first.frame,
|
|
39
|
+
: Readonly<Record<PropertyKey, never>>
|
|
40
|
+
export type SlotFacadesOf<C extends UiContract, R = never> = C extends {
|
|
41
|
+
readonly slots: NonNullable<UiContract['slots']>
|
|
42
|
+
}
|
|
43
|
+
? {
|
|
44
|
+
readonly [K in keyof SlotsOf<C>]: SlotFacade<ContractOfSlot<SlotsOf<C>[K]>, R>
|
|
241
45
|
}
|
|
242
|
-
|
|
46
|
+
: Readonly<Record<PropertyKey, never>>
|
|
47
|
+
|
|
48
|
+
export type Action<R = never> = (payload: unknown) => Effect.Effect<unknown, never, R>
|
|
243
49
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
50
|
+
export interface Facade<C extends UiContract, R = never> {
|
|
51
|
+
readonly props: Effect.Effect<C['props'], never, R>
|
|
52
|
+
readonly expectProps: (partial: Partial<C['props']>) => Effect.Effect<void, never, R>
|
|
53
|
+
readonly actions: ActionsOf<C, R>
|
|
54
|
+
readonly slots: SlotFacadesOf<C, R>
|
|
55
|
+
readonly frame: Effect.Effect<void, never, R>
|
|
56
|
+
/**
|
|
57
|
+
* Elapse `duration` of the scene's product time, then settle. Every timer the
|
|
58
|
+
* scene's procedures hold — `Effect.sleep`, `Schedule`, `Clock` reads — moves by
|
|
59
|
+
* exactly this much and no real time passes, so a 1500ms poll interval is observed
|
|
60
|
+
* in microseconds and never races the runner.
|
|
61
|
+
*
|
|
62
|
+
* Requires the scene's layers to be wrapped in `Proof.withTestClock(...)`. Against
|
|
63
|
+
* the real clock there is no virtual time to move, so the proof dies with
|
|
64
|
+
* `NoTestClock` instead of silently doing nothing.
|
|
65
|
+
*/
|
|
66
|
+
readonly advance: (duration: Duration.DurationInput) => Effect.Effect<void, never, R>
|
|
67
|
+
}
|
|
249
68
|
|
|
250
|
-
export
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
69
|
+
export interface SlotFacade<C extends UiContract, R = never> extends Facade<C, R> {
|
|
70
|
+
readonly first: Effect.Effect<Facade<C, R>, never, R>
|
|
71
|
+
readonly at: (index: number) => Effect.Effect<Facade<C, R>, never, R>
|
|
72
|
+
readonly all: Effect.Effect<ReadonlyArray<Facade<C, R>>, never, R>
|
|
73
|
+
readonly byKey: (key: string) => Effect.Effect<Facade<C, R>, never, R>
|
|
74
|
+
readonly where: (
|
|
75
|
+
predicate: (props: C['props']) => boolean,
|
|
76
|
+
) => Effect.Effect<ReadonlyArray<Facade<C, R>>, never, R>
|
|
77
|
+
readonly count: Effect.Effect<number, never, R>
|
|
78
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { formatFingerprint } from './fingerprint'
|
|
3
|
+
|
|
4
|
+
describe('proof render fingerprints', () => {
|
|
5
|
+
it('distinguishes bigint props from strings without throwing', () => {
|
|
6
|
+
expect(formatFingerprint({ sequence: 1n })).not.toBe(formatFingerprint({ sequence: '1' }))
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('stabilizes equivalent cyclic render props', () => {
|
|
10
|
+
const left: Array<unknown> = []
|
|
11
|
+
left.push(left)
|
|
12
|
+
const right: Array<unknown> = []
|
|
13
|
+
right.push(right)
|
|
14
|
+
|
|
15
|
+
expect(formatFingerprint(left)).toBe(formatFingerprint(right))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('tracks Map and Set contents', () => {
|
|
19
|
+
expect(formatFingerprint(new Map([['feature', 'home']]))).not.toBe(
|
|
20
|
+
formatFingerprint(new Map([['feature', 'tasks']])),
|
|
21
|
+
)
|
|
22
|
+
expect(formatFingerprint(new Set(['home']))).not.toBe(formatFingerprint(new Set(['tasks'])))
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as Option from 'effect/Option'
|
|
2
|
+
|
|
3
|
+
const readPropertyOption = Option.liftThrowable((target: object, key: PropertyKey): unknown =>
|
|
4
|
+
Reflect.get(target, key),
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
const readProperty = (target: object, key: PropertyKey): unknown =>
|
|
8
|
+
Option.getOrElse(readPropertyOption(target, key), () => '[Property read threw]')
|
|
9
|
+
|
|
10
|
+
const propertyKeyFingerprint = (key: PropertyKey): string =>
|
|
11
|
+
typeof key === 'symbol'
|
|
12
|
+
? `symbol:${String(key.description)}`
|
|
13
|
+
: typeof key === 'number'
|
|
14
|
+
? `number-key:${String(key)}`
|
|
15
|
+
: `key:${key.length}:${key}`
|
|
16
|
+
|
|
17
|
+
const valueFingerprint = (candidate: unknown, seen: WeakSet<object>): string => {
|
|
18
|
+
if (typeof candidate === 'bigint') {
|
|
19
|
+
return `bigint:${String(candidate)}`
|
|
20
|
+
}
|
|
21
|
+
if (typeof candidate === 'boolean') {
|
|
22
|
+
return candidate ? 'boolean:true' : 'boolean:false'
|
|
23
|
+
}
|
|
24
|
+
if (typeof candidate === 'function') {
|
|
25
|
+
return `function:${String(candidate)}`
|
|
26
|
+
}
|
|
27
|
+
if (typeof candidate === 'number') {
|
|
28
|
+
return `number:${String(candidate)}`
|
|
29
|
+
}
|
|
30
|
+
if (typeof candidate === 'string') {
|
|
31
|
+
return `string:${candidate.length}:${candidate}`
|
|
32
|
+
}
|
|
33
|
+
if (typeof candidate === 'symbol') {
|
|
34
|
+
return `symbol:${String(candidate.description)}`
|
|
35
|
+
}
|
|
36
|
+
if (typeof candidate === 'undefined') {
|
|
37
|
+
return 'undefined'
|
|
38
|
+
}
|
|
39
|
+
if (candidate === null) {
|
|
40
|
+
return 'null'
|
|
41
|
+
}
|
|
42
|
+
if (seen.has(candidate)) {
|
|
43
|
+
return '[Circular]'
|
|
44
|
+
}
|
|
45
|
+
seen.add(candidate)
|
|
46
|
+
if (Array.isArray(candidate)) {
|
|
47
|
+
return `[${candidate.map((arrayEntry) => valueFingerprint(arrayEntry, seen)).join(',')}]`
|
|
48
|
+
}
|
|
49
|
+
if (candidate instanceof Date) {
|
|
50
|
+
return `date:${String(candidate.getTime())}`
|
|
51
|
+
}
|
|
52
|
+
if (candidate instanceof Map) {
|
|
53
|
+
return `map:[${Array.from(
|
|
54
|
+
candidate,
|
|
55
|
+
([mapKey, mapEntry]) =>
|
|
56
|
+
`${valueFingerprint(mapKey, seen)}=>${valueFingerprint(mapEntry, seen)}`,
|
|
57
|
+
).join(',')}]`
|
|
58
|
+
}
|
|
59
|
+
if (candidate instanceof Set) {
|
|
60
|
+
return `set:[${Array.from(candidate, (setEntry) => valueFingerprint(setEntry, seen)).join(
|
|
61
|
+
',',
|
|
62
|
+
)}]`
|
|
63
|
+
}
|
|
64
|
+
return `{${Reflect.ownKeys(candidate)
|
|
65
|
+
.map(
|
|
66
|
+
(key) =>
|
|
67
|
+
`${propertyKeyFingerprint(key)}:${valueFingerprint(readProperty(candidate, key), seen)}`,
|
|
68
|
+
)
|
|
69
|
+
.join(',')}}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const formatFingerprint = (fingerprintInput: unknown): string =>
|
|
73
|
+
valueFingerprint(fingerprintInput, new WeakSet())
|