@player-ui/external-state-plugin 1.0.0--canary.865.36694
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/dist/ExternalStatePlugin.native.js +756 -0
- package/dist/ExternalStatePlugin.native.js.map +1 -0
- package/dist/cjs/index.cjs +135 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/index.legacy-esm.js +110 -0
- package/dist/index.mjs +110 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +30 -0
- package/src/__tests__/index.test.ts +538 -0
- package/src/index.ts +176 -0
- package/src/symbols.ts +4 -0
- package/types/index.d.ts +52 -0
- package/types/symbols.d.ts +2 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { expect, test, vitest, describe } from "vitest";
|
|
2
|
+
import type { Flow, InProgressState, NamedState } from "@player-ui/player";
|
|
3
|
+
import { Player } from "@player-ui/player";
|
|
4
|
+
import { ExternalStatePlugin } from "..";
|
|
5
|
+
import { waitFor } from "@testing-library/react";
|
|
6
|
+
|
|
7
|
+
const externalFlow = {
|
|
8
|
+
id: "test-flow",
|
|
9
|
+
data: {
|
|
10
|
+
transitionValue: "Next",
|
|
11
|
+
},
|
|
12
|
+
navigation: {
|
|
13
|
+
BEGIN: "FLOW_1",
|
|
14
|
+
FLOW_1: {
|
|
15
|
+
startState: "EXT_1",
|
|
16
|
+
EXT_1: {
|
|
17
|
+
state_type: "EXTERNAL",
|
|
18
|
+
ref: "test-1",
|
|
19
|
+
transitions: {
|
|
20
|
+
Next: "END_FWD",
|
|
21
|
+
Prev: "END_BCK",
|
|
22
|
+
},
|
|
23
|
+
testProperty: "testValue",
|
|
24
|
+
},
|
|
25
|
+
END_FWD: {
|
|
26
|
+
state_type: "END",
|
|
27
|
+
outcome: "FWD",
|
|
28
|
+
},
|
|
29
|
+
END_BCK: {
|
|
30
|
+
state_type: "END",
|
|
31
|
+
outcome: "BCK",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
test("handles the external state", async () => {
|
|
38
|
+
const player = new Player({
|
|
39
|
+
plugins: [
|
|
40
|
+
new ExternalStatePlugin([
|
|
41
|
+
{
|
|
42
|
+
ref: "test-1",
|
|
43
|
+
handlerFunction: (state, options) => {
|
|
44
|
+
return options.data.get("transitionValue");
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
]),
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const completed = await player.start(externalFlow as Flow);
|
|
52
|
+
|
|
53
|
+
expect(completed.endState.outcome).toBe("FWD");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("thrown errors will fail player", async () => {
|
|
57
|
+
const player = new Player({
|
|
58
|
+
plugins: [
|
|
59
|
+
new ExternalStatePlugin([
|
|
60
|
+
{
|
|
61
|
+
ref: "test-1",
|
|
62
|
+
handlerFunction: () => {
|
|
63
|
+
throw new Error("Bad Code");
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
]),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await expect(player.start(externalFlow as Flow)).rejects.toThrow();
|
|
71
|
+
|
|
72
|
+
expect(player.getState().status).toBe("error");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("works async", async () => {
|
|
76
|
+
const player = new Player({
|
|
77
|
+
plugins: [
|
|
78
|
+
new ExternalStatePlugin([
|
|
79
|
+
{
|
|
80
|
+
ref: "test-1",
|
|
81
|
+
handlerFunction: () => {
|
|
82
|
+
return Promise.resolve("Prev");
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]),
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const completed = await player.start(externalFlow as Flow);
|
|
90
|
+
|
|
91
|
+
expect(completed.endState.outcome).toBe("BCK");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("allows multiple plugins - last one wins", async () => {
|
|
95
|
+
const player = new Player({
|
|
96
|
+
plugins: [
|
|
97
|
+
new ExternalStatePlugin([
|
|
98
|
+
{
|
|
99
|
+
ref: "test-1",
|
|
100
|
+
handlerFunction: () => {
|
|
101
|
+
return "Next";
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
]),
|
|
105
|
+
new ExternalStatePlugin([
|
|
106
|
+
{
|
|
107
|
+
ref: "test-1",
|
|
108
|
+
handlerFunction: () => {
|
|
109
|
+
return "Prev";
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
]),
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const completed = await player.start(externalFlow as Flow);
|
|
117
|
+
|
|
118
|
+
// Last handler registered wins (Prev)
|
|
119
|
+
expect(completed.endState.outcome).toBe("BCK");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("logs debug message when replacing handler", async () => {
|
|
123
|
+
const mockDebug = vitest.fn();
|
|
124
|
+
|
|
125
|
+
const player = new Player({
|
|
126
|
+
plugins: [
|
|
127
|
+
new ExternalStatePlugin([
|
|
128
|
+
{
|
|
129
|
+
ref: "test-1",
|
|
130
|
+
handlerFunction: () => {
|
|
131
|
+
return "Next";
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
]),
|
|
135
|
+
new ExternalStatePlugin([
|
|
136
|
+
{
|
|
137
|
+
ref: "test-1",
|
|
138
|
+
handlerFunction: () => {
|
|
139
|
+
return "Prev";
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
]),
|
|
143
|
+
],
|
|
144
|
+
logger: {
|
|
145
|
+
trace: vitest.fn(),
|
|
146
|
+
debug: mockDebug,
|
|
147
|
+
info: vitest.fn(),
|
|
148
|
+
warn: vitest.fn(),
|
|
149
|
+
error: vitest.fn(),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await player.start(externalFlow as Flow);
|
|
154
|
+
|
|
155
|
+
// Should have logged that the handler was replaced
|
|
156
|
+
const replacementCalls = mockDebug.mock.calls.filter(
|
|
157
|
+
(call) =>
|
|
158
|
+
call[0] === "Registry: Replacing existing entry for key " &&
|
|
159
|
+
JSON.stringify(call[1]) === JSON.stringify({ ref: "test-1" }),
|
|
160
|
+
);
|
|
161
|
+
expect(replacementCalls.length).toBeGreaterThan(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("different plugins, more specific match overrides less specific match", async () => {
|
|
165
|
+
const player = new Player({
|
|
166
|
+
plugins: [
|
|
167
|
+
new ExternalStatePlugin([
|
|
168
|
+
{
|
|
169
|
+
ref: "test-1",
|
|
170
|
+
match: { testProperty: "testValue" },
|
|
171
|
+
handlerFunction: () => {
|
|
172
|
+
return "Next";
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
]),
|
|
176
|
+
new ExternalStatePlugin([
|
|
177
|
+
{
|
|
178
|
+
ref: "test-1",
|
|
179
|
+
handlerFunction: () => {
|
|
180
|
+
return "Prev";
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
]),
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const completed = await player.start(externalFlow as Flow);
|
|
188
|
+
|
|
189
|
+
// More specific match (with testProperty) should win, returning "Next" which leads to outcome "FWD"
|
|
190
|
+
expect(completed.endState.outcome).toBe("FWD");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("within same plugin, more specific match overrides less specific match", async () => {
|
|
194
|
+
const moreSpecificHandlerCalled = vitest.fn();
|
|
195
|
+
const lessSpecificHandlerCalled = vitest.fn();
|
|
196
|
+
|
|
197
|
+
const player = new Player({
|
|
198
|
+
plugins: [
|
|
199
|
+
new ExternalStatePlugin([
|
|
200
|
+
{
|
|
201
|
+
ref: "test-1",
|
|
202
|
+
match: { testProperty: "testValue" },
|
|
203
|
+
handlerFunction: () => {
|
|
204
|
+
moreSpecificHandlerCalled();
|
|
205
|
+
return "Next";
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
ref: "test-1",
|
|
210
|
+
handlerFunction: () => {
|
|
211
|
+
lessSpecificHandlerCalled();
|
|
212
|
+
return "Prev";
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
]),
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const completed = await player.start(externalFlow as Flow);
|
|
220
|
+
|
|
221
|
+
// More specific match should win regardless of insertion order
|
|
222
|
+
expect(moreSpecificHandlerCalled).toHaveBeenCalledOnce();
|
|
223
|
+
expect(lessSpecificHandlerCalled).not.toHaveBeenCalled();
|
|
224
|
+
expect(completed.endState.outcome).toBe("FWD");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("only transitions if player still on this external state", async () => {
|
|
228
|
+
let resolver: (() => void) | undefined;
|
|
229
|
+
const player = new Player({
|
|
230
|
+
plugins: [
|
|
231
|
+
new ExternalStatePlugin([
|
|
232
|
+
{
|
|
233
|
+
ref: "test-1",
|
|
234
|
+
handlerFunction: (state, options) => {
|
|
235
|
+
return new Promise((res) => {
|
|
236
|
+
// Only save resolver for first external state
|
|
237
|
+
if (!resolver) {
|
|
238
|
+
resolver = () => {
|
|
239
|
+
res(options.data.get("transitionValue"));
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
]),
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
player.start({
|
|
250
|
+
id: "test-flow",
|
|
251
|
+
data: {
|
|
252
|
+
transitionValue: "Next",
|
|
253
|
+
},
|
|
254
|
+
navigation: {
|
|
255
|
+
BEGIN: "FLOW_1",
|
|
256
|
+
FLOW_1: {
|
|
257
|
+
startState: "EXT_1",
|
|
258
|
+
EXT_1: {
|
|
259
|
+
state_type: "EXTERNAL",
|
|
260
|
+
ref: "test-1",
|
|
261
|
+
transitions: {
|
|
262
|
+
Next: "EXT_2",
|
|
263
|
+
Prev: "END_BCK",
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
EXT_2: {
|
|
267
|
+
state_type: "EXTERNAL",
|
|
268
|
+
ref: "test-2",
|
|
269
|
+
transitions: {
|
|
270
|
+
Next: "END_FWD",
|
|
271
|
+
Prev: "END_BCK",
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
END_FWD: {
|
|
275
|
+
state_type: "END",
|
|
276
|
+
outcome: "FWD",
|
|
277
|
+
},
|
|
278
|
+
END_BCK: {
|
|
279
|
+
state_type: "END",
|
|
280
|
+
outcome: "BCK",
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
} as Flow);
|
|
285
|
+
|
|
286
|
+
let state = player.getState();
|
|
287
|
+
expect(state.status).toBe("in-progress");
|
|
288
|
+
expect(
|
|
289
|
+
(state as InProgressState).controllers.flow.current?.currentState?.name,
|
|
290
|
+
).toBe("EXT_1");
|
|
291
|
+
|
|
292
|
+
// probably dumb way to wait for async stuff to resolve
|
|
293
|
+
await new Promise<void>((res) => {
|
|
294
|
+
/**
|
|
295
|
+
*
|
|
296
|
+
*/
|
|
297
|
+
function waitForResolver() {
|
|
298
|
+
if (resolver) res();
|
|
299
|
+
else setTimeout(waitForResolver, 50);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
waitForResolver();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
(state as InProgressState).controllers.flow.transition("Next");
|
|
306
|
+
|
|
307
|
+
state = player.getState();
|
|
308
|
+
expect(
|
|
309
|
+
(state as InProgressState).controllers.flow.current?.currentState?.name,
|
|
310
|
+
).toBe("EXT_2");
|
|
311
|
+
|
|
312
|
+
// Attempt to resolve _after_ Player has transitioned
|
|
313
|
+
resolver?.();
|
|
314
|
+
|
|
315
|
+
// Should be same as prev
|
|
316
|
+
state = player.getState();
|
|
317
|
+
expect(
|
|
318
|
+
(state as InProgressState).controllers.flow.current?.currentState?.name,
|
|
319
|
+
).toBe("EXT_2");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("edge cases", () => {
|
|
323
|
+
test("async action nodes not transitioning from navigation states with *", async () => {
|
|
324
|
+
const player = new Player({
|
|
325
|
+
plugins: [
|
|
326
|
+
new ExternalStatePlugin([
|
|
327
|
+
{
|
|
328
|
+
ref: "view_1",
|
|
329
|
+
handlerFunction: () => {
|
|
330
|
+
return new Promise((resolve) => {
|
|
331
|
+
setTimeout(() => {
|
|
332
|
+
resolve("next");
|
|
333
|
+
}, 100);
|
|
334
|
+
});
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
]),
|
|
338
|
+
],
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
player.hooks.expressionEvaluator.tap("test", (expEval) => {
|
|
342
|
+
expEval.addExpressionFunction("testAsync", async (ctx, name) => {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
setTimeout(() => {
|
|
345
|
+
resolve(name);
|
|
346
|
+
}, 10);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
player.start({
|
|
352
|
+
id: "test-flow",
|
|
353
|
+
data: {
|
|
354
|
+
my: {
|
|
355
|
+
puppy: "Ginger",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
views: [
|
|
359
|
+
{
|
|
360
|
+
id: "next",
|
|
361
|
+
type: "test",
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
id: "back",
|
|
365
|
+
type: "test",
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: "star",
|
|
369
|
+
type: "test",
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
navigation: {
|
|
373
|
+
BEGIN: "FLOW_1",
|
|
374
|
+
FLOW_1: {
|
|
375
|
+
startState: "ACTION_1",
|
|
376
|
+
ACTION_1: {
|
|
377
|
+
state_type: "ASYNC_ACTION",
|
|
378
|
+
exp: "{{my.puppy}} = await(testAsync('Daisy'))",
|
|
379
|
+
transitions: {
|
|
380
|
+
Daisy: "EXTERNAL_1",
|
|
381
|
+
},
|
|
382
|
+
await: true,
|
|
383
|
+
},
|
|
384
|
+
EXTERNAL_1: {
|
|
385
|
+
state_type: "EXTERNAL",
|
|
386
|
+
ref: "view_1",
|
|
387
|
+
param: {
|
|
388
|
+
best: "{{my.puppy}}",
|
|
389
|
+
},
|
|
390
|
+
transitions: {
|
|
391
|
+
next: "VIEW_1",
|
|
392
|
+
back: "VIEW_2",
|
|
393
|
+
"*": "VIEW_3",
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
VIEW_1: {
|
|
397
|
+
state_type: "VIEW",
|
|
398
|
+
ref: "next",
|
|
399
|
+
transitions: {
|
|
400
|
+
"*": "END",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
VIEW_2: {
|
|
404
|
+
state_type: "VIEW",
|
|
405
|
+
ref: "back",
|
|
406
|
+
transitions: {
|
|
407
|
+
"*": "END",
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
VIEW_3: {
|
|
411
|
+
state_type: "VIEW",
|
|
412
|
+
ref: "star",
|
|
413
|
+
transitions: {
|
|
414
|
+
"*": "END",
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await vitest.waitFor(() =>
|
|
422
|
+
expect(player.getState().status).toBe("in-progress"),
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
let currentState: NamedState | undefined;
|
|
426
|
+
|
|
427
|
+
await waitFor(() => {
|
|
428
|
+
const state = player.getState();
|
|
429
|
+
currentState = (state as InProgressState).controllers.flow.current
|
|
430
|
+
?.currentState;
|
|
431
|
+
expect(currentState?.name).toBe("EXTERNAL_1");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await waitFor(() => {
|
|
435
|
+
const state = player.getState();
|
|
436
|
+
currentState = (state as InProgressState).controllers.flow.current
|
|
437
|
+
?.currentState;
|
|
438
|
+
expect(currentState?.name).toBe("VIEW_1");
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("no handler registered for external state - no transition occurs", async () => {
|
|
443
|
+
// Create a player with NO handlers registered
|
|
444
|
+
const player = new Player({
|
|
445
|
+
plugins: [
|
|
446
|
+
new ExternalStatePlugin([
|
|
447
|
+
// Register handler for a different ref - not matching our external state
|
|
448
|
+
{ ref: "different-ref", handlerFunction: () => "Next" },
|
|
449
|
+
]),
|
|
450
|
+
],
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const started = player.start(externalFlow as Flow);
|
|
454
|
+
|
|
455
|
+
// Wait for player to reach the external state
|
|
456
|
+
await vitest.waitFor(() =>
|
|
457
|
+
expect(player.getState().status).toBe("in-progress"),
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// Get the current state
|
|
461
|
+
const state = player.getState() as InProgressState;
|
|
462
|
+
const currentState = state.controllers.flow.current?.currentState;
|
|
463
|
+
|
|
464
|
+
// Should be stuck on the external state
|
|
465
|
+
expect(currentState?.name).toBe("EXT_1");
|
|
466
|
+
expect(currentState?.value.state_type).toBe("EXTERNAL");
|
|
467
|
+
|
|
468
|
+
// Wait a bit to ensure no transition occurs
|
|
469
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
470
|
+
|
|
471
|
+
// Should still be on the external state (no transition occurred)
|
|
472
|
+
const laterState = player.getState() as InProgressState;
|
|
473
|
+
const laterCurrentState = laterState.controllers.flow.current?.currentState;
|
|
474
|
+
expect(laterCurrentState?.name).toBe("EXT_1");
|
|
475
|
+
expect(laterCurrentState?.value.state_type).toBe("EXTERNAL");
|
|
476
|
+
|
|
477
|
+
// Clean up - manually transition to end the flow
|
|
478
|
+
laterState.controllers.flow.transition("Next");
|
|
479
|
+
await started;
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe("Type Safety", () => {
|
|
484
|
+
test("handlers with superfluous match.ref should warn and be skipped", async () => {
|
|
485
|
+
const superflousHandlerCalled = vitest.fn();
|
|
486
|
+
const validHandlerCalled = vitest.fn();
|
|
487
|
+
const warnSpy = vitest.fn();
|
|
488
|
+
|
|
489
|
+
const player = new Player({
|
|
490
|
+
plugins: [
|
|
491
|
+
new ExternalStatePlugin([
|
|
492
|
+
// Handler with superfluous match.ref (TypeScript error suppressed for testing)
|
|
493
|
+
{
|
|
494
|
+
ref: "test-1",
|
|
495
|
+
// @ts-expect-error - testing runtime behavior with superfluous match.ref
|
|
496
|
+
match: { ref: "test-1", testProperty: "testValue" },
|
|
497
|
+
handlerFunction: () => {
|
|
498
|
+
superflousHandlerCalled();
|
|
499
|
+
return "Next";
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
// Valid handler - no match.ref
|
|
503
|
+
{
|
|
504
|
+
ref: "test-1",
|
|
505
|
+
handlerFunction: () => {
|
|
506
|
+
validHandlerCalled();
|
|
507
|
+
return "Next";
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
]),
|
|
511
|
+
],
|
|
512
|
+
logger: {
|
|
513
|
+
trace: vitest.fn(),
|
|
514
|
+
debug: vitest.fn(),
|
|
515
|
+
info: vitest.fn(),
|
|
516
|
+
warn: warnSpy,
|
|
517
|
+
error: vitest.fn(),
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const completed = await player.start(externalFlow as Flow);
|
|
522
|
+
|
|
523
|
+
// Warning should have been logged for the handler with superfluous match.ref
|
|
524
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
525
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
526
|
+
'An ExternalStateHandler contains a superfluous \'match.ref\' property. \'match.ref\' will be ignored. \'ref\' will be used instead. Handler: {"ref":"test-1","match":{"ref":"test-1","testProperty":"testValue"}}',
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// The handler with superfluous match.ref should NOT have been called (it was skipped)
|
|
530
|
+
expect(superflousHandlerCalled).not.toHaveBeenCalled();
|
|
531
|
+
|
|
532
|
+
// The valid handler should have been called
|
|
533
|
+
expect(validHandlerCalled).toHaveBeenCalledTimes(1);
|
|
534
|
+
|
|
535
|
+
// Flow should complete successfully using the valid handler
|
|
536
|
+
expect(completed.endState.outcome).toBe("FWD");
|
|
537
|
+
});
|
|
538
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Player,
|
|
3
|
+
PlayerPlugin,
|
|
4
|
+
InProgressState,
|
|
5
|
+
PlayerFlowState,
|
|
6
|
+
NavigationFlowState,
|
|
7
|
+
NavigationFlowExternalState,
|
|
8
|
+
} from "@player-ui/player";
|
|
9
|
+
import { Registry } from "@player-ui/partial-match-registry";
|
|
10
|
+
import { ExternalStatePluginSymbol } from "./symbols.js";
|
|
11
|
+
|
|
12
|
+
export type ExternalStateHandlerMatch = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
export type ExternalStateHandlerFunction = (
|
|
15
|
+
state: NavigationFlowExternalState,
|
|
16
|
+
options: InProgressState["controllers"],
|
|
17
|
+
) => string | undefined | Promise<string | undefined>;
|
|
18
|
+
|
|
19
|
+
export type ExternalStateHandler = {
|
|
20
|
+
/** The name of the external state. This will appear as it's "ref" property in the DSL. */
|
|
21
|
+
ref: string;
|
|
22
|
+
/** Additional properties to match against the external state. */
|
|
23
|
+
match?: ExternalStateHandlerMatch;
|
|
24
|
+
/** The function to run when the external state is transitioned to. This should return the `ref` of the next state to transition to. */
|
|
25
|
+
handlerFunction: ExternalStateHandlerFunction;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function isExternal(
|
|
29
|
+
state: NavigationFlowState,
|
|
30
|
+
): state is NavigationFlowExternalState {
|
|
31
|
+
return state.state_type === "EXTERNAL";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isInProgress(state: PlayerFlowState): state is InProgressState {
|
|
35
|
+
return state.status === "in-progress";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A plugin to handle external states
|
|
40
|
+
*
|
|
41
|
+
* This plugin uses a registry-based approach to match external states to handler functions.
|
|
42
|
+
* Multiple plugins can be registered, and handlers are matched using partial object matching
|
|
43
|
+
* with specificity ordering (more specific matches take precedence).
|
|
44
|
+
*/
|
|
45
|
+
export class ExternalStatePlugin implements PlayerPlugin {
|
|
46
|
+
name = "ExternalStatePlugin";
|
|
47
|
+
|
|
48
|
+
/** Symbol used to identify and find existing instances of this plugin */
|
|
49
|
+
static Symbol: symbol = ExternalStatePluginSymbol;
|
|
50
|
+
public readonly symbol: symbol = ExternalStatePlugin.Symbol;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The shared registry that maps external states to handlers.
|
|
54
|
+
* All plugin instances use the same registry.
|
|
55
|
+
*/
|
|
56
|
+
private registry?: Registry<ExternalStateHandlerFunction>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The handlers for this plugin instance.
|
|
60
|
+
*/
|
|
61
|
+
private readonly handlers: ExternalStateHandler[];
|
|
62
|
+
|
|
63
|
+
/** Creates a new ExternalStatePlugin */
|
|
64
|
+
constructor(handlers: ExternalStateHandler[]) {
|
|
65
|
+
this.handlers = handlers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
apply(player: Player): void {
|
|
69
|
+
const isFirstInstance = this.createRegistry(player);
|
|
70
|
+
this.registerHandlers(player);
|
|
71
|
+
|
|
72
|
+
// Only the first instance should tap the hooks to avoid redundant taps
|
|
73
|
+
if (!isFirstInstance) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
player.hooks.flowController.tap(this.name, (flowController) => {
|
|
78
|
+
flowController.hooks.flow.tap(this.name, (flow) => {
|
|
79
|
+
flow.hooks.afterTransition.tap(this.name, async (flowInstance) => {
|
|
80
|
+
const toState = flowInstance.currentState;
|
|
81
|
+
const currentState = player.getState();
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
toState &&
|
|
85
|
+
toState.value &&
|
|
86
|
+
isExternal(toState.value) &&
|
|
87
|
+
isInProgress(currentState)
|
|
88
|
+
) {
|
|
89
|
+
try {
|
|
90
|
+
const handler = this.registry?.get(toState.value);
|
|
91
|
+
const transitionValue = await handler?.(
|
|
92
|
+
toState.value,
|
|
93
|
+
currentState.controllers,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (transitionValue !== undefined) {
|
|
97
|
+
const latestState = player.getState();
|
|
98
|
+
|
|
99
|
+
// Ensure the Player is still in the same state after waiting for transitionValue
|
|
100
|
+
if (
|
|
101
|
+
isInProgress(latestState) &&
|
|
102
|
+
latestState.controllers.flow.current?.currentState?.name ===
|
|
103
|
+
toState.name
|
|
104
|
+
) {
|
|
105
|
+
latestState.controllers.flow.transition(transitionValue);
|
|
106
|
+
} else {
|
|
107
|
+
player.logger.warn(
|
|
108
|
+
`External state resolved with [${transitionValue}], but Player already navigated away from [${toState.name}]`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error instanceof Error) {
|
|
114
|
+
currentState.fail(error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create or share the registry for this plugin instance.
|
|
125
|
+
*
|
|
126
|
+
* Uses the Player's plugin registry to find if another instance of ExternalStatePlugin
|
|
127
|
+
* has already been registered. If found, this instance will share that plugin's registry.
|
|
128
|
+
* Otherwise, this instance creates a new registry.
|
|
129
|
+
*/
|
|
130
|
+
private createRegistry(player: Player): boolean {
|
|
131
|
+
// Find the first instance of this plugin registered to the Player
|
|
132
|
+
const existing = player.findPlugin<ExternalStatePlugin>(
|
|
133
|
+
ExternalStatePluginSymbol,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// If we found a plugin and it's not ourselves, we are not the first plugin instance
|
|
137
|
+
if (existing && existing !== this) {
|
|
138
|
+
// Use the first plugin's registry
|
|
139
|
+
this.registry = existing.registry;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// We are the first plugin instance, create the registry
|
|
144
|
+
this.registry = new Registry<ExternalStateHandlerFunction>(
|
|
145
|
+
undefined,
|
|
146
|
+
player.logger,
|
|
147
|
+
);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Register this plugin's handlers to the shared registry.
|
|
153
|
+
*
|
|
154
|
+
* If a handler with the same specificity already exists, it will be replaced
|
|
155
|
+
* and a debug log will be emitted (accessible via player.logger.debug).
|
|
156
|
+
*/
|
|
157
|
+
private registerHandlers(player: Player): void {
|
|
158
|
+
for (const handler of this.handlers) {
|
|
159
|
+
// Runtime check for 'ref' property is necessary despite TypeScript constraint because
|
|
160
|
+
// the Swift bridge allows improperly formatted objects to bypass TypeScript validation.
|
|
161
|
+
// We log this here and not in the constructor because the Logger is not yet available in the constructor.
|
|
162
|
+
if (handler.match?.ref) {
|
|
163
|
+
player.logger.warn(
|
|
164
|
+
`An ExternalStateHandler contains a superfluous 'match.ref' property. 'match.ref' will be ignored. 'ref' will be used instead. Handler: ${JSON.stringify({ ref: handler.ref, match: handler.match })}`,
|
|
165
|
+
);
|
|
166
|
+
delete handler.match?.["ref"];
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// Registry will handle keeping only the last handlerFunction for each match
|
|
170
|
+
this.registry?.set(
|
|
171
|
+
{ ref: handler.ref, ...handler.match },
|
|
172
|
+
handler.handlerFunction,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/symbols.ts
ADDED