@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.
- package/CHANGELOG.md +11 -0
- package/package.json +8 -8
- package/src/Platform.res +17 -0
- package/src/Platform.res.mjs +7 -2
- package/src/ShellConfig.res +20 -2
- package/src/ShellConfig.res.mjs +9 -4
- package/src/UiSlots.res +191 -0
- package/src/UiSlots.res.mjs +91 -0
- package/src/adapter/Api/LocalEvents_Server.res +18 -0
- package/src/adapter/Api/LocalEvents_Server.res.mjs +11 -0
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +50 -3
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +62 -2
- package/tests/ShellConfigTest.res +41 -0
- package/tests/ShellConfigTest.res.mjs +40 -17
- package/tests/UiSlotsTest.res +197 -0
- package/tests/UiSlotsTest.res.mjs +196 -0
- package/tests/adapter/EventHistoryResolverTest.res +46 -0
- package/tests/adapter/EventHistoryResolverTest.res.mjs +41 -0
- package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -1
- package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +2 -2
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +3 -3
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +1 -1
- package/tests/components/commandgenerator/CommandGeneratorFixtures.res.mjs +1 -1
- package/tests/components/commandtopic/CommandTopicFixtures.res.mjs +1 -1
- package/tests/components/commandtopic/CommandTopicStreamFixtures.res.mjs +1 -1
- package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +1 -1
- package/tests/components/dcb/DcbFixtures.res.mjs +1 -1
- package/tests/components/extensionpoint/ExtensionPointFixtures.res.mjs +2 -2
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +1 -1
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +1 -1
- 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,
|
|
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 */
|