@jskit-ai/shell-web 0.1.86 → 0.1.88
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/package.descriptor.mjs +9 -3
- package/package.json +4 -2
- package/src/client/asyncModuleRecovery/index.js +4 -0
- package/src/client/asyncModuleRecovery/inject.js +30 -0
- package/src/client/index.js +10 -0
- package/src/client/providers/ShellWebClientProvider.js +19 -1
- package/src/client/requestRecovery/index.js +8 -0
- package/src/client/requestRecovery/inject.js +30 -0
- package/src/client/requestRecovery/runtime.js +446 -0
- package/src/client/runtime/bootstrapRuntime.js +14 -0
- package/test/asyncModuleRecoveryInject.test.js +49 -0
- package/test/provider.test.js +167 -2
- package/test/requestRecovery.test.js +72 -0
- package/test/settingsPlacementContract.test.js +47 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/shell-web",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.88",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Web shell layout runtime with outlet-based placement contributions.",
|
|
7
7
|
dependsOn: [],
|
|
@@ -9,7 +9,8 @@ export default Object.freeze({
|
|
|
9
9
|
provides: [
|
|
10
10
|
"runtime.web-placement",
|
|
11
11
|
"runtime.web-error",
|
|
12
|
-
"runtime.web-async-module-recovery"
|
|
12
|
+
"runtime.web-async-module-recovery",
|
|
13
|
+
"runtime.web-request-recovery"
|
|
13
14
|
],
|
|
14
15
|
requires: []
|
|
15
16
|
},
|
|
@@ -41,6 +42,10 @@ export default Object.freeze({
|
|
|
41
42
|
subpath: "./client/error",
|
|
42
43
|
summary: "Exports default error policy and runtime error reporter hook."
|
|
43
44
|
},
|
|
45
|
+
{
|
|
46
|
+
subpath: "./client/requestRecovery",
|
|
47
|
+
summary: "Exports request connectivity recovery classification and runtime access for app-caught request failures."
|
|
48
|
+
},
|
|
44
49
|
{
|
|
45
50
|
subpath: "./client/bootstrap",
|
|
46
51
|
summary: "Exports the shared client bootstrap handler registry used to extend /api/bootstrap handling."
|
|
@@ -57,6 +62,7 @@ export default Object.freeze({
|
|
|
57
62
|
"runtime.web-bootstrap.client",
|
|
58
63
|
"runtime.web-refresh.client",
|
|
59
64
|
"runtime.web-async-module-recovery.client",
|
|
65
|
+
"runtime.web-request-recovery.client",
|
|
60
66
|
"runtime.web-error.client",
|
|
61
67
|
"runtime.web-error.presentation-store.client"
|
|
62
68
|
]
|
|
@@ -294,7 +300,7 @@ export default Object.freeze({
|
|
|
294
300
|
dependencies: {
|
|
295
301
|
runtime: {
|
|
296
302
|
"@mdi/js": "^7.4.47",
|
|
297
|
-
"@jskit-ai/kernel": "0.1.
|
|
303
|
+
"@jskit-ai/kernel": "0.1.89"
|
|
298
304
|
},
|
|
299
305
|
dev: {}
|
|
300
306
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/shell-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.88",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"./client": "./src/client/index.js",
|
|
10
10
|
"./client/error": "./src/client/error/index.js",
|
|
11
11
|
"./client/placement": "./src/client/placement/index.js",
|
|
12
|
+
"./client/asyncModuleRecovery": "./src/client/asyncModuleRecovery/index.js",
|
|
13
|
+
"./client/requestRecovery": "./src/client/requestRecovery/index.js",
|
|
12
14
|
"./client/bootstrap": "./src/client/bootstrap/index.js",
|
|
13
15
|
"./server/support/localLinkItemScaffolds": "./src/server/support/localLinkItemScaffolds.js",
|
|
14
16
|
"./client/navigation/linkResolver": "./src/client/navigation/linkResolver.js",
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
},
|
|
27
29
|
"dependencies": {
|
|
28
30
|
"@mdi/js": "^7.4.47",
|
|
29
|
-
"@jskit-ai/kernel": "0.1.
|
|
31
|
+
"@jskit-ai/kernel": "0.1.89"
|
|
30
32
|
},
|
|
31
33
|
"peerDependencies": {
|
|
32
34
|
"pinia": "^3.0.4",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasInjectionContext,
|
|
3
|
+
inject
|
|
4
|
+
} from "vue";
|
|
5
|
+
|
|
6
|
+
const SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY =
|
|
7
|
+
"jskit.shell-web.runtime.web-async-module-recovery.client";
|
|
8
|
+
|
|
9
|
+
function isShellAsyncModuleRecoveryRuntime(value) {
|
|
10
|
+
return Boolean(
|
|
11
|
+
value &&
|
|
12
|
+
typeof value.notify === "function" &&
|
|
13
|
+
typeof value.reload === "function"
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function useShellAsyncModuleRecoveryRuntime() {
|
|
18
|
+
if (!hasInjectionContext()) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const runtime = inject(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, null);
|
|
23
|
+
return isShellAsyncModuleRecoveryRuntime(runtime) ? runtime : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY,
|
|
28
|
+
isShellAsyncModuleRecoveryRuntime,
|
|
29
|
+
useShellAsyncModuleRecoveryRuntime
|
|
30
|
+
};
|
package/src/client/index.js
CHANGED
|
@@ -17,6 +17,16 @@ export { default as ShellTabLinkItem } from "./components/ShellTabLinkItem.vue";
|
|
|
17
17
|
export { useShellLayoutState } from "./composables/useShellLayoutState.js";
|
|
18
18
|
export { useShellLayoutStore } from "./stores/useShellLayoutStore.js";
|
|
19
19
|
export { useShellErrorPresentationStore } from "./stores/useShellErrorPresentationStore.js";
|
|
20
|
+
export {
|
|
21
|
+
SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY,
|
|
22
|
+
useShellAsyncModuleRecoveryRuntime
|
|
23
|
+
} from "./asyncModuleRecovery/index.js";
|
|
24
|
+
export {
|
|
25
|
+
SHELL_REQUEST_RECOVERY_RUNTIME_KEY,
|
|
26
|
+
isRecoverableRequestError,
|
|
27
|
+
requestRecoveryMessage,
|
|
28
|
+
useShellRequestRecoveryRuntime
|
|
29
|
+
} from "./requestRecovery/index.js";
|
|
20
30
|
export {
|
|
21
31
|
BOOTSTRAP_PAYLOAD_HANDLER_TAG,
|
|
22
32
|
registerBootstrapPayloadHandler,
|
|
@@ -10,6 +10,15 @@ import {
|
|
|
10
10
|
isMissingDynamicModule,
|
|
11
11
|
notifyDynamicImportFailure
|
|
12
12
|
} from "./appModuleLoadFailure.js";
|
|
13
|
+
import {
|
|
14
|
+
SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY
|
|
15
|
+
} from "../asyncModuleRecovery/inject.js";
|
|
16
|
+
import {
|
|
17
|
+
SHELL_REQUEST_RECOVERY_RUNTIME_KEY
|
|
18
|
+
} from "../requestRecovery/inject.js";
|
|
19
|
+
import {
|
|
20
|
+
createShellRequestRecoveryRuntime
|
|
21
|
+
} from "../requestRecovery/runtime.js";
|
|
13
22
|
import {
|
|
14
23
|
isRecord
|
|
15
24
|
} from "@jskit-ai/kernel/shared/support";
|
|
@@ -574,6 +583,12 @@ class ShellWebClientProvider {
|
|
|
574
583
|
logger
|
|
575
584
|
})
|
|
576
585
|
);
|
|
586
|
+
app.singleton("runtime.web-request-recovery.client", (scope) =>
|
|
587
|
+
createShellRequestRecoveryRuntime({
|
|
588
|
+
app: scope,
|
|
589
|
+
logger
|
|
590
|
+
})
|
|
591
|
+
);
|
|
577
592
|
app.singleton("runtime.web-error.presentation-store.client", () => createErrorPresentationStore());
|
|
578
593
|
app.singleton("runtime.web-error.client", (scope) =>
|
|
579
594
|
createErrorRuntime({
|
|
@@ -595,6 +610,7 @@ class ShellWebClientProvider {
|
|
|
595
610
|
const logger = createSharedProviderLogger(isRecord(app) ? app : null);
|
|
596
611
|
const errorRuntime = app.make("runtime.web-error.client");
|
|
597
612
|
const asyncModuleRecoveryRuntime = app.make("runtime.web-async-module-recovery.client");
|
|
613
|
+
const requestRecoveryRuntime = app.make("runtime.web-request-recovery.client");
|
|
598
614
|
asyncModuleRecoveryRuntime.install();
|
|
599
615
|
|
|
600
616
|
const placementRuntime = app.make("runtime.web-placement.client");
|
|
@@ -629,6 +645,7 @@ class ShellWebClientProvider {
|
|
|
629
645
|
|
|
630
646
|
const errorConfig = await loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime);
|
|
631
647
|
applyAppErrorConfig(errorRuntime, errorConfig);
|
|
648
|
+
requestRecoveryRuntime.install();
|
|
632
649
|
|
|
633
650
|
const bootstrapRuntime = app.make("runtime.web-bootstrap.client");
|
|
634
651
|
if (bootstrapRuntime && typeof bootstrapRuntime.initialize === "function") {
|
|
@@ -654,7 +671,8 @@ class ShellWebClientProvider {
|
|
|
654
671
|
vueApp.provide("jskit.shell-web.runtime.web-placement.client", placementRuntime);
|
|
655
672
|
vueApp.provide("jskit.shell-web.runtime.web-refresh.client", refreshRuntime);
|
|
656
673
|
vueApp.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
|
|
657
|
-
vueApp.provide(
|
|
674
|
+
vueApp.provide(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, asyncModuleRecoveryRuntime);
|
|
675
|
+
vueApp.provide(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, requestRecoveryRuntime);
|
|
658
676
|
vueApp.provide(
|
|
659
677
|
"jskit.shell-web.runtime.web-error.presentation-store.client",
|
|
660
678
|
errorPresentationStore
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasInjectionContext,
|
|
3
|
+
inject
|
|
4
|
+
} from "vue";
|
|
5
|
+
|
|
6
|
+
const SHELL_REQUEST_RECOVERY_RUNTIME_KEY =
|
|
7
|
+
"jskit.shell-web.runtime.web-request-recovery.client";
|
|
8
|
+
|
|
9
|
+
function isShellRequestRecoveryRuntime(value) {
|
|
10
|
+
return Boolean(
|
|
11
|
+
value &&
|
|
12
|
+
typeof value.report === "function" &&
|
|
13
|
+
typeof value.reload === "function"
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function useShellRequestRecoveryRuntime() {
|
|
18
|
+
if (!hasInjectionContext()) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const runtime = inject(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, null);
|
|
23
|
+
return isShellRequestRecoveryRuntime(runtime) ? runtime : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
SHELL_REQUEST_RECOVERY_RUNTIME_KEY,
|
|
28
|
+
isShellRequestRecoveryRuntime,
|
|
29
|
+
useShellRequestRecoveryRuntime
|
|
30
|
+
};
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { guardedReloadApp } from "@jskit-ai/kernel/client/asyncModuleRecovery";
|
|
2
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support";
|
|
3
|
+
import { createProviderLogger as createSharedProviderLogger } from "@jskit-ai/kernel/shared/support/providerLogger";
|
|
4
|
+
|
|
5
|
+
const REQUEST_RECOVERY_RELOAD_FAILURE_MESSAGE =
|
|
6
|
+
"The app cannot reload because the app server is not reachable. Restart the server, then click Reload.";
|
|
7
|
+
|
|
8
|
+
const RECOVERABLE_REQUEST_ERROR_MESSAGES = Object.freeze([
|
|
9
|
+
/Failed to fetch/iu,
|
|
10
|
+
/Load failed/iu,
|
|
11
|
+
/Network request failed/iu,
|
|
12
|
+
/NetworkError when attempting to fetch resource/iu,
|
|
13
|
+
/The Internet connection appears to be offline/iu
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const RECOVERABLE_REQUEST_ERROR_CODES = Object.freeze(new Set([
|
|
17
|
+
"EAI_AGAIN",
|
|
18
|
+
"ECONNABORTED",
|
|
19
|
+
"ECONNREFUSED",
|
|
20
|
+
"ECONNRESET",
|
|
21
|
+
"ENETDOWN",
|
|
22
|
+
"ENETUNREACH",
|
|
23
|
+
"ENOTFOUND",
|
|
24
|
+
"ERR_NETWORK",
|
|
25
|
+
"ETIMEDOUT"
|
|
26
|
+
]));
|
|
27
|
+
|
|
28
|
+
const CANCELED_REQUEST_ERROR_CODES = Object.freeze(new Set([
|
|
29
|
+
"ABORT_ERR",
|
|
30
|
+
"ERR_ABORTED",
|
|
31
|
+
"ERR_CANCELED"
|
|
32
|
+
]));
|
|
33
|
+
|
|
34
|
+
function normalizeText(value, fallback = "") {
|
|
35
|
+
const normalized = String(value || "").trim();
|
|
36
|
+
return normalized || String(fallback || "").trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function errorMessage(error = null) {
|
|
40
|
+
return normalizeText(error?.message || error?.cause?.message || error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function errorCode(error = null) {
|
|
44
|
+
return normalizeText(error?.code || error?.cause?.code).toUpperCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function errorName(error = null) {
|
|
48
|
+
return normalizeText(error?.name || error?.cause?.name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeRequestErrorStatus(error = null) {
|
|
52
|
+
if (!isRecord(error)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hasStatus = Object.prototype.hasOwnProperty.call(error, "status");
|
|
57
|
+
const hasStatusCode = Object.prototype.hasOwnProperty.call(error, "statusCode");
|
|
58
|
+
if (!hasStatus && !hasStatusCode) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const status = Number(hasStatus ? error.status : error.statusCode);
|
|
63
|
+
return Number.isInteger(status) ? status : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isCanceledRequestError(error = null) {
|
|
67
|
+
const name = errorName(error).toLowerCase();
|
|
68
|
+
if (name === "aborterror" || name === "cancelederror") {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return CANCELED_REQUEST_ERROR_CODES.has(errorCode(error));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isRecoverableRequestError(error = null) {
|
|
75
|
+
if (!error || isCanceledRequestError(error)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const status = normalizeRequestErrorStatus(error);
|
|
80
|
+
if (status === 0) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (RECOVERABLE_REQUEST_ERROR_CODES.has(errorCode(error))) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const message = errorMessage(error);
|
|
89
|
+
return Boolean(
|
|
90
|
+
message &&
|
|
91
|
+
RECOVERABLE_REQUEST_ERROR_MESSAGES.some((pattern) => pattern.test(message))
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requestRecoveryMessage(error = null, {
|
|
96
|
+
label = "Request",
|
|
97
|
+
message = ""
|
|
98
|
+
} = {}) {
|
|
99
|
+
const explicitMessage = normalizeText(message);
|
|
100
|
+
if (explicitMessage) {
|
|
101
|
+
return explicitMessage;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const requestLabel = normalizeText(label, "Request");
|
|
105
|
+
if (requestLabel === "Request") {
|
|
106
|
+
return "The app could not reach the server or network. Check the connection and try again.";
|
|
107
|
+
}
|
|
108
|
+
return `${requestLabel} could not reach the server or network. Check the connection and try again.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveQueryMeta(query = null) {
|
|
112
|
+
if (isRecord(query?.meta)) {
|
|
113
|
+
return query.meta;
|
|
114
|
+
}
|
|
115
|
+
if (isRecord(query?.options?.meta)) {
|
|
116
|
+
return query.options.meta;
|
|
117
|
+
}
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isRequestRecoveryDisabled(query = null) {
|
|
122
|
+
const meta = resolveQueryMeta(query);
|
|
123
|
+
if (meta.jskitRequestRecovery === false || meta.requestRecovery === false) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
128
|
+
return jskitMeta.requestRecovery === false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveQueryRecoveryLabel(query = null) {
|
|
132
|
+
const meta = resolveQueryMeta(query);
|
|
133
|
+
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
134
|
+
|
|
135
|
+
return normalizeText(
|
|
136
|
+
jskitMeta.requestRecoveryLabel ||
|
|
137
|
+
jskitMeta.label ||
|
|
138
|
+
meta.jskitRequestRecoveryLabel ||
|
|
139
|
+
meta.requestRecoveryLabel ||
|
|
140
|
+
meta.label,
|
|
141
|
+
"Request"
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isActiveQuery(query = null) {
|
|
146
|
+
if (typeof query?.isActive === "function") {
|
|
147
|
+
return Boolean(query.isActive());
|
|
148
|
+
}
|
|
149
|
+
if (typeof query?.getObserversCount === "function") {
|
|
150
|
+
return Number(query.getObserversCount()) > 0;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function recoverableQueryError(query = null) {
|
|
156
|
+
const state = isRecord(query?.state) ? query.state : {};
|
|
157
|
+
if (state.status !== "error" || state.fetchStatus !== "idle") {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const error = state.error || state.fetchFailureReason || null;
|
|
162
|
+
return isRecoverableRequestError(error) ? error : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createQueryRetry(queryClient, query = null) {
|
|
166
|
+
if (!queryClient || typeof queryClient.refetchQueries !== "function") {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const queryKey = query?.queryKey;
|
|
171
|
+
if (!Array.isArray(queryKey)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return () =>
|
|
176
|
+
queryClient.refetchQueries(
|
|
177
|
+
{
|
|
178
|
+
queryKey,
|
|
179
|
+
exact: true,
|
|
180
|
+
type: "active"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
throwOnError: false
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveQueryHash(query = null) {
|
|
189
|
+
const explicitHash = normalizeText(query?.queryHash);
|
|
190
|
+
if (explicitHash) {
|
|
191
|
+
return explicitHash;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
return normalizeText(JSON.stringify(query?.queryKey || []));
|
|
196
|
+
} catch {
|
|
197
|
+
return normalizeText(String(query?.queryKey || ""));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function installRecoverableQueryObserver({
|
|
202
|
+
app,
|
|
203
|
+
runtime,
|
|
204
|
+
logger
|
|
205
|
+
} = {}) {
|
|
206
|
+
if (!app?.has?.("jskit.client.query-client")) {
|
|
207
|
+
return Object.freeze({
|
|
208
|
+
dispose() {}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const queryClient = app.make("jskit.client.query-client");
|
|
213
|
+
const queryCache =
|
|
214
|
+
queryClient && typeof queryClient.getQueryCache === "function"
|
|
215
|
+
? queryClient.getQueryCache()
|
|
216
|
+
: null;
|
|
217
|
+
if (!queryCache || typeof queryCache.subscribe !== "function") {
|
|
218
|
+
return Object.freeze({
|
|
219
|
+
dispose() {}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const reportedErrorUpdateByQueryHash = new Map();
|
|
224
|
+
|
|
225
|
+
function inspectQuery(query = null) {
|
|
226
|
+
if (!query || isRequestRecoveryDisabled(query) || !isActiveQuery(query)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const error = recoverableQueryError(query);
|
|
231
|
+
const queryHash = resolveQueryHash(query);
|
|
232
|
+
if (!error || !queryHash) {
|
|
233
|
+
reportedErrorUpdateByQueryHash.delete(queryHash);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const errorUpdateCount = Number(query?.state?.errorUpdateCount || 0);
|
|
238
|
+
const reportKey = `${errorUpdateCount}:${errorMessage(error)}`;
|
|
239
|
+
if (reportedErrorUpdateByQueryHash.get(queryHash) === reportKey) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
reportedErrorUpdateByQueryHash.set(queryHash, reportKey);
|
|
243
|
+
|
|
244
|
+
runtime.report(error, {
|
|
245
|
+
label: resolveQueryRecoveryLabel(query),
|
|
246
|
+
retry: createQueryRetry(queryClient, query),
|
|
247
|
+
source: "shell-web.request-recovery.query",
|
|
248
|
+
stale: query?.state?.data !== undefined,
|
|
249
|
+
dedupeKey: `shell-web.request-recovery.query.${queryHash}.${errorUpdateCount}`
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
if (typeof queryCache.getAll === "function") {
|
|
255
|
+
for (const query of queryCache.getAll()) {
|
|
256
|
+
inspectQuery(query);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
logger.warn(
|
|
261
|
+
{
|
|
262
|
+
error: errorMessage(error) || "unknown error"
|
|
263
|
+
},
|
|
264
|
+
"Shell request recovery could not inspect existing queries."
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const unsubscribe = queryCache.subscribe((event = {}) => {
|
|
269
|
+
try {
|
|
270
|
+
inspectQuery(event?.query);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
logger.warn(
|
|
273
|
+
{
|
|
274
|
+
error: errorMessage(error) || "unknown error"
|
|
275
|
+
},
|
|
276
|
+
"Shell request recovery query observer failed."
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
dispose() {
|
|
283
|
+
reportedErrorUpdateByQueryHash.clear();
|
|
284
|
+
if (typeof unsubscribe === "function") {
|
|
285
|
+
unsubscribe();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function createShellRequestRecoveryRuntime({
|
|
292
|
+
app,
|
|
293
|
+
logger = null
|
|
294
|
+
} = {}) {
|
|
295
|
+
if (!app || typeof app.has !== "function" || typeof app.make !== "function") {
|
|
296
|
+
throw new Error("createShellRequestRecoveryRuntime requires application has()/make().");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const runtimeLogger = logger || createSharedProviderLogger(app);
|
|
300
|
+
let installedQueryObserver = null;
|
|
301
|
+
let reloadAttempt = 0;
|
|
302
|
+
|
|
303
|
+
function errorRuntime() {
|
|
304
|
+
if (!app.has("runtime.web-error.client")) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const runtime = app.make("runtime.web-error.client");
|
|
308
|
+
return runtime && typeof runtime.report === "function" ? runtime : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function reload({
|
|
312
|
+
label = "App",
|
|
313
|
+
message = REQUEST_RECOVERY_RELOAD_FAILURE_MESSAGE
|
|
314
|
+
} = {}) {
|
|
315
|
+
const reloaded = await guardedReloadApp({
|
|
316
|
+
label,
|
|
317
|
+
message
|
|
318
|
+
});
|
|
319
|
+
if (!reloaded) {
|
|
320
|
+
reloadAttempt += 1;
|
|
321
|
+
report(new Error("Network request failed."), {
|
|
322
|
+
label,
|
|
323
|
+
message,
|
|
324
|
+
reload: true,
|
|
325
|
+
force: true,
|
|
326
|
+
source: "shell-web.request-recovery.reload",
|
|
327
|
+
dedupeKey: `shell-web.request-recovery.reload.${reloadAttempt}`
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return reloaded;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function retryAction(error, options, retry) {
|
|
334
|
+
return {
|
|
335
|
+
label: normalizeText(options.actionLabel, "Retry"),
|
|
336
|
+
dismissOnRun: true,
|
|
337
|
+
async handler() {
|
|
338
|
+
try {
|
|
339
|
+
return await retry();
|
|
340
|
+
} catch (retryError) {
|
|
341
|
+
if (isRecoverableRequestError(retryError)) {
|
|
342
|
+
return report(retryError, options);
|
|
343
|
+
}
|
|
344
|
+
throw retryError;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function reloadAction(options) {
|
|
351
|
+
return {
|
|
352
|
+
label: normalizeText(options.actionLabel, "Reload"),
|
|
353
|
+
dismissOnRun: true,
|
|
354
|
+
handler() {
|
|
355
|
+
return reload({
|
|
356
|
+
label: options.label,
|
|
357
|
+
message: options.reloadFailureMessage || REQUEST_RECOVERY_RELOAD_FAILURE_MESSAGE
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function report(error = null, options = {}) {
|
|
364
|
+
if (!isRecoverableRequestError(error) && options.force !== true) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const runtime = errorRuntime();
|
|
369
|
+
const source = normalizeText(options.source, "shell-web.request-recovery");
|
|
370
|
+
if (!runtime) {
|
|
371
|
+
runtimeLogger.warn(
|
|
372
|
+
{
|
|
373
|
+
source,
|
|
374
|
+
error: errorMessage(error) || "unknown error"
|
|
375
|
+
},
|
|
376
|
+
"Shell request recovery could not report because the shell error runtime is unavailable."
|
|
377
|
+
);
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const retry = typeof options.retry === "function" ? options.retry : null;
|
|
382
|
+
const action = retry
|
|
383
|
+
? retryAction(error, options, retry)
|
|
384
|
+
: options.reload === true
|
|
385
|
+
? reloadAction(options)
|
|
386
|
+
: null;
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
return runtime.report({
|
|
390
|
+
source,
|
|
391
|
+
message: requestRecoveryMessage(error, options),
|
|
392
|
+
cause: error || null,
|
|
393
|
+
intent: "app-recoverable",
|
|
394
|
+
severity: normalizeText(options.severity, options.stale ? "warning" : "error"),
|
|
395
|
+
dedupeKey: normalizeText(options.dedupeKey),
|
|
396
|
+
dedupeWindowMs: Number.isFinite(Number(options.dedupeWindowMs))
|
|
397
|
+
? Math.max(0, Number(options.dedupeWindowMs))
|
|
398
|
+
: 2000,
|
|
399
|
+
action
|
|
400
|
+
});
|
|
401
|
+
} catch (reportError) {
|
|
402
|
+
runtimeLogger.error(
|
|
403
|
+
{
|
|
404
|
+
source,
|
|
405
|
+
error: errorMessage(reportError) || "unknown error"
|
|
406
|
+
},
|
|
407
|
+
"Shell request recovery could not report through the shell error runtime."
|
|
408
|
+
);
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function install() {
|
|
414
|
+
if (installedQueryObserver) {
|
|
415
|
+
return installedQueryObserver;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
installedQueryObserver = installRecoverableQueryObserver({
|
|
419
|
+
app,
|
|
420
|
+
runtime: api,
|
|
421
|
+
logger: runtimeLogger
|
|
422
|
+
});
|
|
423
|
+
return installedQueryObserver;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function dispose() {
|
|
427
|
+
installedQueryObserver?.dispose?.();
|
|
428
|
+
installedQueryObserver = null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const api = Object.freeze({
|
|
432
|
+
dispose,
|
|
433
|
+
install,
|
|
434
|
+
isRecoverableRequestError,
|
|
435
|
+
reload,
|
|
436
|
+
report
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
return api;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export {
|
|
443
|
+
createShellRequestRecoveryRuntime,
|
|
444
|
+
isRecoverableRequestError,
|
|
445
|
+
requestRecoveryMessage
|
|
446
|
+
};
|
|
@@ -50,6 +50,14 @@ function createShellBootstrapRuntime({
|
|
|
50
50
|
let initialized = false;
|
|
51
51
|
let refreshQueue = Promise.resolve();
|
|
52
52
|
|
|
53
|
+
function requestRecoveryRuntime() {
|
|
54
|
+
if (!app.has("runtime.web-request-recovery.client")) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const runtime = app.make("runtime.web-request-recovery.client");
|
|
58
|
+
return runtime && typeof runtime.report === "function" ? runtime : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
async function resolveBootstrapRequest(reason = "manual") {
|
|
54
62
|
const handlers = resolveBootstrapPayloadHandlers(app);
|
|
55
63
|
let request = {
|
|
@@ -162,6 +170,12 @@ function createShellBootstrapRuntime({
|
|
|
162
170
|
return applyBootstrapPayload(payload, reason, request);
|
|
163
171
|
} catch (error) {
|
|
164
172
|
await applyBootstrapError(error, reason, request);
|
|
173
|
+
requestRecoveryRuntime()?.report?.(error, {
|
|
174
|
+
label: "App data",
|
|
175
|
+
retry: () => refresh(reason),
|
|
176
|
+
source: "shell-web.bootstrap",
|
|
177
|
+
dedupeKey: `shell-web.bootstrap.${String(reason || "manual").trim() || "manual"}.request-failed`
|
|
178
|
+
});
|
|
165
179
|
runtimeLogger.warn(
|
|
166
180
|
{
|
|
167
181
|
reason,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createApp } from "vue";
|
|
4
|
+
import {
|
|
5
|
+
SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY,
|
|
6
|
+
useShellAsyncModuleRecoveryRuntime
|
|
7
|
+
} from "../src/client/asyncModuleRecovery/index.js";
|
|
8
|
+
|
|
9
|
+
test("shell async module recovery runtime composable returns null outside Vue injection context", () => {
|
|
10
|
+
assert.equal(useShellAsyncModuleRecoveryRuntime(), null);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("shell async module recovery runtime composable resolves the provided public runtime", async () => {
|
|
14
|
+
const runtime = {
|
|
15
|
+
notify(error, options) {
|
|
16
|
+
return { error, options };
|
|
17
|
+
},
|
|
18
|
+
async reload() {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const app = createApp({
|
|
23
|
+
render() {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
app.provide(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, runtime);
|
|
28
|
+
|
|
29
|
+
assert.equal(
|
|
30
|
+
app.runWithContext(() => useShellAsyncModuleRecoveryRuntime()),
|
|
31
|
+
runtime
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("shell async module recovery runtime composable rejects incomplete runtimes", () => {
|
|
36
|
+
const app = createApp({
|
|
37
|
+
render() {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
app.provide(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, {
|
|
42
|
+
notify() {}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.equal(
|
|
46
|
+
app.runWithContext(() => useShellAsyncModuleRecoveryRuntime()),
|
|
47
|
+
null
|
|
48
|
+
);
|
|
49
|
+
});
|
package/test/provider.test.js
CHANGED
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
isMissingDynamicModule
|
|
10
10
|
} from "../src/client/providers/appModuleLoadFailure.js";
|
|
11
|
+
import {
|
|
12
|
+
SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY
|
|
13
|
+
} from "../src/client/asyncModuleRecovery/index.js";
|
|
14
|
+
import {
|
|
15
|
+
SHELL_REQUEST_RECOVERY_RUNTIME_KEY
|
|
16
|
+
} from "../src/client/requestRecovery/index.js";
|
|
11
17
|
import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
|
|
12
18
|
const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
|
|
13
19
|
|
|
@@ -166,6 +172,32 @@ function createWindowDouble({ href = "https://example.test/home" } = {}) {
|
|
|
166
172
|
};
|
|
167
173
|
}
|
|
168
174
|
|
|
175
|
+
function createQueryCacheDouble(initialQueries = []) {
|
|
176
|
+
const listeners = [];
|
|
177
|
+
const queries = [...initialQueries];
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
queries,
|
|
181
|
+
getAll() {
|
|
182
|
+
return queries;
|
|
183
|
+
},
|
|
184
|
+
subscribe(listener) {
|
|
185
|
+
listeners.push(listener);
|
|
186
|
+
return () => {
|
|
187
|
+
const index = listeners.indexOf(listener);
|
|
188
|
+
if (index >= 0) {
|
|
189
|
+
listeners.splice(index, 1);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
emit(event) {
|
|
194
|
+
for (const listener of [...listeners]) {
|
|
195
|
+
listener(event);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
169
201
|
test("shell web client provider preserves append-only placement topology object exports", () => {
|
|
170
202
|
const warnings = [];
|
|
171
203
|
const topology = {
|
|
@@ -220,6 +252,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
220
252
|
assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
|
|
221
253
|
assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
|
|
222
254
|
assert.equal(app.singletons.has("runtime.web-async-module-recovery.client"), true);
|
|
255
|
+
assert.equal(app.singletons.has("runtime.web-request-recovery.client"), true);
|
|
223
256
|
|
|
224
257
|
await provider.boot(app);
|
|
225
258
|
assert.equal(app.plugins.length, 0);
|
|
@@ -229,7 +262,8 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
229
262
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
|
|
230
263
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
|
|
231
264
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
|
|
232
|
-
assert.equal(providedByKey.has(
|
|
265
|
+
assert.equal(providedByKey.has(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY), true);
|
|
266
|
+
assert.equal(providedByKey.has(SHELL_REQUEST_RECOVERY_RUNTIME_KEY), true);
|
|
233
267
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
|
|
234
268
|
|
|
235
269
|
const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
|
|
@@ -245,11 +279,16 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
245
279
|
const refreshRuntime = providedByKey.get("jskit.shell-web.runtime.web-refresh.client");
|
|
246
280
|
assert.equal(typeof refreshRuntime.refresh, "function");
|
|
247
281
|
|
|
248
|
-
const asyncModuleRecoveryRuntime = providedByKey.get(
|
|
282
|
+
const asyncModuleRecoveryRuntime = providedByKey.get(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY);
|
|
249
283
|
assert.equal(typeof asyncModuleRecoveryRuntime.install, "function");
|
|
250
284
|
assert.equal(typeof asyncModuleRecoveryRuntime.notify, "function");
|
|
251
285
|
assert.equal(typeof asyncModuleRecoveryRuntime.reload, "function");
|
|
252
286
|
|
|
287
|
+
const requestRecoveryRuntime = providedByKey.get(SHELL_REQUEST_RECOVERY_RUNTIME_KEY);
|
|
288
|
+
assert.equal(typeof requestRecoveryRuntime.install, "function");
|
|
289
|
+
assert.equal(typeof requestRecoveryRuntime.report, "function");
|
|
290
|
+
assert.equal(typeof requestRecoveryRuntime.reload, "function");
|
|
291
|
+
|
|
253
292
|
const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
|
|
254
293
|
assert.equal(typeof errorStore.getState, "function");
|
|
255
294
|
assert.equal(typeof errorStore.present, "function");
|
|
@@ -316,6 +355,132 @@ test("shell refresh runtime reports recoverable retry errors as banners", async
|
|
|
316
355
|
});
|
|
317
356
|
});
|
|
318
357
|
|
|
358
|
+
test("shell request recovery reports active query transport failures with retry actions", async () => {
|
|
359
|
+
await withFetchStub({ surfaceAccess: {} }, async () => {
|
|
360
|
+
const refetchCalls = [];
|
|
361
|
+
const queryCache = createQueryCacheDouble();
|
|
362
|
+
const queryClient = {
|
|
363
|
+
getQueryCache() {
|
|
364
|
+
return queryCache;
|
|
365
|
+
},
|
|
366
|
+
async refetchQueries(filters = {}, options = {}) {
|
|
367
|
+
refetchCalls.push({ filters, options });
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const app = createAppDouble({ queryClient });
|
|
371
|
+
const provider = new ShellWebClientProvider();
|
|
372
|
+
provider.register(app);
|
|
373
|
+
await provider.boot(app);
|
|
374
|
+
|
|
375
|
+
const failedQuery = {
|
|
376
|
+
queryHash: "[\"project-access\"]",
|
|
377
|
+
queryKey: ["project-access"],
|
|
378
|
+
meta: {
|
|
379
|
+
jskit: {
|
|
380
|
+
requestRecoveryLabel: "Project access"
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
state: {
|
|
384
|
+
status: "error",
|
|
385
|
+
fetchStatus: "idle",
|
|
386
|
+
error: {
|
|
387
|
+
status: 0,
|
|
388
|
+
message: "Network request failed."
|
|
389
|
+
},
|
|
390
|
+
errorUpdateCount: 1
|
|
391
|
+
},
|
|
392
|
+
isActive() {
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
queryCache.emit({ type: "updated", query: failedQuery });
|
|
397
|
+
|
|
398
|
+
const errorStore = app.make("runtime.web-error.presentation-store.client");
|
|
399
|
+
const state = errorStore.getState();
|
|
400
|
+
assert.equal(state.channels.banner.length, 1);
|
|
401
|
+
assert.equal(
|
|
402
|
+
state.channels.banner[0].message,
|
|
403
|
+
"Project access could not reach the server or network. Check the connection and try again."
|
|
404
|
+
);
|
|
405
|
+
assert.equal(state.channels.banner[0].action.label, "Retry");
|
|
406
|
+
|
|
407
|
+
await state.channels.banner[0].action.handler();
|
|
408
|
+
assert.equal(refetchCalls.length, 1);
|
|
409
|
+
assert.deepEqual(refetchCalls[0].filters, {
|
|
410
|
+
queryKey: ["project-access"],
|
|
411
|
+
exact: true,
|
|
412
|
+
type: "active"
|
|
413
|
+
});
|
|
414
|
+
assert.deepEqual(refetchCalls[0].options, {
|
|
415
|
+
throwOnError: false
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("shell request recovery ignores ordinary active query validation failures", async () => {
|
|
421
|
+
await withFetchStub({ surfaceAccess: {} }, async () => {
|
|
422
|
+
const queryCache = createQueryCacheDouble();
|
|
423
|
+
const queryClient = {
|
|
424
|
+
getQueryCache() {
|
|
425
|
+
return queryCache;
|
|
426
|
+
},
|
|
427
|
+
async refetchQueries() {}
|
|
428
|
+
};
|
|
429
|
+
const app = createAppDouble({ queryClient });
|
|
430
|
+
const provider = new ShellWebClientProvider();
|
|
431
|
+
provider.register(app);
|
|
432
|
+
await provider.boot(app);
|
|
433
|
+
|
|
434
|
+
queryCache.emit({
|
|
435
|
+
type: "updated",
|
|
436
|
+
query: {
|
|
437
|
+
queryHash: "[\"project-access\"]",
|
|
438
|
+
queryKey: ["project-access"],
|
|
439
|
+
state: {
|
|
440
|
+
status: "error",
|
|
441
|
+
fetchStatus: "idle",
|
|
442
|
+
error: {
|
|
443
|
+
status: 422,
|
|
444
|
+
message: "Invalid input."
|
|
445
|
+
},
|
|
446
|
+
errorUpdateCount: 1
|
|
447
|
+
},
|
|
448
|
+
isActive() {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const errorStore = app.make("runtime.web-error.presentation-store.client");
|
|
455
|
+
assert.equal(errorStore.getState().channels.banner.length, 0);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("shell bootstrap transport failures report through request recovery", async () => {
|
|
460
|
+
let fetchCalls = 0;
|
|
461
|
+
await withFetchImplementation(async () => {
|
|
462
|
+
fetchCalls += 1;
|
|
463
|
+
throw new TypeError("Failed to fetch");
|
|
464
|
+
}, async () => {
|
|
465
|
+
const app = createAppDouble();
|
|
466
|
+
const provider = new ShellWebClientProvider();
|
|
467
|
+
provider.register(app);
|
|
468
|
+
await provider.boot(app);
|
|
469
|
+
|
|
470
|
+
const errorStore = app.make("runtime.web-error.presentation-store.client");
|
|
471
|
+
const state = errorStore.getState();
|
|
472
|
+
assert.equal(state.channels.banner.length, 1);
|
|
473
|
+
assert.equal(
|
|
474
|
+
state.channels.banner[0].message,
|
|
475
|
+
"App data could not reach the server or network. Check the connection and try again."
|
|
476
|
+
);
|
|
477
|
+
assert.equal(state.channels.banner[0].action.label, "Retry");
|
|
478
|
+
|
|
479
|
+
await state.channels.banner[0].action.handler();
|
|
480
|
+
assert.equal(fetchCalls, 2);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
319
484
|
test("shell web client provider reports dynamic import failures through async module recovery", async () => {
|
|
320
485
|
await withFetchStub({ surfaceAccess: {} }, async () => {
|
|
321
486
|
const routerErrorHandlers = [];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createApp } from "vue";
|
|
4
|
+
import {
|
|
5
|
+
SHELL_REQUEST_RECOVERY_RUNTIME_KEY,
|
|
6
|
+
isRecoverableRequestError,
|
|
7
|
+
requestRecoveryMessage,
|
|
8
|
+
useShellRequestRecoveryRuntime
|
|
9
|
+
} from "../src/client/requestRecovery/index.js";
|
|
10
|
+
|
|
11
|
+
test("shell request recovery classifier accepts transport failures only", () => {
|
|
12
|
+
assert.equal(isRecoverableRequestError({ status: 0, message: "Network request failed." }), true);
|
|
13
|
+
assert.equal(isRecoverableRequestError(new TypeError("Failed to fetch")), true);
|
|
14
|
+
assert.equal(isRecoverableRequestError({ code: "ECONNREFUSED", message: "fetch failed" }), true);
|
|
15
|
+
assert.equal(isRecoverableRequestError({ status: 422, message: "Invalid input." }), false);
|
|
16
|
+
assert.equal(isRecoverableRequestError(new Error("Business rule failed.")), false);
|
|
17
|
+
assert.equal(isRecoverableRequestError({ name: "AbortError", message: "The operation was aborted." }), false);
|
|
18
|
+
assert.equal(isRecoverableRequestError({ code: "ERR_CANCELED", message: "canceled" }), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("shell request recovery message uses app-facing labels", () => {
|
|
22
|
+
assert.equal(
|
|
23
|
+
requestRecoveryMessage(null, { label: "Project access" }),
|
|
24
|
+
"Project access could not reach the server or network. Check the connection and try again."
|
|
25
|
+
);
|
|
26
|
+
assert.equal(
|
|
27
|
+
requestRecoveryMessage(null, { message: "Custom recovery copy." }),
|
|
28
|
+
"Custom recovery copy."
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("shell request recovery runtime composable returns null outside Vue injection context", () => {
|
|
33
|
+
assert.equal(useShellRequestRecoveryRuntime(), null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("shell request recovery runtime composable resolves the provided public runtime", () => {
|
|
37
|
+
const runtime = {
|
|
38
|
+
report(error, options) {
|
|
39
|
+
return { error, options };
|
|
40
|
+
},
|
|
41
|
+
async reload() {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const app = createApp({
|
|
46
|
+
render() {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
app.provide(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, runtime);
|
|
51
|
+
|
|
52
|
+
assert.equal(
|
|
53
|
+
app.runWithContext(() => useShellRequestRecoveryRuntime()),
|
|
54
|
+
runtime
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("shell request recovery runtime composable rejects incomplete runtimes", () => {
|
|
59
|
+
const app = createApp({
|
|
60
|
+
render() {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
app.provide(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, {
|
|
65
|
+
report() {}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
assert.equal(
|
|
69
|
+
app.runWithContext(() => useShellRequestRecoveryRuntime()),
|
|
70
|
+
null
|
|
71
|
+
);
|
|
72
|
+
});
|
|
@@ -148,6 +148,52 @@ test("shell-web installs generated adaptive shell Playwright smoke coverage", as
|
|
|
148
148
|
assert.equal(packageJson?.exports?.["./test/adaptiveShellSmoke"], "./src/test/adaptiveShellSmoke.js");
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
test("shell-web exports async module recovery runtime access as a public client API", async () => {
|
|
152
|
+
const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
|
|
153
|
+
const clientIndex = await readFile(path.join(PACKAGE_DIR, "src", "client", "index.js"), "utf8");
|
|
154
|
+
const recoveryIndex = await readFile(
|
|
155
|
+
path.join(PACKAGE_DIR, "src", "client", "asyncModuleRecovery", "index.js"),
|
|
156
|
+
"utf8"
|
|
157
|
+
);
|
|
158
|
+
const providerSource = await readFile(
|
|
159
|
+
path.join(PACKAGE_DIR, "src", "client", "providers", "ShellWebClientProvider.js"),
|
|
160
|
+
"utf8"
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
assert.equal(
|
|
164
|
+
packageJson?.exports?.["./client/asyncModuleRecovery"],
|
|
165
|
+
"./src/client/asyncModuleRecovery/index.js"
|
|
166
|
+
);
|
|
167
|
+
assert.match(clientIndex, /useShellAsyncModuleRecoveryRuntime/);
|
|
168
|
+
assert.match(clientIndex, /SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY/);
|
|
169
|
+
assert.match(recoveryIndex, /useShellAsyncModuleRecoveryRuntime/);
|
|
170
|
+
assert.match(recoveryIndex, /SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY/);
|
|
171
|
+
assert.match(providerSource, /SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("shell-web exports request recovery runtime access as a public client API", async () => {
|
|
175
|
+
const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
|
|
176
|
+
const clientIndex = await readFile(path.join(PACKAGE_DIR, "src", "client", "index.js"), "utf8");
|
|
177
|
+
const recoveryIndex = await readFile(
|
|
178
|
+
path.join(PACKAGE_DIR, "src", "client", "requestRecovery", "index.js"),
|
|
179
|
+
"utf8"
|
|
180
|
+
);
|
|
181
|
+
const providerSource = await readFile(
|
|
182
|
+
path.join(PACKAGE_DIR, "src", "client", "providers", "ShellWebClientProvider.js"),
|
|
183
|
+
"utf8"
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
assert.equal(
|
|
187
|
+
packageJson?.exports?.["./client/requestRecovery"],
|
|
188
|
+
"./src/client/requestRecovery/index.js"
|
|
189
|
+
);
|
|
190
|
+
assert.match(clientIndex, /useShellRequestRecoveryRuntime/);
|
|
191
|
+
assert.match(clientIndex, /SHELL_REQUEST_RECOVERY_RUNTIME_KEY/);
|
|
192
|
+
assert.match(recoveryIndex, /useShellRequestRecoveryRuntime/);
|
|
193
|
+
assert.match(recoveryIndex, /SHELL_REQUEST_RECOVERY_RUNTIME_KEY/);
|
|
194
|
+
assert.match(providerSource, /SHELL_REQUEST_RECOVERY_RUNTIME_KEY/);
|
|
195
|
+
});
|
|
196
|
+
|
|
151
197
|
test("shell-web route transition keeps mobile route motion placement-driven", async () => {
|
|
152
198
|
const source = await readFile(
|
|
153
199
|
path.join(PACKAGE_DIR, "src", "client", "components", "ShellRouteTransition.vue"),
|
|
@@ -275,6 +321,7 @@ test("shell-web descriptor metadata advertises adaptive shell outlets, default l
|
|
|
275
321
|
"runtime.web-bootstrap.client",
|
|
276
322
|
"runtime.web-refresh.client",
|
|
277
323
|
"runtime.web-async-module-recovery.client",
|
|
324
|
+
"runtime.web-request-recovery.client",
|
|
278
325
|
"runtime.web-error.client",
|
|
279
326
|
"runtime.web-error.presentation-store.client"
|
|
280
327
|
]);
|