@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,237 @@
|
|
|
1
|
+
import { describe, it, beforeEach, expect, vitest } from "vitest";
|
|
2
|
+
import { ERROR_BINDING_PREFIX, ErrorStateMiddleware } from "../middleware";
|
|
3
|
+
import { BindingInstance, BindingParser } from "../../../binding";
|
|
4
|
+
import type {
|
|
5
|
+
BatchSetTransaction,
|
|
6
|
+
DataModelImpl,
|
|
7
|
+
DataModelOptions,
|
|
8
|
+
} from "../../../data";
|
|
9
|
+
import { LocalModel } from "../../../data";
|
|
10
|
+
import type { Logger } from "../../../logger";
|
|
11
|
+
|
|
12
|
+
describe("ErrorStateMiddleware", () => {
|
|
13
|
+
let middleware: ErrorStateMiddleware;
|
|
14
|
+
let baseDataModel: DataModelImpl;
|
|
15
|
+
// Shortcut to using middleware with baseDataModel as "next"
|
|
16
|
+
let pipelineModel: DataModelImpl;
|
|
17
|
+
let mockLogger: Logger;
|
|
18
|
+
let parser: BindingParser;
|
|
19
|
+
let writeSymbol: symbol;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockLogger = {
|
|
23
|
+
trace: vitest.fn(),
|
|
24
|
+
debug: vitest.fn(),
|
|
25
|
+
info: vitest.fn(),
|
|
26
|
+
warn: vitest.fn(),
|
|
27
|
+
error: vitest.fn(),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
writeSymbol = Symbol("test-write");
|
|
31
|
+
middleware = new ErrorStateMiddleware({
|
|
32
|
+
logger: mockLogger,
|
|
33
|
+
writeSymbol,
|
|
34
|
+
});
|
|
35
|
+
baseDataModel = new LocalModel({ foo: "bar" });
|
|
36
|
+
|
|
37
|
+
parser = new BindingParser({
|
|
38
|
+
get: () => undefined,
|
|
39
|
+
set: () => undefined,
|
|
40
|
+
evaluate: () => undefined,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pipelineModel = {
|
|
44
|
+
get: (binding: BindingInstance, options?: DataModelOptions) =>
|
|
45
|
+
middleware.get(binding, options, baseDataModel),
|
|
46
|
+
set: (transaction: BatchSetTransaction, options?: DataModelOptions) =>
|
|
47
|
+
middleware.set(transaction, options, baseDataModel),
|
|
48
|
+
delete: (binding: BindingInstance, options?: DataModelOptions) =>
|
|
49
|
+
middleware.delete(binding, options, baseDataModel),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("set", () => {
|
|
54
|
+
it("should not write to the base data model", () => {
|
|
55
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
56
|
+
pipelineModel.set([[binding, { message: "test" }]], {
|
|
57
|
+
writeSymbol,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(pipelineModel.get(binding)).toStrictEqual({ message: "test" });
|
|
61
|
+
expect(baseDataModel.get(binding)).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
it("should block writes to errorState without writeSymbol", () => {
|
|
64
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
65
|
+
const updates = pipelineModel.set([[binding, { message: "test" }]]);
|
|
66
|
+
|
|
67
|
+
// Should not write to base model
|
|
68
|
+
expect(pipelineModel.get(binding)).toBeUndefined();
|
|
69
|
+
|
|
70
|
+
// Should log warning
|
|
71
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
72
|
+
expect.stringContaining("Blocked write to protected path: errorState"),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Should return no-op update
|
|
76
|
+
expect(updates.length).toBe(1);
|
|
77
|
+
expect(updates[0]!.binding).toBe(binding);
|
|
78
|
+
expect(updates[0]!.newValue).toBeUndefined();
|
|
79
|
+
expect(updates[0]!.oldValue).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should block writes to nested errorState paths", () => {
|
|
83
|
+
const binding = parser.parse(`${ERROR_BINDING_PREFIX}.message`);
|
|
84
|
+
pipelineModel.set([[binding, "test message"]]);
|
|
85
|
+
|
|
86
|
+
expect(pipelineModel.get(binding)).toBeUndefined();
|
|
87
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
88
|
+
expect.stringContaining(
|
|
89
|
+
"Blocked write to protected path: errorState.message",
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should allow writes to other paths", () => {
|
|
95
|
+
const binding = parser.parse("foo");
|
|
96
|
+
const updates = pipelineModel.set([[binding, "newValue"]]);
|
|
97
|
+
|
|
98
|
+
expect(pipelineModel.get(binding)).toBe("newValue");
|
|
99
|
+
expect(mockLogger.warn).not.toHaveBeenCalled();
|
|
100
|
+
expect(updates.length).toBe(1);
|
|
101
|
+
expect(updates[0]!.newValue).toBe("newValue");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should allow writes when authorized with writeSymbol", () => {
|
|
105
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
106
|
+
|
|
107
|
+
const updates = pipelineModel.set([[binding, { message: "test" }]], {
|
|
108
|
+
writeSymbol: writeSymbol,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(pipelineModel.get(binding)).toEqual({ message: "test" });
|
|
112
|
+
expect(mockLogger.warn).not.toHaveBeenCalled();
|
|
113
|
+
expect(updates.length).toBe(1);
|
|
114
|
+
expect(updates[0]!.newValue).toEqual({ message: "test" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should block writes with wrong writeSymbol", () => {
|
|
118
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
119
|
+
const wrongSymbol = Symbol("wrong-auth");
|
|
120
|
+
|
|
121
|
+
pipelineModel.set([[binding, { message: "test" }]], {
|
|
122
|
+
writeSymbol: wrongSymbol,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(pipelineModel.get(binding)).toBeUndefined();
|
|
126
|
+
expect(mockLogger.warn).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should handle mixed transactions with blocked and allowed paths", () => {
|
|
130
|
+
const errorBinding = parser.parse(ERROR_BINDING_PREFIX);
|
|
131
|
+
const fooBinding = parser.parse("foo");
|
|
132
|
+
|
|
133
|
+
const updates = pipelineModel.set([
|
|
134
|
+
[errorBinding, { message: "blocked" }],
|
|
135
|
+
[fooBinding, "allowed"],
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
// foo should be updated
|
|
139
|
+
expect(pipelineModel.get(fooBinding)).toBe("allowed");
|
|
140
|
+
|
|
141
|
+
// errorState should not be updated
|
|
142
|
+
expect(pipelineModel.get(errorBinding)).toBeUndefined();
|
|
143
|
+
|
|
144
|
+
// Should have logged warning for errorState
|
|
145
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
146
|
+
expect.stringContaining("Blocked write to protected path: errorState"),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Should have updates for both paths
|
|
150
|
+
expect(updates.length).toBe(2);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("get", () => {
|
|
155
|
+
it("should not read error state from the base model", () => {
|
|
156
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
157
|
+
|
|
158
|
+
// Set value directly on base model
|
|
159
|
+
baseDataModel.set([[binding, { message: "test" }]]);
|
|
160
|
+
|
|
161
|
+
expect(pipelineModel.get(binding)).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should read without needing any permissions", () => {
|
|
165
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
166
|
+
pipelineModel.set([[binding, { message: "test" }]], { writeSymbol });
|
|
167
|
+
|
|
168
|
+
const value = pipelineModel.get(binding);
|
|
169
|
+
expect(value).toStrictEqual({ message: "test" });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("delete", () => {
|
|
174
|
+
it("should block deletes to errorState without writeSymbol", () => {
|
|
175
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
176
|
+
|
|
177
|
+
// Set value first
|
|
178
|
+
pipelineModel.set([[binding, { message: "test" }]], { writeSymbol });
|
|
179
|
+
|
|
180
|
+
pipelineModel.delete(binding);
|
|
181
|
+
|
|
182
|
+
// Should still exist
|
|
183
|
+
expect(pipelineModel.get(binding)).toEqual({ message: "test" });
|
|
184
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
185
|
+
expect.stringContaining("Blocked delete of protected path: errorState"),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should allow deletes when authorized with writeSymbol", () => {
|
|
190
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
191
|
+
|
|
192
|
+
// Set value first
|
|
193
|
+
pipelineModel.set([[binding, { message: "test" }]], { writeSymbol });
|
|
194
|
+
|
|
195
|
+
pipelineModel.delete(binding, { writeSymbol: writeSymbol });
|
|
196
|
+
|
|
197
|
+
expect(pipelineModel.get(binding)).toBeUndefined();
|
|
198
|
+
expect(mockLogger.warn).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("should block deletes with wrong writeSymbol", () => {
|
|
202
|
+
const binding = parser.parse(ERROR_BINDING_PREFIX);
|
|
203
|
+
const wrongSymbol = Symbol("wrong-auth");
|
|
204
|
+
|
|
205
|
+
// Set value first
|
|
206
|
+
pipelineModel.set([[binding, { message: "test" }]], { writeSymbol });
|
|
207
|
+
|
|
208
|
+
pipelineModel.delete(binding, { writeSymbol: wrongSymbol });
|
|
209
|
+
|
|
210
|
+
expect(pipelineModel.get(binding)).toEqual({ message: "test" });
|
|
211
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
212
|
+
expect.stringContaining("Blocked delete of protected path: errorState"),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("should allow deletes to other paths", () => {
|
|
217
|
+
const binding = parser.parse("foo");
|
|
218
|
+
|
|
219
|
+
pipelineModel.delete(binding);
|
|
220
|
+
|
|
221
|
+
expect(baseDataModel.get(binding)).toBeUndefined();
|
|
222
|
+
expect(mockLogger.warn).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should allow deletes to nested errorState paths when authorized", () => {
|
|
226
|
+
const binding = parser.parse(`${ERROR_BINDING_PREFIX}.nested.path`);
|
|
227
|
+
|
|
228
|
+
// Set value first
|
|
229
|
+
pipelineModel.set([[binding, "test"]], { writeSymbol });
|
|
230
|
+
|
|
231
|
+
pipelineModel.delete(binding, { writeSymbol: writeSymbol });
|
|
232
|
+
|
|
233
|
+
expect(pipelineModel.get(binding)).toBeUndefined();
|
|
234
|
+
expect(mockLogger.warn).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, beforeEach, expect, vitest } from "vitest";
|
|
2
|
+
import { ErrorController } from "../controller";
|
|
3
|
+
import { FlowController } from "../../flow/controller";
|
|
4
|
+
import { FlowInstance } from "../../flow/flow";
|
|
5
|
+
import {
|
|
6
|
+
ErrorMetadata,
|
|
7
|
+
ErrorSeverity,
|
|
8
|
+
ErrorTypes,
|
|
9
|
+
PlayerErrorMetadata,
|
|
10
|
+
} from "../types";
|
|
11
|
+
import type { Logger } from "../../../logger";
|
|
12
|
+
import type { DataController } from "../../data/controller";
|
|
13
|
+
|
|
14
|
+
/** Test class to create an error with any additional properties */
|
|
15
|
+
class ErrorWithProps extends Error implements PlayerErrorMetadata {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
public type: string,
|
|
19
|
+
public severity?: ErrorSeverity,
|
|
20
|
+
public metadata?: ErrorMetadata,
|
|
21
|
+
) {
|
|
22
|
+
super(message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("ErrorController Navigation", () => {
|
|
27
|
+
let errorController: ErrorController;
|
|
28
|
+
let mockFlowController: FlowController;
|
|
29
|
+
let mockFlowInstance: FlowInstance;
|
|
30
|
+
let mockDataController: DataController;
|
|
31
|
+
let mockLogger: Logger;
|
|
32
|
+
let mockFail: ReturnType<typeof vitest.fn>;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
mockLogger = {
|
|
36
|
+
trace: vitest.fn(),
|
|
37
|
+
debug: vitest.fn(),
|
|
38
|
+
info: vitest.fn(),
|
|
39
|
+
warn: vitest.fn(),
|
|
40
|
+
error: vitest.fn(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
mockDataController = {
|
|
44
|
+
set: vitest.fn(),
|
|
45
|
+
get: vitest.fn(),
|
|
46
|
+
delete: vitest.fn(),
|
|
47
|
+
} as any;
|
|
48
|
+
|
|
49
|
+
mockFail = vitest.fn();
|
|
50
|
+
|
|
51
|
+
// Mock FlowInstance
|
|
52
|
+
mockFlowInstance = {
|
|
53
|
+
currentState: {
|
|
54
|
+
name: "VIEW_Start",
|
|
55
|
+
value: {
|
|
56
|
+
state_type: "VIEW",
|
|
57
|
+
ref: "start-view",
|
|
58
|
+
transitions: {
|
|
59
|
+
next: "VIEW_Next",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
errorTransition: vitest.fn(),
|
|
64
|
+
getErrorTransitionState: vitest.fn(() => true),
|
|
65
|
+
} as any;
|
|
66
|
+
|
|
67
|
+
// Mock FlowController
|
|
68
|
+
mockFlowController = {
|
|
69
|
+
current: mockFlowInstance,
|
|
70
|
+
reject: vitest.fn(),
|
|
71
|
+
} as any;
|
|
72
|
+
|
|
73
|
+
errorController = new ErrorController({
|
|
74
|
+
logger: mockLogger,
|
|
75
|
+
model: mockDataController,
|
|
76
|
+
flow: mockFlowController,
|
|
77
|
+
fail: mockFail,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("errorTransitions navigation", () => {
|
|
82
|
+
it("should navigate using errorTransition method", () => {
|
|
83
|
+
const error = new ErrorWithProps("Test error", ErrorTypes.VIEW);
|
|
84
|
+
errorController.captureError(error);
|
|
85
|
+
|
|
86
|
+
// Should call errorTransition with errorType
|
|
87
|
+
expect(mockFlowInstance.errorTransition).toHaveBeenCalledWith(
|
|
88
|
+
ErrorTypes.VIEW,
|
|
89
|
+
);
|
|
90
|
+
expect(mockFail).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should navigate using a wildcard transition when no error type is available", () => {
|
|
94
|
+
const error = new Error("Test error");
|
|
95
|
+
errorController.captureError(error);
|
|
96
|
+
|
|
97
|
+
// Should call errorTransition with errorType
|
|
98
|
+
expect(mockFlowInstance.errorTransition).toHaveBeenCalledWith("*");
|
|
99
|
+
expect(mockFail).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should reject flow when errorTransition throws", () => {
|
|
103
|
+
mockFlowInstance.errorTransition = vitest.fn().mockImplementation(() => {
|
|
104
|
+
throw new Error("No errorTransitions defined");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const error = new ErrorWithProps("Test error", ErrorTypes.NAVIGATION);
|
|
108
|
+
errorController.captureError(error);
|
|
109
|
+
|
|
110
|
+
expect(mockFlowInstance.errorTransition).toHaveBeenCalledWith(
|
|
111
|
+
ErrorTypes.NAVIGATION,
|
|
112
|
+
);
|
|
113
|
+
expect(mockFail).toHaveBeenCalledWith(error);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should pass correct errorType to errorTransition", () => {
|
|
117
|
+
const error = new ErrorWithProps("Binding failed", "binding");
|
|
118
|
+
errorController.captureError(error);
|
|
119
|
+
|
|
120
|
+
expect(mockFlowInstance.errorTransition).toHaveBeenCalledWith("binding");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should pass custom errorType to errorTransition", () => {
|
|
124
|
+
const error = new ErrorWithProps("Custom error", "custom_type");
|
|
125
|
+
errorController.captureError(error);
|
|
126
|
+
|
|
127
|
+
expect(mockFlowInstance.errorTransition).toHaveBeenCalledWith(
|
|
128
|
+
"custom_type",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should fail the player state when there is no available transition", () => {
|
|
133
|
+
vitest
|
|
134
|
+
.mocked(mockFlowController.current?.getErrorTransitionState)
|
|
135
|
+
?.mockReturnValue(undefined);
|
|
136
|
+
const error = new ErrorWithProps("Test error", ErrorTypes.VIEW);
|
|
137
|
+
errorController.captureError(error);
|
|
138
|
+
expect(mockFail).toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("Hook integration", () => {
|
|
143
|
+
it("should skip navigation when plugin bails", () => {
|
|
144
|
+
errorController.hooks.onError.tap("test", () => true);
|
|
145
|
+
|
|
146
|
+
const error = new ErrorWithProps("Test error", ErrorTypes.NAVIGATION);
|
|
147
|
+
errorController.captureError(error);
|
|
148
|
+
|
|
149
|
+
// Should not navigate when bailed
|
|
150
|
+
expect(mockFlowInstance.errorTransition).not.toHaveBeenCalled();
|
|
151
|
+
expect(mockFail).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should navigate when plugin does not bail", () => {
|
|
155
|
+
errorController.hooks.onError.tap("test", () => undefined);
|
|
156
|
+
|
|
157
|
+
const error = new ErrorWithProps("Test error", ErrorTypes.NAVIGATION);
|
|
158
|
+
errorController.captureError(error);
|
|
159
|
+
|
|
160
|
+
expect(mockFlowInstance.errorTransition).toHaveBeenCalledWith(
|
|
161
|
+
ErrorTypes.NAVIGATION,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should navigate when plugin returns false", () => {
|
|
166
|
+
errorController.hooks.onError.tap("test", () => false);
|
|
167
|
+
|
|
168
|
+
const error = new ErrorWithProps("Test error", ErrorTypes.VIEW);
|
|
169
|
+
errorController.captureError(error);
|
|
170
|
+
|
|
171
|
+
expect(mockFlowInstance.errorTransition).toHaveBeenCalledWith(
|
|
172
|
+
ErrorTypes.VIEW,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("No active flow", () => {
|
|
178
|
+
it("should warn and not navigate when no active flow", () => {
|
|
179
|
+
mockFlowController.current = undefined;
|
|
180
|
+
|
|
181
|
+
const error = new ErrorWithProps("Test error", ErrorTypes.VIEW);
|
|
182
|
+
errorController.captureError(error);
|
|
183
|
+
|
|
184
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
185
|
+
"[ErrorController] No active flow instance for error navigation",
|
|
186
|
+
);
|
|
187
|
+
expect(mockFail).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { SyncBailHook } from "tapable-ts";
|
|
2
|
+
import type { Logger } from "../../logger";
|
|
3
|
+
import type { DataController } from "../data/controller";
|
|
4
|
+
import type { FlowController } from "../flow/controller";
|
|
5
|
+
import type { PlayerError } from "./types";
|
|
6
|
+
import { ErrorStateMiddleware } from "./middleware";
|
|
7
|
+
import { isErrorWithMetadata, makeJsonStringifyReplacer } from "./utils";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Private symbol used to authorize ErrorController's writes to errorState
|
|
11
|
+
* Only ErrorController has access to this symbol
|
|
12
|
+
*/
|
|
13
|
+
const errorControllerWriteSymbol: unique symbol = Symbol(
|
|
14
|
+
"errorControllerWrite",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export interface ErrorControllerHooks {
|
|
18
|
+
/**
|
|
19
|
+
* Fired when any error is captured
|
|
20
|
+
* - Called in order for each tapped plugin
|
|
21
|
+
* - Return true to bail and prevent error state navigation
|
|
22
|
+
* - Return undefined/false to continue to next handler
|
|
23
|
+
* - Once true is returned, no further plugins are called
|
|
24
|
+
*/
|
|
25
|
+
onError: SyncBailHook<[PlayerError], boolean | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ErrorControllerOptions {
|
|
29
|
+
/** Logger for error operations */
|
|
30
|
+
logger: Logger;
|
|
31
|
+
/** Flow controller for error navigation */
|
|
32
|
+
flow: FlowController;
|
|
33
|
+
/** Callback to fail/reject the flow */
|
|
34
|
+
fail: (error: Error) => void;
|
|
35
|
+
/** Data model for setting errorState (can be set later via setOptions) */
|
|
36
|
+
model?: DataController;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The orchestrator for player error handling */
|
|
40
|
+
export class ErrorController {
|
|
41
|
+
public hooks: ErrorControllerHooks = {
|
|
42
|
+
onError: new SyncBailHook(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
private options: ErrorControllerOptions;
|
|
46
|
+
private readonly middleware: ErrorStateMiddleware;
|
|
47
|
+
/**
|
|
48
|
+
* Complete history of all captured errors in chronological order
|
|
49
|
+
* Newest errors are APPENDED to the end of the array
|
|
50
|
+
*/
|
|
51
|
+
private errorHistory: PlayerError[] = [];
|
|
52
|
+
private currentError?: PlayerError;
|
|
53
|
+
|
|
54
|
+
constructor(options: ErrorControllerOptions) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
|
|
57
|
+
this.middleware = new ErrorStateMiddleware({
|
|
58
|
+
logger: options.logger,
|
|
59
|
+
writeSymbol: errorControllerWriteSymbol,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the middleware for protecting errorState
|
|
65
|
+
* This should be added to DataController's middleware array
|
|
66
|
+
*/
|
|
67
|
+
public getDataMiddleware(): ErrorStateMiddleware {
|
|
68
|
+
return this.middleware;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set the DataController after initialization
|
|
73
|
+
*/
|
|
74
|
+
public setOptions(options: Pick<ErrorControllerOptions, "model">): void {
|
|
75
|
+
this.options.model = options.model;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Capture an error and try to recover. Errors implementing the `PlayerErrorMetadata` interface will be added to history, fire hooks and update data model. As a fallback, all errors will try to trigger an errorTransition. If the error does not have a `type` property, it will default to only using the wildcard navigation.
|
|
80
|
+
*/
|
|
81
|
+
public captureError(error: Error): boolean {
|
|
82
|
+
if (!isErrorWithMetadata(error)) {
|
|
83
|
+
this.options.logger.debug(
|
|
84
|
+
`[ErrorController] Captured error: ${error.message}\n`,
|
|
85
|
+
"Cannot determine optional error metadata, attempting default error navigation...",
|
|
86
|
+
);
|
|
87
|
+
return this.tryNavigateToErrorState(error, "*");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Add to history
|
|
91
|
+
this.errorHistory.push(error);
|
|
92
|
+
|
|
93
|
+
// Set as current error
|
|
94
|
+
this.currentError = error;
|
|
95
|
+
|
|
96
|
+
this.options.logger.debug(
|
|
97
|
+
`[ErrorController] Captured error: ${error.message}`,
|
|
98
|
+
JSON.stringify(
|
|
99
|
+
{
|
|
100
|
+
errorType: error.type,
|
|
101
|
+
severity: error.severity,
|
|
102
|
+
metadata: error.metadata,
|
|
103
|
+
},
|
|
104
|
+
makeJsonStringifyReplacer(),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Notify listeners and check if navigation should be skipped
|
|
109
|
+
// Plugins can observe the error and optionally return true to bail
|
|
110
|
+
const shouldSkip = this.hooks.onError.call(error) ?? false;
|
|
111
|
+
|
|
112
|
+
if (shouldSkip) {
|
|
113
|
+
this.options.logger.debug(
|
|
114
|
+
"[ErrorController] Error state navigation skipped by plugin",
|
|
115
|
+
);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Set error in data model
|
|
120
|
+
this.setErrorInDataModel(error);
|
|
121
|
+
|
|
122
|
+
// Navigate to error state
|
|
123
|
+
return this.tryNavigateToErrorState(error, error.type);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Navigate to error state using errorTransitions.
|
|
128
|
+
* Uses errorTransition() which handles node-level and flow-level fallback internally.
|
|
129
|
+
*/
|
|
130
|
+
private tryNavigateToErrorState(error: Error, transition: string): boolean {
|
|
131
|
+
const flowInstance = this.options.flow.current;
|
|
132
|
+
|
|
133
|
+
if (!flowInstance) {
|
|
134
|
+
this.options.logger.warn(
|
|
135
|
+
"[ErrorController] No active flow instance for error navigation",
|
|
136
|
+
);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (flowInstance.getErrorTransitionState(transition) === undefined) {
|
|
141
|
+
this.options.fail(error);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
flowInstance.errorTransition(transition);
|
|
147
|
+
return true;
|
|
148
|
+
} catch (e) {
|
|
149
|
+
this.options.logger.error(
|
|
150
|
+
`[ErrorController] Error transition failed with unexpected error: ${e}`,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Fallback: Reject flow
|
|
154
|
+
this.options.logger.debug("[ErrorController] Rejecting flow with error");
|
|
155
|
+
this.options.fail(error);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get most recent error
|
|
162
|
+
*/
|
|
163
|
+
public getCurrentError(): PlayerError | undefined {
|
|
164
|
+
return this.currentError;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get error history (read-only)
|
|
169
|
+
*/
|
|
170
|
+
public getErrors(): ReadonlyArray<PlayerError> {
|
|
171
|
+
return this.errorHistory;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Clear all errors (history + current + data model)
|
|
176
|
+
*/
|
|
177
|
+
public clearErrors(): void {
|
|
178
|
+
this.errorHistory = [];
|
|
179
|
+
this.currentError = undefined;
|
|
180
|
+
this.deleteErrorFromDataModel();
|
|
181
|
+
this.options.logger.debug("[ErrorController] All errors cleared");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Clear only current error and remove from data model, preserve history
|
|
186
|
+
*/
|
|
187
|
+
public clearCurrentError(): void {
|
|
188
|
+
this.currentError = undefined;
|
|
189
|
+
this.deleteErrorFromDataModel();
|
|
190
|
+
this.options.logger.debug("[ErrorController] Current error cleared");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Write error to data model errorState
|
|
195
|
+
*/
|
|
196
|
+
private setErrorInDataModel(playerError: PlayerError): void {
|
|
197
|
+
if (!this.options.model) {
|
|
198
|
+
this.options.logger.warn("[ErrorController] No DataController available");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const { type, severity, metadata, name, message } = playerError;
|
|
204
|
+
|
|
205
|
+
// Pass write symbol to authorize write through middleware
|
|
206
|
+
this.options.model.set(
|
|
207
|
+
[
|
|
208
|
+
[
|
|
209
|
+
"errorState",
|
|
210
|
+
{
|
|
211
|
+
message,
|
|
212
|
+
name,
|
|
213
|
+
errorType: type,
|
|
214
|
+
severity,
|
|
215
|
+
...metadata,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
],
|
|
219
|
+
{ writeSymbol: errorControllerWriteSymbol },
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
this.options.logger.debug(
|
|
223
|
+
"[ErrorController] Error set in data model at 'data.errorState'",
|
|
224
|
+
);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
this.options.logger.error(
|
|
227
|
+
"[ErrorController] Failed to set error in data model",
|
|
228
|
+
e,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Remove errorState from data model
|
|
235
|
+
*/
|
|
236
|
+
private deleteErrorFromDataModel(): void {
|
|
237
|
+
if (!this.options.model) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
// Pass write symbol to authorize delete through middleware
|
|
243
|
+
this.options.model.delete("errorState", {
|
|
244
|
+
writeSymbol: errorControllerWriteSymbol,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
this.options.logger.debug(
|
|
248
|
+
"[ErrorController] errorState deleted from data model",
|
|
249
|
+
);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
this.options.logger.error(
|
|
252
|
+
"[ErrorController] Failed to delete errorState from data model",
|
|
253
|
+
e,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|