@reventlessdev/reventless-local 3.0.0-alpha.245 → 3.0.0-alpha.246

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 (31) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +17 -0
  4. package/src/Platform.res.mjs +7 -2
  5. package/src/ShellConfig.res +20 -2
  6. package/src/ShellConfig.res.mjs +9 -4
  7. package/src/UiSlots.res +191 -0
  8. package/src/UiSlots.res.mjs +91 -0
  9. package/src/adapter/Api/LocalEvents_Server.res +18 -0
  10. package/src/adapter/Api/LocalEvents_Server.res.mjs +11 -0
  11. package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +50 -3
  12. package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +62 -2
  13. package/tests/ShellConfigTest.res +41 -0
  14. package/tests/ShellConfigTest.res.mjs +40 -17
  15. package/tests/UiSlotsTest.res +197 -0
  16. package/tests/UiSlotsTest.res.mjs +196 -0
  17. package/tests/adapter/EventHistoryResolverTest.res +46 -0
  18. package/tests/adapter/EventHistoryResolverTest.res.mjs +41 -0
  19. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -1
  20. package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +2 -2
  21. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +3 -3
  22. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +1 -1
  23. package/tests/components/commandgenerator/CommandGeneratorFixtures.res.mjs +1 -1
  24. package/tests/components/commandtopic/CommandTopicFixtures.res.mjs +1 -1
  25. package/tests/components/commandtopic/CommandTopicStreamFixtures.res.mjs +1 -1
  26. package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +1 -1
  27. package/tests/components/dcb/DcbFixtures.res.mjs +1 -1
  28. package/tests/components/extensionpoint/ExtensionPointFixtures.res.mjs +2 -2
  29. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +1 -1
  30. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +1 -1
  31. package/tests/components/readmodel/DcbReadModelE2EFixtures.res.mjs +1 -1
@@ -8,6 +8,7 @@ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js
8
8
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
9
9
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
10
10
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
11
+ import * as Auth_GraphqlContext$ReventlessLocal from "../Auth/Auth_GraphqlContext.res.mjs";
11
12
  import * as Plugin_EventQuerySchema$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_EventQuerySchema.res.mjs";
12
13
 
13
14
  function comparePosition(a, b) {
@@ -153,6 +154,62 @@ function readFilter(args) {
153
154
  };
154
155
  }
155
156
 
157
+ function suppliedFilterKeys(f) {
158
+ return Stdlib_Array.filterMap([
159
+ [
160
+ "entityId",
161
+ Stdlib_Option.isSome(f.entityId)
162
+ ],
163
+ [
164
+ "tagKey",
165
+ Stdlib_Option.isSome(f.tagKey)
166
+ ],
167
+ [
168
+ "tagValue",
169
+ Stdlib_Option.isSome(f.tagValue)
170
+ ],
171
+ [
172
+ "eventTypes",
173
+ Stdlib_Option.isSome(f.eventTypes)
174
+ ],
175
+ [
176
+ "user",
177
+ Stdlib_Option.isSome(f.user)
178
+ ],
179
+ [
180
+ "timeFrom",
181
+ Stdlib_Option.isSome(f.timeFrom)
182
+ ],
183
+ [
184
+ "timeTo",
185
+ Stdlib_Option.isSome(f.timeTo)
186
+ ]
187
+ ], param => {
188
+ if (param[1]) {
189
+ return param[0];
190
+ }
191
+ });
192
+ }
193
+
194
+ function callerOf(ctx) {
195
+ let operation = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(ctx), d => d["params"]), Stdlib_JSON.Decode.object), d => d["operationName"]), Stdlib_JSON.Decode.string);
196
+ let user = Auth_GraphqlContext$ReventlessLocal.extractIdentity(ctx).userId;
197
+ if (operation !== undefined) {
198
+ return `operation "` + operation + `" as ` + user;
199
+ } else {
200
+ return `an unnamed operation as ` + user;
201
+ }
202
+ }
203
+
204
+ function describeCaller(ctx, filter) {
205
+ let keys = suppliedFilterKeys(filter);
206
+ if (keys.length !== 0) {
207
+ return callerOf(ctx) + `, with filter [` + keys.join(", ") + `]`;
208
+ } else {
209
+ return callerOf(ctx) + `, with no filter`;
210
+ }
211
+ }
212
+
156
213
  function matchesTag(r, f) {
157
214
  let match = f.tagKey;
158
215
  let match$1 = f.tagValue;
@@ -305,7 +362,7 @@ function Make(Bus) {
305
362
  }
306
363
  seen.add(displayName);
307
364
  let fieldName = Plugin_EventQuerySchema$ReventlessCore.historyFieldName(params.pluginName, displayName);
308
- let resolver = async (_root, args, _ctx) => {
365
+ let resolver = async (_root, args, ctx) => {
309
366
  let f = readFilter(args);
310
367
  let replay = Bus.getEventLogReplay(entry.busKey);
311
368
  let records;
@@ -314,7 +371,7 @@ function Make(Bus) {
314
371
  if (entityId !== undefined) {
315
372
  records = await readAggregate(replay, entityId);
316
373
  } else {
317
- log.warn("EventHistoryResolvers_GraphQL", undefined, fieldName + `: ` + displayName + ` is an aggregate event log — it can only be read per entity. Supply filter.entityId.`);
374
+ log.warn("EventHistoryResolvers_GraphQL", undefined, fieldName + `: ` + displayName + ` is an aggregate event log — it can only be read per entity. ` + (`Supply filter.entityId. Asked by ` + describeCaller(ctx, f) + `.`));
318
375
  records = [];
319
376
  }
320
377
  } else {
@@ -353,6 +410,9 @@ export {
353
410
  metaJsonFromEnvelope,
354
411
  recordJson,
355
412
  readFilter,
413
+ suppliedFilterKeys,
414
+ callerOf,
415
+ describeCaller,
356
416
  matchesTag,
357
417
  matchesFilter,
358
418
  paginate,
@@ -77,6 +77,47 @@ describe("ShellConfig.overlay", () => {
77
77
  )
78
78
  })
79
79
 
80
+ // The key that makes the served module reachable. `UiSlots` writes the file and
81
+ // this writes its name, and neither alone is the feature: a file nothing imports
82
+ // and a key pointing at a 404 both leave every mode drawing its own regions,
83
+ // which is what an undeclared deployment already does. Nothing about the running
84
+ // app distinguishes the three.
85
+ describe("ShellConfig.overlay — uiSlotsFile", () => {
86
+ testSync("unset ⇒ no uiSlotsUrl", () => {
87
+ let out = ShellConfig.overlay(~bakedManifest=None, ~shellConfig=None)
88
+ expect(out->Dict.get("uiSlotsUrl")->Option.isNone)->toBe(true)
89
+ })
90
+
91
+ testSync("declared ⇒ the URL the platform serves the module from", () => {
92
+ let out = ShellConfig.overlay(
93
+ ~bakedManifest=None,
94
+ ~uiSlotsFile="/src/storefront-slots.js",
95
+ ~shellConfig=None,
96
+ )
97
+ expect(out->Dict.get("uiSlotsUrl"))->toEqual(Some(JSON.Encode.string("/ui-slots.js")))
98
+ })
99
+
100
+ // Withdrawal has to clear the key as well as the file. The baseline is what
101
+ // does it — this pins that the overlay stops asserting the key, without which
102
+ // the shipped config could never come back.
103
+ testSync("withdrawn ⇒ the overlay stops naming it, so the baseline restores", () => {
104
+ let out = ShellConfig.overlay(~bakedManifest=None, ~shellConfig=None)
105
+ expect(out->Dict.keysToArray->Array.includes("uiSlotsUrl"))->toBe(false)
106
+ })
107
+
108
+ testSync("refuses a shellConfig key redirecting the computed module", () =>
109
+ expect(
110
+ threw(() =>
111
+ ShellConfig.overlay(
112
+ ~bakedManifest=None,
113
+ ~uiSlotsFile="/src/storefront-slots.js",
114
+ ~shellConfig=Some(Dict.fromArray([("uiSlotsUrl", JSON.Encode.string("/elsewhere.js"))])),
115
+ )->ignore
116
+ ),
117
+ )->toBe(true)
118
+ )
119
+ })
120
+
80
121
  describe("ShellConfig.emit", () => {
81
122
  let shipped = `{\n "apiEndpoint": "/graphql",\n "appName": "Shipped"\n}`
82
123
 
@@ -36,19 +36,19 @@ function threw(f) {
36
36
 
37
37
  globalThis.describe("ShellConfig.overlay", () => {
38
38
  globalThis.test("is empty when the deployment declares nothing", () => {
39
- let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined);
39
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined);
40
40
  globalThis.expect(Object.keys(out)).toEqual([]);
41
41
  });
42
42
  globalThis.test("points manifestUrl at the bake's default key", () => {
43
- let out = ShellConfig$ReventlessLocal.overlay(manifest(undefined), undefined);
43
+ let out = ShellConfig$ReventlessLocal.overlay(manifest(undefined), undefined, undefined);
44
44
  globalThis.expect(str(out, "manifestUrl")).toEqual("/component-manifest.json");
45
45
  });
46
46
  globalThis.test("follows a renamed bake", () => {
47
- let out = ShellConfig$ReventlessLocal.overlay(manifest("storefront.json"), undefined);
47
+ let out = ShellConfig$ReventlessLocal.overlay(manifest("storefront.json"), undefined, undefined);
48
48
  globalThis.expect(str(out, "manifestUrl")).toEqual("/storefront.json");
49
49
  });
50
50
  globalThis.test("passes the deployment's own keys through verbatim", () => {
51
- let out = ShellConfig$ReventlessLocal.overlay(undefined, Object.fromEntries([
51
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined, Object.fromEntries([
52
52
  [
53
53
  "appName",
54
54
  "Online Shop"
@@ -63,7 +63,7 @@ globalThis.describe("ShellConfig.overlay", () => {
63
63
  });
64
64
  globalThis.test("refuses a shellConfig key the platform computes", () => {
65
65
  globalThis.expect(threw(() => {
66
- ShellConfig$ReventlessLocal.overlay(manifest(undefined), Object.fromEntries([[
66
+ ShellConfig$ReventlessLocal.overlay(manifest(undefined), undefined, Object.fromEntries([[
67
67
  "manifestUrl",
68
68
  "/elsewhere.json"
69
69
  ]]));
@@ -71,6 +71,29 @@ globalThis.describe("ShellConfig.overlay", () => {
71
71
  });
72
72
  });
73
73
 
74
+ globalThis.describe("ShellConfig.overlay — uiSlotsFile", () => {
75
+ globalThis.test("unset ⇒ no uiSlotsUrl", () => {
76
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined);
77
+ globalThis.expect(Stdlib_Option.isNone(out["uiSlotsUrl"])).toBe(true);
78
+ });
79
+ globalThis.test("declared ⇒ the URL the platform serves the module from", () => {
80
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, "/src/storefront-slots.js", undefined);
81
+ globalThis.expect(out["uiSlotsUrl"]).toEqual("/ui-slots.js");
82
+ });
83
+ globalThis.test("withdrawn ⇒ the overlay stops naming it, so the baseline restores", () => {
84
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined);
85
+ globalThis.expect(Object.keys(out).includes("uiSlotsUrl")).toBe(false);
86
+ });
87
+ globalThis.test("refuses a shellConfig key redirecting the computed module", () => {
88
+ globalThis.expect(threw(() => {
89
+ ShellConfig$ReventlessLocal.overlay(undefined, "/src/storefront-slots.js", Object.fromEntries([[
90
+ "uiSlotsUrl",
91
+ "/elsewhere.js"
92
+ ]]));
93
+ })).toBe(true);
94
+ });
95
+ });
96
+
74
97
  globalThis.describe("ShellConfig.emit", () => {
75
98
  let shipped = `{\n "apiEndpoint": "/graphql",\n "appName": "Shipped"\n}`;
76
99
  let distWithConfig = () => {
@@ -87,35 +110,35 @@ globalThis.describe("ShellConfig.emit", () => {
87
110
  ]]);
88
111
  globalThis.test("merges the overlay onto the shipped file and keeps its other keys", () => {
89
112
  let dir = distWithConfig();
90
- ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, dir);
113
+ ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, undefined, dir);
91
114
  let out = readConfig(dir);
92
115
  globalThis.expect(str(out, "manifestUrl")).toEqual("/component-manifest.json");
93
116
  globalThis.expect(str(out, "apiEndpoint")).toEqual("/graphql");
94
117
  });
95
118
  globalThis.test("leaves a platform that declares nothing entirely alone", () => {
96
119
  let dir = distWithConfig();
97
- ShellConfig$ReventlessLocal.emit(undefined, undefined, dir);
120
+ ShellConfig$ReventlessLocal.emit(undefined, undefined, undefined, dir);
98
121
  globalThis.expect(Nodefs.readFileSync(Nodepath.join(dir, "config.json"), "utf8")).toEqual(shipped);
99
122
  globalThis.expect(Nodefs.existsSync(Nodepath.join(dir, "config.base.json"))).toBe(false);
100
123
  });
101
124
  globalThis.test("starts every boot from the shipped file, not the last output", () => {
102
125
  let dir = distWithConfig();
103
- ShellConfig$ReventlessLocal.emit(undefined, named("First"), dir);
104
- ShellConfig$ReventlessLocal.emit(undefined, named("Second"), dir);
126
+ ShellConfig$ReventlessLocal.emit(undefined, undefined, named("First"), dir);
127
+ ShellConfig$ReventlessLocal.emit(undefined, undefined, named("Second"), dir);
105
128
  globalThis.expect(str(readConfig(dir), "appName")).toEqual("Second");
106
129
  globalThis.expect(str(readBaseline(dir), "appName")).toEqual("Shipped");
107
130
  });
108
131
  globalThis.test("restores the shipped file once the declaration is withdrawn", () => {
109
132
  let dir = distWithConfig();
110
- ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, dir);
111
- ShellConfig$ReventlessLocal.emit(undefined, undefined, dir);
133
+ ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, undefined, dir);
134
+ ShellConfig$ReventlessLocal.emit(undefined, undefined, undefined, dir);
112
135
  let out = readConfig(dir);
113
136
  globalThis.expect(out["manifestUrl"]).toEqual(undefined);
114
137
  globalThis.expect(str(out, "appName")).toEqual("Shipped");
115
138
  });
116
139
  globalThis.test("refuses to overlay a dist that ships no config.json", () => {
117
140
  let dir = Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), "reventless-shellconfig-"));
118
- globalThis.expect(threw(() => ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, dir))).toBe(true);
141
+ globalThis.expect(threw(() => ShellConfig$ReventlessLocal.emit(manifest(undefined), undefined, undefined, dir))).toBe(true);
119
142
  });
120
143
  });
121
144
 
@@ -157,22 +180,22 @@ let fulfilment = {
157
180
 
158
181
  globalThis.describe("ShellConfig.overlay — journeys", () => {
159
182
  globalThis.test("a bake declaring no journeys writes no map", () => {
160
- let out = ShellConfig$ReventlessLocal.overlay(manifest(undefined), undefined);
183
+ let out = ShellConfig$ReventlessLocal.overlay(manifest(undefined), undefined, undefined);
161
184
  globalThis.expect(out["journeyManifestUrls"]).toEqual(undefined);
162
185
  });
163
186
  globalThis.test("an empty journeys array is the same as none", () => {
164
- let out = ShellConfig$ReventlessLocal.overlay(withJourneys([]), undefined);
187
+ let out = ShellConfig$ReventlessLocal.overlay(withJourneys([]), undefined, undefined);
165
188
  globalThis.expect(out["journeyManifestUrls"]).toEqual(undefined);
166
189
  });
167
190
  globalThis.test("keeps manifestUrl as the default journey", () => {
168
- let out = ShellConfig$ReventlessLocal.overlay(withJourneys([shopper]), undefined);
191
+ let out = ShellConfig$ReventlessLocal.overlay(withJourneys([shopper]), undefined, undefined);
169
192
  globalThis.expect(str(out, "manifestUrl")).toEqual("/component-manifest.json");
170
193
  });
171
194
  globalThis.test("maps each declared group to its own file", () => {
172
195
  let out = ShellConfig$ReventlessLocal.overlay(withJourneys([
173
196
  shopper,
174
197
  fulfilment
175
- ]), undefined);
198
+ ]), undefined, undefined);
176
199
  let map = Stdlib_Option.getOrThrow(Stdlib_Option.flatMap(out["journeyManifestUrls"], Stdlib_JSON.Decode.object), undefined);
177
200
  globalThis.expect([
178
201
  str(map, "Shopper"),
@@ -184,7 +207,7 @@ globalThis.describe("ShellConfig.overlay — journeys", () => {
184
207
  });
185
208
  globalThis.test("refuses a shellConfig that sets the journey map itself", () => {
186
209
  globalThis.expect(threw(() => {
187
- ShellConfig$ReventlessLocal.overlay(withJourneys([shopper]), Object.fromEntries([[
210
+ ShellConfig$ReventlessLocal.overlay(withJourneys([shopper]), undefined, Object.fromEntries([[
188
211
  "journeyManifestUrls",
189
212
  {}
190
213
  ]]));
@@ -0,0 +1,197 @@
1
+ open JestGlobals
2
+
3
+ // The `ui-slots.js` the local platform serves. Two things are under test: that a
4
+ // declared module reaches the served dist/ unaltered, and that an undeclared
5
+ // platform serves *nothing* — which is where this parts company with the hints
6
+ // file, whose "nothing" means the host-shell package's own fallback.
7
+ //
8
+ // Every failure here is quiet in the worst way. Hints going unapplied read as a
9
+ // menu that is slightly wrong; renderers going unapplied read as a surface that
10
+ // was never customised, which is indistinguishable from one nobody customised.
11
+
12
+ let _ = TestRunner.setup()
13
+
14
+ // The shape the shell actually calls (`SlotModules.load`): one `register`
15
+ // export, handed `h` and the registry. Nothing here evaluates it — the bytes are
16
+ // what is under test — but a fixture that misstated the contract would be read
17
+ // as documentation of it.
18
+ let declared = `export function register({ h, slots }) {
19
+ slots.row("gallery.tile", ({ row }) => h("span", null, row.name))
20
+ }
21
+ `
22
+ let edited = `export function register({ h, slots }) {
23
+ slots.row("gallery.tile", ({ row }) => h("strong", null, row.name))
24
+ }
25
+ `
26
+
27
+ let tmpdir = prefix => NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), prefix]))
28
+
29
+ // No shipped counterpart, deliberately: the shell ships no slots module, and a
30
+ // fixture that invented one would test a fallback this seam must not have.
31
+ let emptyDist = () => tmpdir("reventless-uislots-dist-")
32
+
33
+ let declaredFile = (contents: string) => {
34
+ let path = NodePath.join([tmpdir("reventless-uislots-src-"), "storefront-slots.js"])
35
+ NodeFs.writeFileSync(path, contents)
36
+ path
37
+ }
38
+
39
+ let servedPath = (dir: string) => NodePath.join([dir, "ui-slots.js"])
40
+ let served = (dir: string) => NodeFs.readFileSync(servedPath(dir))
41
+ let isServed = (dir: string) => NodeFs.existsSync(servedPath(dir))
42
+
43
+ let threw = (f: unit => unit): bool =>
44
+ try {
45
+ f()
46
+ false
47
+ } catch {
48
+ | _ => true
49
+ }
50
+
51
+ describe("UiSlots.emit", () => {
52
+ testSync("serves the declared module verbatim", () => {
53
+ let dir = emptyDist()
54
+ UiSlots.emit(~uiSlotsFile=Some(declaredFile(declared)), ~dir)
55
+ expect(served(dir))->toEqual(declared)
56
+ })
57
+
58
+ // An undeclared platform has to be byte-identical to one built before this
59
+ // module existed.
60
+ testSync("writes nothing when the platform declares no slots file", () => {
61
+ let dir = emptyDist()
62
+ UiSlots.emit(~uiSlotsFile=None, ~dir)
63
+ expect(isServed(dir))->toBe(false)
64
+ })
65
+
66
+ // The counterpart to the hints baseline. There is no file to restore, so the
67
+ // state to return to is "no file" — leaving yesterday's renderers in place
68
+ // with nothing in the deployment to explain them is the failure a baseline
69
+ // exists to prevent, arriving by the other road.
70
+ testSync("removes the served module once the declaration is withdrawn", () => {
71
+ let dir = emptyDist()
72
+ UiSlots.emit(~uiSlotsFile=Some(declaredFile(declared)), ~dir)
73
+ UiSlots.emit(~uiSlotsFile=None, ~dir)
74
+ expect(isServed(dir))->toBe(false)
75
+ })
76
+
77
+ testSync("withdrawing twice is not an error", () => {
78
+ let dir = emptyDist()
79
+ UiSlots.emit(~uiSlotsFile=None, ~dir)
80
+ expect(threw(() => UiSlots.emit(~uiSlotsFile=None, ~dir)))->toBe(false)
81
+ })
82
+
83
+ // The one failure this side can see, and it has to be the boot's problem: a
84
+ // declaration pointing nowhere registers no renderers and says nothing about
85
+ // why, which reads as a seam that does not work.
86
+ testSync("refuses a declaration naming a file that does not exist", () => {
87
+ let dir = emptyDist()
88
+ let missing = NodePath.join([tmpdir("reventless-uislots-src-"), "absent.js"])
89
+ expect(threw(() => UiSlots.emit(~uiSlotsFile=Some(missing), ~dir)))->toBe(true)
90
+ })
91
+
92
+ // Read before anything is touched: a bad declaration must not leave the served
93
+ // module half-replaced, which would be worse than either outcome.
94
+ testSync("leaves the last good module in place when the declaration is bad", () => {
95
+ let dir = emptyDist()
96
+ UiSlots.emit(~uiSlotsFile=Some(declaredFile(declared)), ~dir)
97
+ let missing = NodePath.join([tmpdir("reventless-uislots-src-"), "absent.js"])
98
+ let _ = threw(() => UiSlots.emit(~uiSlotsFile=Some(missing), ~dir))
99
+ expect(served(dir))->toEqual(declared)
100
+ })
101
+
102
+ // The deliberate non-check. There is no cheap way to know a module is good
103
+ // short of evaluating it, the deploy half cannot do it either, and a check
104
+ // that only ran locally would let a file pass the loop it was authored in and
105
+ // fail the one it ships to. The shell reports what it could not import.
106
+ testSync("serves a module it cannot vouch for rather than refusing it", () => {
107
+ let dir = emptyDist()
108
+ let broken = "export const register = (r) => {"
109
+ UiSlots.emit(~uiSlotsFile=Some(declaredFile(broken)), ~dir)
110
+ expect(served(dir))->toEqual(broken)
111
+ })
112
+ })
113
+
114
+ // The dev loop. Real `fs.watch` events rather than a stubbed clock, because what
115
+ // is being tested IS the plumbing — a debounce over a fake timer would pass with
116
+ // the watcher wired to nothing.
117
+ //
118
+ // The repeated edit is not belt-and-braces: `fs.watch` does not promise that a
119
+ // write landing near the watcher's creation is reported, and on macOS a write
120
+ // that beats the FSEvents stream up is not reported late but not at all. See the
121
+ // long note in `UiHintsTest` — the same reasoning and the same interval.
122
+ let retryEvery = 1000
123
+
124
+ let waitForReload = (~timeoutMs: int=4000, ~edit: unit => unit, ~uiSlotsFile, ~dir) =>
125
+ Promise.make((resolve, _) => {
126
+ let fired = ref(0)
127
+ let watcher = ref(None)
128
+ let deadline = ref(None)
129
+ let retry = ref(None)
130
+ // Resolves with a count rather than rejecting on the deadline: "nothing was
131
+ // re-served" is an expected answer here, and a rejection would make the
132
+ // assertion read as an infrastructure failure.
133
+ let finish = () => {
134
+ watcher.contents->Option.forEach(NodeFs.watcherClose)
135
+ deadline.contents->Option.forEach(clearTimeout)
136
+ retry.contents->Option.forEach(clearTimeout)
137
+ resolve(fired.contents)
138
+ }
139
+ let rec editUntilSeen = () => {
140
+ edit()
141
+ retry := Some(setTimeout(editUntilSeen, retryEvery))
142
+ }
143
+ watcher :=
144
+ UiSlots.watch(~uiSlotsFile, ~dir, ~onReload=() => {
145
+ fired := fired.contents + 1
146
+ finish()
147
+ })
148
+ deadline := Some(setTimeout(finish, timeoutMs))
149
+ editUntilSeen()
150
+ })
151
+
152
+ describe("UiSlots.watch", () => {
153
+ testSync("watches nothing when the platform declares no slots file", () =>
154
+ expect(UiSlots.watch(~uiSlotsFile=None, ~onReload=() => ()))->toEqual(None)
155
+ )
156
+
157
+ testSync("declines to watch a path whose directory is gone, without throwing", () => {
158
+ let missing = NodePath.join([tmpdir("reventless-uislots-gone-"), "nowhere", "slots.js"])
159
+ expect(UiSlots.watch(~uiSlotsFile=Some(missing), ~onReload=() => ()))->toEqual(None)
160
+ })
161
+
162
+ test("re-serves the module when it changes, and says so", async () => {
163
+ let dir = emptyDist()
164
+ let path = declaredFile(declared)
165
+ UiSlots.emit(~uiSlotsFile=Some(path), ~dir)
166
+ let fired = await waitForReload(
167
+ ~uiSlotsFile=Some(path),
168
+ ~dir,
169
+ ~edit=() => NodeFs.writeFileSync(path, edited),
170
+ )
171
+ expect((fired > 0, served(dir)))->toEqual((true, edited))
172
+ })
173
+
174
+ // Mid-session the file going unreadable is almost always an editor between two
175
+ // writes, and killing a running dev server over a keystroke would make this
176
+ // worse than the restart it replaces. The previous copy stands, and the save
177
+ // completing is itself the next event, so recovery needs no second trigger.
178
+ test("keeps serving the last good copy when the file is momentarily unreadable", async () => {
179
+ let dir = emptyDist()
180
+ let path = declaredFile(declared)
181
+ UiSlots.emit(~uiSlotsFile=Some(path), ~dir)
182
+ let fired = await waitForReload(
183
+ ~timeoutMs=1500,
184
+ ~uiSlotsFile=Some(path),
185
+ ~dir,
186
+ // Write-then-unlink rather than a bare unlink, so the edit stays repeatable
187
+ // for the retry above: it raises events every time and lands in the same
188
+ // absent state each time. It is also what a rename-based editor save
189
+ // actually does — the window this is about is the one between the two.
190
+ ~edit=() => {
191
+ NodeFs.writeFileSync(path, edited)
192
+ NodeFs.unlinkSync(path)
193
+ },
194
+ )
195
+ expect((fired, served(dir)))->toEqual((0, declared))
196
+ })
197
+ })
@@ -0,0 +1,196 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeos from "node:os";
5
+ import * as Nodepath from "node:path";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
8
+ import * as UiSlots$ReventlessLocal from "../src/UiSlots.res.mjs";
9
+ import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
10
+
11
+ TestRunner$ReventlessLocal.setup();
12
+
13
+ let declared = `export function register({ h, slots }) {
14
+ slots.row("gallery.tile", ({ row }) => h("span", null, row.name))
15
+ }
16
+ `;
17
+
18
+ let edited = `export function register({ h, slots }) {
19
+ slots.row("gallery.tile", ({ row }) => h("strong", null, row.name))
20
+ }
21
+ `;
22
+
23
+ function tmpdir(prefix) {
24
+ return Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), prefix));
25
+ }
26
+
27
+ function emptyDist() {
28
+ return tmpdir("reventless-uislots-dist-");
29
+ }
30
+
31
+ function declaredFile(contents) {
32
+ let path = Nodepath.join(tmpdir("reventless-uislots-src-"), "storefront-slots.js");
33
+ Nodefs.writeFileSync(path, contents, "utf8");
34
+ return path;
35
+ }
36
+
37
+ function servedPath(dir) {
38
+ return Nodepath.join(dir, "ui-slots.js");
39
+ }
40
+
41
+ function served(dir) {
42
+ return Nodefs.readFileSync(Nodepath.join(dir, "ui-slots.js"), "utf8");
43
+ }
44
+
45
+ function isServed(dir) {
46
+ return Nodefs.existsSync(Nodepath.join(dir, "ui-slots.js"));
47
+ }
48
+
49
+ function threw(f) {
50
+ try {
51
+ f();
52
+ return false;
53
+ } catch (exn) {
54
+ return true;
55
+ }
56
+ }
57
+
58
+ globalThis.describe("UiSlots.emit", () => {
59
+ globalThis.test("serves the declared module verbatim", () => {
60
+ let dir = tmpdir("reventless-uislots-dist-");
61
+ UiSlots$ReventlessLocal.emit(declaredFile(declared), dir);
62
+ globalThis.expect(Nodefs.readFileSync(Nodepath.join(dir, "ui-slots.js"), "utf8")).toEqual(declared);
63
+ });
64
+ globalThis.test("writes nothing when the platform declares no slots file", () => {
65
+ let dir = tmpdir("reventless-uislots-dist-");
66
+ UiSlots$ReventlessLocal.emit(undefined, dir);
67
+ globalThis.expect(Nodefs.existsSync(Nodepath.join(dir, "ui-slots.js"))).toBe(false);
68
+ });
69
+ globalThis.test("removes the served module once the declaration is withdrawn", () => {
70
+ let dir = tmpdir("reventless-uislots-dist-");
71
+ UiSlots$ReventlessLocal.emit(declaredFile(declared), dir);
72
+ UiSlots$ReventlessLocal.emit(undefined, dir);
73
+ globalThis.expect(Nodefs.existsSync(Nodepath.join(dir, "ui-slots.js"))).toBe(false);
74
+ });
75
+ globalThis.test("withdrawing twice is not an error", () => {
76
+ let dir = tmpdir("reventless-uislots-dist-");
77
+ UiSlots$ReventlessLocal.emit(undefined, dir);
78
+ globalThis.expect(threw(() => UiSlots$ReventlessLocal.emit(undefined, dir))).toBe(false);
79
+ });
80
+ globalThis.test("refuses a declaration naming a file that does not exist", () => {
81
+ let dir = tmpdir("reventless-uislots-dist-");
82
+ let missing = Nodepath.join(tmpdir("reventless-uislots-src-"), "absent.js");
83
+ globalThis.expect(threw(() => UiSlots$ReventlessLocal.emit(missing, dir))).toBe(true);
84
+ });
85
+ globalThis.test("leaves the last good module in place when the declaration is bad", () => {
86
+ let dir = tmpdir("reventless-uislots-dist-");
87
+ UiSlots$ReventlessLocal.emit(declaredFile(declared), dir);
88
+ let missing = Nodepath.join(tmpdir("reventless-uislots-src-"), "absent.js");
89
+ threw(() => UiSlots$ReventlessLocal.emit(missing, dir));
90
+ globalThis.expect(Nodefs.readFileSync(Nodepath.join(dir, "ui-slots.js"), "utf8")).toEqual(declared);
91
+ });
92
+ globalThis.test("serves a module it cannot vouch for rather than refusing it", () => {
93
+ let dir = tmpdir("reventless-uislots-dist-");
94
+ let broken = "export const register = (r) => {";
95
+ UiSlots$ReventlessLocal.emit(declaredFile(broken), dir);
96
+ globalThis.expect(Nodefs.readFileSync(Nodepath.join(dir, "ui-slots.js"), "utf8")).toEqual(broken);
97
+ });
98
+ });
99
+
100
+ function waitForReload(timeoutMsOpt, edit, uiSlotsFile, dir) {
101
+ let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 4000;
102
+ return new Promise((resolve, param) => {
103
+ let fired = {
104
+ contents: 0
105
+ };
106
+ let watcher = {
107
+ contents: undefined
108
+ };
109
+ let deadline = {
110
+ contents: undefined
111
+ };
112
+ let retry = {
113
+ contents: undefined
114
+ };
115
+ let finish = () => {
116
+ Stdlib_Option.forEach(watcher.contents, prim => {
117
+ prim.close();
118
+ });
119
+ Stdlib_Option.forEach(deadline.contents, prim => {
120
+ clearTimeout(prim);
121
+ });
122
+ Stdlib_Option.forEach(retry.contents, prim => {
123
+ clearTimeout(prim);
124
+ });
125
+ resolve(fired.contents);
126
+ };
127
+ let editUntilSeen = () => {
128
+ edit();
129
+ retry.contents = Primitive_option.some(setTimeout(editUntilSeen, 1000));
130
+ };
131
+ watcher.contents = UiSlots$ReventlessLocal.watch(uiSlotsFile, dir, () => {
132
+ fired.contents = fired.contents + 1 | 0;
133
+ finish();
134
+ });
135
+ deadline.contents = Primitive_option.some(setTimeout(finish, timeoutMs));
136
+ editUntilSeen();
137
+ });
138
+ }
139
+
140
+ globalThis.describe("UiSlots.watch", () => {
141
+ globalThis.test("watches nothing when the platform declares no slots file", () => {
142
+ globalThis.expect(UiSlots$ReventlessLocal.watch(undefined, undefined, () => {})).toEqual(undefined);
143
+ });
144
+ globalThis.test("declines to watch a path whose directory is gone, without throwing", () => {
145
+ let missing = Nodepath.join(tmpdir("reventless-uislots-gone-"), "nowhere", "slots.js");
146
+ globalThis.expect(UiSlots$ReventlessLocal.watch(missing, undefined, () => {})).toEqual(undefined);
147
+ });
148
+ globalThis.test("re-serves the module when it changes, and says so", async () => {
149
+ let dir = tmpdir("reventless-uislots-dist-");
150
+ let path = declaredFile(declared);
151
+ UiSlots$ReventlessLocal.emit(path, dir);
152
+ let fired = await waitForReload(undefined, () => {
153
+ Nodefs.writeFileSync(path, edited, "utf8");
154
+ }, path, dir);
155
+ globalThis.expect([
156
+ fired > 0,
157
+ Nodefs.readFileSync(Nodepath.join(dir, "ui-slots.js"), "utf8")
158
+ ]).toEqual([
159
+ true,
160
+ edited
161
+ ]);
162
+ });
163
+ globalThis.test("keeps serving the last good copy when the file is momentarily unreadable", async () => {
164
+ let dir = tmpdir("reventless-uislots-dist-");
165
+ let path = declaredFile(declared);
166
+ UiSlots$ReventlessLocal.emit(path, dir);
167
+ let fired = await waitForReload(1500, () => {
168
+ Nodefs.writeFileSync(path, edited, "utf8");
169
+ Nodefs.unlinkSync(path);
170
+ }, path, dir);
171
+ globalThis.expect([
172
+ fired,
173
+ Nodefs.readFileSync(Nodepath.join(dir, "ui-slots.js"), "utf8")
174
+ ]).toEqual([
175
+ 0,
176
+ declared
177
+ ]);
178
+ });
179
+ });
180
+
181
+ let retryEvery = 1000;
182
+
183
+ export {
184
+ declared,
185
+ edited,
186
+ tmpdir,
187
+ emptyDist,
188
+ declaredFile,
189
+ servedPath,
190
+ served,
191
+ isServed,
192
+ threw,
193
+ retryEvery,
194
+ waitForReload,
195
+ }
196
+ /* Not a pure module */