@player-ui/player 0.15.3 → 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.
Files changed (56) hide show
  1. package/dist/Player.native.js +3259 -2768
  2. package/dist/Player.native.js.map +1 -1
  3. package/dist/cjs/index.cjs +2553 -2114
  4. package/dist/cjs/index.cjs.map +1 -1
  5. package/dist/index.legacy-esm.js +2535 -2103
  6. package/dist/index.mjs +2535 -2103
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +4 -4
  9. package/src/__tests__/data.test.ts +0 -13
  10. package/src/__tests__/view.test.ts +34 -1
  11. package/src/controllers/data/controller.ts +1 -1
  12. package/src/controllers/data/utils.ts +5 -26
  13. package/src/controllers/error/__tests__/controller.test.ts +359 -0
  14. package/src/controllers/error/__tests__/middleware.test.ts +237 -0
  15. package/src/controllers/error/__tests__/navigation.test.ts +190 -0
  16. package/src/controllers/error/controller.ts +257 -0
  17. package/src/controllers/error/index.ts +3 -0
  18. package/src/controllers/error/middleware.ts +106 -0
  19. package/src/controllers/error/types.ts +42 -0
  20. package/src/controllers/error/utils/__tests__/isErrorWithMetadata.test.ts +114 -0
  21. package/src/controllers/error/utils/__tests__/makeJsonStringifyReplacer.test.ts +24 -0
  22. package/src/controllers/error/utils/index.ts +2 -0
  23. package/src/controllers/error/utils/isErrorWithMetadata.ts +28 -0
  24. package/src/controllers/error/utils/makeJsonStringifyReplacer.ts +17 -0
  25. package/src/controllers/flow/__tests__/flow.test.ts +268 -0
  26. package/src/controllers/flow/flow.ts +96 -4
  27. package/src/controllers/index.ts +1 -0
  28. package/src/controllers/view/controller.ts +22 -3
  29. package/src/data/model.ts +6 -0
  30. package/src/expressions/types.ts +8 -4
  31. package/src/player.ts +20 -1
  32. package/src/types.ts +6 -0
  33. package/src/validator/types.ts +2 -1
  34. package/src/view/parser/types.ts +6 -3
  35. package/src/view/plugins/__tests__/template.test.ts +7 -2
  36. package/src/view/resolver/ResolverError.ts +25 -0
  37. package/src/view/resolver/__tests__/index.test.ts +53 -1
  38. package/src/view/resolver/index.ts +68 -37
  39. package/src/view/resolver/types.ts +13 -0
  40. package/src/view/resolver/utils.ts +1 -1
  41. package/types/controllers/data/utils.d.ts +3 -7
  42. package/types/controllers/error/controller.d.ts +82 -0
  43. package/types/controllers/error/index.d.ts +4 -0
  44. package/types/controllers/error/middleware.d.ts +23 -0
  45. package/types/controllers/error/types.d.ts +35 -0
  46. package/types/controllers/error/utils/index.d.ts +3 -0
  47. package/types/controllers/error/utils/isErrorWithMetadata.d.ts +3 -0
  48. package/types/controllers/error/utils/makeJsonStringifyReplacer.d.ts +5 -0
  49. package/types/controllers/flow/flow.d.ts +17 -0
  50. package/types/controllers/index.d.ts +1 -0
  51. package/types/controllers/view/controller.d.ts +4 -0
  52. package/types/data/model.d.ts +5 -0
  53. package/types/types.d.ts +5 -1
  54. package/types/view/resolver/ResolverError.d.ts +13 -0
  55. package/types/view/resolver/index.d.ts +2 -1
  56. package/types/view/resolver/types.d.ts +11 -0
@@ -0,0 +1,106 @@
1
+ import type { BindingInstance } from "../../binding";
2
+ import {
3
+ BatchSetTransaction,
4
+ DataModelImpl,
5
+ DataModelMiddleware,
6
+ DataModelOptions,
7
+ LocalModel,
8
+ Updates,
9
+ } from "../../data";
10
+ import type { Logger } from "../../logger";
11
+
12
+ /** Top-level key for all error information. */
13
+ export const ERROR_BINDING_PREFIX = "errorState";
14
+
15
+ const isErrorBinding = (binding: BindingInstance): boolean =>
16
+ binding.asArray()[0] === ERROR_BINDING_PREFIX;
17
+
18
+ /**
19
+ * Middleware that prevents external writes to errorState
20
+ * Only authorized callers (with the write symbol) can write to this path
21
+ */
22
+ export class ErrorStateMiddleware implements DataModelMiddleware {
23
+ name = "error-state-middleware";
24
+
25
+ private logger?: Logger;
26
+ private writeSymbol: symbol;
27
+ // Internal model for error state to avoid data serialization
28
+ private dataModel: LocalModel = new LocalModel();
29
+
30
+ constructor(options: { logger?: Logger; writeSymbol: symbol }) {
31
+ this.logger = options.logger;
32
+ this.writeSymbol = options.writeSymbol;
33
+ }
34
+
35
+ public set(
36
+ transaction: BatchSetTransaction,
37
+ options?: DataModelOptions,
38
+ next?: DataModelImpl,
39
+ ): Updates {
40
+ // Filter out any writes to errorState
41
+ const filteredTransaction: BatchSetTransaction = [];
42
+ const errorTransaction: BatchSetTransaction = [];
43
+
44
+ transaction.forEach((transaction) => {
45
+ const [binding] = transaction;
46
+ const targetArray = isErrorBinding(binding)
47
+ ? errorTransaction
48
+ : filteredTransaction;
49
+
50
+ targetArray.push(transaction);
51
+ });
52
+
53
+ // Process allowed writes
54
+ const nonErrorResults = next?.set(filteredTransaction, options) ?? [];
55
+
56
+ const errorResults =
57
+ options?.writeSymbol === this.writeSymbol
58
+ ? this.dataModel.set(errorTransaction)
59
+ : errorTransaction.map((transaction) => {
60
+ const [binding] = transaction;
61
+ this.logger?.warn(
62
+ `[ErrorStateMiddleware] Blocked write to protected path: ${binding.asString()}`,
63
+ );
64
+
65
+ const oldValue = next?.get(binding, options);
66
+ return {
67
+ binding,
68
+ oldValue,
69
+ newValue: oldValue, // Keep old value
70
+ force: false,
71
+ };
72
+ });
73
+
74
+ return [...nonErrorResults, ...errorResults];
75
+ }
76
+
77
+ public get(
78
+ binding: BindingInstance,
79
+ options?: DataModelOptions,
80
+ next?: DataModelImpl,
81
+ ): unknown {
82
+ return isErrorBinding(binding)
83
+ ? this.dataModel.get(binding)
84
+ : next?.get(binding, options);
85
+ }
86
+
87
+ public delete(
88
+ binding: BindingInstance,
89
+ options?: DataModelOptions,
90
+ next?: DataModelImpl,
91
+ ): void {
92
+ if (!isErrorBinding(binding)) {
93
+ next?.delete(binding, options);
94
+ return;
95
+ }
96
+ // Block deletes to errorState namespace
97
+ if (options?.writeSymbol !== this.writeSymbol) {
98
+ this.logger?.warn(
99
+ `[ErrorStateMiddleware] Blocked delete of protected path: ${binding.asString()}`,
100
+ );
101
+ return;
102
+ }
103
+
104
+ this.dataModel.delete(binding);
105
+ }
106
+ }
@@ -0,0 +1,42 @@
1
+ /** Severity levels */
2
+ export enum ErrorSeverity {
3
+ FATAL = "fatal", // Cannot continue, flow must end
4
+ ERROR = "error", // Standard error, may allow recovery
5
+ WARNING = "warning", // Non-blocking, logged for telemetry
6
+ }
7
+
8
+ /** Known error types for Player */
9
+ export const ErrorTypes = {
10
+ EXPRESSION: "expression",
11
+ BINDING: "binding",
12
+ VIEW: "view",
13
+ ASSET: "asset",
14
+ NAVIGATION: "navigation",
15
+ VALIDATION: "validation",
16
+ DATA: "data",
17
+ SCHEMA: "schema",
18
+ NETWORK: "network",
19
+ PLUGIN: "plugin",
20
+ RENDER: "render",
21
+ EXTERNAL_STATE: "externalState",
22
+ } as const;
23
+
24
+ /**
25
+ * Error metadata
26
+ */
27
+ export interface ErrorMetadata {
28
+ /** Allow custom fields for domain-specific information */
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ export interface PlayerErrorMetadata<
33
+ ErrorMetadataType extends ErrorMetadata = ErrorMetadata,
34
+ > {
35
+ type: string;
36
+ severity?: ErrorSeverity;
37
+ metadata?: ErrorMetadataType;
38
+ }
39
+
40
+ export type PlayerError<
41
+ ErrorMetadataType extends ErrorMetadata = ErrorMetadata,
42
+ > = Error & PlayerErrorMetadata<ErrorMetadataType>;
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isErrorWithMetadata } from "../isErrorWithMetadata";
3
+ import { ErrorSeverity } from "../../types";
4
+
5
+ /** Test class to create an error with any additional properties */
6
+ class ErrorWithProps extends Error implements Record<PropertyKey, unknown> {
7
+ [key: PropertyKey]: unknown;
8
+ }
9
+
10
+ const createTestError = (
11
+ additionalProps?: Record<PropertyKey, unknown>,
12
+ ): ErrorWithProps => {
13
+ const err: ErrorWithProps = new ErrorWithProps("Message");
14
+ if (additionalProps) {
15
+ for (const [key, val] of Object.entries(additionalProps)) {
16
+ err[key] = val;
17
+ }
18
+ }
19
+
20
+ return err;
21
+ };
22
+
23
+ describe("isErrorWithMetadata", () => {
24
+ const correctCases = [
25
+ createTestError({ type: "type" }),
26
+ createTestError({ type: "type", metadata: {} }),
27
+ createTestError({ type: "type", severity: ErrorSeverity.ERROR }),
28
+ createTestError({
29
+ type: "type",
30
+ metadata: {},
31
+ severity: ErrorSeverity.ERROR,
32
+ }),
33
+ createTestError({
34
+ type: "type",
35
+ metadata: {},
36
+ severity: ErrorSeverity.ERROR,
37
+ someUnknownProperty: "more data should not impact test case.",
38
+ }),
39
+ ];
40
+ it.each(correctCases)(
41
+ "should return true if type is present and all properties match their expected types.",
42
+ (err) => {
43
+ expect(isErrorWithMetadata(err)).toBe(true);
44
+ },
45
+ );
46
+
47
+ const badTypeCases = [
48
+ // `type` must be defined
49
+ createTestError({
50
+ metadata: {},
51
+ severity: ErrorSeverity.ERROR,
52
+ }),
53
+ // `type` must be a string
54
+ createTestError({
55
+ type: 100,
56
+ metadata: {},
57
+ severity: ErrorSeverity.ERROR,
58
+ }),
59
+ ];
60
+ it.each(badTypeCases)(
61
+ "should return false if type is not present or not a string",
62
+ (err) => {
63
+ expect(isErrorWithMetadata(err)).toBe(false);
64
+ },
65
+ );
66
+
67
+ const badSeverityCases = [
68
+ // `severity` must be a string
69
+ createTestError({
70
+ type: "type",
71
+ metadata: {},
72
+ severity: 100,
73
+ }),
74
+ // `severity` must be an option in the `ErrorSeverity` enum
75
+ createTestError({
76
+ type: "type",
77
+ metadata: {},
78
+ severity: "NotARealErrorSeverity",
79
+ }),
80
+ ];
81
+ it.each(badSeverityCases)(
82
+ "should return false if severity is not a value from the ErrorSeverity enum",
83
+ (err) => {
84
+ expect(isErrorWithMetadata(err)).toBe(false);
85
+ },
86
+ );
87
+
88
+ const badMetadataCases = [
89
+ // `metadata` must be an object
90
+ createTestError({
91
+ type: "type",
92
+ metadata: 100,
93
+ severity: ErrorSeverity.ERROR,
94
+ }),
95
+ // `metadata` cannot be an array
96
+ createTestError({
97
+ type: "type",
98
+ metadata: [],
99
+ severity: ErrorSeverity.ERROR,
100
+ }),
101
+ // `metadata` cannot be null
102
+ createTestError({
103
+ type: "type",
104
+ metadata: null,
105
+ severity: ErrorSeverity.ERROR,
106
+ }),
107
+ ];
108
+ it.each(badMetadataCases)(
109
+ "should return false if metadata is not an object",
110
+ (err) => {
111
+ expect(isErrorWithMetadata(err)).toBe(false);
112
+ },
113
+ );
114
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeJsonStringifyReplacer } from "../makeJsonStringifyReplacer";
3
+
4
+ describe("makeJsonStringifyReplacer", () => {
5
+ it("should return [CIRCULAR] when the same object is used as the value multiple times", () => {
6
+ const val = {
7
+ prop: "value",
8
+ };
9
+ const fn = makeJsonStringifyReplacer();
10
+
11
+ expect(fn("", val)).toStrictEqual({
12
+ prop: "value",
13
+ });
14
+
15
+ expect(fn("", val)).toBe("[CIRCULAR]");
16
+ });
17
+
18
+ it("should return the value when it is not an object or is null", () => {
19
+ const fn = makeJsonStringifyReplacer();
20
+
21
+ expect(fn("", null)).toBeNull();
22
+ expect(fn("", "test")).toBe("test");
23
+ });
24
+ });
@@ -0,0 +1,2 @@
1
+ export * from "./isErrorWithMetadata";
2
+ export * from "./makeJsonStringifyReplacer";
@@ -0,0 +1,28 @@
1
+ import { ErrorSeverity, PlayerError } from "../types";
2
+
3
+ const SEVERITY_SET = new Set<string>(Object.values(ErrorSeverity));
4
+
5
+ export const isErrorWithMetadata = (error: Error): error is PlayerError => {
6
+ // 1. "type" property must be present and a string
7
+ if (!("type" in error) || typeof error.type !== "string") {
8
+ return false;
9
+ }
10
+
11
+ // 2. "severity" property is optional. If present, must be a string within the set of severity options
12
+ if (
13
+ "severity" in error &&
14
+ error.severity !== undefined &&
15
+ (typeof error.severity !== "string" || !SEVERITY_SET.has(error.severity))
16
+ ) {
17
+ return false;
18
+ }
19
+
20
+ // 3. "metadata" property is optional. If present, must be a non-array object.
21
+ return (
22
+ !("metadata" in error) ||
23
+ error.metadata === undefined ||
24
+ (typeof error.metadata === "object" &&
25
+ error.metadata !== null &&
26
+ !Array.isArray(error.metadata))
27
+ );
28
+ };
@@ -0,0 +1,17 @@
1
+ type ReplacerFunction = (key: string, value: any) => any;
2
+
3
+ /** Returns a function to be used as the `replacer` for JSON.stringify that tracks and ignores circular references. */
4
+ export const makeJsonStringifyReplacer = (): ReplacerFunction => {
5
+ const cache = new Set();
6
+ return (_: string, value: any) => {
7
+ if (typeof value === "object" && value !== null) {
8
+ if (cache.has(value)) {
9
+ // Circular reference found, discard key
10
+ return "[CIRCULAR]";
11
+ }
12
+ // Store value in our collection
13
+ cache.add(value);
14
+ }
15
+ return value;
16
+ };
17
+ };
@@ -379,3 +379,271 @@ test("fails if transitioning to unknown state", () => {
379
379
  flow.transition("Next");
380
380
  expect(flow.currentState?.name).toBe("View1");
381
381
  });
382
+
383
+ describe("errorTransition", () => {
384
+ test("navigates using node-level errorTransitions", () => {
385
+ const flow = new FlowInstance("flow", {
386
+ startState: "View1",
387
+ View1: {
388
+ state_type: "VIEW",
389
+ ref: "view-1",
390
+ transitions: {
391
+ next: "End",
392
+ },
393
+ errorTransitions: {
394
+ network: "NetworkError",
395
+ validation: "ValidationError",
396
+ },
397
+ },
398
+ NetworkError: {
399
+ state_type: "VIEW",
400
+ ref: "network-error",
401
+ transitions: {},
402
+ },
403
+ ValidationError: {
404
+ state_type: "VIEW",
405
+ ref: "validation-error",
406
+ transitions: {},
407
+ },
408
+ End: {
409
+ state_type: "END",
410
+ outcome: "done",
411
+ },
412
+ });
413
+
414
+ flow.start();
415
+ flow.errorTransition("network");
416
+
417
+ expect(flow.currentState?.name).toBe("NetworkError");
418
+ });
419
+
420
+ test("uses wildcard in node-level errorTransitions", () => {
421
+ const flow = new FlowInstance("flow", {
422
+ startState: "View1",
423
+ View1: {
424
+ state_type: "VIEW",
425
+ ref: "view-1",
426
+ transitions: {},
427
+ errorTransitions: {
428
+ network: "NetworkError",
429
+ "*": "GenericError",
430
+ },
431
+ },
432
+ NetworkError: {
433
+ state_type: "VIEW",
434
+ ref: "network-error",
435
+ transitions: {},
436
+ },
437
+ GenericError: {
438
+ state_type: "VIEW",
439
+ ref: "generic-error",
440
+ transitions: {},
441
+ },
442
+ });
443
+
444
+ flow.start();
445
+ flow.errorTransition("unknown");
446
+
447
+ expect(flow.currentState?.name).toBe("GenericError");
448
+ });
449
+
450
+ test("falls back to flow-level errorTransitions", () => {
451
+ const flow = new FlowInstance("flow", {
452
+ startState: "View1",
453
+ errorTransitions: {
454
+ network: "NetworkError",
455
+ "*": "GenericError",
456
+ },
457
+ View1: {
458
+ state_type: "VIEW",
459
+ ref: "view-1",
460
+ transitions: {},
461
+ },
462
+ NetworkError: {
463
+ state_type: "VIEW",
464
+ ref: "network-error",
465
+ transitions: {},
466
+ },
467
+ GenericError: {
468
+ state_type: "VIEW",
469
+ ref: "generic-error",
470
+ transitions: {},
471
+ },
472
+ });
473
+
474
+ flow.start();
475
+ flow.errorTransition("network");
476
+
477
+ expect(flow.currentState?.name).toBe("NetworkError");
478
+ });
479
+
480
+ test("node-level errorTransitions takes priority over flow-level", () => {
481
+ const flow = new FlowInstance("flow", {
482
+ startState: "View1",
483
+ errorTransitions: {
484
+ network: "FlowNetworkError",
485
+ },
486
+ View1: {
487
+ state_type: "VIEW",
488
+ ref: "view-1",
489
+ transitions: {},
490
+ errorTransitions: {
491
+ network: "NodeNetworkError",
492
+ },
493
+ },
494
+ NodeNetworkError: {
495
+ state_type: "VIEW",
496
+ ref: "node-network-error",
497
+ transitions: {},
498
+ },
499
+ FlowNetworkError: {
500
+ state_type: "VIEW",
501
+ ref: "flow-network-error",
502
+ transitions: {},
503
+ },
504
+ });
505
+
506
+ flow.start();
507
+ flow.errorTransition("network");
508
+
509
+ expect(flow.currentState?.name).toBe("NodeNetworkError");
510
+ });
511
+
512
+ test("warns when no errorTransitions match", () => {
513
+ const logger = {
514
+ trace: vitest.fn(),
515
+ debug: vitest.fn(),
516
+ info: vitest.fn(),
517
+ warn: vitest.fn(),
518
+ error: vitest.fn(),
519
+ };
520
+
521
+ const flow = new FlowInstance(
522
+ "flow",
523
+ {
524
+ startState: "View1",
525
+ View1: {
526
+ state_type: "VIEW",
527
+ ref: "view-1",
528
+ transitions: {},
529
+ },
530
+ },
531
+ { logger },
532
+ );
533
+
534
+ flow.start();
535
+ flow.errorTransition("network");
536
+
537
+ expect(logger.warn).toHaveBeenCalledWith(
538
+ expect.stringContaining("No errorTransition found"),
539
+ );
540
+ expect(flow.currentState?.name).toBe("View1");
541
+ });
542
+
543
+ test("cannot transition from END state", () => {
544
+ const logger = {
545
+ trace: vitest.fn(),
546
+ debug: vitest.fn(),
547
+ info: vitest.fn(),
548
+ warn: vitest.fn(),
549
+ error: vitest.fn(),
550
+ };
551
+
552
+ const flow = new FlowInstance(
553
+ "flow",
554
+ {
555
+ startState: "End",
556
+ errorTransitions: {
557
+ network: "ErrorView",
558
+ },
559
+ End: {
560
+ state_type: "END",
561
+ outcome: "done",
562
+ },
563
+ ErrorView: {
564
+ state_type: "VIEW",
565
+ ref: "error-view",
566
+ transitions: {},
567
+ },
568
+ },
569
+ { logger },
570
+ );
571
+
572
+ flow.start();
573
+ flow.errorTransition("network");
574
+
575
+ expect(logger.warn).toHaveBeenCalledWith(
576
+ "Cannot error transition from END state",
577
+ );
578
+ expect(flow.currentState?.name).toBe("End");
579
+ });
580
+
581
+ test("uses flow-level errorTransitions when no currentState", () => {
582
+ const flow = new FlowInstance("flow", {
583
+ startState: "View1",
584
+ errorTransitions: {
585
+ init: "ErrorView",
586
+ },
587
+ View1: {
588
+ state_type: "VIEW",
589
+ ref: "view-1",
590
+ transitions: {},
591
+ },
592
+ ErrorView: {
593
+ state_type: "VIEW",
594
+ ref: "error-view",
595
+ transitions: {},
596
+ },
597
+ });
598
+
599
+ // Don't call flow.start() - no currentState
600
+ flow.errorTransition("init");
601
+
602
+ // Should navigate to ErrorView via flow-level errorTransitions
603
+ expect(flow.currentState?.name).toBe("ErrorView");
604
+ });
605
+ });
606
+
607
+ describe("getErrorTransitionState", () => {
608
+ test("should return true when the error exists", () => {
609
+ const flow = new FlowInstance("flow", {
610
+ startState: "View1",
611
+ errorTransitions: {
612
+ init: "ErrorView",
613
+ },
614
+ View1: {
615
+ state_type: "VIEW",
616
+ ref: "view-1",
617
+ transitions: {},
618
+ },
619
+ ErrorView: {
620
+ state_type: "VIEW",
621
+ ref: "error-view",
622
+ transitions: {},
623
+ },
624
+ });
625
+
626
+ expect(flow.getErrorTransitionState("init")).toBe("ErrorView");
627
+ });
628
+
629
+ test("should return false when the error does not exist", () => {
630
+ const flow = new FlowInstance("flow", {
631
+ startState: "View1",
632
+ errorTransitions: {
633
+ init: "ErrorView",
634
+ },
635
+ View1: {
636
+ state_type: "VIEW",
637
+ ref: "view-1",
638
+ transitions: {},
639
+ },
640
+ ErrorView: {
641
+ state_type: "VIEW",
642
+ ref: "error-view",
643
+ transitions: {},
644
+ },
645
+ });
646
+
647
+ expect(flow.getErrorTransitionState("not-init")).toBe(undefined);
648
+ });
649
+ });