@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.
- package/dist/Player.native.js +3259 -2768
- package/dist/Player.native.js.map +1 -1
- package/dist/cjs/index.cjs +2553 -2114
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.legacy-esm.js +2535 -2103
- package/dist/index.mjs +2535 -2103
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/data.test.ts +0 -13
- package/src/__tests__/view.test.ts +34 -1
- package/src/controllers/data/controller.ts +1 -1
- package/src/controllers/data/utils.ts +5 -26
- package/src/controllers/error/__tests__/controller.test.ts +359 -0
- package/src/controllers/error/__tests__/middleware.test.ts +237 -0
- package/src/controllers/error/__tests__/navigation.test.ts +190 -0
- package/src/controllers/error/controller.ts +257 -0
- package/src/controllers/error/index.ts +3 -0
- package/src/controllers/error/middleware.ts +106 -0
- package/src/controllers/error/types.ts +42 -0
- package/src/controllers/error/utils/__tests__/isErrorWithMetadata.test.ts +114 -0
- package/src/controllers/error/utils/__tests__/makeJsonStringifyReplacer.test.ts +24 -0
- package/src/controllers/error/utils/index.ts +2 -0
- package/src/controllers/error/utils/isErrorWithMetadata.ts +28 -0
- package/src/controllers/error/utils/makeJsonStringifyReplacer.ts +17 -0
- package/src/controllers/flow/__tests__/flow.test.ts +268 -0
- package/src/controllers/flow/flow.ts +96 -4
- package/src/controllers/index.ts +1 -0
- package/src/controllers/view/controller.ts +22 -3
- package/src/data/model.ts +6 -0
- package/src/expressions/types.ts +8 -4
- package/src/player.ts +20 -1
- package/src/types.ts +6 -0
- package/src/validator/types.ts +2 -1
- package/src/view/parser/types.ts +6 -3
- package/src/view/plugins/__tests__/template.test.ts +7 -2
- package/src/view/resolver/ResolverError.ts +25 -0
- package/src/view/resolver/__tests__/index.test.ts +53 -1
- package/src/view/resolver/index.ts +68 -37
- package/src/view/resolver/types.ts +13 -0
- package/src/view/resolver/utils.ts +1 -1
- package/types/controllers/data/utils.d.ts +3 -7
- package/types/controllers/error/controller.d.ts +82 -0
- package/types/controllers/error/index.d.ts +4 -0
- package/types/controllers/error/middleware.d.ts +23 -0
- package/types/controllers/error/types.d.ts +35 -0
- package/types/controllers/error/utils/index.d.ts +3 -0
- package/types/controllers/error/utils/isErrorWithMetadata.d.ts +3 -0
- package/types/controllers/error/utils/makeJsonStringifyReplacer.d.ts +5 -0
- package/types/controllers/flow/flow.d.ts +17 -0
- package/types/controllers/index.d.ts +1 -0
- package/types/controllers/view/controller.d.ts +4 -0
- package/types/data/model.d.ts +5 -0
- package/types/types.d.ts +5 -1
- package/types/view/resolver/ResolverError.d.ts +13 -0
- package/types/view/resolver/index.d.ts +2 -1
- 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,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
|
+
});
|