@typeonce/effect-machine 0.6.0 → 0.6.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 +5 -5
- package/src/Machine.ts +6873 -0
- package/src/index.ts +1 -0
- package/src/internal/machine/activities.ts +108 -0
- package/src/internal/machine/atom.ts +636 -0
- package/src/internal/machine/cluster.ts +394 -0
- package/src/internal/machine/command.ts +58 -0
- package/src/internal/machine/commandRuntime.ts +43 -0
- package/src/internal/machine/configuration.ts +1331 -0
- package/src/internal/machine/errors.ts +87 -0
- package/src/internal/machine/executionPlan.ts +996 -0
- package/src/internal/machine/invocation.ts +119 -0
- package/src/internal/machine/machine.ts +1747 -0
- package/src/internal/machine/planner.ts +1933 -0
- package/src/internal/machine/process.ts +906 -0
- package/src/internal/machine/protocol.ts +322 -0
- package/src/internal/machine/readiness.ts +10 -0
- package/src/internal/machine/runtime.ts +2512 -0
- package/src/internal/machine/serialization.ts +498 -0
- package/src/internal/machine/stateDefinition.ts +270 -0
- package/src/internal/machine/symbols.ts +2 -0
- package/src/internal/machine/topology.ts +479 -0
- package/src/internal/testing/machine/arbitrary.ts +102 -0
- package/src/internal/testing/machine/exploration.ts +331 -0
- package/src/internal/testing/machine/finiteModel.ts +1498 -0
- package/src/internal/testing/machine/invariant.ts +372 -0
- package/src/internal/testing/machine/probe.ts +79 -0
- package/src/internal/testing/machine/referenceModel.ts +1505 -0
- package/src/internal/testing/machine/runtime.ts +1710 -0
- package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
- package/src/internal/testing/machine/trace.ts +150 -0
- package/src/internal/testing/machine/verification.ts +1890 -0
- package/src/testing/MachineTest.ts +2067 -0
- package/src/testing/index.ts +7 -0
- package/src/unstable/cluster/ClusterMachine.ts +390 -0
- package/src/unstable/cluster/index.ts +1 -0
- package/src/unstable/reactivity/AtomMachine.ts +649 -0
- package/src/unstable/reactivity/index.ts +1 -0
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect-native command-model testing for live machine references.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.4.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Cause from "effect/Cause"
|
|
8
|
+
import * as Data from "effect/Data"
|
|
9
|
+
import * as Deferred from "effect/Deferred"
|
|
10
|
+
import * as Duration from "effect/Duration"
|
|
11
|
+
import * as Effect from "effect/Effect"
|
|
12
|
+
import * as Inspectable from "effect/Inspectable"
|
|
13
|
+
import * as Queue from "effect/Queue"
|
|
14
|
+
import * as Stream from "effect/Stream"
|
|
15
|
+
import { FastCheck, TestClock } from "effect/testing"
|
|
16
|
+
import * as Machine from "../../../Machine.js"
|
|
17
|
+
import type { CausalRuntimeEvidence, Probe, ProbeStep, RuntimeInvariant } from "../../../testing/MachineTest.js"
|
|
18
|
+
import { type SchemaArbitraryReport, toArbitraryWithReport } from "./arbitrary.js"
|
|
19
|
+
import { assertRuntimeInvariants, type RuntimeInvariantError } from "./runtimeInvariant.js"
|
|
20
|
+
|
|
21
|
+
type AnyMachine = Machine.Machine.Any
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A command applied to a running machine during model-based testing.
|
|
25
|
+
*
|
|
26
|
+
* @category models
|
|
27
|
+
* @since 0.4.0
|
|
28
|
+
*/
|
|
29
|
+
export type RuntimeCommand<Event> =
|
|
30
|
+
| {
|
|
31
|
+
readonly _tag: "Send"
|
|
32
|
+
readonly event: Event
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
readonly _tag: "Advance"
|
|
36
|
+
readonly duration: Duration.Input
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
readonly _tag: "Stop"
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
readonly _tag: "Checkpoint"
|
|
43
|
+
readonly label: string | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Constructs a command that sends one public event.
|
|
48
|
+
*
|
|
49
|
+
* @category constructors
|
|
50
|
+
* @since 0.4.0
|
|
51
|
+
*/
|
|
52
|
+
export const sendCommand = <Event>(event: Event): RuntimeCommand<Event> => ({
|
|
53
|
+
_tag: "Send",
|
|
54
|
+
event
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Constructs a command that advances Effect's `TestClock`.
|
|
59
|
+
*
|
|
60
|
+
* @category constructors
|
|
61
|
+
* @since 0.4.0
|
|
62
|
+
*/
|
|
63
|
+
export const advanceCommand = <Event = never>(duration: Duration.Input): RuntimeCommand<Event> => ({
|
|
64
|
+
_tag: "Advance",
|
|
65
|
+
duration
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Constructs an idempotent command that stops the machine.
|
|
70
|
+
*
|
|
71
|
+
* @category constructors
|
|
72
|
+
* @since 0.4.0
|
|
73
|
+
*/
|
|
74
|
+
export const stopCommand = <Event = never>(): RuntimeCommand<Event> => ({ _tag: "Stop" })
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Constructs a no-op command used to synchronize with work enqueued by earlier
|
|
78
|
+
* commands. Its behavior is selected by the reference-model step.
|
|
79
|
+
*
|
|
80
|
+
* @category constructors
|
|
81
|
+
* @since 0.4.0
|
|
82
|
+
*/
|
|
83
|
+
export const checkpointCommand = <Event = never>(label?: string): RuntimeCommand<Event> => ({
|
|
84
|
+
_tag: "Checkpoint",
|
|
85
|
+
label
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The result of executing one runtime command.
|
|
90
|
+
*
|
|
91
|
+
* @category models
|
|
92
|
+
* @since 0.4.0
|
|
93
|
+
*/
|
|
94
|
+
export type RuntimeCommandResult =
|
|
95
|
+
| { readonly _tag: "SendAccepted" }
|
|
96
|
+
| { readonly _tag: "SendRejected"; readonly error: Machine.StoppedError }
|
|
97
|
+
| { readonly _tag: "ClockAdvanced" }
|
|
98
|
+
| { readonly _tag: "Stopped" }
|
|
99
|
+
| { readonly _tag: "Checkpoint" }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Defines how a model step synchronizes with public machine observations.
|
|
103
|
+
*
|
|
104
|
+
* `None` is appropriate when a send is intentionally only enqueued. `Next`
|
|
105
|
+
* consumes the next snapshot published after the previously consumed one.
|
|
106
|
+
* `Until` also consumes intermediate snapshots and is useful for a checkpoint
|
|
107
|
+
* after several queued sends. `Current` is only a sample; it should be used
|
|
108
|
+
* when the model already knows there is no outstanding asynchronous work.
|
|
109
|
+
*
|
|
110
|
+
* @category models
|
|
111
|
+
* @since 0.4.0
|
|
112
|
+
*/
|
|
113
|
+
export type RuntimeSynchronization<State, Error, Output> =
|
|
114
|
+
| { readonly _tag: "None" }
|
|
115
|
+
| { readonly _tag: "Current" }
|
|
116
|
+
| { readonly _tag: "Next" }
|
|
117
|
+
| {
|
|
118
|
+
readonly _tag: "Until"
|
|
119
|
+
readonly predicate: (snapshot: Machine.RuntimeSnapshot<State, Error, Output>) => boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Constructors for runtime synchronization policies.
|
|
124
|
+
*
|
|
125
|
+
* @category constructors
|
|
126
|
+
* @since 0.4.0
|
|
127
|
+
*/
|
|
128
|
+
export const RuntimeSynchronization = {
|
|
129
|
+
none: { _tag: "None" } as const,
|
|
130
|
+
current: { _tag: "Current" } as const,
|
|
131
|
+
next: { _tag: "Next" } as const,
|
|
132
|
+
until: <State, Error = never, Output = never>(
|
|
133
|
+
predicate: (snapshot: Machine.RuntimeSnapshot<State, Error, Output>) => boolean
|
|
134
|
+
): RuntimeSynchronization<State, Error, Output> => ({ _tag: "Until", predicate })
|
|
135
|
+
} as const
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The pure/reference-model result for one runtime command.
|
|
139
|
+
*
|
|
140
|
+
* @category models
|
|
141
|
+
* @since 0.4.0
|
|
142
|
+
*/
|
|
143
|
+
export interface RuntimeModelStep<Model, Expected, State, Error, Output> {
|
|
144
|
+
readonly model: Model
|
|
145
|
+
readonly expected: Expected
|
|
146
|
+
readonly synchronize: RuntimeSynchronization<State, Error, Output>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Explicit name for the model step used by the enqueue-oriented command
|
|
151
|
+
* runner. The `RuntimeModelStep` name remains as a compatibility alias.
|
|
152
|
+
*
|
|
153
|
+
* @category models
|
|
154
|
+
* @since 0.4.0
|
|
155
|
+
*/
|
|
156
|
+
export type EnqueuedRuntimeModelStep<Model, Expected, State, Error, Output> = RuntimeModelStep<
|
|
157
|
+
Model,
|
|
158
|
+
Expected,
|
|
159
|
+
State,
|
|
160
|
+
Error,
|
|
161
|
+
Output
|
|
162
|
+
>
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Additional asynchronous observation requested after a causal command has
|
|
166
|
+
* completed. `None` never guesses that later invoke, timer, or child work is
|
|
167
|
+
* finished. `Until` observes the current runtime snapshot and subsequent
|
|
168
|
+
* publications until its predicate matches.
|
|
169
|
+
*
|
|
170
|
+
* @category models
|
|
171
|
+
* @since 0.4.0
|
|
172
|
+
*/
|
|
173
|
+
export type RuntimeAwait<State, Error, Output> =
|
|
174
|
+
| { readonly _tag: "None" }
|
|
175
|
+
| {
|
|
176
|
+
readonly _tag: "Until"
|
|
177
|
+
readonly predicate: (snapshot: Machine.RuntimeSnapshot<State, Error, Output>) => boolean
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The pure/reference-model result for one causally executed command.
|
|
182
|
+
*
|
|
183
|
+
* `await` is only for asynchronous behavior after the command boundary. A
|
|
184
|
+
* submitted `Send` always completes its exact managed macrostep first.
|
|
185
|
+
*
|
|
186
|
+
* @category models
|
|
187
|
+
* @since 0.4.0
|
|
188
|
+
*/
|
|
189
|
+
export interface CausalRuntimeModelStep<Model, Expected, State, Error, Output> {
|
|
190
|
+
readonly model: Model
|
|
191
|
+
readonly expected: Expected
|
|
192
|
+
readonly await?: RuntimeAwait<State, Error, Output>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Exact execution result for one causal runtime command.
|
|
197
|
+
*
|
|
198
|
+
* @category models
|
|
199
|
+
* @since 0.4.0
|
|
200
|
+
*/
|
|
201
|
+
export type CausalRuntimeCommandResult<M extends AnyMachine> =
|
|
202
|
+
| { readonly _tag: "SendProcessed"; readonly step: ProbeStep<M> }
|
|
203
|
+
| { readonly _tag: "SendRejected"; readonly error: Machine.StoppedError }
|
|
204
|
+
| { readonly _tag: "ClockAdvanced" }
|
|
205
|
+
| { readonly _tag: "Stopped" }
|
|
206
|
+
| { readonly _tag: "Checkpoint" }
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Actual causal evidence made available to inspection and assertions.
|
|
210
|
+
*
|
|
211
|
+
* @category models
|
|
212
|
+
* @since 0.4.0
|
|
213
|
+
*/
|
|
214
|
+
export interface CausalRuntimeCommandActual<
|
|
215
|
+
M extends AnyMachine,
|
|
216
|
+
Error,
|
|
217
|
+
Output,
|
|
218
|
+
Observed
|
|
219
|
+
> {
|
|
220
|
+
readonly result: CausalRuntimeCommandResult<M>
|
|
221
|
+
readonly snapshot: Machine.RuntimeSnapshot<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
222
|
+
/** Snapshots tested by an explicit `RuntimeAwait.until`, including its current snapshot. */
|
|
223
|
+
readonly awaited: ReadonlyArray<
|
|
224
|
+
Machine.RuntimeSnapshot<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
225
|
+
>
|
|
226
|
+
readonly inspected: Observed | undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Context supplied to a custom causal runtime inspection effect.
|
|
231
|
+
*
|
|
232
|
+
* @category models
|
|
233
|
+
* @since 0.4.0
|
|
234
|
+
*/
|
|
235
|
+
export interface CausalRuntimeInspectionContext<M extends AnyMachine, Error, Output> {
|
|
236
|
+
readonly index: number
|
|
237
|
+
readonly command: RuntimeCommand<Machine.Machine.InputEvent<M>>
|
|
238
|
+
readonly result: CausalRuntimeCommandResult<M>
|
|
239
|
+
readonly probe: Probe<M, Error, Output>
|
|
240
|
+
readonly ref: Probe<M, Error, Output>["ref"]
|
|
241
|
+
readonly snapshot: Machine.RuntimeSnapshot<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
242
|
+
readonly awaited: ReadonlyArray<
|
|
243
|
+
Machine.RuntimeSnapshot<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
244
|
+
>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Context supplied to a causal reference-model assertion.
|
|
249
|
+
*
|
|
250
|
+
* @category models
|
|
251
|
+
* @since 0.4.0
|
|
252
|
+
*/
|
|
253
|
+
export interface CausalRuntimeAssertionContext<
|
|
254
|
+
M extends AnyMachine,
|
|
255
|
+
Model,
|
|
256
|
+
Expected,
|
|
257
|
+
Error,
|
|
258
|
+
Output,
|
|
259
|
+
Observed
|
|
260
|
+
> extends CausalRuntimeInspectionContext<M, Error, Output> {
|
|
261
|
+
readonly model: Model
|
|
262
|
+
readonly expected: Expected
|
|
263
|
+
readonly actual: CausalRuntimeCommandActual<M, Error, Output, Observed>
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Configuration for causally checking runtime commands against a reference
|
|
268
|
+
* model.
|
|
269
|
+
*
|
|
270
|
+
* @category models
|
|
271
|
+
* @since 0.4.0
|
|
272
|
+
*/
|
|
273
|
+
export interface CausalRuntimeModelOptions<
|
|
274
|
+
M extends AnyMachine,
|
|
275
|
+
Model,
|
|
276
|
+
Expected,
|
|
277
|
+
Error,
|
|
278
|
+
Output,
|
|
279
|
+
Observed = never,
|
|
280
|
+
ModelError = never,
|
|
281
|
+
ModelServices = never,
|
|
282
|
+
InspectionError = never,
|
|
283
|
+
InspectionServices = never,
|
|
284
|
+
AssertionError = never,
|
|
285
|
+
AssertionServices = never
|
|
286
|
+
> {
|
|
287
|
+
readonly initialModel: Model
|
|
288
|
+
/** Live-clock bound for an explicit `RuntimeAwait.until`. Defaults to one second. */
|
|
289
|
+
readonly observationTimeout?: Duration.Input
|
|
290
|
+
readonly transition: (
|
|
291
|
+
model: Model,
|
|
292
|
+
command: RuntimeCommand<Machine.Machine.InputEvent<M>>,
|
|
293
|
+
index: number
|
|
294
|
+
) => Effect.Effect<
|
|
295
|
+
CausalRuntimeModelStep<
|
|
296
|
+
Model,
|
|
297
|
+
Expected,
|
|
298
|
+
Machine.Machine.Snapshot<Machine.Machine.States<M>>,
|
|
299
|
+
Error,
|
|
300
|
+
Output
|
|
301
|
+
>,
|
|
302
|
+
ModelError,
|
|
303
|
+
ModelServices
|
|
304
|
+
>
|
|
305
|
+
readonly inspect?: (
|
|
306
|
+
context: CausalRuntimeInspectionContext<M, Error, Output>
|
|
307
|
+
) => Effect.Effect<Observed, InspectionError, InspectionServices>
|
|
308
|
+
readonly assert: (
|
|
309
|
+
context: CausalRuntimeAssertionContext<M, Model, Expected, Error, Output, Observed>
|
|
310
|
+
) => Effect.Effect<void, AssertionError, AssertionServices>
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Context used to select additional asynchronous observation for a law-oriented command run.
|
|
315
|
+
*
|
|
316
|
+
* @category models
|
|
317
|
+
* @since 0.4.0
|
|
318
|
+
*/
|
|
319
|
+
export interface CausalVerificationAwaitContext<M extends AnyMachine, Error, Output> {
|
|
320
|
+
readonly index: number
|
|
321
|
+
readonly command: RuntimeCommand<Machine.Machine.InputEvent<M>>
|
|
322
|
+
readonly probe: Probe<M, Error, Output>
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Configuration for causal verification without a separate reference model.
|
|
327
|
+
*
|
|
328
|
+
* @category models
|
|
329
|
+
* @since 0.4.0
|
|
330
|
+
*/
|
|
331
|
+
export interface CausalVerificationOptions<M extends AnyMachine, Error, Output> {
|
|
332
|
+
readonly invariants: ReadonlyArray<RuntimeInvariant<M>>
|
|
333
|
+
readonly observationTimeout?: Duration.Input
|
|
334
|
+
readonly await?: (
|
|
335
|
+
context: CausalVerificationAwaitContext<M, Error, Output>
|
|
336
|
+
) => RuntimeAwait<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A law-oriented causal transcript without dummy model or expected fields.
|
|
341
|
+
*
|
|
342
|
+
* @category models
|
|
343
|
+
* @since 0.4.0
|
|
344
|
+
*/
|
|
345
|
+
export interface CausalVerificationTranscript<M extends AnyMachine, Error, Output>
|
|
346
|
+
extends CausalRuntimeEvidence<M, Error, Output>
|
|
347
|
+
{}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Actual evidence made available to a runtime command assertion.
|
|
351
|
+
*
|
|
352
|
+
* @category models
|
|
353
|
+
* @since 0.4.0
|
|
354
|
+
*/
|
|
355
|
+
export interface RuntimeCommandActual<State, Error, Output, Observed> {
|
|
356
|
+
readonly result: RuntimeCommandResult
|
|
357
|
+
readonly snapshot: Machine.RuntimeSnapshot<State, Error, Output> | undefined
|
|
358
|
+
readonly published: ReadonlyArray<Machine.RuntimeSnapshot<State, Error, Output>>
|
|
359
|
+
readonly inspected: Observed | undefined
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Context supplied to a custom runtime inspection effect.
|
|
364
|
+
*
|
|
365
|
+
* @category models
|
|
366
|
+
* @since 0.4.0
|
|
367
|
+
*/
|
|
368
|
+
export interface RuntimeInspectionContext<State, Event, Error, Output> {
|
|
369
|
+
readonly index: number
|
|
370
|
+
readonly command: RuntimeCommand<Event>
|
|
371
|
+
readonly result: RuntimeCommandResult
|
|
372
|
+
readonly ref: Machine.MachineRef<State, Event, Error, Output>
|
|
373
|
+
readonly snapshot: Machine.RuntimeSnapshot<State, Error, Output> | undefined
|
|
374
|
+
readonly published: ReadonlyArray<Machine.RuntimeSnapshot<State, Error, Output>>
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Context supplied to the reference-model assertion.
|
|
379
|
+
*
|
|
380
|
+
* @category models
|
|
381
|
+
* @since 0.4.0
|
|
382
|
+
*/
|
|
383
|
+
export interface RuntimeAssertionContext<Model, Expected, State, Event, Error, Output, Observed>
|
|
384
|
+
extends RuntimeInspectionContext<State, Event, Error, Output>
|
|
385
|
+
{
|
|
386
|
+
readonly model: Model
|
|
387
|
+
readonly expected: Expected
|
|
388
|
+
readonly actual: RuntimeCommandActual<State, Error, Output, Observed>
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Configuration for an Effect-native runtime command-model run.
|
|
393
|
+
*
|
|
394
|
+
* @category models
|
|
395
|
+
* @since 0.4.0
|
|
396
|
+
*/
|
|
397
|
+
export interface RuntimeModelOptions<
|
|
398
|
+
Model,
|
|
399
|
+
Expected,
|
|
400
|
+
State,
|
|
401
|
+
Event,
|
|
402
|
+
Error,
|
|
403
|
+
Output,
|
|
404
|
+
Observed = never,
|
|
405
|
+
ModelError = never,
|
|
406
|
+
ModelServices = never,
|
|
407
|
+
InspectionError = never,
|
|
408
|
+
InspectionServices = never,
|
|
409
|
+
AssertionError = never,
|
|
410
|
+
AssertionServices = never
|
|
411
|
+
> {
|
|
412
|
+
readonly initialModel: Model
|
|
413
|
+
/**
|
|
414
|
+
* Live-clock bound for `Next` and `Until` synchronization. Defaults to one
|
|
415
|
+
* second and never advances the machine's virtual `TestClock`.
|
|
416
|
+
*/
|
|
417
|
+
readonly observationTimeout?: Duration.Input
|
|
418
|
+
readonly transition: (
|
|
419
|
+
model: Model,
|
|
420
|
+
command: RuntimeCommand<Event>,
|
|
421
|
+
index: number
|
|
422
|
+
) => Effect.Effect<
|
|
423
|
+
RuntimeModelStep<Model, Expected, State, Error, Output>,
|
|
424
|
+
ModelError,
|
|
425
|
+
ModelServices
|
|
426
|
+
>
|
|
427
|
+
readonly inspect?: (
|
|
428
|
+
context: RuntimeInspectionContext<State, Event, Error, Output>
|
|
429
|
+
) => Effect.Effect<Observed, InspectionError, InspectionServices>
|
|
430
|
+
readonly assert: (
|
|
431
|
+
context: RuntimeAssertionContext<Model, Expected, State, Event, Error, Output, Observed>
|
|
432
|
+
) => Effect.Effect<void, AssertionError, AssertionServices>
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* One successfully checked command in a replayable runtime transcript.
|
|
437
|
+
*
|
|
438
|
+
* @category models
|
|
439
|
+
* @since 0.4.0
|
|
440
|
+
*/
|
|
441
|
+
export interface RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed> {
|
|
442
|
+
readonly index: number
|
|
443
|
+
readonly command: RuntimeCommand<Event>
|
|
444
|
+
readonly model: Model
|
|
445
|
+
readonly expected: Expected
|
|
446
|
+
readonly actual: RuntimeCommandActual<State, Error, Output, Observed>
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* A complete command-model execution transcript.
|
|
451
|
+
*
|
|
452
|
+
* @category models
|
|
453
|
+
* @since 0.4.0
|
|
454
|
+
*/
|
|
455
|
+
export interface RuntimeTranscript<Model, Expected, State, Event, Error, Output, Observed> {
|
|
456
|
+
readonly commands: ReadonlyArray<RuntimeCommand<Event>>
|
|
457
|
+
readonly initial: Machine.RuntimeSnapshot<State, Error, Output>
|
|
458
|
+
readonly records: ReadonlyArray<RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed>>
|
|
459
|
+
readonly finalModel: Model
|
|
460
|
+
/** The last explicitly synchronized snapshot, never a racy trailing sample. */
|
|
461
|
+
readonly final: Machine.RuntimeSnapshot<State, Error, Output>
|
|
462
|
+
/**
|
|
463
|
+
* `false` when potentially outstanding work has not been bounded by an
|
|
464
|
+
* `Until` predicate or a terminal snapshot.
|
|
465
|
+
*/
|
|
466
|
+
readonly synchronized: boolean
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Enqueue-oriented name for actual runtime command evidence.
|
|
471
|
+
*
|
|
472
|
+
* @category models
|
|
473
|
+
* @since 0.4.0
|
|
474
|
+
*/
|
|
475
|
+
export type EnqueuedRuntimeCommandActual<State, Error, Output, Observed> = RuntimeCommandActual<
|
|
476
|
+
State,
|
|
477
|
+
Error,
|
|
478
|
+
Output,
|
|
479
|
+
Observed
|
|
480
|
+
>
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Enqueue-oriented name for runtime inspection context.
|
|
484
|
+
*
|
|
485
|
+
* @category models
|
|
486
|
+
* @since 0.4.0
|
|
487
|
+
*/
|
|
488
|
+
export type EnqueuedRuntimeInspectionContext<State, Event, Error, Output> = RuntimeInspectionContext<
|
|
489
|
+
State,
|
|
490
|
+
Event,
|
|
491
|
+
Error,
|
|
492
|
+
Output
|
|
493
|
+
>
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Enqueue-oriented name for runtime assertion context.
|
|
497
|
+
*
|
|
498
|
+
* @category models
|
|
499
|
+
* @since 0.4.0
|
|
500
|
+
*/
|
|
501
|
+
export type EnqueuedRuntimeAssertionContext<Model, Expected, State, Event, Error, Output, Observed> =
|
|
502
|
+
RuntimeAssertionContext<Model, Expected, State, Event, Error, Output, Observed>
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Enqueue-oriented options for a runtime reference model.
|
|
506
|
+
*
|
|
507
|
+
* @category models
|
|
508
|
+
* @since 0.4.0
|
|
509
|
+
*/
|
|
510
|
+
export type EnqueuedRuntimeModelOptions<
|
|
511
|
+
Model,
|
|
512
|
+
Expected,
|
|
513
|
+
State,
|
|
514
|
+
Event,
|
|
515
|
+
Error,
|
|
516
|
+
Output,
|
|
517
|
+
Observed = never,
|
|
518
|
+
ModelError = never,
|
|
519
|
+
ModelServices = never,
|
|
520
|
+
InspectionError = never,
|
|
521
|
+
InspectionServices = never,
|
|
522
|
+
AssertionError = never,
|
|
523
|
+
AssertionServices = never
|
|
524
|
+
> = RuntimeModelOptions<
|
|
525
|
+
Model,
|
|
526
|
+
Expected,
|
|
527
|
+
State,
|
|
528
|
+
Event,
|
|
529
|
+
Error,
|
|
530
|
+
Output,
|
|
531
|
+
Observed,
|
|
532
|
+
ModelError,
|
|
533
|
+
ModelServices,
|
|
534
|
+
InspectionError,
|
|
535
|
+
InspectionServices,
|
|
536
|
+
AssertionError,
|
|
537
|
+
AssertionServices
|
|
538
|
+
>
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Enqueue-oriented name for a checked runtime command record.
|
|
542
|
+
*
|
|
543
|
+
* @category models
|
|
544
|
+
* @since 0.4.0
|
|
545
|
+
*/
|
|
546
|
+
export type EnqueuedRuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed> = RuntimeCommandRecord<
|
|
547
|
+
Model,
|
|
548
|
+
Expected,
|
|
549
|
+
State,
|
|
550
|
+
Event,
|
|
551
|
+
Error,
|
|
552
|
+
Output,
|
|
553
|
+
Observed
|
|
554
|
+
>
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Enqueue-oriented name for a complete runtime transcript.
|
|
558
|
+
*
|
|
559
|
+
* @category models
|
|
560
|
+
* @since 0.4.0
|
|
561
|
+
*/
|
|
562
|
+
export type EnqueuedRuntimeTranscript<Model, Expected, State, Event, Error, Output, Observed> = RuntimeTranscript<
|
|
563
|
+
Model,
|
|
564
|
+
Expected,
|
|
565
|
+
State,
|
|
566
|
+
Event,
|
|
567
|
+
Error,
|
|
568
|
+
Output,
|
|
569
|
+
Observed
|
|
570
|
+
>
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* One successfully checked causal command.
|
|
574
|
+
*
|
|
575
|
+
* @category models
|
|
576
|
+
* @since 0.4.0
|
|
577
|
+
*/
|
|
578
|
+
export interface CausalRuntimeCommandRecord<
|
|
579
|
+
M extends AnyMachine,
|
|
580
|
+
Model,
|
|
581
|
+
Expected,
|
|
582
|
+
Error,
|
|
583
|
+
Output,
|
|
584
|
+
Observed
|
|
585
|
+
> {
|
|
586
|
+
readonly index: number
|
|
587
|
+
readonly command: RuntimeCommand<Machine.Machine.InputEvent<M>>
|
|
588
|
+
readonly model: Model
|
|
589
|
+
readonly expected: Expected
|
|
590
|
+
readonly actual: CausalRuntimeCommandActual<M, Error, Output, Observed>
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* A complete causally executed command-model transcript.
|
|
595
|
+
*
|
|
596
|
+
* Every accepted send in `records` completed its managed macrostep. This does
|
|
597
|
+
* not claim that later timer, invoke, or child work has completed unless the
|
|
598
|
+
* corresponding model step requested `probe.await.until`.
|
|
599
|
+
*
|
|
600
|
+
* @category models
|
|
601
|
+
* @since 0.4.0
|
|
602
|
+
*/
|
|
603
|
+
export interface CausalRuntimeTranscript<
|
|
604
|
+
M extends AnyMachine,
|
|
605
|
+
Model,
|
|
606
|
+
Expected,
|
|
607
|
+
Error,
|
|
608
|
+
Output,
|
|
609
|
+
Observed
|
|
610
|
+
> {
|
|
611
|
+
readonly commands: ReadonlyArray<RuntimeCommand<Machine.Machine.InputEvent<M>>>
|
|
612
|
+
readonly initial: Machine.RuntimeSnapshot<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
613
|
+
readonly records: ReadonlyArray<CausalRuntimeCommandRecord<M, Model, Expected, Error, Output, Observed>>
|
|
614
|
+
readonly finalModel: Model
|
|
615
|
+
readonly final: Machine.RuntimeSnapshot<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Partial evidence retained when a causal command fails after execution has
|
|
620
|
+
* begun but before a complete checked record exists.
|
|
621
|
+
*
|
|
622
|
+
* @category models
|
|
623
|
+
* @since 0.4.0
|
|
624
|
+
*/
|
|
625
|
+
export interface CausalRuntimeCommandAttempt<
|
|
626
|
+
M extends AnyMachine,
|
|
627
|
+
Model,
|
|
628
|
+
Expected,
|
|
629
|
+
Error,
|
|
630
|
+
Output,
|
|
631
|
+
Observed
|
|
632
|
+
> {
|
|
633
|
+
readonly index: number
|
|
634
|
+
readonly command: RuntimeCommand<Machine.Machine.InputEvent<M>>
|
|
635
|
+
readonly model: Model
|
|
636
|
+
readonly expected: Expected
|
|
637
|
+
readonly result: CausalRuntimeCommandResult<M> | undefined
|
|
638
|
+
readonly snapshot:
|
|
639
|
+
| Machine.RuntimeSnapshot<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
640
|
+
| undefined
|
|
641
|
+
readonly awaited: ReadonlyArray<
|
|
642
|
+
Machine.RuntimeSnapshot<Machine.Machine.Snapshot<Machine.Machine.States<M>>, Error, Output>
|
|
643
|
+
>
|
|
644
|
+
readonly inspected: Observed | undefined
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Failure raised when an expected public change stream observation is absent.
|
|
649
|
+
*
|
|
650
|
+
* @category errors
|
|
651
|
+
* @since 0.4.0
|
|
652
|
+
*/
|
|
653
|
+
export class RuntimeObservationError extends Data.TaggedError("MachineTestRuntimeObservationError")<{
|
|
654
|
+
readonly index: number
|
|
655
|
+
readonly synchronization: "Next" | "Until"
|
|
656
|
+
readonly reason: "ended" | "timeout"
|
|
657
|
+
readonly message: string
|
|
658
|
+
}> {}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* A typed command-model failure retaining the successfully checked prefix.
|
|
662
|
+
*
|
|
663
|
+
* @category errors
|
|
664
|
+
* @since 0.4.0
|
|
665
|
+
*/
|
|
666
|
+
export class RuntimeCommandFailure<
|
|
667
|
+
Failure = unknown,
|
|
668
|
+
Model = unknown,
|
|
669
|
+
Expected = unknown,
|
|
670
|
+
State = unknown,
|
|
671
|
+
Event = unknown,
|
|
672
|
+
Error = unknown,
|
|
673
|
+
Output = unknown,
|
|
674
|
+
Observed = unknown
|
|
675
|
+
> extends Data.TaggedError("MachineTestRuntimeCommandFailure")<{
|
|
676
|
+
readonly phase: "model" | "execution" | "observation" | "inspection" | "assertion"
|
|
677
|
+
readonly index: number
|
|
678
|
+
readonly command: RuntimeCommand<Event>
|
|
679
|
+
readonly cause: Cause.Cause<Failure>
|
|
680
|
+
readonly prefix: ReadonlyArray<RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed>>
|
|
681
|
+
readonly attempted: RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed> | undefined
|
|
682
|
+
}> {}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* A typed causal command-model failure retaining the successfully checked
|
|
686
|
+
* prefix and exact attempted command.
|
|
687
|
+
*
|
|
688
|
+
* @category errors
|
|
689
|
+
* @since 0.4.0
|
|
690
|
+
*/
|
|
691
|
+
export class CausalRuntimeCommandFailure<
|
|
692
|
+
Failure = unknown,
|
|
693
|
+
M extends AnyMachine = AnyMachine,
|
|
694
|
+
Model = unknown,
|
|
695
|
+
Expected = unknown,
|
|
696
|
+
Error = unknown,
|
|
697
|
+
Output = unknown,
|
|
698
|
+
Observed = unknown
|
|
699
|
+
> extends Data.TaggedError("MachineTestCausalRuntimeCommandFailure")<{
|
|
700
|
+
readonly phase: "model" | "execution" | "observation" | "inspection" | "assertion"
|
|
701
|
+
readonly index: number
|
|
702
|
+
readonly command: RuntimeCommand<Machine.Machine.InputEvent<M>>
|
|
703
|
+
readonly cause: Cause.Cause<Failure>
|
|
704
|
+
readonly prefix: ReadonlyArray<CausalRuntimeCommandRecord<M, Model, Expected, Error, Output, Observed>>
|
|
705
|
+
readonly attempted: CausalRuntimeCommandAttempt<M, Model, Expected, Error, Output, Observed> | undefined
|
|
706
|
+
}> {}
|
|
707
|
+
|
|
708
|
+
type ChangeEntry<State, Error, Output> =
|
|
709
|
+
| { readonly _tag: "Snapshot"; readonly snapshot: Machine.RuntimeSnapshot<State, Error, Output> }
|
|
710
|
+
| { readonly _tag: "End" }
|
|
711
|
+
|
|
712
|
+
const makeFailure = <Failure, Model, Expected, State, Event, Error, Output, Observed>(options: {
|
|
713
|
+
readonly phase: "model" | "execution" | "observation" | "inspection" | "assertion"
|
|
714
|
+
readonly index: number
|
|
715
|
+
readonly command: RuntimeCommand<Event>
|
|
716
|
+
readonly cause: Cause.Cause<Failure>
|
|
717
|
+
readonly prefix: ReadonlyArray<RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed>>
|
|
718
|
+
readonly attempted?: RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed>
|
|
719
|
+
}): RuntimeCommandFailure<Failure, Model, Expected, State, Event, Error, Output, Observed> =>
|
|
720
|
+
new RuntimeCommandFailure({
|
|
721
|
+
...options,
|
|
722
|
+
prefix: options.prefix.slice(),
|
|
723
|
+
attempted: options.attempted
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
const executeCommand = <State, Event, Error, Output>(
|
|
727
|
+
ref: Machine.MachineRef<State, Event, Error, Output>,
|
|
728
|
+
command: RuntimeCommand<Event>
|
|
729
|
+
): Effect.Effect<RuntimeCommandResult> => {
|
|
730
|
+
switch (command._tag) {
|
|
731
|
+
case "Send":
|
|
732
|
+
return ref.send(command.event).pipe(
|
|
733
|
+
Effect.match({
|
|
734
|
+
onFailure: (error): RuntimeCommandResult => ({ _tag: "SendRejected", error }),
|
|
735
|
+
onSuccess: (): RuntimeCommandResult => ({ _tag: "SendAccepted" })
|
|
736
|
+
})
|
|
737
|
+
)
|
|
738
|
+
case "Advance":
|
|
739
|
+
return TestClock.adjust(command.duration).pipe(Effect.as({ _tag: "ClockAdvanced" } as const))
|
|
740
|
+
case "Stop":
|
|
741
|
+
return ref.stop.pipe(Effect.as({ _tag: "Stopped" } as const))
|
|
742
|
+
case "Checkpoint":
|
|
743
|
+
return Effect.succeed({ _tag: "Checkpoint" } as const)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const synchronize = <State, Event, Error, Output>(
|
|
748
|
+
ref: Machine.MachineRef<State, Event, Error, Output>,
|
|
749
|
+
queue: Queue.Dequeue<ChangeEntry<State, Error, Output>>,
|
|
750
|
+
policy: RuntimeSynchronization<State, Error, Output>,
|
|
751
|
+
index: number,
|
|
752
|
+
timeout: Duration.Input
|
|
753
|
+
): Effect.Effect<{
|
|
754
|
+
readonly snapshot: Machine.RuntimeSnapshot<State, Error, Output> | undefined
|
|
755
|
+
readonly published: ReadonlyArray<Machine.RuntimeSnapshot<State, Error, Output>>
|
|
756
|
+
}, RuntimeObservationError> => {
|
|
757
|
+
const wait = <A>(
|
|
758
|
+
synchronization: "Next" | "Until",
|
|
759
|
+
effect: Effect.Effect<A, RuntimeObservationError>
|
|
760
|
+
): Effect.Effect<A, RuntimeObservationError> =>
|
|
761
|
+
TestClock.withLive(effect.pipe(Effect.timeout(timeout))).pipe(
|
|
762
|
+
Effect.mapError((cause) =>
|
|
763
|
+
cause instanceof RuntimeObservationError
|
|
764
|
+
? cause
|
|
765
|
+
: new RuntimeObservationError({
|
|
766
|
+
index,
|
|
767
|
+
synchronization,
|
|
768
|
+
reason: "timeout",
|
|
769
|
+
message: `timed out after ${Duration.toMillis(timeout)}ms waiting for the expected published snapshot`
|
|
770
|
+
})
|
|
771
|
+
)
|
|
772
|
+
)
|
|
773
|
+
switch (policy._tag) {
|
|
774
|
+
case "None":
|
|
775
|
+
return Effect.succeed({ snapshot: undefined, published: [] })
|
|
776
|
+
case "Current":
|
|
777
|
+
return ref.snapshot.pipe(Effect.map((snapshot) => ({ snapshot, published: [] })))
|
|
778
|
+
case "Next":
|
|
779
|
+
return wait(
|
|
780
|
+
"Next",
|
|
781
|
+
Queue.take(queue).pipe(
|
|
782
|
+
Effect.flatMap((entry) =>
|
|
783
|
+
entry._tag === "Snapshot"
|
|
784
|
+
? Effect.succeed({ snapshot: entry.snapshot, published: [entry.snapshot] })
|
|
785
|
+
: Effect.fail(
|
|
786
|
+
new RuntimeObservationError({
|
|
787
|
+
index,
|
|
788
|
+
synchronization: "Next",
|
|
789
|
+
reason: "ended",
|
|
790
|
+
message: "the machine changes stream ended before publishing the expected snapshot"
|
|
791
|
+
})
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
case "Until":
|
|
797
|
+
return wait(
|
|
798
|
+
"Until",
|
|
799
|
+
Effect.gen(function*() {
|
|
800
|
+
const published: Array<Machine.RuntimeSnapshot<State, Error, Output>> = []
|
|
801
|
+
while (true) {
|
|
802
|
+
const entry = yield* Queue.take(queue)
|
|
803
|
+
if (entry._tag === "End") {
|
|
804
|
+
return yield* Effect.fail(
|
|
805
|
+
new RuntimeObservationError({
|
|
806
|
+
index,
|
|
807
|
+
synchronization: "Until",
|
|
808
|
+
reason: "ended",
|
|
809
|
+
message: "the machine changes stream ended before a published snapshot matched the predicate"
|
|
810
|
+
})
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
published.push(entry.snapshot)
|
|
814
|
+
if (policy.predicate(entry.snapshot)) {
|
|
815
|
+
return { snapshot: entry.snapshot, published }
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
})
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const executeCausalCommand = <M extends AnyMachine, Error, Output>(
|
|
824
|
+
probe: Probe<M, Error, Output>,
|
|
825
|
+
command: RuntimeCommand<Machine.Machine.InputEvent<M>>
|
|
826
|
+
): Effect.Effect<CausalRuntimeCommandResult<M>, Error> => {
|
|
827
|
+
switch (command._tag) {
|
|
828
|
+
case "Send":
|
|
829
|
+
return Effect.matchEffect(probe.sendAndAwait(command.event), {
|
|
830
|
+
onFailure: (error) =>
|
|
831
|
+
error instanceof Machine.StoppedError
|
|
832
|
+
? probe.ref.snapshot.pipe(
|
|
833
|
+
Effect.flatMap((snapshot) =>
|
|
834
|
+
snapshot.status === "stopped"
|
|
835
|
+
? Effect.succeed({ _tag: "SendRejected", error } as const)
|
|
836
|
+
: Effect.fail(error as Error)
|
|
837
|
+
)
|
|
838
|
+
)
|
|
839
|
+
: Effect.fail(error as Error),
|
|
840
|
+
onSuccess: (step) => Effect.succeed({ _tag: "SendProcessed", step } as const)
|
|
841
|
+
})
|
|
842
|
+
case "Advance":
|
|
843
|
+
return TestClock.adjust(command.duration).pipe(Effect.as({ _tag: "ClockAdvanced" } as const))
|
|
844
|
+
case "Stop":
|
|
845
|
+
return probe.ref.stop.pipe(Effect.as({ _tag: "Stopped" } as const))
|
|
846
|
+
case "Checkpoint":
|
|
847
|
+
return Effect.succeed({ _tag: "Checkpoint" } as const)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const awaitCausal = <State, Event, Error, Output>(
|
|
852
|
+
ref: Machine.MachineRef<State, Event, Error, Output>,
|
|
853
|
+
policy: RuntimeAwait<State, Error, Output>,
|
|
854
|
+
index: number,
|
|
855
|
+
timeout: Duration.Input
|
|
856
|
+
): Effect.Effect<{
|
|
857
|
+
readonly snapshot: Machine.RuntimeSnapshot<State, Error, Output>
|
|
858
|
+
readonly awaited: ReadonlyArray<Machine.RuntimeSnapshot<State, Error, Output>>
|
|
859
|
+
}, RuntimeObservationError> => {
|
|
860
|
+
if (policy._tag === "None") {
|
|
861
|
+
return ref.snapshot.pipe(Effect.map((snapshot) => ({ snapshot, awaited: [] })))
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const observation = Effect.scoped(
|
|
865
|
+
Effect.gen(function*() {
|
|
866
|
+
const changes = yield* Queue.unbounded<ChangeEntry<State, Error, Output>>()
|
|
867
|
+
yield* ref.changes.pipe(
|
|
868
|
+
Stream.runForEach((snapshot) => Queue.offer(changes, { _tag: "Snapshot", snapshot })),
|
|
869
|
+
Effect.ensuring(Queue.offer(changes, { _tag: "End" })),
|
|
870
|
+
Effect.forkScoped({ startImmediately: true })
|
|
871
|
+
)
|
|
872
|
+
const awaited: Array<Machine.RuntimeSnapshot<State, Error, Output>> = []
|
|
873
|
+
while (true) {
|
|
874
|
+
const entry = yield* Queue.take(changes)
|
|
875
|
+
if (entry._tag === "End") {
|
|
876
|
+
return yield* Effect.fail(
|
|
877
|
+
new RuntimeObservationError({
|
|
878
|
+
index,
|
|
879
|
+
synchronization: "Until",
|
|
880
|
+
reason: "ended",
|
|
881
|
+
message: "the machine changes stream ended before an awaited snapshot matched the predicate"
|
|
882
|
+
})
|
|
883
|
+
)
|
|
884
|
+
}
|
|
885
|
+
awaited.push(entry.snapshot)
|
|
886
|
+
if (policy.predicate(entry.snapshot)) return { snapshot: entry.snapshot, awaited }
|
|
887
|
+
}
|
|
888
|
+
})
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
return TestClock.withLive(observation.pipe(Effect.timeout(timeout))).pipe(
|
|
892
|
+
Effect.mapError((cause) =>
|
|
893
|
+
cause instanceof RuntimeObservationError
|
|
894
|
+
? cause
|
|
895
|
+
: new RuntimeObservationError({
|
|
896
|
+
index,
|
|
897
|
+
synchronization: "Until",
|
|
898
|
+
reason: "timeout",
|
|
899
|
+
message: `timed out after ${Duration.toMillis(timeout)}ms waiting for an awaited runtime snapshot`
|
|
900
|
+
})
|
|
901
|
+
)
|
|
902
|
+
)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Runs typed commands against a live `MachineRef` and checks them against a
|
|
907
|
+
* supplied Effect-native reference model.
|
|
908
|
+
*
|
|
909
|
+
* The runner observes only public `MachineRef` behavior. In particular, a
|
|
910
|
+
* successful send means enqueue acceptance, not completed processing. A model
|
|
911
|
+
* must request `Next`/`Until` only when it predicts a publication, or use an
|
|
912
|
+
* explicit checkpoint to drain previously enqueued work. Machine emissions can
|
|
913
|
+
* be captured by the runtime service used by the machine and returned from the
|
|
914
|
+
* optional `inspect` effect.
|
|
915
|
+
*
|
|
916
|
+
* Typed failures and defects from model transitions, command execution,
|
|
917
|
+
* inspection, and assertions are retained as full `Cause` values. A cause
|
|
918
|
+
* containing only interruption is propagated as interruption so cancelling a
|
|
919
|
+
* property run cannot be mistaken for a machine counterexample.
|
|
920
|
+
*
|
|
921
|
+
* @category constructors
|
|
922
|
+
* @since 0.4.0
|
|
923
|
+
*/
|
|
924
|
+
export const runEnqueuedCommands = <
|
|
925
|
+
Model,
|
|
926
|
+
Expected,
|
|
927
|
+
State,
|
|
928
|
+
Event,
|
|
929
|
+
Error,
|
|
930
|
+
Output,
|
|
931
|
+
Observed = never,
|
|
932
|
+
ModelError = never,
|
|
933
|
+
ModelServices = never,
|
|
934
|
+
InspectionError = never,
|
|
935
|
+
InspectionServices = never,
|
|
936
|
+
AssertionError = never,
|
|
937
|
+
AssertionServices = never
|
|
938
|
+
>(
|
|
939
|
+
ref: Machine.MachineRef<State, Event, Error, Output>,
|
|
940
|
+
commands: Iterable<RuntimeCommand<Event>>,
|
|
941
|
+
options: RuntimeModelOptions<
|
|
942
|
+
Model,
|
|
943
|
+
Expected,
|
|
944
|
+
State,
|
|
945
|
+
Event,
|
|
946
|
+
Error,
|
|
947
|
+
Output,
|
|
948
|
+
Observed,
|
|
949
|
+
ModelError,
|
|
950
|
+
ModelServices,
|
|
951
|
+
InspectionError,
|
|
952
|
+
InspectionServices,
|
|
953
|
+
AssertionError,
|
|
954
|
+
AssertionServices
|
|
955
|
+
>
|
|
956
|
+
): Effect.Effect<
|
|
957
|
+
RuntimeTranscript<Model, Expected, State, Event, Error, Output, Observed>,
|
|
958
|
+
RuntimeCommandFailure<
|
|
959
|
+
ModelError | InspectionError | AssertionError | RuntimeObservationError,
|
|
960
|
+
Model,
|
|
961
|
+
Expected,
|
|
962
|
+
State,
|
|
963
|
+
Event,
|
|
964
|
+
Error,
|
|
965
|
+
Output,
|
|
966
|
+
Observed
|
|
967
|
+
>,
|
|
968
|
+
ModelServices | InspectionServices | AssertionServices
|
|
969
|
+
> =>
|
|
970
|
+
Effect.scoped(
|
|
971
|
+
Effect.gen(function*() {
|
|
972
|
+
const sequence = Array.from(commands)
|
|
973
|
+
const observationTimeout = options.observationTimeout ?? "1 second"
|
|
974
|
+
const observationTimeoutMillis = Duration.toMillis(observationTimeout)
|
|
975
|
+
if (!Number.isFinite(observationTimeoutMillis) || observationTimeoutMillis < 0) {
|
|
976
|
+
return yield* Effect.die(
|
|
977
|
+
new Error("MachineTest.runEnqueuedCommands expected observationTimeout to be a finite non-negative duration")
|
|
978
|
+
)
|
|
979
|
+
}
|
|
980
|
+
const changes = yield* Queue.unbounded<ChangeEntry<State, Error, Output>>()
|
|
981
|
+
const ready = yield* Deferred.make<void>()
|
|
982
|
+
yield* ref.changes.pipe(
|
|
983
|
+
Stream.runForEach((snapshot) =>
|
|
984
|
+
Queue.offer(changes, { _tag: "Snapshot", snapshot }).pipe(
|
|
985
|
+
Effect.andThen(Deferred.succeed(ready, undefined)),
|
|
986
|
+
Effect.asVoid
|
|
987
|
+
)
|
|
988
|
+
),
|
|
989
|
+
Effect.ensuring(
|
|
990
|
+
Deferred.succeed(ready, undefined).pipe(
|
|
991
|
+
Effect.andThen(Queue.offer(changes, { _tag: "End" })),
|
|
992
|
+
Effect.asVoid
|
|
993
|
+
)
|
|
994
|
+
),
|
|
995
|
+
Effect.forkScoped({ startImmediately: true })
|
|
996
|
+
)
|
|
997
|
+
yield* Deferred.await(ready)
|
|
998
|
+
const initialEntry = yield* Queue.take(changes)
|
|
999
|
+
const initial = initialEntry._tag === "Snapshot" ? initialEntry.snapshot : yield* ref.snapshot
|
|
1000
|
+
const records: Array<RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed>> = []
|
|
1001
|
+
let model = options.initialModel
|
|
1002
|
+
let lastSynchronized = initial
|
|
1003
|
+
let outstandingWorkUnknown = false
|
|
1004
|
+
|
|
1005
|
+
const capture = <A, Failure, R>(options: {
|
|
1006
|
+
readonly phase: "model" | "observation" | "inspection" | "assertion" | "execution"
|
|
1007
|
+
readonly index: number
|
|
1008
|
+
readonly command: RuntimeCommand<Event>
|
|
1009
|
+
readonly effect: () => Effect.Effect<A, Failure, R>
|
|
1010
|
+
readonly attempted?: RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed>
|
|
1011
|
+
}): Effect.Effect<
|
|
1012
|
+
A,
|
|
1013
|
+
RuntimeCommandFailure<Failure, Model, Expected, State, Event, Error, Output, Observed>,
|
|
1014
|
+
R
|
|
1015
|
+
> =>
|
|
1016
|
+
Effect.catchCause(Effect.suspend(options.effect), (cause) =>
|
|
1017
|
+
Cause.hasInterruptsOnly(cause)
|
|
1018
|
+
? Effect.failCause(cause as Cause.Cause<never>)
|
|
1019
|
+
: Effect.fail(
|
|
1020
|
+
makeFailure<Failure, Model, Expected, State, Event, Error, Output, Observed>({
|
|
1021
|
+
phase: options.phase,
|
|
1022
|
+
index: options.index,
|
|
1023
|
+
command: options.command,
|
|
1024
|
+
cause,
|
|
1025
|
+
prefix: records,
|
|
1026
|
+
...(options.attempted === undefined ? {} : { attempted: options.attempted })
|
|
1027
|
+
})
|
|
1028
|
+
))
|
|
1029
|
+
|
|
1030
|
+
for (let index = 0; index < sequence.length; index++) {
|
|
1031
|
+
const command = sequence[index]!
|
|
1032
|
+
const step = yield* capture({
|
|
1033
|
+
phase: "model",
|
|
1034
|
+
index,
|
|
1035
|
+
command,
|
|
1036
|
+
effect: () => options.transition(model, command, index)
|
|
1037
|
+
})
|
|
1038
|
+
model = step.model
|
|
1039
|
+
const result = yield* capture({
|
|
1040
|
+
phase: "execution",
|
|
1041
|
+
index,
|
|
1042
|
+
command,
|
|
1043
|
+
effect: () => executeCommand(ref, command)
|
|
1044
|
+
})
|
|
1045
|
+
const attemptedBeforeObservation: RuntimeCommandRecord<
|
|
1046
|
+
Model,
|
|
1047
|
+
Expected,
|
|
1048
|
+
State,
|
|
1049
|
+
Event,
|
|
1050
|
+
Error,
|
|
1051
|
+
Output,
|
|
1052
|
+
Observed
|
|
1053
|
+
> = {
|
|
1054
|
+
index,
|
|
1055
|
+
command,
|
|
1056
|
+
model,
|
|
1057
|
+
expected: step.expected,
|
|
1058
|
+
actual: {
|
|
1059
|
+
result,
|
|
1060
|
+
snapshot: undefined,
|
|
1061
|
+
published: [],
|
|
1062
|
+
inspected: undefined
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const synchronized = yield* capture({
|
|
1066
|
+
phase: "observation",
|
|
1067
|
+
index,
|
|
1068
|
+
command,
|
|
1069
|
+
effect: () => synchronize(ref, changes, step.synchronize, index, observationTimeout),
|
|
1070
|
+
attempted: attemptedBeforeObservation
|
|
1071
|
+
})
|
|
1072
|
+
const terminal = synchronized.snapshot !== undefined && synchronized.snapshot.status !== "active"
|
|
1073
|
+
const previouslyOutstanding: boolean = outstandingWorkUnknown
|
|
1074
|
+
switch (step.synchronize._tag) {
|
|
1075
|
+
case "None":
|
|
1076
|
+
if (
|
|
1077
|
+
command._tag === "Advance" || command._tag === "Stop" ||
|
|
1078
|
+
(command._tag === "Send" && result._tag === "SendAccepted")
|
|
1079
|
+
) outstandingWorkUnknown = true
|
|
1080
|
+
break
|
|
1081
|
+
case "Next":
|
|
1082
|
+
if (synchronized.snapshot !== undefined) lastSynchronized = synchronized.snapshot
|
|
1083
|
+
outstandingWorkUnknown = terminal
|
|
1084
|
+
? false
|
|
1085
|
+
: previouslyOutstanding || command._tag === "Advance" ||
|
|
1086
|
+
(command._tag === "Send" && result._tag === "SendAccepted")
|
|
1087
|
+
break
|
|
1088
|
+
case "Until":
|
|
1089
|
+
if (synchronized.snapshot !== undefined) lastSynchronized = synchronized.snapshot
|
|
1090
|
+
outstandingWorkUnknown = false
|
|
1091
|
+
break
|
|
1092
|
+
case "Current":
|
|
1093
|
+
if (terminal) outstandingWorkUnknown = false
|
|
1094
|
+
if (!outstandingWorkUnknown && synchronized.snapshot !== undefined) lastSynchronized = synchronized.snapshot
|
|
1095
|
+
break
|
|
1096
|
+
}
|
|
1097
|
+
const inspectionContext: RuntimeInspectionContext<State, Event, Error, Output> = {
|
|
1098
|
+
index,
|
|
1099
|
+
command,
|
|
1100
|
+
result,
|
|
1101
|
+
ref,
|
|
1102
|
+
snapshot: synchronized.snapshot,
|
|
1103
|
+
published: synchronized.published
|
|
1104
|
+
}
|
|
1105
|
+
const attemptedBeforeInspection: RuntimeCommandRecord<
|
|
1106
|
+
Model,
|
|
1107
|
+
Expected,
|
|
1108
|
+
State,
|
|
1109
|
+
Event,
|
|
1110
|
+
Error,
|
|
1111
|
+
Output,
|
|
1112
|
+
Observed
|
|
1113
|
+
> = {
|
|
1114
|
+
...attemptedBeforeObservation,
|
|
1115
|
+
actual: {
|
|
1116
|
+
result,
|
|
1117
|
+
snapshot: synchronized.snapshot,
|
|
1118
|
+
published: synchronized.published,
|
|
1119
|
+
inspected: undefined
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const inspected = options.inspect === undefined
|
|
1123
|
+
? undefined
|
|
1124
|
+
: yield* capture({
|
|
1125
|
+
phase: "inspection",
|
|
1126
|
+
index,
|
|
1127
|
+
command,
|
|
1128
|
+
effect: () => options.inspect!(inspectionContext),
|
|
1129
|
+
attempted: attemptedBeforeInspection
|
|
1130
|
+
})
|
|
1131
|
+
const actual: RuntimeCommandActual<State, Error, Output, Observed> = {
|
|
1132
|
+
result,
|
|
1133
|
+
snapshot: synchronized.snapshot,
|
|
1134
|
+
published: synchronized.published,
|
|
1135
|
+
inspected
|
|
1136
|
+
}
|
|
1137
|
+
const record: RuntimeCommandRecord<Model, Expected, State, Event, Error, Output, Observed> = {
|
|
1138
|
+
index,
|
|
1139
|
+
command,
|
|
1140
|
+
model,
|
|
1141
|
+
expected: step.expected,
|
|
1142
|
+
actual
|
|
1143
|
+
}
|
|
1144
|
+
yield* capture({
|
|
1145
|
+
phase: "assertion",
|
|
1146
|
+
index,
|
|
1147
|
+
command,
|
|
1148
|
+
effect: () =>
|
|
1149
|
+
options.assert({
|
|
1150
|
+
...inspectionContext,
|
|
1151
|
+
model,
|
|
1152
|
+
expected: step.expected,
|
|
1153
|
+
actual
|
|
1154
|
+
}),
|
|
1155
|
+
attempted: record
|
|
1156
|
+
})
|
|
1157
|
+
records.push(record)
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
return {
|
|
1161
|
+
commands: sequence,
|
|
1162
|
+
initial,
|
|
1163
|
+
records,
|
|
1164
|
+
finalModel: model,
|
|
1165
|
+
final: lastSynchronized,
|
|
1166
|
+
synchronized: !outstandingWorkUnknown
|
|
1167
|
+
}
|
|
1168
|
+
})
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Compatibility alias for enqueue-oriented runtime command execution.
|
|
1173
|
+
*
|
|
1174
|
+
* @deprecated Use `runEnqueuedCommands`. This compatibility name does not
|
|
1175
|
+
* expose whether sends are merely enqueued or causally processed.
|
|
1176
|
+
*
|
|
1177
|
+
* @category constructors
|
|
1178
|
+
* @since 0.4.0
|
|
1179
|
+
*/
|
|
1180
|
+
export const runRuntimeCommands: typeof runEnqueuedCommands = runEnqueuedCommands
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Runs typed commands against a probe and checks them against an Effect-native
|
|
1184
|
+
* reference model.
|
|
1185
|
+
*
|
|
1186
|
+
* Every accepted `Send` completes its exact managed runtime macrostep before
|
|
1187
|
+
* inspection, assertion, and the next command. Use `probe.await.until` only
|
|
1188
|
+
* for later asynchronous work such as timer, invoke, or child delivery.
|
|
1189
|
+
* Processing failures are attributed to the exact submitted command and retain
|
|
1190
|
+
* the successfully checked prefix for FastCheck shrinking and replay.
|
|
1191
|
+
*
|
|
1192
|
+
* Use `runEnqueuedCommands` instead when the behavior under test intentionally
|
|
1193
|
+
* depends on burst enqueueing or outstanding mailbox work.
|
|
1194
|
+
*
|
|
1195
|
+
* @category constructors
|
|
1196
|
+
* @since 0.4.0
|
|
1197
|
+
*/
|
|
1198
|
+
export const runCausalCommands = <
|
|
1199
|
+
M extends AnyMachine,
|
|
1200
|
+
Error,
|
|
1201
|
+
Output,
|
|
1202
|
+
Model,
|
|
1203
|
+
Expected,
|
|
1204
|
+
Observed = never,
|
|
1205
|
+
ModelError = never,
|
|
1206
|
+
ModelServices = never,
|
|
1207
|
+
InspectionError = never,
|
|
1208
|
+
InspectionServices = never,
|
|
1209
|
+
AssertionError = never,
|
|
1210
|
+
AssertionServices = never
|
|
1211
|
+
>(
|
|
1212
|
+
probe: Probe<M, Error, Output>,
|
|
1213
|
+
commands: Iterable<RuntimeCommand<Machine.Machine.InputEvent<M>>>,
|
|
1214
|
+
options: CausalRuntimeModelOptions<
|
|
1215
|
+
M,
|
|
1216
|
+
Model,
|
|
1217
|
+
Expected,
|
|
1218
|
+
Error,
|
|
1219
|
+
Output,
|
|
1220
|
+
Observed,
|
|
1221
|
+
ModelError,
|
|
1222
|
+
ModelServices,
|
|
1223
|
+
InspectionError,
|
|
1224
|
+
InspectionServices,
|
|
1225
|
+
AssertionError,
|
|
1226
|
+
AssertionServices
|
|
1227
|
+
>
|
|
1228
|
+
): Effect.Effect<
|
|
1229
|
+
CausalRuntimeTranscript<M, Model, Expected, Error, Output, Observed>,
|
|
1230
|
+
CausalRuntimeCommandFailure<
|
|
1231
|
+
Error | ModelError | InspectionError | AssertionError | RuntimeObservationError,
|
|
1232
|
+
M,
|
|
1233
|
+
Model,
|
|
1234
|
+
Expected,
|
|
1235
|
+
Error,
|
|
1236
|
+
Output,
|
|
1237
|
+
Observed
|
|
1238
|
+
>,
|
|
1239
|
+
ModelServices | InspectionServices | AssertionServices
|
|
1240
|
+
> =>
|
|
1241
|
+
Effect.gen(function*() {
|
|
1242
|
+
const sequence = Array.from(commands)
|
|
1243
|
+
const observationTimeout = options.observationTimeout ?? "1 second"
|
|
1244
|
+
const observationTimeoutMillis = Duration.toMillis(observationTimeout)
|
|
1245
|
+
if (!Number.isFinite(observationTimeoutMillis) || observationTimeoutMillis < 0) {
|
|
1246
|
+
return yield* Effect.die(
|
|
1247
|
+
new Error("MachineTest.runCausalCommands expected observationTimeout to be a finite non-negative duration")
|
|
1248
|
+
)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const initial = yield* probe.ref.snapshot
|
|
1252
|
+
const records: Array<CausalRuntimeCommandRecord<M, Model, Expected, Error, Output, Observed>> = []
|
|
1253
|
+
let model = options.initialModel
|
|
1254
|
+
let final = initial
|
|
1255
|
+
|
|
1256
|
+
const capture = <A, Failure, R>(captureOptions: {
|
|
1257
|
+
readonly phase: "model" | "observation" | "inspection" | "assertion" | "execution"
|
|
1258
|
+
readonly index: number
|
|
1259
|
+
readonly command: RuntimeCommand<Machine.Machine.InputEvent<M>>
|
|
1260
|
+
readonly effect: () => Effect.Effect<A, Failure, R>
|
|
1261
|
+
readonly attempted?: CausalRuntimeCommandAttempt<M, Model, Expected, Error, Output, Observed>
|
|
1262
|
+
}): Effect.Effect<
|
|
1263
|
+
A,
|
|
1264
|
+
CausalRuntimeCommandFailure<Failure, M, Model, Expected, Error, Output, Observed>,
|
|
1265
|
+
R
|
|
1266
|
+
> =>
|
|
1267
|
+
Effect.catchCause(Effect.suspend(captureOptions.effect), (cause) =>
|
|
1268
|
+
Cause.hasInterruptsOnly(cause)
|
|
1269
|
+
? Effect.failCause(cause as Cause.Cause<never>)
|
|
1270
|
+
: Effect.fail(
|
|
1271
|
+
new CausalRuntimeCommandFailure({
|
|
1272
|
+
phase: captureOptions.phase,
|
|
1273
|
+
index: captureOptions.index,
|
|
1274
|
+
command: captureOptions.command,
|
|
1275
|
+
cause,
|
|
1276
|
+
prefix: records.slice(),
|
|
1277
|
+
attempted: captureOptions.attempted
|
|
1278
|
+
})
|
|
1279
|
+
))
|
|
1280
|
+
|
|
1281
|
+
for (let index = 0; index < sequence.length; index++) {
|
|
1282
|
+
const command = sequence[index]!
|
|
1283
|
+
const step = yield* capture({
|
|
1284
|
+
phase: "model",
|
|
1285
|
+
index,
|
|
1286
|
+
command,
|
|
1287
|
+
effect: () => options.transition(model, command, index)
|
|
1288
|
+
})
|
|
1289
|
+
model = step.model
|
|
1290
|
+
|
|
1291
|
+
const attemptedBeforeExecution: CausalRuntimeCommandAttempt<
|
|
1292
|
+
M,
|
|
1293
|
+
Model,
|
|
1294
|
+
Expected,
|
|
1295
|
+
Error,
|
|
1296
|
+
Output,
|
|
1297
|
+
Observed
|
|
1298
|
+
> = {
|
|
1299
|
+
index,
|
|
1300
|
+
command,
|
|
1301
|
+
model,
|
|
1302
|
+
expected: step.expected,
|
|
1303
|
+
result: undefined,
|
|
1304
|
+
snapshot: undefined,
|
|
1305
|
+
awaited: [],
|
|
1306
|
+
inspected: undefined
|
|
1307
|
+
}
|
|
1308
|
+
const result = yield* capture({
|
|
1309
|
+
phase: "execution",
|
|
1310
|
+
index,
|
|
1311
|
+
command,
|
|
1312
|
+
effect: () => executeCausalCommand(probe, command),
|
|
1313
|
+
attempted: attemptedBeforeExecution
|
|
1314
|
+
})
|
|
1315
|
+
const attemptedAfterExecution: CausalRuntimeCommandAttempt<
|
|
1316
|
+
M,
|
|
1317
|
+
Model,
|
|
1318
|
+
Expected,
|
|
1319
|
+
Error,
|
|
1320
|
+
Output,
|
|
1321
|
+
Observed
|
|
1322
|
+
> = {
|
|
1323
|
+
...attemptedBeforeExecution,
|
|
1324
|
+
result
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const observation = yield* capture({
|
|
1328
|
+
phase: "observation",
|
|
1329
|
+
index,
|
|
1330
|
+
command,
|
|
1331
|
+
effect: () => awaitCausal(probe.ref, step.await ?? { _tag: "None" }, index, observationTimeout),
|
|
1332
|
+
attempted: attemptedAfterExecution
|
|
1333
|
+
})
|
|
1334
|
+
final = observation.snapshot
|
|
1335
|
+
const inspectionContext: CausalRuntimeInspectionContext<M, Error, Output> = {
|
|
1336
|
+
index,
|
|
1337
|
+
command,
|
|
1338
|
+
result,
|
|
1339
|
+
probe,
|
|
1340
|
+
ref: probe.ref,
|
|
1341
|
+
snapshot: observation.snapshot,
|
|
1342
|
+
awaited: observation.awaited
|
|
1343
|
+
}
|
|
1344
|
+
const attemptedAfterObservation: CausalRuntimeCommandAttempt<
|
|
1345
|
+
M,
|
|
1346
|
+
Model,
|
|
1347
|
+
Expected,
|
|
1348
|
+
Error,
|
|
1349
|
+
Output,
|
|
1350
|
+
Observed
|
|
1351
|
+
> = {
|
|
1352
|
+
...attemptedAfterExecution,
|
|
1353
|
+
snapshot: observation.snapshot,
|
|
1354
|
+
awaited: observation.awaited
|
|
1355
|
+
}
|
|
1356
|
+
const inspected = options.inspect === undefined
|
|
1357
|
+
? undefined
|
|
1358
|
+
: yield* capture({
|
|
1359
|
+
phase: "inspection",
|
|
1360
|
+
index,
|
|
1361
|
+
command,
|
|
1362
|
+
effect: () => options.inspect!(inspectionContext),
|
|
1363
|
+
attempted: attemptedAfterObservation
|
|
1364
|
+
})
|
|
1365
|
+
const actual: CausalRuntimeCommandActual<M, Error, Output, Observed> = {
|
|
1366
|
+
result,
|
|
1367
|
+
snapshot: observation.snapshot,
|
|
1368
|
+
awaited: observation.awaited,
|
|
1369
|
+
inspected
|
|
1370
|
+
}
|
|
1371
|
+
const record: CausalRuntimeCommandRecord<M, Model, Expected, Error, Output, Observed> = {
|
|
1372
|
+
index,
|
|
1373
|
+
command,
|
|
1374
|
+
model,
|
|
1375
|
+
expected: step.expected,
|
|
1376
|
+
actual
|
|
1377
|
+
}
|
|
1378
|
+
yield* capture({
|
|
1379
|
+
phase: "assertion",
|
|
1380
|
+
index,
|
|
1381
|
+
command,
|
|
1382
|
+
effect: () =>
|
|
1383
|
+
options.assert({
|
|
1384
|
+
...inspectionContext,
|
|
1385
|
+
model,
|
|
1386
|
+
expected: step.expected,
|
|
1387
|
+
actual
|
|
1388
|
+
}),
|
|
1389
|
+
attempted: {
|
|
1390
|
+
...attemptedAfterObservation,
|
|
1391
|
+
inspected
|
|
1392
|
+
}
|
|
1393
|
+
})
|
|
1394
|
+
records.push(record)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
return {
|
|
1398
|
+
commands: sequence,
|
|
1399
|
+
initial,
|
|
1400
|
+
records,
|
|
1401
|
+
finalModel: model,
|
|
1402
|
+
final
|
|
1403
|
+
}
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Causally executes commands and checks reusable runtime invariants without
|
|
1408
|
+
* requiring a dummy reference model. Use `runCausalCommands` when exact
|
|
1409
|
+
* expected results come from an application model.
|
|
1410
|
+
*
|
|
1411
|
+
* @category constructors
|
|
1412
|
+
* @since 0.4.0
|
|
1413
|
+
*/
|
|
1414
|
+
export const verifyCausalCommands = <M extends AnyMachine, Error, Output>(
|
|
1415
|
+
probe: Probe<M, Error, Output>,
|
|
1416
|
+
commands: Iterable<RuntimeCommand<Machine.Machine.InputEvent<M>>>,
|
|
1417
|
+
options: CausalVerificationOptions<M, Error, Output>
|
|
1418
|
+
): Effect.Effect<
|
|
1419
|
+
CausalVerificationTranscript<M, Error, Output>,
|
|
1420
|
+
| CausalRuntimeCommandFailure<
|
|
1421
|
+
Error | RuntimeObservationError,
|
|
1422
|
+
M,
|
|
1423
|
+
undefined,
|
|
1424
|
+
undefined,
|
|
1425
|
+
Error,
|
|
1426
|
+
Output,
|
|
1427
|
+
never
|
|
1428
|
+
>
|
|
1429
|
+
| RuntimeInvariantError<M>
|
|
1430
|
+
> =>
|
|
1431
|
+
Effect.gen(function*() {
|
|
1432
|
+
const transcript = yield* runCausalCommands(probe, commands, {
|
|
1433
|
+
initialModel: undefined,
|
|
1434
|
+
...(options.observationTimeout === undefined ? {} : { observationTimeout: options.observationTimeout }),
|
|
1435
|
+
transition: (_model, command, index) =>
|
|
1436
|
+
Effect.sync(() => ({
|
|
1437
|
+
model: undefined,
|
|
1438
|
+
expected: undefined,
|
|
1439
|
+
...(options.await === undefined ? {} : { await: options.await({ index, command, probe }) })
|
|
1440
|
+
})),
|
|
1441
|
+
assert: () => Effect.void
|
|
1442
|
+
})
|
|
1443
|
+
const evidence: CausalVerificationTranscript<M, Error, Output> = {
|
|
1444
|
+
commands: transcript.commands,
|
|
1445
|
+
initial: transcript.initial,
|
|
1446
|
+
records: transcript.records.map(({ actual, command, index }) => ({ index, command, actual })),
|
|
1447
|
+
final: transcript.final
|
|
1448
|
+
}
|
|
1449
|
+
yield* assertRuntimeInvariants(probe.machine, evidence, options.invariants)
|
|
1450
|
+
return evidence
|
|
1451
|
+
})
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Options controlling schema-derived runtime command generation.
|
|
1455
|
+
*
|
|
1456
|
+
* @category models
|
|
1457
|
+
* @since 0.4.0
|
|
1458
|
+
*/
|
|
1459
|
+
export interface RuntimeCommandsOptions<M extends AnyMachine> {
|
|
1460
|
+
readonly minCommands?: number
|
|
1461
|
+
readonly maxCommands?: number
|
|
1462
|
+
readonly eventArbitrary?: FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>
|
|
1463
|
+
readonly advanceArbitrary?: FastCheck.Arbitrary<Duration.Input>
|
|
1464
|
+
readonly includeAdvance?: boolean
|
|
1465
|
+
readonly includeStop?: boolean
|
|
1466
|
+
readonly includeCheckpoint?: boolean
|
|
1467
|
+
readonly additionalCommands?: ReadonlyArray<FastCheck.Arbitrary<RuntimeCommand<Machine.Machine.InputEvent<M>>>>
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Diagnostics describing a schema-derived runtime command arbitrary.
|
|
1472
|
+
*
|
|
1473
|
+
* @category models
|
|
1474
|
+
* @since 0.4.0
|
|
1475
|
+
*/
|
|
1476
|
+
export interface RuntimeCommandsDiagnostics {
|
|
1477
|
+
readonly events: "none" | "schema" | "override"
|
|
1478
|
+
readonly schemaReports: ReadonlyArray<SchemaArbitraryReport>
|
|
1479
|
+
readonly includesAdvance: boolean
|
|
1480
|
+
readonly includesStop: boolean
|
|
1481
|
+
readonly includesCheckpoint: boolean
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* A shrinkable runtime command arbitrary and its derivation diagnostics.
|
|
1486
|
+
*
|
|
1487
|
+
* @category models
|
|
1488
|
+
* @since 0.4.0
|
|
1489
|
+
*/
|
|
1490
|
+
export interface RuntimeCommands<M extends AnyMachine> {
|
|
1491
|
+
readonly arbitrary: FastCheck.Arbitrary<ReadonlyArray<RuntimeCommand<Machine.Machine.InputEvent<M>>>>
|
|
1492
|
+
readonly diagnostics: RuntimeCommandsDiagnostics
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const validateCommandLength = (name: "minCommands" | "maxCommands", value: number): void => {
|
|
1496
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
1497
|
+
throw new Error(`MachineTest.runtimeCommands expected ${name} to be a non-negative safe integer`)
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Derives a shrinkable command sequence from public event schemas and explicit
|
|
1503
|
+
* clock/stop/checkpoint command choices.
|
|
1504
|
+
*
|
|
1505
|
+
* This deliberately returns ordinary Effect FastCheck arbitraries instead of
|
|
1506
|
+
* adapting the runner through `asyncModelRun`: the latter requires Promise
|
|
1507
|
+
* callbacks and would erase Effect error and service channels.
|
|
1508
|
+
*
|
|
1509
|
+
* @category constructors
|
|
1510
|
+
* @since 0.4.0
|
|
1511
|
+
*/
|
|
1512
|
+
export const runtimeCommands = <M extends AnyMachine>(
|
|
1513
|
+
machine: M,
|
|
1514
|
+
options: RuntimeCommandsOptions<M> = {}
|
|
1515
|
+
): RuntimeCommands<M> => {
|
|
1516
|
+
const minCommands = options.minCommands ?? 0
|
|
1517
|
+
const maxCommands = options.maxCommands ?? 50
|
|
1518
|
+
validateCommandLength("minCommands", minCommands)
|
|
1519
|
+
validateCommandLength("maxCommands", maxCommands)
|
|
1520
|
+
if (minCommands > maxCommands) {
|
|
1521
|
+
throw new Error("MachineTest.runtimeCommands expected minCommands to be less than or equal to maxCommands")
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const reports: Array<SchemaArbitraryReport> = []
|
|
1525
|
+
const eventArbitraries = options.eventArbitrary === undefined
|
|
1526
|
+
? machine.events.map((schema) => {
|
|
1527
|
+
const derived = toArbitraryWithReport(schema)
|
|
1528
|
+
reports.push(derived.report)
|
|
1529
|
+
return derived.value as FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>
|
|
1530
|
+
})
|
|
1531
|
+
: []
|
|
1532
|
+
const eventArbitrary = options.eventArbitrary ?? (eventArbitraries.length === 0
|
|
1533
|
+
? undefined
|
|
1534
|
+
: FastCheck.oneof(
|
|
1535
|
+
...eventArbitraries as [
|
|
1536
|
+
FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>,
|
|
1537
|
+
...Array<FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>>
|
|
1538
|
+
]
|
|
1539
|
+
))
|
|
1540
|
+
const commandArbitraries: Array<FastCheck.Arbitrary<RuntimeCommand<Machine.Machine.InputEvent<M>>>> = []
|
|
1541
|
+
if (eventArbitrary !== undefined) commandArbitraries.push(eventArbitrary.map(sendCommand))
|
|
1542
|
+
|
|
1543
|
+
if (options.includeAdvance !== false) {
|
|
1544
|
+
const advanceArbitrary = options.advanceArbitrary ?? FastCheck.nat({ max: 60_000 })
|
|
1545
|
+
commandArbitraries.push(advanceArbitrary.map(advanceCommand))
|
|
1546
|
+
}
|
|
1547
|
+
if (options.includeStop !== false) commandArbitraries.push(FastCheck.constant(stopCommand()))
|
|
1548
|
+
if (options.includeCheckpoint !== false) commandArbitraries.push(FastCheck.constant(checkpointCommand()))
|
|
1549
|
+
commandArbitraries.push(...options.additionalCommands ?? [])
|
|
1550
|
+
if (commandArbitraries.length === 0) {
|
|
1551
|
+
if (minCommands > 0) {
|
|
1552
|
+
throw new Error("MachineTest.runtimeCommands cannot generate a non-empty command sequence without commands")
|
|
1553
|
+
}
|
|
1554
|
+
return {
|
|
1555
|
+
arbitrary: FastCheck.constant([]),
|
|
1556
|
+
diagnostics: {
|
|
1557
|
+
events: options.eventArbitrary !== undefined ? "override" : eventArbitraries.length === 0 ? "none" : "schema",
|
|
1558
|
+
schemaReports: reports,
|
|
1559
|
+
includesAdvance: false,
|
|
1560
|
+
includesStop: false,
|
|
1561
|
+
includesCheckpoint: false
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
return {
|
|
1567
|
+
arbitrary: FastCheck.array(FastCheck.oneof(...commandArbitraries), {
|
|
1568
|
+
minLength: minCommands,
|
|
1569
|
+
maxLength: maxCommands
|
|
1570
|
+
}),
|
|
1571
|
+
diagnostics: {
|
|
1572
|
+
events: options.eventArbitrary !== undefined ? "override" : eventArbitraries.length === 0 ? "none" : "schema",
|
|
1573
|
+
schemaReports: reports,
|
|
1574
|
+
includesAdvance: options.includeAdvance !== false,
|
|
1575
|
+
includesStop: options.includeStop !== false,
|
|
1576
|
+
includesCheckpoint: options.includeCheckpoint !== false
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* Formats a runtime transcript or failure as replayable line-oriented evidence.
|
|
1583
|
+
*
|
|
1584
|
+
* @category formatting
|
|
1585
|
+
* @since 0.4.0
|
|
1586
|
+
*/
|
|
1587
|
+
export const formatEnqueuedTranscript = (
|
|
1588
|
+
value:
|
|
1589
|
+
| RuntimeTranscript<any, any, any, any, any, any, any>
|
|
1590
|
+
| RuntimeCommandFailure<any, any, any, any, any, any, any, any>
|
|
1591
|
+
): string => {
|
|
1592
|
+
const failure = value instanceof RuntimeCommandFailure
|
|
1593
|
+
const records = failure ? value.prefix : value.records
|
|
1594
|
+
const lines = [
|
|
1595
|
+
`commands: ${
|
|
1596
|
+
Inspectable.toStringUnknown(
|
|
1597
|
+
failure ?
|
|
1598
|
+
[
|
|
1599
|
+
...value.prefix.map((record) => record.command),
|
|
1600
|
+
value.command
|
|
1601
|
+
] :
|
|
1602
|
+
value.commands,
|
|
1603
|
+
0
|
|
1604
|
+
)
|
|
1605
|
+
}`
|
|
1606
|
+
]
|
|
1607
|
+
for (const record of records) {
|
|
1608
|
+
lines.push(
|
|
1609
|
+
`command ${record.index}: command=${Inspectable.toStringUnknown(record.command, 0)} ` +
|
|
1610
|
+
`model=${Inspectable.toStringUnknown(record.model, 0)} ` +
|
|
1611
|
+
`expected=${Inspectable.toStringUnknown(record.expected, 0)} ` +
|
|
1612
|
+
`result=${Inspectable.toStringUnknown(record.actual.result, 0)} ` +
|
|
1613
|
+
`snapshot=${Inspectable.toStringUnknown(record.actual.snapshot, 0)} ` +
|
|
1614
|
+
`published=${Inspectable.toStringUnknown(record.actual.published, 0)} ` +
|
|
1615
|
+
`inspected=${Inspectable.toStringUnknown(record.actual.inspected, 0)}`
|
|
1616
|
+
)
|
|
1617
|
+
}
|
|
1618
|
+
if (failure) {
|
|
1619
|
+
if (value.attempted !== undefined) {
|
|
1620
|
+
lines.push(
|
|
1621
|
+
`attempted ${value.attempted.index}: command=${Inspectable.toStringUnknown(value.attempted.command, 0)} ` +
|
|
1622
|
+
`model=${Inspectable.toStringUnknown(value.attempted.model, 0)} ` +
|
|
1623
|
+
`expected=${Inspectable.toStringUnknown(value.attempted.expected, 0)} ` +
|
|
1624
|
+
`result=${Inspectable.toStringUnknown(value.attempted.actual.result, 0)} ` +
|
|
1625
|
+
`snapshot=${Inspectable.toStringUnknown(value.attempted.actual.snapshot, 0)} ` +
|
|
1626
|
+
`published=${Inspectable.toStringUnknown(value.attempted.actual.published, 0)} ` +
|
|
1627
|
+
`inspected=${Inspectable.toStringUnknown(value.attempted.actual.inspected, 0)}`
|
|
1628
|
+
)
|
|
1629
|
+
}
|
|
1630
|
+
lines.push(
|
|
1631
|
+
`failure: phase=${value.phase} index=${value.index} command=${Inspectable.toStringUnknown(value.command, 0)} ` +
|
|
1632
|
+
`cause=${Inspectable.toStringUnknown(value.cause, 0)}`
|
|
1633
|
+
)
|
|
1634
|
+
} else {
|
|
1635
|
+
lines.push(
|
|
1636
|
+
`final: synchronized=${String(value.synchronized)} snapshot=${Inspectable.toStringUnknown(value.final, 0)}`
|
|
1637
|
+
)
|
|
1638
|
+
}
|
|
1639
|
+
return lines.join("\n")
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Compatibility alias for enqueue-oriented transcript formatting.
|
|
1644
|
+
*
|
|
1645
|
+
* @deprecated Use `formatEnqueuedTranscript`.
|
|
1646
|
+
*
|
|
1647
|
+
* @category formatting
|
|
1648
|
+
* @since 0.4.0
|
|
1649
|
+
*/
|
|
1650
|
+
export const formatRuntimeTranscript: typeof formatEnqueuedTranscript = formatEnqueuedTranscript
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Formats a causal runtime transcript or failure as replayable line-oriented
|
|
1654
|
+
* evidence, including exact probe steps and explicit asynchronous observations.
|
|
1655
|
+
*
|
|
1656
|
+
* @category formatting
|
|
1657
|
+
* @since 0.4.0
|
|
1658
|
+
*/
|
|
1659
|
+
export const formatCausalTranscript = (
|
|
1660
|
+
value:
|
|
1661
|
+
| CausalRuntimeTranscript<any, any, any, any, any, any>
|
|
1662
|
+
| CausalRuntimeCommandFailure<any, any, any, any, any, any, any>
|
|
1663
|
+
): string => {
|
|
1664
|
+
const failure = value instanceof CausalRuntimeCommandFailure
|
|
1665
|
+
const records = failure ? value.prefix : value.records
|
|
1666
|
+
const lines = [
|
|
1667
|
+
`commands: ${
|
|
1668
|
+
Inspectable.toStringUnknown(
|
|
1669
|
+
failure
|
|
1670
|
+
? [
|
|
1671
|
+
...value.prefix.map((record) => record.command),
|
|
1672
|
+
value.command
|
|
1673
|
+
]
|
|
1674
|
+
: value.commands,
|
|
1675
|
+
0
|
|
1676
|
+
)
|
|
1677
|
+
}`
|
|
1678
|
+
]
|
|
1679
|
+
for (const record of records) {
|
|
1680
|
+
lines.push(
|
|
1681
|
+
`command ${record.index}: command=${Inspectable.toStringUnknown(record.command, 0)} ` +
|
|
1682
|
+
`model=${Inspectable.toStringUnknown(record.model, 0)} ` +
|
|
1683
|
+
`expected=${Inspectable.toStringUnknown(record.expected, 0)} ` +
|
|
1684
|
+
`result=${Inspectable.toStringUnknown(record.actual.result, 0)} ` +
|
|
1685
|
+
`snapshot=${Inspectable.toStringUnknown(record.actual.snapshot, 0)} ` +
|
|
1686
|
+
`awaited=${Inspectable.toStringUnknown(record.actual.awaited, 0)} ` +
|
|
1687
|
+
`inspected=${Inspectable.toStringUnknown(record.actual.inspected, 0)}`
|
|
1688
|
+
)
|
|
1689
|
+
}
|
|
1690
|
+
if (failure) {
|
|
1691
|
+
if (value.attempted !== undefined) {
|
|
1692
|
+
lines.push(
|
|
1693
|
+
`attempted ${value.attempted.index}: command=${Inspectable.toStringUnknown(value.attempted.command, 0)} ` +
|
|
1694
|
+
`model=${Inspectable.toStringUnknown(value.attempted.model, 0)} ` +
|
|
1695
|
+
`expected=${Inspectable.toStringUnknown(value.attempted.expected, 0)} ` +
|
|
1696
|
+
`result=${Inspectable.toStringUnknown(value.attempted.result, 0)} ` +
|
|
1697
|
+
`snapshot=${Inspectable.toStringUnknown(value.attempted.snapshot, 0)} ` +
|
|
1698
|
+
`awaited=${Inspectable.toStringUnknown(value.attempted.awaited, 0)} ` +
|
|
1699
|
+
`inspected=${Inspectable.toStringUnknown(value.attempted.inspected, 0)}`
|
|
1700
|
+
)
|
|
1701
|
+
}
|
|
1702
|
+
lines.push(
|
|
1703
|
+
`failure: phase=${value.phase} index=${value.index} command=${Inspectable.toStringUnknown(value.command, 0)} ` +
|
|
1704
|
+
`cause=${Inspectable.toStringUnknown(value.cause, 0)}`
|
|
1705
|
+
)
|
|
1706
|
+
} else {
|
|
1707
|
+
lines.push(`final: snapshot=${Inspectable.toStringUnknown(value.final, 0)}`)
|
|
1708
|
+
}
|
|
1709
|
+
return lines.join("\n")
|
|
1710
|
+
}
|