@player-ui/external-state-plugin 0.15.4--canary.881.37421
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 +8500 -0
- package/dist/ExternalStatePlugin.native.js.map +1 -0
- package/dist/cjs/index.cjs +196 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/index.legacy-esm.js +170 -0
- package/dist/index.mjs +170 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +30 -0
- package/src/ExternalStateError.ts +45 -0
- package/src/__tests__/index.test.ts +698 -0
- package/src/index.ts +238 -0
- package/src/symbols.ts +4 -0
- package/types/ExternalStateError.d.ts +20 -0
- package/types/index.d.ts +66 -0
- package/types/symbols.d.ts +2 -0
|
@@ -0,0 +1,698 @@
|
|
|
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, ExternalStateError } 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
|
+
ref: "test-2",
|
|
247
|
+
handlerFunction: () => {
|
|
248
|
+
return "Next";
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
]),
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
player.start({
|
|
256
|
+
id: "test-flow",
|
|
257
|
+
data: {
|
|
258
|
+
transitionValue: "Next",
|
|
259
|
+
},
|
|
260
|
+
navigation: {
|
|
261
|
+
BEGIN: "FLOW_1",
|
|
262
|
+
FLOW_1: {
|
|
263
|
+
startState: "EXT_1",
|
|
264
|
+
EXT_1: {
|
|
265
|
+
state_type: "EXTERNAL",
|
|
266
|
+
ref: "test-1",
|
|
267
|
+
transitions: {
|
|
268
|
+
Next: "EXT_2",
|
|
269
|
+
Prev: "END_BCK",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
EXT_2: {
|
|
273
|
+
state_type: "EXTERNAL",
|
|
274
|
+
ref: "test-2",
|
|
275
|
+
transitions: {
|
|
276
|
+
Next: "END_FWD",
|
|
277
|
+
Prev: "END_BCK",
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
END_FWD: {
|
|
281
|
+
state_type: "END",
|
|
282
|
+
outcome: "FWD",
|
|
283
|
+
},
|
|
284
|
+
END_BCK: {
|
|
285
|
+
state_type: "END",
|
|
286
|
+
outcome: "BCK",
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
} as Flow);
|
|
291
|
+
|
|
292
|
+
let state = player.getState();
|
|
293
|
+
expect(state.status).toBe("in-progress");
|
|
294
|
+
expect(
|
|
295
|
+
(state as InProgressState).controllers.flow.current?.currentState?.name,
|
|
296
|
+
).toBe("EXT_1");
|
|
297
|
+
|
|
298
|
+
// probably dumb way to wait for async stuff to resolve
|
|
299
|
+
await new Promise<void>((res) => {
|
|
300
|
+
/**
|
|
301
|
+
*
|
|
302
|
+
*/
|
|
303
|
+
function waitForResolver() {
|
|
304
|
+
if (resolver) res();
|
|
305
|
+
else setTimeout(waitForResolver, 50);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
waitForResolver();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
(state as InProgressState).controllers.flow.transition("Next");
|
|
312
|
+
|
|
313
|
+
state = player.getState();
|
|
314
|
+
expect(
|
|
315
|
+
(state as InProgressState).controllers.flow.current?.currentState?.name,
|
|
316
|
+
).toBe("EXT_2");
|
|
317
|
+
|
|
318
|
+
// Attempt to resolve _after_ Player has transitioned
|
|
319
|
+
resolver?.();
|
|
320
|
+
|
|
321
|
+
// Should be same as prev
|
|
322
|
+
state = player.getState();
|
|
323
|
+
expect(
|
|
324
|
+
(state as InProgressState).controllers.flow.current?.currentState?.name,
|
|
325
|
+
).toBe("EXT_2");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const flowWithErrorTransitions = {
|
|
329
|
+
id: "test-flow",
|
|
330
|
+
data: {},
|
|
331
|
+
views: [
|
|
332
|
+
{
|
|
333
|
+
id: "error-view",
|
|
334
|
+
type: "test",
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
navigation: {
|
|
338
|
+
BEGIN: "FLOW_1",
|
|
339
|
+
FLOW_1: {
|
|
340
|
+
startState: "EXT_1",
|
|
341
|
+
errorTransitions: {
|
|
342
|
+
externalState: "ERROR_VIEW",
|
|
343
|
+
},
|
|
344
|
+
EXT_1: {
|
|
345
|
+
state_type: "EXTERNAL",
|
|
346
|
+
ref: "test-1",
|
|
347
|
+
transitions: { Next: "END_FWD" },
|
|
348
|
+
},
|
|
349
|
+
ERROR_VIEW: {
|
|
350
|
+
state_type: "VIEW",
|
|
351
|
+
ref: "error-view",
|
|
352
|
+
transitions: {},
|
|
353
|
+
},
|
|
354
|
+
END_FWD: {
|
|
355
|
+
state_type: "END",
|
|
356
|
+
outcome: "FWD",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
test("missing handler error navigates via content errorTransitions", async () => {
|
|
363
|
+
const player = new Player({
|
|
364
|
+
plugins: [new ExternalStatePlugin([])],
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
player.start(flowWithErrorTransitions as Flow);
|
|
368
|
+
|
|
369
|
+
await waitFor(() => {
|
|
370
|
+
const state = player.getState() as InProgressState;
|
|
371
|
+
expect(state.controllers.flow.current?.currentState?.name).toBe(
|
|
372
|
+
"ERROR_VIEW",
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("missing transition value error navigates via content errorTransitions", async () => {
|
|
378
|
+
const player = new Player({
|
|
379
|
+
plugins: [
|
|
380
|
+
new ExternalStatePlugin([
|
|
381
|
+
{ ref: "test-1", handlerFunction: () => undefined },
|
|
382
|
+
]),
|
|
383
|
+
],
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
player.start(flowWithErrorTransitions as Flow);
|
|
387
|
+
|
|
388
|
+
await waitFor(() => {
|
|
389
|
+
const state = player.getState() as InProgressState;
|
|
390
|
+
expect(state.controllers.flow.current?.currentState?.name).toBe(
|
|
391
|
+
"ERROR_VIEW",
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("missing handler error is observable via onError tap", async () => {
|
|
397
|
+
const onErrorSpy = vitest.fn().mockReturnValue(true);
|
|
398
|
+
|
|
399
|
+
const player = new Player({
|
|
400
|
+
plugins: [new ExternalStatePlugin([])],
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
player.hooks.errorController.tap("test", (ec) => {
|
|
404
|
+
ec.hooks.onError.tap("test", onErrorSpy);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
player.start(externalFlow as Flow);
|
|
408
|
+
|
|
409
|
+
await waitFor(() => expect(onErrorSpy).toHaveBeenCalledOnce());
|
|
410
|
+
|
|
411
|
+
const error = onErrorSpy.mock.calls[0]?.[0] as ExternalStateError;
|
|
412
|
+
expect(error).toBeInstanceOf(ExternalStateError);
|
|
413
|
+
expect(error.type).toBe("externalState");
|
|
414
|
+
expect(error.metadata).toStrictEqual({
|
|
415
|
+
ref: "test-1",
|
|
416
|
+
reason: "missing-handler",
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("missing transition value error is observable via onError tap", async () => {
|
|
421
|
+
const onErrorSpy = vitest.fn().mockReturnValue(true);
|
|
422
|
+
|
|
423
|
+
const player = new Player({
|
|
424
|
+
plugins: [
|
|
425
|
+
new ExternalStatePlugin([
|
|
426
|
+
{ ref: "test-1", handlerFunction: () => undefined },
|
|
427
|
+
]),
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
player.hooks.errorController.tap("test", (ec) => {
|
|
432
|
+
ec.hooks.onError.tap("test", onErrorSpy);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
player.start(externalFlow as Flow);
|
|
436
|
+
|
|
437
|
+
await waitFor(() => expect(onErrorSpy).toHaveBeenCalledOnce());
|
|
438
|
+
|
|
439
|
+
const error = onErrorSpy.mock.calls[0]?.[0] as ExternalStateError;
|
|
440
|
+
expect(error).toBeInstanceOf(ExternalStateError);
|
|
441
|
+
expect(error.metadata).toStrictEqual({
|
|
442
|
+
ref: "test-1",
|
|
443
|
+
reason: "missing-transition-value",
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe("ExternalStateError", () => {
|
|
448
|
+
test("missingHandler has correct shape", () => {
|
|
449
|
+
const error = ExternalStateError.missingHandler("my-ref");
|
|
450
|
+
expect(error.type).toBe("externalState");
|
|
451
|
+
expect(error.metadata).toStrictEqual({
|
|
452
|
+
ref: "my-ref",
|
|
453
|
+
reason: "missing-handler",
|
|
454
|
+
});
|
|
455
|
+
expect(error.message).toBe(
|
|
456
|
+
'No handler found for external state with ref: "my-ref". Ensure a handler is registered for this state.',
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("missingTransitionValue has correct shape", () => {
|
|
461
|
+
const error = ExternalStateError.missingTransitionValue("my-ref");
|
|
462
|
+
expect(error.type).toBe("externalState");
|
|
463
|
+
expect(error.metadata).toStrictEqual({
|
|
464
|
+
ref: "my-ref",
|
|
465
|
+
reason: "missing-transition-value",
|
|
466
|
+
});
|
|
467
|
+
expect(error.message).toBe(
|
|
468
|
+
'Handler for external state with ref: "my-ref" did not return a transition value. Ensure the handler returns the name of a valid transition.',
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe("edge cases", () => {
|
|
474
|
+
test("async action nodes not transitioning from navigation states with *", async () => {
|
|
475
|
+
const player = new Player({
|
|
476
|
+
plugins: [
|
|
477
|
+
new ExternalStatePlugin([
|
|
478
|
+
{
|
|
479
|
+
ref: "view_1",
|
|
480
|
+
handlerFunction: () => {
|
|
481
|
+
return new Promise((resolve) => {
|
|
482
|
+
setTimeout(() => {
|
|
483
|
+
resolve("next");
|
|
484
|
+
}, 100);
|
|
485
|
+
});
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
]),
|
|
489
|
+
],
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
player.hooks.expressionEvaluator.tap("test", (expEval) => {
|
|
493
|
+
expEval.addExpressionFunction("testAsync", async (ctx, name) => {
|
|
494
|
+
return new Promise((resolve) => {
|
|
495
|
+
setTimeout(() => {
|
|
496
|
+
resolve(name);
|
|
497
|
+
}, 10);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
player.start({
|
|
503
|
+
id: "test-flow",
|
|
504
|
+
data: {
|
|
505
|
+
my: {
|
|
506
|
+
puppy: "Ginger",
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
views: [
|
|
510
|
+
{
|
|
511
|
+
id: "next",
|
|
512
|
+
type: "test",
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
id: "back",
|
|
516
|
+
type: "test",
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
id: "star",
|
|
520
|
+
type: "test",
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
navigation: {
|
|
524
|
+
BEGIN: "FLOW_1",
|
|
525
|
+
FLOW_1: {
|
|
526
|
+
startState: "ACTION_1",
|
|
527
|
+
ACTION_1: {
|
|
528
|
+
state_type: "ASYNC_ACTION",
|
|
529
|
+
exp: "{{my.puppy}} = await(testAsync('Daisy'))",
|
|
530
|
+
transitions: {
|
|
531
|
+
Daisy: "EXTERNAL_1",
|
|
532
|
+
},
|
|
533
|
+
await: true,
|
|
534
|
+
},
|
|
535
|
+
EXTERNAL_1: {
|
|
536
|
+
state_type: "EXTERNAL",
|
|
537
|
+
ref: "view_1",
|
|
538
|
+
param: {
|
|
539
|
+
best: "{{my.puppy}}",
|
|
540
|
+
},
|
|
541
|
+
transitions: {
|
|
542
|
+
next: "VIEW_1",
|
|
543
|
+
back: "VIEW_2",
|
|
544
|
+
"*": "VIEW_3",
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
VIEW_1: {
|
|
548
|
+
state_type: "VIEW",
|
|
549
|
+
ref: "next",
|
|
550
|
+
transitions: {
|
|
551
|
+
"*": "END",
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
VIEW_2: {
|
|
555
|
+
state_type: "VIEW",
|
|
556
|
+
ref: "back",
|
|
557
|
+
transitions: {
|
|
558
|
+
"*": "END",
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
VIEW_3: {
|
|
562
|
+
state_type: "VIEW",
|
|
563
|
+
ref: "star",
|
|
564
|
+
transitions: {
|
|
565
|
+
"*": "END",
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await vitest.waitFor(() =>
|
|
573
|
+
expect(player.getState().status).toBe("in-progress"),
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
let currentState: NamedState | undefined;
|
|
577
|
+
|
|
578
|
+
await waitFor(() => {
|
|
579
|
+
const state = player.getState();
|
|
580
|
+
currentState = (state as InProgressState).controllers.flow.current
|
|
581
|
+
?.currentState;
|
|
582
|
+
expect(currentState?.name).toBe("EXTERNAL_1");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
await waitFor(() => {
|
|
586
|
+
const state = player.getState();
|
|
587
|
+
currentState = (state as InProgressState).controllers.flow.current
|
|
588
|
+
?.currentState;
|
|
589
|
+
expect(currentState?.name).toBe("VIEW_1");
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("no handler registered for external state - calls captureError with ExternalStateError", async () => {
|
|
594
|
+
const player = new Player({
|
|
595
|
+
plugins: [new ExternalStatePlugin([])],
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const captureSpy = vitest.fn().mockReturnValue(false);
|
|
599
|
+
player.hooks.errorController.tap("test", (ec) => {
|
|
600
|
+
vitest.spyOn(ec, "captureError").mockImplementation(captureSpy);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
player.start(externalFlow as Flow);
|
|
604
|
+
|
|
605
|
+
await waitFor(() => expect(captureSpy).toHaveBeenCalledOnce());
|
|
606
|
+
|
|
607
|
+
const error = captureSpy.mock.calls[0]?.[0] as ExternalStateError;
|
|
608
|
+
expect(error).toBeInstanceOf(ExternalStateError);
|
|
609
|
+
expect(error.metadata).toStrictEqual({
|
|
610
|
+
ref: "test-1",
|
|
611
|
+
reason: "missing-handler",
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("no handler for this ref (but other refs registered) - calls captureError", async () => {
|
|
616
|
+
const player = new Player({
|
|
617
|
+
plugins: [
|
|
618
|
+
new ExternalStatePlugin([
|
|
619
|
+
// Register handler for a different ref - not matching our external state
|
|
620
|
+
{ ref: "different-ref", handlerFunction: () => "Next" },
|
|
621
|
+
]),
|
|
622
|
+
],
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const captureSpy = vitest.fn().mockReturnValue(false);
|
|
626
|
+
player.hooks.errorController.tap("test", (ec) => {
|
|
627
|
+
vitest.spyOn(ec, "captureError").mockImplementation(captureSpy);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
player.start(externalFlow as Flow);
|
|
631
|
+
|
|
632
|
+
await waitFor(() => expect(captureSpy).toHaveBeenCalledOnce());
|
|
633
|
+
|
|
634
|
+
const error = captureSpy.mock.calls[0]?.[0] as ExternalStateError;
|
|
635
|
+
expect(error).toBeInstanceOf(ExternalStateError);
|
|
636
|
+
expect(error.metadata).toStrictEqual({
|
|
637
|
+
ref: "test-1",
|
|
638
|
+
reason: "missing-handler",
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe("Type Safety", () => {
|
|
644
|
+
test("handlers with superfluous match.ref should warn and be skipped", async () => {
|
|
645
|
+
const superflousHandlerCalled = vitest.fn();
|
|
646
|
+
const validHandlerCalled = vitest.fn();
|
|
647
|
+
const warnSpy = vitest.fn();
|
|
648
|
+
|
|
649
|
+
const player = new Player({
|
|
650
|
+
plugins: [
|
|
651
|
+
new ExternalStatePlugin([
|
|
652
|
+
// Handler with superfluous match.ref (TypeScript error suppressed for testing)
|
|
653
|
+
{
|
|
654
|
+
ref: "test-1",
|
|
655
|
+
// @ts-expect-error - testing runtime behavior with superfluous match.ref
|
|
656
|
+
match: { ref: "test-1", testProperty: "testValue" },
|
|
657
|
+
handlerFunction: () => {
|
|
658
|
+
superflousHandlerCalled();
|
|
659
|
+
return "Next";
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
// Valid handler - no match.ref
|
|
663
|
+
{
|
|
664
|
+
ref: "test-1",
|
|
665
|
+
handlerFunction: () => {
|
|
666
|
+
validHandlerCalled();
|
|
667
|
+
return "Next";
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
]),
|
|
671
|
+
],
|
|
672
|
+
logger: {
|
|
673
|
+
trace: vitest.fn(),
|
|
674
|
+
debug: vitest.fn(),
|
|
675
|
+
info: vitest.fn(),
|
|
676
|
+
warn: warnSpy,
|
|
677
|
+
error: vitest.fn(),
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const completed = await player.start(externalFlow as Flow);
|
|
682
|
+
|
|
683
|
+
// Warning should have been logged for the handler with superfluous match.ref
|
|
684
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
685
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
686
|
+
'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"}}',
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// The handler with superfluous match.ref should NOT have been called (it was skipped)
|
|
690
|
+
expect(superflousHandlerCalled).not.toHaveBeenCalled();
|
|
691
|
+
|
|
692
|
+
// The valid handler should have been called
|
|
693
|
+
expect(validHandlerCalled).toHaveBeenCalledTimes(1);
|
|
694
|
+
|
|
695
|
+
// Flow should complete successfully using the valid handler
|
|
696
|
+
expect(completed.endState.outcome).toBe("FWD");
|
|
697
|
+
});
|
|
698
|
+
});
|