@jskit-ai/shell-web 0.1.85 → 0.1.86
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 +6 -3
- package/package.json +2 -2
- package/src/client/providers/ShellWebClientProvider.js +158 -17
- package/src/client/providers/appModuleLoadFailure.js +29 -0
- package/templates/expected-existing/src/pages/home/index.vue +8 -2
- package/templates/src/pages/home/index.vue +6 -2
- package/templates/src/pages/home/settings.vue +6 -2
- package/test/bootstrapClaimContract.test.js +2 -2
- package/test/provider.test.js +201 -24
- package/test/settingsPlacementContract.test.js +14 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
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.86",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Web shell layout runtime with outlet-based placement contributions.",
|
|
7
7
|
dependsOn: [],
|
|
8
8
|
capabilities: {
|
|
9
9
|
provides: [
|
|
10
10
|
"runtime.web-placement",
|
|
11
|
-
"runtime.web-error"
|
|
11
|
+
"runtime.web-error",
|
|
12
|
+
"runtime.web-async-module-recovery"
|
|
12
13
|
],
|
|
13
14
|
requires: []
|
|
14
15
|
},
|
|
@@ -54,6 +55,8 @@ export default Object.freeze({
|
|
|
54
55
|
client: [
|
|
55
56
|
"runtime.web-placement.client",
|
|
56
57
|
"runtime.web-bootstrap.client",
|
|
58
|
+
"runtime.web-refresh.client",
|
|
59
|
+
"runtime.web-async-module-recovery.client",
|
|
57
60
|
"runtime.web-error.client",
|
|
58
61
|
"runtime.web-error.presentation-store.client"
|
|
59
62
|
]
|
|
@@ -291,7 +294,7 @@ export default Object.freeze({
|
|
|
291
294
|
dependencies: {
|
|
292
295
|
runtime: {
|
|
293
296
|
"@mdi/js": "^7.4.47",
|
|
294
|
-
"@jskit-ai/kernel": "0.1.
|
|
297
|
+
"@jskit-ai/kernel": "0.1.87"
|
|
295
298
|
},
|
|
296
299
|
dev: {}
|
|
297
300
|
},
|
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.86",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@mdi/js": "^7.4.47",
|
|
29
|
-
"@jskit-ai/kernel": "0.1.
|
|
29
|
+
"@jskit-ai/kernel": "0.1.87"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"pinia": "^3.0.4",
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { getClientAppConfig } from "@jskit-ai/kernel/client";
|
|
2
|
+
import {
|
|
3
|
+
createAsyncModuleRecoveryState,
|
|
4
|
+
guardedReloadApp,
|
|
5
|
+
installAsyncModuleRecoveryHandlers,
|
|
6
|
+
isDynamicImportError,
|
|
7
|
+
notifyAsyncModuleLoadError
|
|
8
|
+
} from "@jskit-ai/kernel/client/asyncModuleRecovery";
|
|
9
|
+
import {
|
|
10
|
+
isMissingDynamicModule,
|
|
11
|
+
notifyDynamicImportFailure
|
|
12
|
+
} from "./appModuleLoadFailure.js";
|
|
2
13
|
import {
|
|
3
14
|
isRecord
|
|
4
15
|
} from "@jskit-ai/kernel/shared/support";
|
|
@@ -29,18 +40,10 @@ import { resolveBootstrapErrorStatusCode } from "../bootstrap/bootstrapErrorStat
|
|
|
29
40
|
const APP_PLACEMENT_MODULE_SPECIFIER = "/src/placement.js";
|
|
30
41
|
const APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER = "/src/placementTopology.js";
|
|
31
42
|
const APP_ERROR_MODULE_SPECIFIER = "/src/error.js";
|
|
43
|
+
const ASYNC_MODULE_RELOAD_FAILURE_MESSAGE =
|
|
44
|
+
"The app cannot reload because the app server is not reachable. Restart the server, then click Reload.";
|
|
32
45
|
|
|
33
|
-
function
|
|
34
|
-
const message = String(error?.message || error || "");
|
|
35
|
-
return (
|
|
36
|
-
message.includes(moduleSpecifier) &&
|
|
37
|
-
(message.includes("Cannot find module") ||
|
|
38
|
-
message.includes("Failed to fetch dynamically imported module") ||
|
|
39
|
-
message.includes("ERR_MODULE_NOT_FOUND"))
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function loadAppPlacementDefinitions(logger) {
|
|
46
|
+
async function loadAppPlacementDefinitions(logger, asyncModuleRecoveryRuntime = null) {
|
|
44
47
|
try {
|
|
45
48
|
const moduleNamespace = await import("/src/placement.js");
|
|
46
49
|
const exported = moduleNamespace?.default;
|
|
@@ -60,6 +63,9 @@ async function loadAppPlacementDefinitions(logger) {
|
|
|
60
63
|
if (isMissingDynamicModule(error, APP_PLACEMENT_MODULE_SPECIFIER)) {
|
|
61
64
|
return [];
|
|
62
65
|
}
|
|
66
|
+
notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
|
|
67
|
+
label: "Placement config"
|
|
68
|
+
});
|
|
63
69
|
|
|
64
70
|
logger.warn(
|
|
65
71
|
{
|
|
@@ -73,7 +79,7 @@ async function loadAppPlacementDefinitions(logger) {
|
|
|
73
79
|
return [];
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
async function loadAppPlacementTopology(logger) {
|
|
82
|
+
async function loadAppPlacementTopology(logger, asyncModuleRecoveryRuntime = null) {
|
|
77
83
|
try {
|
|
78
84
|
const moduleNamespace = await import("/src/placementTopology.js");
|
|
79
85
|
const exported = moduleNamespace?.default;
|
|
@@ -82,6 +88,9 @@ async function loadAppPlacementTopology(logger) {
|
|
|
82
88
|
if (isMissingDynamicModule(error, APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER)) {
|
|
83
89
|
return [];
|
|
84
90
|
}
|
|
91
|
+
notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
|
|
92
|
+
label: "Placement topology"
|
|
93
|
+
});
|
|
85
94
|
|
|
86
95
|
logger.warn(
|
|
87
96
|
{
|
|
@@ -127,7 +136,7 @@ function createErrorConfigToolkit(errorRuntime) {
|
|
|
127
136
|
});
|
|
128
137
|
}
|
|
129
138
|
|
|
130
|
-
async function loadAppErrorConfig(logger, errorRuntime) {
|
|
139
|
+
async function loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime = null) {
|
|
131
140
|
try {
|
|
132
141
|
const moduleNamespace = await import("/src/error.js");
|
|
133
142
|
const exported = moduleNamespace?.default;
|
|
@@ -149,6 +158,9 @@ async function loadAppErrorConfig(logger, errorRuntime) {
|
|
|
149
158
|
if (isMissingDynamicModule(error, APP_ERROR_MODULE_SPECIFIER)) {
|
|
150
159
|
return {};
|
|
151
160
|
}
|
|
161
|
+
notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
|
|
162
|
+
label: "Error config"
|
|
163
|
+
});
|
|
152
164
|
|
|
153
165
|
logger.warn(
|
|
154
166
|
{
|
|
@@ -362,6 +374,10 @@ function installRouterErrorBridge(app, errorRuntime, logger) {
|
|
|
362
374
|
}
|
|
363
375
|
|
|
364
376
|
router.onError((error) => {
|
|
377
|
+
if (isDynamicImportError(error)) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
365
381
|
try {
|
|
366
382
|
errorRuntime.report({
|
|
367
383
|
source: "shell-web.router.on-error",
|
|
@@ -384,6 +400,121 @@ function installRouterErrorBridge(app, errorRuntime, logger) {
|
|
|
384
400
|
});
|
|
385
401
|
}
|
|
386
402
|
|
|
403
|
+
function createShellAsyncModuleRecoveryRuntime({
|
|
404
|
+
app,
|
|
405
|
+
logger = null
|
|
406
|
+
} = {}) {
|
|
407
|
+
if (!app || typeof app.has !== "function" || typeof app.make !== "function") {
|
|
408
|
+
throw new Error("createShellAsyncModuleRecoveryRuntime requires application has()/make().");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const runtimeLogger = logger || createSharedProviderLogger(app);
|
|
412
|
+
const state = createAsyncModuleRecoveryState();
|
|
413
|
+
let installedRecovery = null;
|
|
414
|
+
|
|
415
|
+
function errorRuntime() {
|
|
416
|
+
if (!app.has("runtime.web-error.client")) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
const runtime = app.make("runtime.web-error.client");
|
|
420
|
+
return runtime && typeof runtime.report === "function" ? runtime : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function report(nextState = state) {
|
|
424
|
+
const runtime = errorRuntime();
|
|
425
|
+
if (!runtime) {
|
|
426
|
+
runtimeLogger.warn(
|
|
427
|
+
{
|
|
428
|
+
label: String(nextState?.label || "")
|
|
429
|
+
},
|
|
430
|
+
"Async module recovery could not report because the shell error runtime is unavailable."
|
|
431
|
+
);
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
return runtime.report({
|
|
437
|
+
source: "shell-web.async-module-recovery",
|
|
438
|
+
message: String(nextState?.message || "A required app module could not load."),
|
|
439
|
+
cause: nextState?.error || null,
|
|
440
|
+
intent: "app-recoverable",
|
|
441
|
+
severity: "warning",
|
|
442
|
+
dedupeKey: `shell-web.async-module-recovery.${Number(nextState?.attempt || 0)}`,
|
|
443
|
+
action: {
|
|
444
|
+
label: "Reload",
|
|
445
|
+
dismissOnRun: true,
|
|
446
|
+
handler() {
|
|
447
|
+
return reload();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
runtimeLogger.error(
|
|
453
|
+
{
|
|
454
|
+
label: String(nextState?.label || ""),
|
|
455
|
+
error: String(error?.message || error || "unknown error")
|
|
456
|
+
},
|
|
457
|
+
"Async module recovery could not report through the shell error runtime."
|
|
458
|
+
);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function reload() {
|
|
464
|
+
const reloaded = await guardedReloadApp({
|
|
465
|
+
state,
|
|
466
|
+
label: "App",
|
|
467
|
+
message: ASYNC_MODULE_RELOAD_FAILURE_MESSAGE
|
|
468
|
+
});
|
|
469
|
+
if (!reloaded) {
|
|
470
|
+
report(state);
|
|
471
|
+
}
|
|
472
|
+
return reloaded;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function notify(error = null, {
|
|
476
|
+
label = "App module",
|
|
477
|
+
message = "",
|
|
478
|
+
stale = isDynamicImportError(error)
|
|
479
|
+
} = {}) {
|
|
480
|
+
notifyAsyncModuleLoadError(state, error, {
|
|
481
|
+
label,
|
|
482
|
+
message,
|
|
483
|
+
retry: state.retry,
|
|
484
|
+
stale
|
|
485
|
+
});
|
|
486
|
+
return report(state);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function install() {
|
|
490
|
+
if (installedRecovery) {
|
|
491
|
+
return installedRecovery;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
installedRecovery = installAsyncModuleRecoveryHandlers({
|
|
495
|
+
state,
|
|
496
|
+
label: "App module",
|
|
497
|
+
router: app.has("jskit.client.router") ? app.make("jskit.client.router") : null,
|
|
498
|
+
onNotify: report
|
|
499
|
+
});
|
|
500
|
+
return installedRecovery;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function dispose() {
|
|
504
|
+
installedRecovery?.dispose?.();
|
|
505
|
+
installedRecovery = null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return Object.freeze({
|
|
509
|
+
dispose,
|
|
510
|
+
install,
|
|
511
|
+
notify,
|
|
512
|
+
reload,
|
|
513
|
+
report,
|
|
514
|
+
state
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
387
518
|
class ShellWebClientProvider {
|
|
388
519
|
static id = "shell.web.client";
|
|
389
520
|
|
|
@@ -437,6 +568,12 @@ class ShellWebClientProvider {
|
|
|
437
568
|
logger
|
|
438
569
|
})
|
|
439
570
|
);
|
|
571
|
+
app.singleton("runtime.web-async-module-recovery.client", (scope) =>
|
|
572
|
+
createShellAsyncModuleRecoveryRuntime({
|
|
573
|
+
app: scope,
|
|
574
|
+
logger
|
|
575
|
+
})
|
|
576
|
+
);
|
|
440
577
|
app.singleton("runtime.web-error.presentation-store.client", () => createErrorPresentationStore());
|
|
441
578
|
app.singleton("runtime.web-error.client", (scope) =>
|
|
442
579
|
createErrorRuntime({
|
|
@@ -456,13 +593,17 @@ class ShellWebClientProvider {
|
|
|
456
593
|
}
|
|
457
594
|
|
|
458
595
|
const logger = createSharedProviderLogger(isRecord(app) ? app : null);
|
|
596
|
+
const errorRuntime = app.make("runtime.web-error.client");
|
|
597
|
+
const asyncModuleRecoveryRuntime = app.make("runtime.web-async-module-recovery.client");
|
|
598
|
+
asyncModuleRecoveryRuntime.install();
|
|
599
|
+
|
|
459
600
|
const placementRuntime = app.make("runtime.web-placement.client");
|
|
460
601
|
if (placementRuntime && typeof placementRuntime.replacePlacements === "function") {
|
|
461
|
-
const placementTopology = await loadAppPlacementTopology(logger);
|
|
602
|
+
const placementTopology = await loadAppPlacementTopology(logger, asyncModuleRecoveryRuntime);
|
|
462
603
|
if (typeof placementRuntime.replacePlacementTopology === "function") {
|
|
463
604
|
placementRuntime.replacePlacementTopology(placementTopology, { source: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER });
|
|
464
605
|
}
|
|
465
|
-
const placements = await loadAppPlacementDefinitions(logger);
|
|
606
|
+
const placements = await loadAppPlacementDefinitions(logger, asyncModuleRecoveryRuntime);
|
|
466
607
|
placementRuntime.replacePlacements(placements, { source: APP_PLACEMENT_MODULE_SPECIFIER });
|
|
467
608
|
const appConfig = getClientAppConfig();
|
|
468
609
|
const surfaceRuntime = app.has("jskit.client.surface.runtime")
|
|
@@ -486,8 +627,7 @@ class ShellWebClientProvider {
|
|
|
486
627
|
);
|
|
487
628
|
}
|
|
488
629
|
|
|
489
|
-
const
|
|
490
|
-
const errorConfig = await loadAppErrorConfig(logger, errorRuntime);
|
|
630
|
+
const errorConfig = await loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime);
|
|
491
631
|
applyAppErrorConfig(errorRuntime, errorConfig);
|
|
492
632
|
|
|
493
633
|
const bootstrapRuntime = app.make("runtime.web-bootstrap.client");
|
|
@@ -514,6 +654,7 @@ class ShellWebClientProvider {
|
|
|
514
654
|
vueApp.provide("jskit.shell-web.runtime.web-placement.client", placementRuntime);
|
|
515
655
|
vueApp.provide("jskit.shell-web.runtime.web-refresh.client", refreshRuntime);
|
|
516
656
|
vueApp.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
|
|
657
|
+
vueApp.provide("jskit.shell-web.runtime.web-async-module-recovery.client", asyncModuleRecoveryRuntime);
|
|
517
658
|
vueApp.provide(
|
|
518
659
|
"jskit.shell-web.runtime.web-error.presentation-store.client",
|
|
519
660
|
errorPresentationStore
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isDynamicImportError
|
|
3
|
+
} from "@jskit-ai/kernel/client/asyncModuleRecovery";
|
|
4
|
+
|
|
5
|
+
function isMissingDynamicModule(error, moduleSpecifier) {
|
|
6
|
+
const message = String(error?.message || error || "");
|
|
7
|
+
return (
|
|
8
|
+
message.includes(moduleSpecifier) &&
|
|
9
|
+
(message.includes("Cannot find module") ||
|
|
10
|
+
message.includes("ERR_MODULE_NOT_FOUND"))
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
|
|
15
|
+
label = "App module"
|
|
16
|
+
} = {}) {
|
|
17
|
+
if (!isDynamicImportError(error) || typeof asyncModuleRecoveryRuntime?.notify !== "function") {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
asyncModuleRecoveryRuntime.notify(error, {
|
|
22
|
+
label
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
isMissingDynamicModule,
|
|
28
|
+
notifyDynamicImportFailure
|
|
29
|
+
};
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
<style scoped>
|
|
21
21
|
.generated-ui-screen {
|
|
22
|
-
--generated-ui-screen-title-size:
|
|
22
|
+
--generated-ui-screen-title-size: 2rem;
|
|
23
23
|
--generated-ui-screen-panel-padding: 1rem;
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
.home-start-screen__title {
|
|
32
32
|
font-size: var(--generated-ui-screen-title-size);
|
|
33
33
|
font-weight: 700;
|
|
34
|
-
letter-spacing:
|
|
34
|
+
letter-spacing: 0;
|
|
35
35
|
line-height: 1.08;
|
|
36
36
|
margin: 0 0 0.45rem;
|
|
37
37
|
}
|
|
@@ -39,4 +39,10 @@
|
|
|
39
39
|
.home-start-screen__panel {
|
|
40
40
|
padding: var(--generated-ui-screen-panel-padding);
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
@media (max-width: 640px) {
|
|
44
|
+
.generated-ui-screen {
|
|
45
|
+
--generated-ui-screen-title-size: 1.5rem;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
42
48
|
</style>
|
|
@@ -56,7 +56,7 @@ const health = computed(() => {
|
|
|
56
56
|
|
|
57
57
|
<style scoped>
|
|
58
58
|
.generated-ui-screen {
|
|
59
|
-
--generated-ui-screen-title-size:
|
|
59
|
+
--generated-ui-screen-title-size: 2rem;
|
|
60
60
|
--generated-ui-screen-panel-padding: 1rem;
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -70,7 +70,7 @@ const health = computed(() => {
|
|
|
70
70
|
.home-surface-screen__title {
|
|
71
71
|
font-size: var(--generated-ui-screen-title-size);
|
|
72
72
|
font-weight: 700;
|
|
73
|
-
letter-spacing:
|
|
73
|
+
letter-spacing: 0;
|
|
74
74
|
line-height: 1.1;
|
|
75
75
|
margin: 0 0 0.4rem;
|
|
76
76
|
}
|
|
@@ -90,6 +90,10 @@ const health = computed(() => {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
@media (max-width: 640px) {
|
|
93
|
+
.generated-ui-screen {
|
|
94
|
+
--generated-ui-screen-title-size: 1.5rem;
|
|
95
|
+
}
|
|
96
|
+
|
|
93
97
|
.home-surface-screen__header {
|
|
94
98
|
flex-direction: column;
|
|
95
99
|
}
|
|
@@ -29,14 +29,14 @@ import { RouterView } from "vue-router";
|
|
|
29
29
|
|
|
30
30
|
<style scoped>
|
|
31
31
|
.generated-ui-screen {
|
|
32
|
-
--generated-ui-screen-title-size:
|
|
32
|
+
--generated-ui-screen-title-size: 1.85rem;
|
|
33
33
|
--generated-ui-screen-panel-padding: 1rem;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
.settings-shell__title {
|
|
37
37
|
font-size: var(--generated-ui-screen-title-size);
|
|
38
38
|
font-weight: 650;
|
|
39
|
-
letter-spacing:
|
|
39
|
+
letter-spacing: 0;
|
|
40
40
|
line-height: 1.15;
|
|
41
41
|
margin: 0 0 0.35rem;
|
|
42
42
|
}
|
|
@@ -81,6 +81,10 @@ import { RouterView } from "vue-router";
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
@media (max-width: 960px) {
|
|
84
|
+
.generated-ui-screen {
|
|
85
|
+
--generated-ui-screen-title-size: 1.35rem;
|
|
86
|
+
}
|
|
87
|
+
|
|
84
88
|
.settings-shell__body {
|
|
85
89
|
grid-template-columns: 1fr;
|
|
86
90
|
}
|
|
@@ -7,7 +7,7 @@ import descriptor from "../package.descriptor.mjs";
|
|
|
7
7
|
|
|
8
8
|
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
|
|
10
|
-
const CREATE_APP_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, "..", "..", "tooling", "create-app", "templates", "
|
|
10
|
+
const CREATE_APP_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, "..", "..", "tooling", "create-app", "templates", "minimal-shell");
|
|
11
11
|
|
|
12
12
|
function findFileMutation(id) {
|
|
13
13
|
const files = descriptor?.mutations?.files;
|
|
@@ -48,7 +48,7 @@ test("shell-web claims starter shell files as app-owned scaffolds", () => {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test("shell-web expected-existing starter files stay aligned with create-app
|
|
51
|
+
test("shell-web expected-existing starter files stay aligned with create-app minimal-shell", async () => {
|
|
52
52
|
const comparedFiles = [
|
|
53
53
|
"src/App.vue",
|
|
54
54
|
"src/pages/home.vue",
|
package/test/provider.test.js
CHANGED
|
@@ -5,9 +5,28 @@ import {
|
|
|
5
5
|
ShellWebClientProvider,
|
|
6
6
|
resolveAppPlacementTopologyExport
|
|
7
7
|
} from "../src/client/providers/ShellWebClientProvider.js";
|
|
8
|
+
import {
|
|
9
|
+
isMissingDynamicModule
|
|
10
|
+
} from "../src/client/providers/appModuleLoadFailure.js";
|
|
8
11
|
import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
|
|
9
12
|
const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
|
|
10
13
|
|
|
14
|
+
async function withTemporaryGlobal(name, value, callback) {
|
|
15
|
+
const hadPrevious = Object.prototype.hasOwnProperty.call(globalThis, name);
|
|
16
|
+
const previousValue = globalThis[name];
|
|
17
|
+
globalThis[name] = value;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return await callback();
|
|
21
|
+
} finally {
|
|
22
|
+
if (hadPrevious) {
|
|
23
|
+
globalThis[name] = previousValue;
|
|
24
|
+
} else {
|
|
25
|
+
delete globalThis[name];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
function setClientAppConfig(source = {}) {
|
|
12
31
|
const normalized =
|
|
13
32
|
source && typeof source === "object" && !Array.isArray(source) ? Object.freeze({ ...source }) : Object.freeze({});
|
|
@@ -17,7 +36,7 @@ function setClientAppConfig(source = {}) {
|
|
|
17
36
|
return normalized;
|
|
18
37
|
}
|
|
19
38
|
|
|
20
|
-
function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
|
|
39
|
+
function createAppDouble({ surfaceRuntime = null, queryClient = null, router = null } = {}) {
|
|
21
40
|
const singletons = new Map();
|
|
22
41
|
const singletonInstances = new Map();
|
|
23
42
|
const provided = [];
|
|
@@ -67,6 +86,9 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
|
|
|
67
86
|
if (token === "jskit.client.query-client") {
|
|
68
87
|
return Boolean(queryClient);
|
|
69
88
|
}
|
|
89
|
+
if (token === "jskit.client.router") {
|
|
90
|
+
return Boolean(router);
|
|
91
|
+
}
|
|
70
92
|
return singletons.has(token) || singletonInstances.has(token);
|
|
71
93
|
},
|
|
72
94
|
make(token) {
|
|
@@ -82,6 +104,9 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
|
|
|
82
104
|
if (token === "jskit.client.query-client" && queryClient) {
|
|
83
105
|
return queryClient;
|
|
84
106
|
}
|
|
107
|
+
if (token === "jskit.client.router" && router) {
|
|
108
|
+
return router;
|
|
109
|
+
}
|
|
85
110
|
if (singletonInstances.has(token)) {
|
|
86
111
|
return singletonInstances.get(token);
|
|
87
112
|
}
|
|
@@ -100,38 +125,45 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
|
|
|
100
125
|
}
|
|
101
126
|
|
|
102
127
|
async function withFetchStub(responsePayload, callback) {
|
|
103
|
-
|
|
104
|
-
globalThis.fetch = async () => ({
|
|
128
|
+
return withFetchImplementation(async () => ({
|
|
105
129
|
ok: true,
|
|
106
130
|
async json() {
|
|
107
131
|
return responsePayload;
|
|
108
132
|
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
return await callback();
|
|
113
|
-
} finally {
|
|
114
|
-
if (previousFetch === undefined) {
|
|
115
|
-
delete globalThis.fetch;
|
|
116
|
-
} else {
|
|
117
|
-
globalThis.fetch = previousFetch;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
133
|
+
}), callback);
|
|
120
134
|
}
|
|
121
135
|
|
|
122
136
|
async function withFetchImplementation(fetchImplementation, callback) {
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
return withTemporaryGlobal("fetch", fetchImplementation, callback);
|
|
138
|
+
}
|
|
125
139
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
async function withWindowDouble(windowObject, callback) {
|
|
141
|
+
return withTemporaryGlobal("window", windowObject, callback);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createWindowDouble({ href = "https://example.test/home" } = {}) {
|
|
145
|
+
const listeners = new Map();
|
|
146
|
+
const reloadCalls = [];
|
|
147
|
+
const location = {
|
|
148
|
+
href,
|
|
149
|
+
reload() {
|
|
150
|
+
reloadCalls.push(location.href);
|
|
133
151
|
}
|
|
134
|
-
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
listeners,
|
|
156
|
+
reloadCalls,
|
|
157
|
+
location,
|
|
158
|
+
addEventListener(type, handler) {
|
|
159
|
+
listeners.set(type, handler);
|
|
160
|
+
},
|
|
161
|
+
removeEventListener(type, handler) {
|
|
162
|
+
if (listeners.get(type) === handler) {
|
|
163
|
+
listeners.delete(type);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
test("shell web client provider preserves append-only placement topology object exports", () => {
|
|
@@ -160,6 +192,23 @@ test("shell web client provider preserves append-only placement topology object
|
|
|
160
192
|
assert.deepEqual(warnings, []);
|
|
161
193
|
});
|
|
162
194
|
|
|
195
|
+
test("shell web boot import classifier does not swallow fetched dynamic module failures", () => {
|
|
196
|
+
assert.equal(
|
|
197
|
+
isMissingDynamicModule(
|
|
198
|
+
new Error("Cannot find module '/src/placement.js' imported from /src/main.js"),
|
|
199
|
+
"/src/placement.js"
|
|
200
|
+
),
|
|
201
|
+
true
|
|
202
|
+
);
|
|
203
|
+
assert.equal(
|
|
204
|
+
isMissingDynamicModule(
|
|
205
|
+
new Error("Failed to fetch dynamically imported module: http://localhost:5173/src/placement.js?t=123"),
|
|
206
|
+
"/src/placement.js"
|
|
207
|
+
),
|
|
208
|
+
false
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
163
212
|
test("shell web client provider binds runtime and injects it into Vue app", async () => {
|
|
164
213
|
await withFetchStub({ surfaceAccess: {} }, async () => {
|
|
165
214
|
const app = createAppDouble();
|
|
@@ -170,6 +219,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
170
219
|
assert.equal(app.singletons.has("runtime.web-error.client"), true);
|
|
171
220
|
assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
|
|
172
221
|
assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
|
|
222
|
+
assert.equal(app.singletons.has("runtime.web-async-module-recovery.client"), true);
|
|
173
223
|
|
|
174
224
|
await provider.boot(app);
|
|
175
225
|
assert.equal(app.plugins.length, 0);
|
|
@@ -179,6 +229,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
179
229
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
|
|
180
230
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
|
|
181
231
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
|
|
232
|
+
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-async-module-recovery.client"), true);
|
|
182
233
|
assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
|
|
183
234
|
|
|
184
235
|
const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
|
|
@@ -194,6 +245,11 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
|
|
|
194
245
|
const refreshRuntime = providedByKey.get("jskit.shell-web.runtime.web-refresh.client");
|
|
195
246
|
assert.equal(typeof refreshRuntime.refresh, "function");
|
|
196
247
|
|
|
248
|
+
const asyncModuleRecoveryRuntime = providedByKey.get("jskit.shell-web.runtime.web-async-module-recovery.client");
|
|
249
|
+
assert.equal(typeof asyncModuleRecoveryRuntime.install, "function");
|
|
250
|
+
assert.equal(typeof asyncModuleRecoveryRuntime.notify, "function");
|
|
251
|
+
assert.equal(typeof asyncModuleRecoveryRuntime.reload, "function");
|
|
252
|
+
|
|
197
253
|
const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
|
|
198
254
|
assert.equal(typeof errorStore.getState, "function");
|
|
199
255
|
assert.equal(typeof errorStore.present, "function");
|
|
@@ -260,6 +316,127 @@ test("shell refresh runtime reports recoverable retry errors as banners", async
|
|
|
260
316
|
});
|
|
261
317
|
});
|
|
262
318
|
|
|
319
|
+
test("shell web client provider reports dynamic import failures through async module recovery", async () => {
|
|
320
|
+
await withFetchStub({ surfaceAccess: {} }, async () => {
|
|
321
|
+
const routerErrorHandlers = [];
|
|
322
|
+
const replacedPaths = [];
|
|
323
|
+
const router = {
|
|
324
|
+
onError(handler) {
|
|
325
|
+
routerErrorHandlers.push(handler);
|
|
326
|
+
return () => null;
|
|
327
|
+
},
|
|
328
|
+
replace(fullPath) {
|
|
329
|
+
replacedPaths.push(fullPath);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const app = createAppDouble({ router });
|
|
333
|
+
const provider = new ShellWebClientProvider();
|
|
334
|
+
provider.register(app);
|
|
335
|
+
await provider.boot(app);
|
|
336
|
+
|
|
337
|
+
assert.equal(routerErrorHandlers.length, 2);
|
|
338
|
+
|
|
339
|
+
const chunkError = new Error("Failed to fetch dynamically imported module: /assets/page.js");
|
|
340
|
+
for (const handler of routerErrorHandlers) {
|
|
341
|
+
handler(chunkError, {
|
|
342
|
+
fullPath: "/app/dashboard"
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const errorStore = app.make("runtime.web-error.presentation-store.client");
|
|
347
|
+
const state = errorStore.getState();
|
|
348
|
+
assert.equal(state.channels.banner.length, 1);
|
|
349
|
+
assert.equal(
|
|
350
|
+
state.channels.banner[0].message,
|
|
351
|
+
"Page did not download. The app may have been updated, or the network request failed."
|
|
352
|
+
);
|
|
353
|
+
assert.equal(state.channels.banner[0].severity, "warning");
|
|
354
|
+
assert.equal(state.channels.banner[0].action.label, "Reload");
|
|
355
|
+
assert.deepEqual(replacedPaths, []);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("shell web async module recovery reload action checks the document before reloading", async () => {
|
|
360
|
+
const windowObject = createWindowDouble();
|
|
361
|
+
await withWindowDouble(windowObject, async () => {
|
|
362
|
+
const fetchCalls = [];
|
|
363
|
+
await withFetchImplementation(async (input, init = {}) => {
|
|
364
|
+
fetchCalls.push({ input, init });
|
|
365
|
+
return {
|
|
366
|
+
ok: true,
|
|
367
|
+
status: 200,
|
|
368
|
+
async json() {
|
|
369
|
+
return { surfaceAccess: {} };
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}, async () => {
|
|
373
|
+
const routerErrorHandlers = [];
|
|
374
|
+
const router = {
|
|
375
|
+
onError(handler) {
|
|
376
|
+
routerErrorHandlers.push(handler);
|
|
377
|
+
return () => null;
|
|
378
|
+
},
|
|
379
|
+
replace() {}
|
|
380
|
+
};
|
|
381
|
+
const app = createAppDouble({ router });
|
|
382
|
+
const provider = new ShellWebClientProvider();
|
|
383
|
+
provider.register(app);
|
|
384
|
+
await provider.boot(app);
|
|
385
|
+
|
|
386
|
+
const chunkError = new Error("Failed to fetch dynamically imported module: /assets/page.js");
|
|
387
|
+
routerErrorHandlers[0](chunkError, {
|
|
388
|
+
fullPath: "/home/settings"
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const errorStore = app.make("runtime.web-error.presentation-store.client");
|
|
392
|
+
const banner = errorStore.getState().channels.banner[0];
|
|
393
|
+
await banner.action.handler();
|
|
394
|
+
|
|
395
|
+
assert.deepEqual(windowObject.reloadCalls, ["https://example.test/home"]);
|
|
396
|
+
const reloadFetch = fetchCalls.find((entry) => entry.input === "https://example.test/home");
|
|
397
|
+
assert.equal(reloadFetch?.init?.cache, "no-store");
|
|
398
|
+
assert.equal(reloadFetch?.init?.credentials, "same-origin");
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("shell web async module recovery keeps the page alive when reload check fails", async () => {
|
|
404
|
+
const windowObject = createWindowDouble();
|
|
405
|
+
await withWindowDouble(windowObject, async () => {
|
|
406
|
+
await withFetchImplementation(async (input) => {
|
|
407
|
+
if (input === "https://example.test/home") {
|
|
408
|
+
throw new TypeError("Failed to fetch");
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
ok: true,
|
|
412
|
+
status: 200,
|
|
413
|
+
async json() {
|
|
414
|
+
return { surfaceAccess: {} };
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}, async () => {
|
|
418
|
+
const app = createAppDouble();
|
|
419
|
+
const provider = new ShellWebClientProvider();
|
|
420
|
+
provider.register(app);
|
|
421
|
+
await provider.boot(app);
|
|
422
|
+
|
|
423
|
+
const recoveryRuntime = app.make("runtime.web-async-module-recovery.client");
|
|
424
|
+
const reloaded = await recoveryRuntime.reload();
|
|
425
|
+
|
|
426
|
+
assert.equal(reloaded, false);
|
|
427
|
+
assert.deepEqual(windowObject.reloadCalls, []);
|
|
428
|
+
const errorStore = app.make("runtime.web-error.presentation-store.client");
|
|
429
|
+
const state = errorStore.getState();
|
|
430
|
+
assert.equal(state.channels.banner.length, 1);
|
|
431
|
+
assert.equal(
|
|
432
|
+
state.channels.banner[0].message,
|
|
433
|
+
"The app cannot reload because the app server is not reachable. Restart the server, then click Reload."
|
|
434
|
+
);
|
|
435
|
+
assert.equal(state.channels.banner[0].action.label, "Reload");
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
263
440
|
test("shell web client provider resolves surface config from client app config", async () => {
|
|
264
441
|
setClientAppConfig({
|
|
265
442
|
tenancyMode: "workspaces",
|
|
@@ -38,6 +38,11 @@ function readTopology(id = "", owner = "") {
|
|
|
38
38
|
: [];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function readClientContainerTokens() {
|
|
42
|
+
const tokens = descriptor?.metadata?.apiSummary?.containerTokens?.client;
|
|
43
|
+
return Array.isArray(tokens) ? tokens : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
function findFileMutation(id) {
|
|
42
47
|
const files = descriptor?.mutations?.files;
|
|
43
48
|
return Array.isArray(files)
|
|
@@ -265,6 +270,15 @@ test("shell-web placement topology seeds global actions as a semantic shell plac
|
|
|
265
270
|
});
|
|
266
271
|
|
|
267
272
|
test("shell-web descriptor metadata advertises adaptive shell outlets, default links, and installs the scaffold page", () => {
|
|
273
|
+
assert.deepEqual(readClientContainerTokens(), [
|
|
274
|
+
"runtime.web-placement.client",
|
|
275
|
+
"runtime.web-bootstrap.client",
|
|
276
|
+
"runtime.web-refresh.client",
|
|
277
|
+
"runtime.web-async-module-recovery.client",
|
|
278
|
+
"runtime.web-error.client",
|
|
279
|
+
"runtime.web-error.presentation-store.client"
|
|
280
|
+
]);
|
|
281
|
+
|
|
268
282
|
assert.deepEqual(
|
|
269
283
|
readOutlets("shell-layout:primary-bottom-nav"),
|
|
270
284
|
[
|