@reventlessdev/reventless-gwt 1.0.0-alpha.247 → 1.0.0-alpha.249

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 (50) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -7
  4. package/src/.gwtignore +1 -1
  5. package/src/{Behavior_GWT.res → Decision_GWT.res} +110 -40
  6. package/src/{Behavior_GWT.res.mjs → Decision_GWT.res.mjs} +92 -42
  7. package/src/Delegate_GWT.res +1 -1
  8. package/src/EventMapping_GWT.res +7 -7
  9. package/src/EventMapping_GWT.res.mjs +3 -3
  10. package/src/Flow_GWT.res +14 -14
  11. package/src/Flow_GWT.res.mjs +6 -6
  12. package/src/Hint.res +1 -1
  13. package/src/Mapping_GWT.res +8 -8
  14. package/src/Mapping_GWT.res.mjs +6 -6
  15. package/src/Outcome.res +1 -1
  16. package/src/Query_GWT.res +1 -1
  17. package/src/RunnerHook.res +1 -1
  18. package/src/ScenarioRecorder.res +182 -0
  19. package/src/ScenarioRecorder.res.mjs +287 -0
  20. package/src/StateChange/ExternalAddCategorySlice.res +1 -1
  21. package/src/StateChange/WithFixtures.res +1 -1
  22. package/src/StateChange/WithManualOpen.res +1 -1
  23. package/tests/CallerTest.res +7 -7
  24. package/tests/CallerTest.res.mjs +8 -8
  25. package/tests/FlowAggregateGwtTest.res +6 -6
  26. package/tests/FlowAggregateGwtTest.res.mjs +6 -6
  27. package/tests/FlowCrossPluginGwtTest.res +8 -8
  28. package/tests/FlowCrossPluginGwtTest.res.mjs +8 -8
  29. package/tests/FlowGwtTest.res +4 -4
  30. package/tests/FlowGwtTest.res.mjs +4 -4
  31. package/tests/HintTest.res +2 -2
  32. package/tests/HintTest.res.mjs +1 -1
  33. package/tests/MappingGwtTest.res +5 -5
  34. package/tests/MappingGwtTest.res.mjs +6 -6
  35. package/tests/ScenarioRecorderTest.res +101 -0
  36. package/tests/ScenarioRecorderTest.res.mjs +163 -0
  37. package/tests/StateChange/ExternalAddCategorySlice_GWT.res +3 -3
  38. package/tests/StateChange/ExternalAddCategorySlice_GWT.res.mjs +5 -5
  39. package/tests/StateChange/WithFixtures_GWT.res.mjs +5 -5
  40. package/tests/StateChange/WithManualOpen_GWT.res.mjs +5 -5
  41. package/tests/StateChangeSliceAuthorizeGwtTest.res +10 -10
  42. package/tests/StateChangeSliceAuthorizeGwtTest.res.mjs +14 -14
  43. package/tests/StateChangeSliceGwtTest.res +9 -9
  44. package/tests/StateChangeSliceGwtTest.res.mjs +12 -12
  45. /package/src/StateChange/{ExternalAddCategorySlice_Behavior.res → ExternalAddCategorySlice_Decision.res} +0 -0
  46. /package/src/StateChange/{ExternalAddCategorySlice_Behavior.res.mjs → ExternalAddCategorySlice_Decision.res.mjs} +0 -0
  47. /package/src/StateChange/{WithFixtures_Behavior.res → WithFixtures_Decision.res} +0 -0
  48. /package/src/StateChange/{WithFixtures_Behavior.res.mjs → WithFixtures_Decision.res.mjs} +0 -0
  49. /package/src/StateChange/{WithManualOpen_Behavior.res → WithManualOpen_Decision.res} +0 -0
  50. /package/src/StateChange/{WithManualOpen_Behavior.res.mjs → WithManualOpen_Decision.res.mjs} +0 -0
@@ -3,7 +3,7 @@ open ReventlessCore
3
3
  // Delegate_GWT — GWT for ExtensionPoint mappings and Extension delegates.
4
4
  //
5
5
  // Both are pure input → published-actions translations and form the one
6
- // cross-plugin behavioural seam (per `.claude/rules/app-developer.md`). Each
6
+ // cross-plugin behavioral seam (per `.claude/rules/app-developer.md`). Each
7
7
  // mapping has TWO directions, and each adapter drives both:
8
8
  //
9
9
  // * an ExtensionPoint mapping turns an internal `Delegate` event into public
@@ -2,20 +2,20 @@ open ReventlessCore
2
2
 
3
3
  // Stage 5 — `EventMapping_GWT` is now a thin alias over `Mapping_GWT`
4
4
  // specialised for the Aggregate → Aggregate case. New tests should use
5
- // `Mapping_GWT.Make` directly with `Mapping_GWT.FromBehavior` adapters; this
5
+ // `Mapping_GWT.Make` directly with `Mapping_GWT.FromDecision` adapters; this
6
6
  // wrapper is retained for migration backward-compat with consumers that were
7
- // already calling `EventMapping_GWT.Make(Source, SourceBehavior, Target,
8
- // TargetBehavior, EventMapping)` before Stage 5 landed.
7
+ // already calling `EventMapping_GWT.Make(Source, SourceDecision, Target,
8
+ // TargetDecision, EventMapping)` before Stage 5 landed.
9
9
 
10
10
  module Make = (
11
11
  Source: Reventless.Aggregate.Spec,
12
- SourceBehavior: Behavior.T with module Spec = Source,
12
+ SourceDecision: Decision.T with module Spec = Source,
13
13
  Target: Reventless.Aggregate.Spec,
14
- TargetBehavior: Behavior.T with module Spec = Target,
14
+ TargetDecision: Decision.T with module Spec = Target,
15
15
  EventMapping: Reventless.EventMapping.T with module Source = Source and module Target = Target,
16
16
  ) => {
17
- module AdaptedSource = Mapping_GWT.FromBehavior(Source, SourceBehavior)
18
- module AdaptedTarget = Mapping_GWT.FromBehavior(Target, TargetBehavior)
17
+ module AdaptedSource = Mapping_GWT.FromDecision(Source, SourceDecision)
18
+ module AdaptedTarget = Mapping_GWT.FromDecision(Target, TargetDecision)
19
19
 
20
20
  module BoundMapping = {
21
21
  module Source = AdaptedSource
@@ -3,9 +3,9 @@
3
3
  import * as Mapping_GWT$ReventlessGwt from "./Mapping_GWT.res.mjs";
4
4
 
5
5
  function Make(Source) {
6
- return SourceBehavior => (Target => (TargetBehavior => (EventMapping => {
7
- let AdaptedSource = Mapping_GWT$ReventlessGwt.FromBehavior(Source)(SourceBehavior);
8
- let AdaptedTarget = Mapping_GWT$ReventlessGwt.FromBehavior(Target)(TargetBehavior);
6
+ return SourceDecision => (Target => (TargetDecision => (EventMapping => {
7
+ let AdaptedSource = Mapping_GWT$ReventlessGwt.FromDecision(Source)(SourceDecision);
8
+ let AdaptedTarget = Mapping_GWT$ReventlessGwt.FromDecision(Target)(TargetDecision);
9
9
  let map = EventMapping.map;
10
10
  let BoundMapping = {
11
11
  Source: undefined,
package/src/Flow_GWT.res CHANGED
@@ -13,15 +13,15 @@ open ReventlessCore
13
13
  // each step folds the slice's own `decide`/`evolve`/`collect`/`project` over
14
14
  // the shared log. The log is JSON-erased (each step (de)serialises through its
15
15
  // own spec's schema, exactly as the runtime routes a DCB event log), and each
16
- // command step honours DCB tag filtering so downstream deciders never see
16
+ // command step honors DCB tag filtering so downstream deciders never see
17
17
  // phantom cross-entity events.
18
18
  //
19
19
  // The chain is built from per-step functors (one per slice), threaded with
20
20
  // pipe-first so it reads top-to-bottom:
21
21
  //
22
- // module Place = Flow_GWT.CommandStep(PlaceOrder, PlaceOrder_Behavior)
22
+ // module Place = Flow_GWT.CommandStep(PlaceOrder, PlaceOrder_Decision)
23
23
  // module Auto = Flow_GWT.AutomationSlice(AutoShipOrder, AutoShipOrder_Automation)
24
- // module Ship = Flow_GWT.CommandStep(ShipOrder, ShipOrder_Behavior)
24
+ // module Ship = Flow_GWT.CommandStep(ShipOrder, ShipOrder_Decision)
25
25
  // module View = Flow_GWT.ViewStep(Orders, Orders_Projection)
26
26
  //
27
27
  // start
@@ -279,8 +279,8 @@ let thenBoundaryScopeResolves = async (
279
279
  // -- CommandStep: a StateChangeSlice / Aggregate decider ---------------------
280
280
 
281
281
  module CommandStep = (
282
- Spec: Behavior_GWT.BehaviorSpec,
283
- Behavior: Behavior_GWT.Behavior with module Spec := Spec,
282
+ Spec: Decision_GWT.DecisionSpec,
283
+ Decision: Decision_GWT.Decision with module Spec := Spec,
284
284
  ) => {
285
285
  let consumedDecoder = Reventless.DcbDecode.makeDecoder(Spec.consumedEventSchema)
286
286
  let consumedEventTypes = consumedDecoder.eventTypes
@@ -335,8 +335,8 @@ module CommandStep = (
335
335
  ~payloadTagKeys,
336
336
  )
337
337
  let history = decodeMatching(s.log, consumedDecoder, query)
338
- let state = history->Array.reduce(Behavior.initialState, Behavior.evolve)
339
- switch Behavior.decide(state, command) {
338
+ let state = history->Array.reduce(Decision.initialState, Decision.evolve)
339
+ switch Decision.decide(state, command) {
340
340
  | Ok(events) => {
341
341
  ...s,
342
342
  log: s.log->appendEvents(events, Spec.eventSchema),
@@ -390,8 +390,8 @@ module CommandStep = (
390
390
 
391
391
  // -- AggregateCommandStep: an Aggregate decider partitioned by aggregate-ID --
392
392
  //
393
- // Mirror of `CommandStep` for `Reventless.Behavior.T` (the aggregate-flavor
394
- // behavior). Differences from the slice form:
393
+ // Mirror of `CommandStep` for `Reventless.Decision.T` (the aggregate-flavor
394
+ // decision). Differences from the slice form:
395
395
  // * No `consumedEvent` distinction — aggregates fold their own `event`
396
396
  // stream; the decoder uses `Spec.eventSchema`.
397
397
  // * No DCB tag inference — `~id` partitions the history. `givenEvents` and
@@ -404,8 +404,8 @@ module CommandStep = (
404
404
  // histories isolated (a `Place` on `o2` does not see the events of `o1`).
405
405
 
406
406
  module AggregateCommandStep = (
407
- Spec: Behavior_GWT.AggregateSpec,
408
- Behavior: Reventless.Behavior.T with module Spec = Spec,
407
+ Spec: Decision_GWT.AggregateSpec,
408
+ Decision: Reventless.Decision.T with module Spec = Spec,
409
409
  ) => {
410
410
  let aggregateDecoder = Reventless.DcbDecode.makeDecoder(Spec.eventSchema)
411
411
 
@@ -422,8 +422,8 @@ module AggregateCommandStep = (
422
422
  let whenCommand = async (flowP: flow, ~id: string, command: Spec.command) => {
423
423
  let s = await flowP
424
424
  let history = decodeAggregateMatching(s.log, ~id, aggregateDecoder)
425
- let state = history->Array.reduce(Behavior.initialState, Behavior.evolve)
426
- switch Behavior.decide(state, command) {
425
+ let state = history->Array.reduce(Decision.initialState, Decision.evolve)
426
+ switch Decision.decide(state, command) {
427
427
  | Ok(events) => {
428
428
  ...s,
429
429
  log: s.log->appendAggregateEvents(~id, events, Spec.eventSchema),
@@ -717,7 +717,7 @@ module ExtensionPointStep = (M: EPMapping.Mapping) => {
717
717
  // Aggregate-style EP mappings address the source aggregate's identity via
718
718
  // the first arg (e.g. catalog Product → ProductBecameAvailable uses it as
719
719
  // `productId`). DCB upstreams don't set `lastAggregateId` and fall back to
720
- // the synthetic `gwt-id` so existing DCB Flow tests keep their behaviour.
720
+ // the synthetic `gwt-id` so existing DCB Flow tests keep their behavior.
721
721
  let runMapping = async (sourceId: string, events: array<M.Delegate.event>): array<
722
722
  M.ExtensionPoint.event,
723
723
  > =>
@@ -171,7 +171,7 @@ async function thenBoundaryScopeResolves(flowP, name, slices) {
171
171
  }
172
172
 
173
173
  function CommandStep(Spec) {
174
- return Behavior => {
174
+ return Decision => {
175
175
  let consumedDecoder = DcbDecode$Reventless.makeDecoder(Spec.consumedEventSchema);
176
176
  let consumedEventTypes = consumedDecoder.eventTypes;
177
177
  let givenEvents = async (flowP, events) => {
@@ -207,8 +207,8 @@ function CommandStep(Spec) {
207
207
  let s = await flowP;
208
208
  let query = DcbTag$Reventless.buildQueryFromCommand(consumedEventTypes, Spec.commandSchema, command, tagKeysByEventType, crossPartitionTagKeys, payloadTagKeys);
209
209
  let history = decodeMatching(s.log, consumedDecoder, query);
210
- let state = Stdlib_Array.reduce(history, Behavior.initialState, Behavior.evolve);
211
- let events = Behavior.decide(state, command);
210
+ let state = Stdlib_Array.reduce(history, Decision.initialState, Decision.evolve);
211
+ let events = Decision.decide(state, command);
212
212
  if (events.TAG !== "Ok") {
213
213
  return {
214
214
  log: s.log,
@@ -288,7 +288,7 @@ function CommandStep(Spec) {
288
288
  }
289
289
 
290
290
  function AggregateCommandStep(Spec) {
291
- return Behavior => {
291
+ return Decision => {
292
292
  let aggregateDecoder = DcbDecode$Reventless.makeDecoder(Spec.eventSchema);
293
293
  let givenEvents = async (flowP, id, events) => {
294
294
  let s = await flowP;
@@ -306,8 +306,8 @@ function AggregateCommandStep(Spec) {
306
306
  let whenCommand = async (flowP, id, command) => {
307
307
  let s = await flowP;
308
308
  let history = decodeAggregateMatching(s.log, id, aggregateDecoder);
309
- let state = Stdlib_Array.reduce(history, Behavior.initialState, Behavior.evolve);
310
- let events = Behavior.decide(state, command);
309
+ let state = Stdlib_Array.reduce(history, Decision.initialState, Decision.evolve);
310
+ let events = Decision.decide(state, command);
311
311
  if (events.TAG !== "Ok") {
312
312
  return {
313
313
  log: s.log,
package/src/Hint.res CHANGED
@@ -5,7 +5,7 @@
5
5
  // See `docs/analysis/given-when-then-specifications.md` §3.2 (locus table)
6
6
  // and §3.3 (JSON `hint` field).
7
7
  //
8
- // `locus` is a dotted pointer like "CategoryBehavior.decide" filled in by
8
+ // `locus` is a dotted pointer like "CategoryDecision.decide" filled in by
9
9
  // the DSL at `then*` call time when the slice module name is in scope, else a
10
10
  // generic pointer. A `StateMismatch` names its fold, so a view's points at
11
11
  // `project` rather than `evolve`.
@@ -3,7 +3,7 @@ open ReventlessCore
3
3
  // Stage 5 — cross-pattern mapping GWT.
4
4
  //
5
5
  // Generalises `EventMapping_GWT` so both the source and target of a mapping
6
- // can be an Aggregate (Behavior) or a StateChangeSlice. Covers all four
6
+ // can be an Aggregate (Decision) or a StateChangeSlice. Covers all four
7
7
  // producer/consumer combinations: Aggr→Aggr, Aggr→DCB, DCB→Aggr, DCB→DCB.
8
8
  // See `docs/plans/done/reventless-gwt.md` Stage 5 for the migration plan.
9
9
  //
@@ -54,20 +54,20 @@ module type Mapping = {
54
54
 
55
55
  // -- Adapter functors --------------------------------------------------------
56
56
 
57
- // Adapter: any `Aggregate.Spec` + matching `Behavior.T` becomes a `GwtSource`.
57
+ // Adapter: any `Aggregate.Spec` + matching `Decision.T` becomes a `GwtSource`.
58
58
  // Aggregates evolve their own events, so `consumedEvent = event`.
59
- module FromBehavior = (
59
+ module FromDecision = (
60
60
  Spec: Reventless.Aggregate.Spec,
61
- Behavior: Behavior.T with module Spec = Spec,
61
+ Decision: Decision.T with module Spec = Spec,
62
62
  ) => {
63
63
  let name = Spec.name
64
64
  module Id = Spec.Id
65
- type state = Behavior.state
66
- let initialState = Behavior.initialState
65
+ type state = Decision.state
66
+ let initialState = Decision.initialState
67
67
 
68
68
  type consumedEvent = Spec.event
69
69
  let consumedEventSchema = Spec.eventSchema
70
- let evolve = Behavior.evolve
70
+ let evolve = Decision.evolve
71
71
 
72
72
  type command = Spec.command
73
73
  let commandSchema = Spec.commandSchema
@@ -78,7 +78,7 @@ module FromBehavior = (
78
78
  type error = Spec.error
79
79
  let errorSchema = Spec.errorSchema
80
80
 
81
- let decide = Behavior.decide
81
+ let decide = Decision.decide
82
82
  }
83
83
 
84
84
  // Adapter: a StateChangeSlice spec becomes a `GwtSource`/`GwtTarget`. DCB
@@ -9,17 +9,17 @@ import * as Outcome$ReventlessGwt from "./Outcome.res.mjs";
9
9
  import * as JestBind$ReventlessGwt from "./JestBind.res.mjs";
10
10
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
11
11
 
12
- function FromBehavior(Spec) {
13
- return Behavior => ({
12
+ function FromDecision(Spec) {
13
+ return Decision => ({
14
14
  name: Spec.name,
15
15
  Id: Spec.Id,
16
- initialState: Behavior.initialState,
16
+ initialState: Decision.initialState,
17
17
  consumedEventSchema: Spec.eventSchema,
18
- evolve: Behavior.evolve,
18
+ evolve: Decision.evolve,
19
19
  commandSchema: Spec.commandSchema,
20
20
  eventSchema: Spec.eventSchema,
21
21
  errorSchema: Spec.errorSchema,
22
- decide: Behavior.decide
22
+ decide: Decision.decide
23
23
  });
24
24
  }
25
25
 
@@ -313,7 +313,7 @@ function Make(M) {
313
313
  }
314
314
 
315
315
  export {
316
- FromBehavior,
316
+ FromDecision,
317
317
  FromStateChangeSlice,
318
318
  Make,
319
319
  }
package/src/Outcome.res CHANGED
@@ -8,7 +8,7 @@
8
8
  // shape and §3.3 for the JSON rendering. Stage 7 adds sury-aware rendering and
9
9
  // structural `fieldDiff`. Stage 4 added `AppendConditionMismatch`.
10
10
 
11
- // The function whose fold produced a state: a behavior's `evolve`, or a view's
11
+ // The function whose fold produced a state: a decision's `evolve`, or a view's
12
12
  // `project`. Absent means `Evolve`, so a harness that does not say keeps its hint.
13
13
  type fold = Evolve | Project
14
14
 
package/src/Query_GWT.res CHANGED
@@ -12,7 +12,7 @@
12
12
  // `subIdConfig`), so both kinds of consumers use the same combinator set.
13
13
  // Adapter functors `FromReadModel` and `FromStateViewSlice` lift their
14
14
  // respective specs into the shared shape — mirroring how `Mapping_GWT` lifts
15
- // `Aggregate.Spec`/`StateChangeSlice.Spec` via `FromBehavior`/`FromStateChangeSlice`.
15
+ // `Aggregate.Spec`/`StateChangeSlice.Spec` via `FromDecision`/`FromStateChangeSlice`.
16
16
 
17
17
  // -- Unified spec shape ------------------------------------------------------
18
18
 
@@ -30,7 +30,7 @@ type sink = {
30
30
  ) => unit,
31
31
  }
32
32
 
33
- // The currently-registered sink, if any. `None` → plain Jest behaviour.
33
+ // The currently-registered sink, if any. `None` → plain Jest behavior.
34
34
  let current: ref<option<sink>> = ref(None)
35
35
 
36
36
  let register = (s: sink) => current := Some(s)
@@ -0,0 +1,182 @@
1
+ // Decision scenarios as they run, written as sidecars `check-lifecycle` reads.
2
+ //
3
+ // The PPX writes a `.gwt.json` sidecar from a `_GWT.res` file's source, so it can only name what
4
+ // the source spells out: a literal constructor in a step. A suite written against a binding (a
5
+ // trait's conformance suite, whose `B.request(…)` is whatever the host's binding builds) names
6
+ // nothing in its source, and the host's file that registers it has no `describe` of its own, so
7
+ // no sidecar is written and `check-lifecycle` sees none of its scenarios. The run has the values,
8
+ // and the spec's schemas name them; this records them.
9
+ //
10
+ // Only with `REVENTLESS_EMIT_SIDECAR` set in the test process, the switch the PPX reads at build
11
+ // time; otherwise every call is a no-op. A test file whose sidecar the PPX wrote is left alone:
12
+ // read from source, it carries values and ids a run cannot.
13
+
14
+ type entry = {kind: string, element: string}
15
+
16
+ type scenario = {
17
+ title: string,
18
+ given: array<entry>,
19
+ when_: array<entry>,
20
+ then_: array<entry>,
21
+ }
22
+
23
+ let enabled = () =>
24
+ switch NodeProcess.env->Dict.get("REVENTLESS_EMIT_SIDECAR") {
25
+ | Some("1" | "true" | "TRUE") => true
26
+ | _ => false
27
+ }
28
+
29
+ // The scenario of the test running now, if one is being recorded.
30
+ let current: ref<option<scenario>> = ref(None)
31
+
32
+ // The test file an external runner says it is running; Jest says it itself.
33
+ let fileOverride: ref<option<string>> = ref(None)
34
+
35
+ /** Names the test file an external runner (registered through `RunnerHook`) is about to run, so
36
+ what its tests record lands beside it. */
37
+ let setFile = (path: string) => fileOverride := Some(path)
38
+
39
+ // The running test's file under Jest, while a test runs.
40
+ let jestTestPath: unit => option<string> = %raw(`function () {
41
+ try {
42
+ const state = globalThis.expect && globalThis.expect.getState && globalThis.expect.getState();
43
+ return state && typeof state.testPath === "string" ? state.testPath : undefined;
44
+ } catch (_) { return undefined; }
45
+ }`)
46
+
47
+ let testFile = () =>
48
+ switch fileOverride.contents {
49
+ | Some(f) => Some(f)
50
+ | None => jestTestPath()
51
+ }
52
+
53
+ // What has been recorded in this process, per sidecar path: the spec it names and its scenarios.
54
+ let recorded: Map.t<string, (string, array<scenario>)> = Map.make()
55
+
56
+ // Per test file, the specs recorded from it in order, the first owning `<File>_GWT.gwt.json`.
57
+ let specsOf: Map.t<string, array<string>> = Map.make()
58
+
59
+ // Sidecar paths found written by the PPX: never written over.
60
+ let foreign: Set.t<string> = Set.make()
61
+
62
+ // `…/X_GWT.res.mjs` (or `.res`, `.mjs`, `.js`) → `…/X_GWT`.
63
+ let baseOf = (file: string) =>
64
+ [".res.mjs", ".res.js", ".res", ".mjs", ".js"]->Array.reduce(file, (f, ext) =>
65
+ f->String.endsWith(ext)
66
+ ? f->String.slice(~start=0, ~end=String.length(f) - String.length(ext))
67
+ : f
68
+ )
69
+
70
+ let sidecarFor = (~file: string, ~spec: string) => {
71
+ let specs = specsOf->Map.get(file)->Option.getOr([])
72
+ if !(specs->Array.includes(spec)) {
73
+ specs->Array.push(spec)
74
+ specsOf->Map.set(file, specs)
75
+ }
76
+ specs->Array.indexOf(spec) == 0 ? `${baseOf(file)}.gwt.json` : `${baseOf(file)}.${spec}.gwt.json`
77
+ }
78
+
79
+ // Whether a sidecar at `path` is the PPX's: present and not marked recorded.
80
+ let writtenByPpx = (path: string) =>
81
+ switch NodeFs.existsSync(path) {
82
+ | false => false
83
+ | true =>
84
+ switch NodeFs.readFileSync(path)->JSON.parseOrThrow {
85
+ | Object(d) => d->Dict.get("recorded") != Some(Boolean(true))
86
+ | _ => true
87
+ | exception _ => true
88
+ }
89
+ }
90
+
91
+ let entryJson = (e: entry) => JSON.Object(
92
+ Dict.fromArray([
93
+ ("kind", JSON.String(e.kind)),
94
+ ("element", JSON.String(e.element)),
95
+ ("values", JSON.Array([])),
96
+ ]),
97
+ )
98
+
99
+ let write = (~path: string, ~file: string, ~spec: string, scenarios: array<scenario>) => {
100
+ let json = JSON.Object(
101
+ Dict.fromArray([
102
+ ("specName", JSON.String(`${spec} (recorded)`)),
103
+ // What `check-lifecycle` takes the file's component from.
104
+ ("stem", JSON.String(spec)),
105
+ ("file", JSON.String(file)),
106
+ ("componentKind", JSON.String("Decision")),
107
+ ("recorded", JSON.Boolean(true)),
108
+ (
109
+ "scenarios",
110
+ JSON.Array(
111
+ scenarios->Array.map(s => JSON.Object(
112
+ Dict.fromArray([
113
+ ("title", JSON.String(s.title)),
114
+ ("given", JSON.Array(s.given->Array.map(entryJson))),
115
+ ("when", JSON.Array(s.when_->Array.map(entryJson))),
116
+ ("then", JSON.Array(s.then_->Array.map(entryJson))),
117
+ ]),
118
+ )),
119
+ ),
120
+ ),
121
+ ]),
122
+ )
123
+ NodeFs.writeFileSync(path, JSON.stringifyAny(json, ~space=2)->Option.getOr("{}") ++ "\n")
124
+ }
125
+
126
+ /** Runs one test's `body`, recording its steps when recording is on, and adds the scenario to
127
+ its file's sidecar. `spec` is the spec the test runs against, the component the sidecar names. */
128
+ let around = (~spec: string, title: string, body: unit => 'outcome): 'outcome =>
129
+ if !enabled() {
130
+ body()
131
+ } else {
132
+ current := Some({title, given: [], when_: [], then_: []})
133
+ let finish = () => {
134
+ let scenario = current.contents
135
+ current := None
136
+ switch (scenario, testFile()) {
137
+ | (Some(s), Some(file)) =>
138
+ let path = sidecarFor(~file, ~spec)
139
+ if !(foreign->Set.has(path)) && !(recorded->Map.has(path)) && writtenByPpx(path) {
140
+ foreign->Set.add(path)
141
+ }
142
+ if !(foreign->Set.has(path)) {
143
+ let (_, scenarios) = recorded->Map.get(path)->Option.getOr((spec, []))
144
+ let scenarios = scenarios->Array.filter(o => o.title != s.title)->Array.concat([s])
145
+ recorded->Map.set(path, (spec, scenarios))
146
+ write(~path, ~file, ~spec, scenarios)
147
+ }
148
+ | _ => ()
149
+ }
150
+ }
151
+ switch body() {
152
+ | outcome =>
153
+ finish()
154
+ outcome
155
+ | exception e =>
156
+ finish()
157
+ throw(e)
158
+ }
159
+ }
160
+
161
+ /** Whether a test is being recorded now: the steps name what they get only then. */
162
+ let recording = () => current.contents->Option.isSome
163
+
164
+ let push = (pick: scenario => array<entry>, entries: array<entry>) =>
165
+ current.contents->Option.forEach(s => entries->Array.forEach(e => pick(s)->Array.push(e)))
166
+
167
+ /** The history a command is decided on, by event name. */
168
+ let given = (names: array<string>) =>
169
+ push(s => s.given, names->Array.map(element => {kind: "event", element}))
170
+
171
+ /** The command a test sends. */
172
+ let whenCommand = (name: string) => push(s => s.when_, [{kind: "command", element: name}])
173
+
174
+ /** What a test expects: these events. */
175
+ let thenEvents = (names: array<string>) =>
176
+ push(s => s.then_, names->Array.map(element => {kind: "event", element}))
177
+
178
+ /** What a test expects: this error. */
179
+ let thenError = (name: string) => push(s => s.then_, [{kind: "error", element: name}])
180
+
181
+ /** What a test expects: no event. */
182
+ let thenNoEvent = () => push(s => s.then_, [{kind: "noEvent", element: ""}])