@player-ui/external-state-plugin 1.0.0--canary.865.36694 → 1.0.0

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.
@@ -1,7 +1,7 @@
1
1
  import { expect, test, vitest, describe } from "vitest";
2
2
  import type { Flow, InProgressState, NamedState } from "@player-ui/player";
3
3
  import { Player } from "@player-ui/player";
4
- import { ExternalStatePlugin } from "..";
4
+ import { ExternalStatePlugin, ExternalStateError } from "..";
5
5
  import { waitFor } from "@testing-library/react";
6
6
 
7
7
  const externalFlow = {
@@ -242,6 +242,12 @@ test("only transitions if player still on this external state", async () => {
242
242
  });
243
243
  },
244
244
  },
245
+ {
246
+ ref: "test-2",
247
+ handlerFunction: () => {
248
+ return "Next";
249
+ },
250
+ },
245
251
  ]),
246
252
  ],
247
253
  });
@@ -319,6 +325,151 @@ test("only transitions if player still on this external state", async () => {
319
325
  ).toBe("EXT_2");
320
326
  });
321
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
+
322
473
  describe("edge cases", () => {
323
474
  test("async action nodes not transitioning from navigation states with *", async () => {
324
475
  const player = new Player({
@@ -439,8 +590,29 @@ describe("edge cases", () => {
439
590
  });
440
591
  });
441
592
 
442
- test("no handler registered for external state - no transition occurs", async () => {
443
- // Create a player with NO handlers registered
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 () => {
444
616
  const player = new Player({
445
617
  plugins: [
446
618
  new ExternalStatePlugin([
@@ -450,33 +622,21 @@ describe("edge cases", () => {
450
622
  ],
451
623
  });
452
624
 
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");
625
+ const captureSpy = vitest.fn().mockReturnValue(false);
626
+ player.hooks.errorController.tap("test", (ec) => {
627
+ vitest.spyOn(ec, "captureError").mockImplementation(captureSpy);
628
+ });
467
629
 
468
- // Wait a bit to ensure no transition occurs
469
- await new Promise((resolve) => setTimeout(resolve, 100));
630
+ player.start(externalFlow as Flow);
470
631
 
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");
632
+ await waitFor(() => expect(captureSpy).toHaveBeenCalledOnce());
476
633
 
477
- // Clean up - manually transition to end the flow
478
- laterState.controllers.flow.transition("Next");
479
- await started;
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
+ });
480
640
  });
481
641
  });
482
642
 
package/src/index.ts CHANGED
@@ -5,9 +5,15 @@ import type {
5
5
  PlayerFlowState,
6
6
  NavigationFlowState,
7
7
  NavigationFlowExternalState,
8
+ ErrorController,
9
+ FlowInstance,
8
10
  } from "@player-ui/player";
9
11
  import { Registry } from "@player-ui/partial-match-registry";
10
12
  import { ExternalStatePluginSymbol } from "./symbols.js";
13
+ import { ExternalStateError } from "./ExternalStateError.js";
14
+
15
+ export { ExternalStateError } from "./ExternalStateError.js";
16
+ export type { ExternalStateErrorMetadata } from "./ExternalStateError.js";
11
17
 
12
18
  export type ExternalStateHandlerMatch = Record<string, unknown>;
13
19
 
@@ -60,6 +66,11 @@ export class ExternalStatePlugin implements PlayerPlugin {
60
66
  */
61
67
  private readonly handlers: ExternalStateHandler[];
62
68
 
69
+ /** The error controller to use for this plugin.
70
+ * Only the first instance of the plugin should tap the error controller hook.
71
+ */
72
+ private errorController?: ErrorController;
73
+
63
74
  /** Creates a new ExternalStatePlugin */
64
75
  constructor(handlers: ExternalStateHandler[]) {
65
76
  this.handlers = handlers;
@@ -74,52 +85,103 @@ export class ExternalStatePlugin implements PlayerPlugin {
74
85
  return;
75
86
  }
76
87
 
88
+ player.hooks.errorController.tap(this.name, (errorController) => {
89
+ this.errorController = errorController;
90
+ });
91
+
77
92
  player.hooks.flowController.tap(this.name, (flowController) => {
78
93
  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
- }
94
+ flow.hooks.afterTransition.tap(this.name, (flowInstance) => {
95
+ this.handleAfterTransition(player, flowInstance);
118
96
  });
119
97
  });
120
98
  });
121
99
  }
122
100
 
101
+ /**
102
+ * Resolve an EXTERNAL state transition.
103
+ */
104
+ private async handleAfterTransition(
105
+ player: Player,
106
+ flowInstance: FlowInstance,
107
+ ): Promise<void> {
108
+ const toState = flowInstance.currentState;
109
+ const currentState = player.getState();
110
+
111
+ if (
112
+ !toState ||
113
+ !toState.value ||
114
+ !isExternal(toState.value) ||
115
+ !isInProgress(currentState)
116
+ ) {
117
+ return;
118
+ }
119
+
120
+ try {
121
+ const handler = this.registry?.get(toState.value);
122
+
123
+ if (!handler) {
124
+ this.reportError(
125
+ player,
126
+ ExternalStateError.missingHandler(toState.value.ref),
127
+ );
128
+ return;
129
+ }
130
+
131
+ const transitionValue = await handler(
132
+ toState.value,
133
+ currentState.controllers,
134
+ );
135
+
136
+ if (!transitionValue) {
137
+ this.reportError(
138
+ player,
139
+ ExternalStateError.missingTransitionValue(toState.value.ref),
140
+ );
141
+ return;
142
+ }
143
+
144
+ const latestState = player.getState();
145
+
146
+ // Ensure the Player is still in the same state after waiting for transitionValue
147
+ if (
148
+ isInProgress(latestState) &&
149
+ latestState.controllers.flow.current?.currentState?.name ===
150
+ toState.name
151
+ ) {
152
+ latestState.controllers.flow.transition(transitionValue);
153
+ } else {
154
+ player.logger.warn(
155
+ `External state resolved with [${transitionValue}], but Player already navigated away from [${toState.name}]`,
156
+ );
157
+ }
158
+ } catch (error) {
159
+ // Thrown errors are treated as purposefully unrecoverable: fail the flow rather than
160
+ // routing through captureError.
161
+ if (error instanceof Error) {
162
+ currentState.fail(error);
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Report an ExternalStateError via the errorController.
169
+ */
170
+ private reportError(player: Player, error: ExternalStateError): void {
171
+ // The compiler believes errorController could be nil, but in practice it should always
172
+ // be set by the time this method runs. The logger fallback exists only as defense
173
+ // against an unexpected lifecycle regression — if it ever fires, that's a bug to
174
+ // investigate, not normal operation.
175
+ if (!this.errorController) {
176
+ player.logger.error(
177
+ `${error.message} (errorController was unexpectedly undefined; it should always be set by the time this code runs)`,
178
+ );
179
+ return;
180
+ }
181
+
182
+ this.errorController.captureError(error);
183
+ }
184
+
123
185
  /**
124
186
  * Create or share the registry for this plugin instance.
125
187
  *
@@ -0,0 +1,20 @@
1
+ import type { ErrorMetadata, PlayerErrorMetadata } from "@player-ui/player";
2
+ import { ErrorSeverity } from "@player-ui/player";
3
+ export type ExternalStateErrorReason = "missing-handler" | "missing-transition-value";
4
+ export interface ExternalStateErrorMetadata extends ErrorMetadata {
5
+ /** The `ref` of the EXTERNAL state that produced the error */
6
+ ref: string;
7
+ /** Which failure mode this error represents */
8
+ reason: ExternalStateErrorReason;
9
+ }
10
+ export declare class ExternalStateError extends Error implements PlayerErrorMetadata<ExternalStateErrorMetadata> {
11
+ readonly type: string;
12
+ readonly severity: ErrorSeverity;
13
+ readonly metadata: ExternalStateErrorMetadata;
14
+ private constructor();
15
+ /** No handler was registered for the EXTERNAL state's ref. */
16
+ static missingHandler(ref: string): ExternalStateError;
17
+ /** A handler ran but returned no transition value. */
18
+ static missingTransitionValue(ref: string): ExternalStateError;
19
+ }
20
+ //# sourceMappingURL=ExternalStateError.d.ts.map
package/types/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Player, PlayerPlugin, InProgressState, NavigationFlowExternalState } from "@player-ui/player";
2
+ export { ExternalStateError } from "./ExternalStateError.js";
3
+ export type { ExternalStateErrorMetadata } from "./ExternalStateError.js";
2
4
  export type ExternalStateHandlerMatch = Record<string, unknown>;
3
5
  export type ExternalStateHandlerFunction = (state: NavigationFlowExternalState, options: InProgressState["controllers"]) => string | undefined | Promise<string | undefined>;
4
6
  export type ExternalStateHandler = {
@@ -30,9 +32,21 @@ export declare class ExternalStatePlugin implements PlayerPlugin {
30
32
  * The handlers for this plugin instance.
31
33
  */
32
34
  private readonly handlers;
35
+ /** The error controller to use for this plugin.
36
+ * Only the first instance of the plugin should tap the error controller hook.
37
+ */
38
+ private errorController?;
33
39
  /** Creates a new ExternalStatePlugin */
34
40
  constructor(handlers: ExternalStateHandler[]);
35
41
  apply(player: Player): void;
42
+ /**
43
+ * Resolve an EXTERNAL state transition.
44
+ */
45
+ private handleAfterTransition;
46
+ /**
47
+ * Report an ExternalStateError via the errorController.
48
+ */
49
+ private reportError;
36
50
  /**
37
51
  * Create or share the registry for this plugin instance.
38
52
  *