@typeonce/effect-machine 0.6.0 → 0.7.0

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.
Files changed (54) hide show
  1. package/README.md +4 -3
  2. package/dist/Machine.d.ts +24 -6
  3. package/dist/Machine.d.ts.map +1 -1
  4. package/dist/Machine.js.map +1 -1
  5. package/dist/internal/machine/atom.d.ts +5 -0
  6. package/dist/internal/machine/atom.d.ts.map +1 -1
  7. package/dist/internal/machine/atom.js +6 -3
  8. package/dist/internal/machine/atom.js.map +1 -1
  9. package/dist/internal/machine/machine.d.ts.map +1 -1
  10. package/dist/internal/machine/machine.js +1 -1
  11. package/dist/internal/machine/machine.js.map +1 -1
  12. package/dist/unstable/reactivity/AtomMachine.d.ts +26 -0
  13. package/dist/unstable/reactivity/AtomMachine.d.ts.map +1 -1
  14. package/dist/unstable/reactivity/AtomMachine.js +23 -0
  15. package/dist/unstable/reactivity/AtomMachine.js.map +1 -1
  16. package/docs/agent-guide.md +14 -0
  17. package/package.json +5 -5
  18. package/src/Machine.ts +6909 -0
  19. package/src/index.ts +1 -0
  20. package/src/internal/machine/activities.ts +108 -0
  21. package/src/internal/machine/atom.ts +684 -0
  22. package/src/internal/machine/cluster.ts +394 -0
  23. package/src/internal/machine/command.ts +58 -0
  24. package/src/internal/machine/commandRuntime.ts +43 -0
  25. package/src/internal/machine/configuration.ts +1331 -0
  26. package/src/internal/machine/errors.ts +87 -0
  27. package/src/internal/machine/executionPlan.ts +996 -0
  28. package/src/internal/machine/invocation.ts +119 -0
  29. package/src/internal/machine/machine.ts +1750 -0
  30. package/src/internal/machine/planner.ts +1933 -0
  31. package/src/internal/machine/process.ts +906 -0
  32. package/src/internal/machine/protocol.ts +322 -0
  33. package/src/internal/machine/readiness.ts +10 -0
  34. package/src/internal/machine/runtime.ts +2512 -0
  35. package/src/internal/machine/serialization.ts +498 -0
  36. package/src/internal/machine/stateDefinition.ts +270 -0
  37. package/src/internal/machine/symbols.ts +2 -0
  38. package/src/internal/machine/topology.ts +479 -0
  39. package/src/internal/testing/machine/arbitrary.ts +102 -0
  40. package/src/internal/testing/machine/exploration.ts +331 -0
  41. package/src/internal/testing/machine/finiteModel.ts +1498 -0
  42. package/src/internal/testing/machine/invariant.ts +372 -0
  43. package/src/internal/testing/machine/probe.ts +79 -0
  44. package/src/internal/testing/machine/referenceModel.ts +1505 -0
  45. package/src/internal/testing/machine/runtime.ts +1710 -0
  46. package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
  47. package/src/internal/testing/machine/trace.ts +150 -0
  48. package/src/internal/testing/machine/verification.ts +1890 -0
  49. package/src/testing/MachineTest.ts +2067 -0
  50. package/src/testing/index.ts +7 -0
  51. package/src/unstable/cluster/ClusterMachine.ts +390 -0
  52. package/src/unstable/cluster/index.ts +1 -0
  53. package/src/unstable/reactivity/AtomMachine.ts +696 -0
  54. 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
+ }