@jskit-ai/shell-web 0.1.4
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 +165 -0
- package/package.json +23 -0
- package/src/client/components/ShellErrorHost.vue +208 -0
- package/src/client/components/ShellLayout.vue +191 -0
- package/src/client/components/ShellOutlet.vue +95 -0
- package/src/client/components/useShellLayout.js +93 -0
- package/src/client/error/index.js +2 -0
- package/src/client/error/inject.js +142 -0
- package/src/client/error/normalize.js +75 -0
- package/src/client/error/policy.js +50 -0
- package/src/client/error/presenters.js +89 -0
- package/src/client/error/runtime.js +418 -0
- package/src/client/error/store.js +176 -0
- package/src/client/error/tokens.js +14 -0
- package/src/client/index.js +17 -0
- package/src/client/navigation/linkResolver.js +117 -0
- package/src/client/placement/debug.js +52 -0
- package/src/client/placement/index.js +26 -0
- package/src/client/placement/inject.js +104 -0
- package/src/client/placement/pathname.js +14 -0
- package/src/client/placement/registry.js +41 -0
- package/src/client/placement/runtime.js +435 -0
- package/src/client/placement/surfaceContext.js +290 -0
- package/src/client/placement/tokens.js +29 -0
- package/src/client/placement/validators.js +210 -0
- package/src/client/providers/ShellWebClientProvider.js +352 -0
- package/templates/src/App.vue +11 -0
- package/templates/src/components/ShellLayout.vue +247 -0
- package/templates/src/error.js +13 -0
- package/templates/src/pages/console/index.vue +24 -0
- package/templates/src/pages/console.vue +20 -0
- package/templates/src/pages/home/index.vue +54 -0
- package/templates/src/pages/home.vue +20 -0
- package/templates/src/placement.js +12 -0
- package/test/errorRuntime.test.js +191 -0
- package/test/errorStore.test.js +26 -0
- package/test/linkResolver.test.js +112 -0
- package/test/placementRegistry.test.js +45 -0
- package/test/placementRuntime.test.js +374 -0
- package/test/provider.test.js +163 -0
- package/test/surfaceContext.test.js +184 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { useRoute } from "vue-router";
|
|
4
|
+
import { normalizeObject } from "@jskit-ai/kernel/shared/support/normalize";
|
|
5
|
+
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface";
|
|
6
|
+
import {
|
|
7
|
+
useWebPlacementContext,
|
|
8
|
+
readPlacementSurfaceConfig,
|
|
9
|
+
resolveSurfaceDefinitionFromPlacementContext,
|
|
10
|
+
resolveSurfaceIdFromPlacementPathname
|
|
11
|
+
} from "@jskit-ai/shell-web/client/placement";
|
|
12
|
+
import ShellOutlet from "@jskit-ai/shell-web/client/components/ShellOutlet";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_ACTION_FALLBACK = Object.freeze({
|
|
15
|
+
label: "",
|
|
16
|
+
to: "",
|
|
17
|
+
variant: "text",
|
|
18
|
+
color: "secondary"
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MENU_FALLBACK = Object.freeze({
|
|
22
|
+
label: "",
|
|
23
|
+
to: "/",
|
|
24
|
+
icon: "$menu"
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const props = defineProps({
|
|
28
|
+
surface: {
|
|
29
|
+
type: String,
|
|
30
|
+
default: ""
|
|
31
|
+
},
|
|
32
|
+
surfaceLabel: {
|
|
33
|
+
type: String,
|
|
34
|
+
default: ""
|
|
35
|
+
},
|
|
36
|
+
title: {
|
|
37
|
+
type: String,
|
|
38
|
+
default: ""
|
|
39
|
+
},
|
|
40
|
+
subtitle: {
|
|
41
|
+
type: String,
|
|
42
|
+
default: ""
|
|
43
|
+
},
|
|
44
|
+
topLeftActions: {
|
|
45
|
+
type: Array,
|
|
46
|
+
default: () => []
|
|
47
|
+
},
|
|
48
|
+
topRightActions: {
|
|
49
|
+
type: Array,
|
|
50
|
+
default: () => []
|
|
51
|
+
},
|
|
52
|
+
menuItems: {
|
|
53
|
+
type: Array,
|
|
54
|
+
default: () => []
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const route = useRoute();
|
|
59
|
+
const { context: placementContext } = useWebPlacementContext();
|
|
60
|
+
const drawerOpen = ref(true);
|
|
61
|
+
|
|
62
|
+
function normalizeAction(action, fallback) {
|
|
63
|
+
const source = normalizeObject(action);
|
|
64
|
+
const fallbackSource = normalizeObject(fallback);
|
|
65
|
+
const label = String(source.label || fallbackSource.label || "").trim();
|
|
66
|
+
if (!label) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
label,
|
|
72
|
+
to: String(source.to || fallbackSource.to || "").trim(),
|
|
73
|
+
variant: String(source.variant || fallbackSource.variant || "text").trim(),
|
|
74
|
+
color: String(source.color || fallbackSource.color || "secondary").trim()
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeMenuItem(item, fallback) {
|
|
79
|
+
const source = normalizeObject(item);
|
|
80
|
+
const fallbackSource = normalizeObject(fallback);
|
|
81
|
+
const label = String(source.label || fallbackSource.label || "").trim();
|
|
82
|
+
if (!label) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
label,
|
|
88
|
+
to: String(source.to || fallbackSource.to || "").trim() || "/",
|
|
89
|
+
icon: String(source.icon || fallbackSource.icon || "$menu").trim() || "$menu"
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeActionList(actions) {
|
|
94
|
+
const source = Array.isArray(actions) ? actions : [];
|
|
95
|
+
return source
|
|
96
|
+
.map((item) => normalizeAction(item, DEFAULT_ACTION_FALLBACK))
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeMenuList(items) {
|
|
101
|
+
const source = Array.isArray(items) ? items : [];
|
|
102
|
+
return source
|
|
103
|
+
.map((item) => normalizeMenuItem(item, DEFAULT_MENU_FALLBACK))
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toSurfaceLabel(surfaceId = "") {
|
|
108
|
+
const normalizedSurfaceId = String(surfaceId || "").trim().toLowerCase();
|
|
109
|
+
if (!normalizedSurfaceId) {
|
|
110
|
+
return "Surface";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return normalizedSurfaceId
|
|
114
|
+
.split(/[^a-z0-9]+/g)
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((segment) => `${segment.slice(0, 1).toUpperCase()}${segment.slice(1)}`)
|
|
117
|
+
.join(" ");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toggleDrawer() {
|
|
121
|
+
drawerOpen.value = !drawerOpen.value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolvedSurface = computed(() => {
|
|
125
|
+
const explicitSurface = normalizeSurfaceId(props.surface);
|
|
126
|
+
if (explicitSurface) {
|
|
127
|
+
return explicitSurface;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const pathname =
|
|
131
|
+
String(route?.path || "").trim() ||
|
|
132
|
+
(typeof window === "object" && window?.location?.pathname ? String(window.location.pathname).trim() : "/");
|
|
133
|
+
const contextValue = placementContext?.value || null;
|
|
134
|
+
const resolvedSurfaceFromPath = resolveSurfaceIdFromPlacementPathname(contextValue, pathname);
|
|
135
|
+
if (resolvedSurfaceFromPath) {
|
|
136
|
+
return resolvedSurfaceFromPath;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const surfaceConfig = readPlacementSurfaceConfig(contextValue);
|
|
140
|
+
if (surfaceConfig.defaultSurfaceId) {
|
|
141
|
+
return surfaceConfig.defaultSurfaceId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return "surface";
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const resolvedSurfaceLabel = computed(() => {
|
|
148
|
+
const explicitLabel = String(props.surfaceLabel || "").trim();
|
|
149
|
+
if (explicitLabel) {
|
|
150
|
+
return explicitLabel;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const surfaceDefinition = resolveSurfaceDefinitionFromPlacementContext(
|
|
154
|
+
placementContext?.value || null,
|
|
155
|
+
resolvedSurface.value
|
|
156
|
+
);
|
|
157
|
+
const configuredLabel = String(surfaceDefinition?.label || "").trim();
|
|
158
|
+
if (configuredLabel) {
|
|
159
|
+
return configuredLabel;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return toSurfaceLabel(resolvedSurface.value);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const resolvedTopLeftActions = computed(() => normalizeActionList(props.topLeftActions));
|
|
166
|
+
const resolvedTopRightActions = computed(() => normalizeActionList(props.topRightActions));
|
|
167
|
+
const resolvedMenuItems = computed(() => normalizeMenuList(props.menuItems));
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<template>
|
|
171
|
+
<v-layout class="shell-layout border rounded-lg overflow-hidden">
|
|
172
|
+
<v-app-bar border density="comfortable" elevation="0" class="bg-surface">
|
|
173
|
+
<v-app-bar-nav-icon aria-label="Toggle navigation menu" @click="toggleDrawer" />
|
|
174
|
+
|
|
175
|
+
<slot name="top-left" :actions="resolvedTopLeftActions" :surface="resolvedSurface">
|
|
176
|
+
<div class="d-flex align-center ga-2">
|
|
177
|
+
<v-btn
|
|
178
|
+
v-for="action in resolvedTopLeftActions"
|
|
179
|
+
:key="`top-left-${action.label}`"
|
|
180
|
+
:to="action.to || undefined"
|
|
181
|
+
:variant="action.variant"
|
|
182
|
+
:color="action.color"
|
|
183
|
+
size="small"
|
|
184
|
+
class="text-none"
|
|
185
|
+
>
|
|
186
|
+
{{ action.label }}
|
|
187
|
+
</v-btn>
|
|
188
|
+
<v-chip color="primary" size="small" label>{{ resolvedSurfaceLabel }}</v-chip>
|
|
189
|
+
<ShellOutlet host="shell-layout" position="top-left" />
|
|
190
|
+
</div>
|
|
191
|
+
</slot>
|
|
192
|
+
|
|
193
|
+
<v-spacer />
|
|
194
|
+
|
|
195
|
+
<slot name="top-right" :actions="resolvedTopRightActions" :surface="resolvedSurface">
|
|
196
|
+
<div class="d-flex align-center ga-2">
|
|
197
|
+
<v-btn
|
|
198
|
+
v-for="action in resolvedTopRightActions"
|
|
199
|
+
:key="`top-right-${action.label}`"
|
|
200
|
+
:to="action.to || undefined"
|
|
201
|
+
:variant="action.variant"
|
|
202
|
+
:color="action.color"
|
|
203
|
+
size="small"
|
|
204
|
+
class="text-none"
|
|
205
|
+
>
|
|
206
|
+
{{ action.label }}
|
|
207
|
+
</v-btn>
|
|
208
|
+
<ShellOutlet host="shell-layout" position="top-right" />
|
|
209
|
+
</div>
|
|
210
|
+
</slot>
|
|
211
|
+
</v-app-bar>
|
|
212
|
+
|
|
213
|
+
<v-navigation-drawer v-model="drawerOpen" border class="bg-surface" :width="248">
|
|
214
|
+
<slot name="menu" :items="resolvedMenuItems" :surface="resolvedSurface">
|
|
215
|
+
<v-list nav density="comfortable" class="pt-2">
|
|
216
|
+
<v-list-subheader class="text-uppercase text-caption">{{ resolvedSurfaceLabel }}</v-list-subheader>
|
|
217
|
+
<v-list-item
|
|
218
|
+
v-for="item in resolvedMenuItems"
|
|
219
|
+
:key="`menu-${item.label}`"
|
|
220
|
+
:title="item.label"
|
|
221
|
+
:to="item.to"
|
|
222
|
+
:prepend-icon="item.icon"
|
|
223
|
+
rounded="lg"
|
|
224
|
+
class="mb-1"
|
|
225
|
+
/>
|
|
226
|
+
<ShellOutlet host="shell-layout" position="primary-menu" />
|
|
227
|
+
<v-divider class="my-2" />
|
|
228
|
+
<ShellOutlet host="shell-layout" position="secondary-menu" />
|
|
229
|
+
</v-list>
|
|
230
|
+
</slot>
|
|
231
|
+
</v-navigation-drawer>
|
|
232
|
+
|
|
233
|
+
<v-main class="bg-background">
|
|
234
|
+
<v-container fluid class="pa-4">
|
|
235
|
+
<h1 class="text-h5 mb-2">{{ title }}</h1>
|
|
236
|
+
<p class="text-body-2 text-medium-emphasis mb-4">{{ subtitle }}</p>
|
|
237
|
+
<slot />
|
|
238
|
+
</v-container>
|
|
239
|
+
</v-main>
|
|
240
|
+
</v-layout>
|
|
241
|
+
</template>
|
|
242
|
+
|
|
243
|
+
<style scoped>
|
|
244
|
+
.shell-layout {
|
|
245
|
+
min-height: 72vh;
|
|
246
|
+
}
|
|
247
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createDefaultErrorPolicy } from "@jskit-ai/shell-web/client/error";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App-owned error handling contract.
|
|
5
|
+
* - policy(event, ctx): decide channel + presenter + message.
|
|
6
|
+
* - defaultPresenterId: used when policy does not set presenterId.
|
|
7
|
+
* - presenters: optional custom presenters registered at boot.
|
|
8
|
+
*/
|
|
9
|
+
export default Object.freeze({
|
|
10
|
+
defaultPresenterId: "material.snackbar",
|
|
11
|
+
policy: createDefaultErrorPolicy(),
|
|
12
|
+
presenters: []
|
|
13
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card rounded="lg" elevation="1" border>
|
|
3
|
+
<v-card-item>
|
|
4
|
+
<template #prepend>
|
|
5
|
+
<v-chip color="primary" size="small" label>Console</v-chip>
|
|
6
|
+
</template>
|
|
7
|
+
<v-card-title class="text-h5">Operations Console</v-card-title>
|
|
8
|
+
<v-card-subtitle>Operator tools, scripts, and diagnostics.</v-card-subtitle>
|
|
9
|
+
</v-card-item>
|
|
10
|
+
<v-divider />
|
|
11
|
+
<v-card-text class="d-flex flex-column ga-4">
|
|
12
|
+
<div class="d-flex flex-wrap ga-3">
|
|
13
|
+
<v-chip color="secondary" variant="tonal" label>Route: /console</v-chip>
|
|
14
|
+
<v-chip color="info" variant="tonal" label>Surface status: enabled</v-chip>
|
|
15
|
+
</div>
|
|
16
|
+
<p class="text-medium-emphasis mb-0">
|
|
17
|
+
Ideal for operator actions, scripts, and technical insights not meant for end users.
|
|
18
|
+
</p>
|
|
19
|
+
<div>
|
|
20
|
+
<v-btn color="primary" variant="flat" to="/home">Back to home</v-btn>
|
|
21
|
+
</div>
|
|
22
|
+
</v-card-text>
|
|
23
|
+
</v-card>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<route lang="json">
|
|
2
|
+
{
|
|
3
|
+
"meta": {
|
|
4
|
+
"jskit": {
|
|
5
|
+
"surface": "console"
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
</route>
|
|
10
|
+
|
|
11
|
+
<script setup>
|
|
12
|
+
import ShellLayout from "@/components/ShellLayout.vue";
|
|
13
|
+
import { RouterView } from "vue-router";
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<ShellLayout title="" subtitle="">
|
|
18
|
+
<RouterView />
|
|
19
|
+
</ShellLayout>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { useQuery } from "@tanstack/vue-query";
|
|
4
|
+
|
|
5
|
+
const healthQuery = useQuery({
|
|
6
|
+
queryKey: ["shell-web", "health"],
|
|
7
|
+
queryFn: async () => {
|
|
8
|
+
const response = await fetch("/api/health");
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error("Health request failed.");
|
|
11
|
+
}
|
|
12
|
+
return response.json();
|
|
13
|
+
},
|
|
14
|
+
refetchOnWindowFocus: false
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const health = computed(() => {
|
|
18
|
+
if (healthQuery.isPending.value || healthQuery.isFetching.value) {
|
|
19
|
+
return "loading...";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (healthQuery.error.value) {
|
|
23
|
+
return "unreachable";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return healthQuery.data.value?.ok ? "ok" : "unhealthy";
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<v-card rounded="lg" elevation="1" border>
|
|
32
|
+
<v-card-item>
|
|
33
|
+
<template #prepend>
|
|
34
|
+
<v-chip color="primary" size="small" label>Home</v-chip>
|
|
35
|
+
</template>
|
|
36
|
+
<v-card-title class="text-h5">welcome</v-card-title>
|
|
37
|
+
<v-card-subtitle>Main public surface</v-card-subtitle>
|
|
38
|
+
</v-card-item>
|
|
39
|
+
<v-divider />
|
|
40
|
+
<v-card-text class="d-flex flex-column ga-4">
|
|
41
|
+
<div class="d-flex flex-wrap ga-3">
|
|
42
|
+
<v-chip color="secondary" variant="tonal" label>Route: /home</v-chip>
|
|
43
|
+
<v-chip color="info" variant="tonal" label>Health: {{ health }}</v-chip>
|
|
44
|
+
</div>
|
|
45
|
+
<p class="text-medium-emphasis mb-0">
|
|
46
|
+
This is your primary landing page. Replace this content with your actual product home.
|
|
47
|
+
</p>
|
|
48
|
+
<div class="d-flex flex-wrap ga-3">
|
|
49
|
+
<v-btn color="primary" variant="flat" to="/console">Open console surface</v-btn>
|
|
50
|
+
<v-btn color="secondary" variant="outlined" to="/auth/signout">Sign out</v-btn>
|
|
51
|
+
</div>
|
|
52
|
+
</v-card-text>
|
|
53
|
+
</v-card>
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<route lang="json">
|
|
2
|
+
{
|
|
3
|
+
"meta": {
|
|
4
|
+
"jskit": {
|
|
5
|
+
"surface": "home"
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
</route>
|
|
10
|
+
|
|
11
|
+
<script setup>
|
|
12
|
+
import ShellLayout from "@/components/ShellLayout.vue";
|
|
13
|
+
import { RouterView } from "vue-router";
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<ShellLayout title="" subtitle="">
|
|
18
|
+
<RouterView />
|
|
19
|
+
</ShellLayout>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createPlacementRegistry } from "@jskit-ai/shell-web/client/placement";
|
|
2
|
+
|
|
3
|
+
const registry = createPlacementRegistry();
|
|
4
|
+
const { addPlacement } = registry;
|
|
5
|
+
|
|
6
|
+
export { addPlacement };
|
|
7
|
+
|
|
8
|
+
// Keep the default export near the top so module installers can append addPlacement(...)
|
|
9
|
+
// blocks at the bottom of this file without changing the export section.
|
|
10
|
+
export default function getPlacements() {
|
|
11
|
+
return registry.build();
|
|
12
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createErrorRuntime } from "../src/client/error/runtime.js";
|
|
4
|
+
|
|
5
|
+
function createPresenter(id, {
|
|
6
|
+
channels = ["snackbar", "banner", "dialog"],
|
|
7
|
+
calls = []
|
|
8
|
+
} = {}) {
|
|
9
|
+
const supported = new Set(channels);
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
id,
|
|
12
|
+
supports(channel = "") {
|
|
13
|
+
return supported.has(String(channel || "").trim().toLowerCase());
|
|
14
|
+
},
|
|
15
|
+
present(payload = {}) {
|
|
16
|
+
calls.push({
|
|
17
|
+
presenterId: id,
|
|
18
|
+
payload
|
|
19
|
+
});
|
|
20
|
+
return `${id}-${calls.length}`;
|
|
21
|
+
},
|
|
22
|
+
dismiss() {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("error runtime prefers policy presenter over app and module defaults", () => {
|
|
29
|
+
const calls = [];
|
|
30
|
+
const runtime = createErrorRuntime({
|
|
31
|
+
presenters: [
|
|
32
|
+
createPresenter("module.presenter", { calls }),
|
|
33
|
+
createPresenter("app.presenter", { calls }),
|
|
34
|
+
createPresenter("policy.presenter", { calls })
|
|
35
|
+
],
|
|
36
|
+
moduleDefaultPresenterId: "module.presenter"
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
runtime.configure({
|
|
40
|
+
defaultPresenterId: "app.presenter",
|
|
41
|
+
policy: () => ({
|
|
42
|
+
channel: "dialog",
|
|
43
|
+
presenterId: "policy.presenter",
|
|
44
|
+
message: "Policy override"
|
|
45
|
+
})
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = runtime.report({
|
|
49
|
+
source: "test.runtime",
|
|
50
|
+
message: "failure"
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(result.decision.presenterId, "policy.presenter");
|
|
54
|
+
assert.equal(calls.length, 1);
|
|
55
|
+
assert.equal(calls[0].presenterId, "policy.presenter");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("error runtime uses app default presenter when policy omits presenter", () => {
|
|
59
|
+
const calls = [];
|
|
60
|
+
const runtime = createErrorRuntime({
|
|
61
|
+
presenters: [
|
|
62
|
+
createPresenter("module.presenter", { calls }),
|
|
63
|
+
createPresenter("app.presenter", { calls })
|
|
64
|
+
],
|
|
65
|
+
moduleDefaultPresenterId: "module.presenter"
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
runtime.configure({
|
|
69
|
+
defaultPresenterId: "app.presenter",
|
|
70
|
+
policy: () => ({
|
|
71
|
+
channel: "banner",
|
|
72
|
+
message: "App default"
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = runtime.report({
|
|
77
|
+
source: "test.runtime",
|
|
78
|
+
message: "failure"
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(result.decision.presenterId, "app.presenter");
|
|
82
|
+
assert.equal(calls.length, 1);
|
|
83
|
+
assert.equal(calls[0].presenterId, "app.presenter");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("error runtime falls back to module default presenter", () => {
|
|
87
|
+
const calls = [];
|
|
88
|
+
const runtime = createErrorRuntime({
|
|
89
|
+
presenters: [
|
|
90
|
+
createPresenter("module.presenter", { calls })
|
|
91
|
+
],
|
|
92
|
+
moduleDefaultPresenterId: "module.presenter",
|
|
93
|
+
policy: () => ({
|
|
94
|
+
channel: "snackbar",
|
|
95
|
+
message: "Module default"
|
|
96
|
+
})
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = runtime.report({
|
|
100
|
+
source: "test.runtime",
|
|
101
|
+
message: "failure"
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.equal(result.decision.presenterId, "module.presenter");
|
|
105
|
+
assert.equal(calls.length, 1);
|
|
106
|
+
assert.equal(calls[0].presenterId, "module.presenter");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("error runtime fails fast when module default presenter is unresolved", () => {
|
|
110
|
+
assert.throws(
|
|
111
|
+
() =>
|
|
112
|
+
createErrorRuntime({
|
|
113
|
+
presenters: [createPresenter("registered.presenter")],
|
|
114
|
+
moduleDefaultPresenterId: "missing.presenter"
|
|
115
|
+
}),
|
|
116
|
+
/Module default error presenter "missing.presenter" is not registered/,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("error runtime fails fast when app default presenter is unresolved", () => {
|
|
121
|
+
const runtime = createErrorRuntime({
|
|
122
|
+
presenters: [createPresenter("module.presenter")],
|
|
123
|
+
moduleDefaultPresenterId: "module.presenter"
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.throws(
|
|
127
|
+
() => runtime.setAppDefaultPresenterId("missing.presenter"),
|
|
128
|
+
/App default error presenter "missing.presenter" is not registered/,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("error runtime throws when policy presenter is unknown", () => {
|
|
133
|
+
const runtime = createErrorRuntime({
|
|
134
|
+
presenters: [createPresenter("module.presenter")],
|
|
135
|
+
moduleDefaultPresenterId: "module.presenter",
|
|
136
|
+
policy: () => ({
|
|
137
|
+
channel: "dialog",
|
|
138
|
+
presenterId: "missing.presenter",
|
|
139
|
+
message: "failure"
|
|
140
|
+
})
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.throws(
|
|
144
|
+
() => runtime.report({ source: "test.runtime", message: "failure" }),
|
|
145
|
+
/Policy-selected error presenter "missing.presenter" is not registered/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("error runtime falls back to a channel-compatible presenter when default presenter does not support channel", () => {
|
|
150
|
+
const runtime = createErrorRuntime({
|
|
151
|
+
presenters: [
|
|
152
|
+
createPresenter("module.presenter"),
|
|
153
|
+
createPresenter("app.presenter", { channels: ["banner"] })
|
|
154
|
+
],
|
|
155
|
+
moduleDefaultPresenterId: "module.presenter"
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
runtime.configure({
|
|
159
|
+
defaultPresenterId: "app.presenter",
|
|
160
|
+
policy: () => ({
|
|
161
|
+
channel: "dialog",
|
|
162
|
+
message: "unsupported channel"
|
|
163
|
+
})
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = runtime.report({ source: "test.runtime", message: "failure" });
|
|
167
|
+
assert.equal(result.decision.presenterId, "module.presenter");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("error runtime throws when no presenter supports channel", () => {
|
|
171
|
+
const runtime = createErrorRuntime({
|
|
172
|
+
presenters: [
|
|
173
|
+
createPresenter("module.presenter", { channels: ["snackbar"] }),
|
|
174
|
+
createPresenter("app.presenter", { channels: ["banner"] })
|
|
175
|
+
],
|
|
176
|
+
moduleDefaultPresenterId: "module.presenter"
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
runtime.configure({
|
|
180
|
+
defaultPresenterId: "app.presenter",
|
|
181
|
+
policy: () => ({
|
|
182
|
+
channel: "dialog",
|
|
183
|
+
message: "unsupported channel"
|
|
184
|
+
})
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.throws(
|
|
188
|
+
() => runtime.report({ source: "test.runtime", message: "failure" }),
|
|
189
|
+
/No error presenter supports channel "dialog"/,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createErrorPresentationStore } from "../src/client/error/store.js";
|
|
4
|
+
|
|
5
|
+
test("error presentation store keeps banner channel singleton", () => {
|
|
6
|
+
const store = createErrorPresentationStore({ now: () => 1000 });
|
|
7
|
+
const firstId = store.present("banner", { message: "First banner" });
|
|
8
|
+
const secondId = store.present("banner", { message: "Second banner" });
|
|
9
|
+
|
|
10
|
+
const state = store.getState();
|
|
11
|
+
assert.equal(state.channels.banner.length, 1);
|
|
12
|
+
assert.equal(state.channels.banner[0].id, secondId);
|
|
13
|
+
assert.equal(state.channels.banner[0].message, "Second banner");
|
|
14
|
+
assert.notEqual(firstId, secondId);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("error presentation store still queues snackbar channel entries", () => {
|
|
18
|
+
const store = createErrorPresentationStore({ now: () => 1000 });
|
|
19
|
+
store.present("snackbar", { message: "One" });
|
|
20
|
+
store.present("snackbar", { message: "Two" });
|
|
21
|
+
|
|
22
|
+
const state = store.getState();
|
|
23
|
+
assert.equal(state.channels.snackbar.length, 2);
|
|
24
|
+
assert.equal(state.channels.snackbar[0].message, "One");
|
|
25
|
+
assert.equal(state.channels.snackbar[1].message, "Two");
|
|
26
|
+
});
|