@jskit-ai/shell-web 0.1.65 → 0.1.66

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.
Files changed (29) hide show
  1. package/package.descriptor.mjs +74 -9
  2. package/package.json +8 -7
  3. package/src/client/components/ShellErrorHost.vue +88 -15
  4. package/src/client/components/ShellLayout.vue +551 -46
  5. package/src/client/components/ShellRouteTransition.vue +480 -0
  6. package/src/client/components/ShellTabLinkItem.vue +22 -6
  7. package/src/client/composables/useShellLayoutState.js +12 -1
  8. package/src/client/error/normalize.js +17 -0
  9. package/src/client/error/policy.js +25 -11
  10. package/src/client/error/runtime.js +2 -0
  11. package/src/client/index.js +1 -0
  12. package/src/client/providers/ShellWebClientProvider.js +163 -39
  13. package/src/client/stores/useShellLayoutStore.js +21 -1
  14. package/src/test/adaptiveShellSmoke.js +121 -0
  15. package/templates/expected-existing/src/pages/home/index.vue +40 -10
  16. package/templates/src/components/ShellLayout.vue +10 -86
  17. package/templates/src/components/menus/TabLinkItem.vue +4 -0
  18. package/templates/src/error.js +7 -1
  19. package/templates/src/pages/home/index.vue +64 -23
  20. package/templates/src/pages/home/settings/general/index.vue +12 -9
  21. package/templates/src/pages/home/settings.vue +68 -21
  22. package/templates/src/placementTopology.js +43 -2
  23. package/templates/tests/e2e/adaptive-shell.spec.ts +4 -0
  24. package/test/errorRuntime.test.js +42 -0
  25. package/test/linkItemScaffoldContract.test.js +9 -2
  26. package/test/placementRuntime.test.js +37 -0
  27. package/test/provider.test.js +97 -5
  28. package/test/settingsPlacementContract.test.js +205 -8
  29. package/test/useShellLayoutState.test.js +19 -0
@@ -28,34 +28,75 @@ const health = computed(() => {
28
28
  </script>
29
29
 
30
30
  <template>
31
- <v-card rounded="lg" elevation="1" border>
32
- <v-card-item class="home-surface-card__header">
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="home-surface-card__body d-flex flex-column ga-3">
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>
31
+ <section class="generated-ui-screen generated-ui-screen--app home-surface-screen d-flex flex-column ga-4">
32
+ <header class="home-surface-screen__header">
33
+ <div>
34
+ <p class="text-overline text-medium-emphasis mb-1">Home</p>
35
+ <h1 class="home-surface-screen__title">Ready</h1>
36
+ <p class="text-body-2 text-medium-emphasis mb-0">
37
+ Core services are available.
38
+ </p>
44
39
  </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
- <p class="text-body-2 text-medium-emphasis mb-0">Use the navigation drawer to move around the shell.</p>
49
- </v-card-text>
50
- </v-card>
40
+ <v-btn color="primary" variant="flat" to="/home/settings/general">Settings</v-btn>
41
+ </header>
42
+
43
+ <v-sheet rounded="lg" border class="home-surface-screen__panel">
44
+ <div class="home-surface-screen__status">
45
+ <span class="text-caption text-medium-emphasis">Service health</span>
46
+ <strong>{{ health }}</strong>
47
+ </div>
48
+ <v-divider vertical class="d-none d-sm-block" />
49
+ <div class="home-surface-screen__status">
50
+ <span class="text-caption text-medium-emphasis">Route</span>
51
+ <strong>/home</strong>
52
+ </div>
53
+ </v-sheet>
54
+ </section>
51
55
  </template>
52
56
 
53
57
  <style scoped>
54
- .home-surface-card__header {
55
- padding: 0.875rem 1rem;
58
+ .generated-ui-screen {
59
+ --generated-ui-screen-title-size: clamp(1.5rem, 2.5vw, 2.25rem);
60
+ --generated-ui-screen-panel-padding: 1rem;
61
+ }
62
+
63
+ .home-surface-screen__header {
64
+ align-items: flex-start;
65
+ display: flex;
66
+ gap: 1rem;
67
+ justify-content: space-between;
68
+ }
69
+
70
+ .home-surface-screen__title {
71
+ font-size: var(--generated-ui-screen-title-size);
72
+ font-weight: 700;
73
+ letter-spacing: -0.03em;
74
+ line-height: 1.1;
75
+ margin: 0 0 0.4rem;
56
76
  }
57
77
 
58
- .home-surface-card__body {
59
- padding: 0.875rem 1rem 1rem;
78
+ .home-surface-screen__panel {
79
+ align-items: stretch;
80
+ display: flex;
81
+ flex-wrap: wrap;
82
+ gap: 1rem;
83
+ padding: var(--generated-ui-screen-panel-padding);
84
+ }
85
+
86
+ .home-surface-screen__status {
87
+ display: grid;
88
+ gap: 0.15rem;
89
+ min-width: 9rem;
90
+ }
91
+
92
+ @media (max-width: 640px) {
93
+ .home-surface-screen__header {
94
+ flex-direction: column;
95
+ }
96
+
97
+ .home-surface-screen__header :deep(.v-btn) {
98
+ min-height: 48px;
99
+ width: 100%;
100
+ }
60
101
  }
61
102
  </style>
@@ -15,10 +15,12 @@ const drawerDefaultOpenModel = computed({
15
15
  </script>
16
16
 
17
17
  <template>
18
- <section class="d-flex flex-column ga-4">
18
+ <section class="generated-ui-screen generated-ui-screen--settings settings-general-screen d-flex flex-column ga-4">
19
19
  <div>
20
- <h2 class="text-h6 mb-2">General</h2>
21
- <p class="text-body-2 text-medium-emphasis mb-0">These starter settings live in this browser only.</p>
20
+ <h2 class="text-h6 mb-2">Navigation</h2>
21
+ <p class="text-body-2 text-medium-emphasis mb-0">
22
+ Choose the default behavior for wider screens. Phone layouts keep primary navigation in the bottom bar.
23
+ </p>
22
24
  </div>
23
25
 
24
26
  <v-switch
@@ -26,12 +28,13 @@ const drawerDefaultOpenModel = computed({
26
28
  color="primary"
27
29
  inset
28
30
  hide-details="auto"
29
- label="Open navigation drawer by default"
31
+ label="Open drawer by default on wider screens"
30
32
  />
31
-
32
- <p class="text-body-2 text-medium-emphasis mb-0">
33
- This tiny example exists to prove that shell-level settings can work without auth or a database. Real apps will
34
- usually replace it.
35
- </p>
36
33
  </section>
37
34
  </template>
35
+
36
+ <style scoped>
37
+ .settings-general-screen :deep(.v-switch) {
38
+ min-height: 48px;
39
+ }
40
+ </style>
@@ -4,26 +4,73 @@ import { RouterView } from "vue-router";
4
4
  </script>
5
5
 
6
6
  <template>
7
- <section class="settings-shell d-flex flex-column ga-4">
8
- <v-card rounded="lg" elevation="1" border>
9
- <v-card-item>
10
- <v-card-title>Home settings</v-card-title>
11
- <v-card-subtitle>Manage settings pages for the home surface.</v-card-subtitle>
12
- </v-card-item>
13
- <v-divider />
14
- <v-card-text class="pt-4">
15
- <v-row no-gutters>
16
- <v-col cols="12" md="3" lg="2" class="pr-md-4 mb-4 mb-md-0">
17
- <v-list nav density="comfortable" rounded="lg" border>
18
- <ShellOutlet target="home-settings:primary-menu" />
19
- </v-list>
20
- </v-col>
21
-
22
- <v-col cols="12" md="9" lg="10">
23
- <RouterView />
24
- </v-col>
25
- </v-row>
26
- </v-card-text>
27
- </v-card>
7
+ <section class="generated-ui-screen generated-ui-screen--settings settings-shell d-flex flex-column ga-4">
8
+ <header>
9
+ <p class="text-overline text-medium-emphasis mb-1">Settings</p>
10
+ <h1 class="settings-shell__title">Home settings</h1>
11
+ <p class="text-body-2 text-medium-emphasis mb-0">Configure shell behavior for this surface.</p>
12
+ </header>
13
+
14
+ <v-sheet rounded="lg" border class="settings-shell__panel">
15
+ <div class="settings-shell__body">
16
+ <nav class="settings-shell__nav" aria-label="Home settings sections">
17
+ <v-list nav density="comfortable" rounded="lg" border>
18
+ <ShellOutlet target="home-settings:primary-menu" />
19
+ </v-list>
20
+ </nav>
21
+
22
+ <main class="settings-shell__content">
23
+ <RouterView />
24
+ </main>
25
+ </div>
26
+ </v-sheet>
28
27
  </section>
29
28
  </template>
29
+
30
+ <style scoped>
31
+ .generated-ui-screen {
32
+ --generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
33
+ --generated-ui-screen-panel-padding: 1rem;
34
+ }
35
+
36
+ .settings-shell__title {
37
+ font-size: var(--generated-ui-screen-title-size);
38
+ font-weight: 650;
39
+ letter-spacing: -0.02em;
40
+ line-height: 1.15;
41
+ margin: 0 0 0.35rem;
42
+ }
43
+
44
+ .settings-shell__panel {
45
+ overflow: hidden;
46
+ }
47
+
48
+ .settings-shell__body {
49
+ display: grid;
50
+ gap: 1rem;
51
+ grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
52
+ padding: var(--generated-ui-screen-panel-padding);
53
+ }
54
+
55
+ .settings-shell__content {
56
+ min-width: 0;
57
+ }
58
+
59
+ @media (max-width: 960px) {
60
+ .settings-shell__body {
61
+ grid-template-columns: 1fr;
62
+ }
63
+
64
+ .settings-shell__nav :deep(.v-list) {
65
+ display: flex;
66
+ gap: 0.25rem;
67
+ overflow-x: auto;
68
+ scrollbar-width: thin;
69
+ }
70
+
71
+ .settings-shell__nav :deep(.v-list-item) {
72
+ flex: 0 0 auto;
73
+ min-height: 48px;
74
+ }
75
+ }
76
+ </style>
@@ -11,6 +11,10 @@ const menuLinkRenderers = Object.freeze({
11
11
  link: "local.main.ui.surface-aware-menu-link-item"
12
12
  });
13
13
 
14
+ const bottomNavLinkRenderers = Object.freeze({
15
+ link: "local.main.ui.tab-link-item"
16
+ });
17
+
14
18
  addPlacementTopology({
15
19
  id: "shell.primary-nav",
16
20
  description: "Primary top-level navigation for the current surface.",
@@ -18,8 +22,8 @@ addPlacementTopology({
18
22
  default: true,
19
23
  variants: {
20
24
  compact: {
21
- outlet: "shell-layout:primary-menu",
22
- renderers: menuLinkRenderers
25
+ outlet: "shell-layout:primary-bottom-nav",
26
+ renderers: bottomNavLinkRenderers
23
27
  },
24
28
  medium: {
25
29
  outlet: "shell-layout:primary-menu",
@@ -86,6 +90,43 @@ addPlacementTopology({
86
90
  }
87
91
  });
88
92
 
93
+ addPlacementTopology({
94
+ id: "shell.global-actions",
95
+ description: "Global surface actions that should stay outside primary navigation.",
96
+ surfaces: ["*"],
97
+ variants: {
98
+ compact: {
99
+ outlet: "shell-layout:top-right",
100
+ renderers: menuLinkRenderers
101
+ },
102
+ medium: {
103
+ outlet: "shell-layout:top-right",
104
+ renderers: menuLinkRenderers
105
+ },
106
+ expanded: {
107
+ outlet: "shell-layout:top-right",
108
+ renderers: menuLinkRenderers
109
+ }
110
+ }
111
+ });
112
+
113
+ addPlacementTopology({
114
+ id: "page.supporting-content",
115
+ description: "Supporting page content that opens as a bottom sheet on compact layouts and a side panel on wider layouts.",
116
+ surfaces: ["*"],
117
+ variants: {
118
+ compact: {
119
+ outlet: "shell-layout:supporting-bottom-sheet"
120
+ },
121
+ medium: {
122
+ outlet: "shell-layout:supporting-side-panel"
123
+ },
124
+ expanded: {
125
+ outlet: "shell-layout:supporting-side-panel"
126
+ }
127
+ }
128
+ });
129
+
89
130
  addPlacementTopology({
90
131
  id: "page.section-nav",
91
132
  owner: "home-settings",
@@ -0,0 +1,4 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { runAdaptiveShellSmoke } from "@jskit-ai/shell-web/test/adaptiveShellSmoke";
3
+
4
+ runAdaptiveShellSmoke({ test, expect });
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { createDefaultErrorPolicy } from "../src/client/error/policy.js";
3
4
  import { createErrorRuntime } from "../src/client/error/runtime.js";
4
5
 
5
6
  function createPresenter(id, {
@@ -25,6 +26,47 @@ function createPresenter(id, {
25
26
  });
26
27
  }
27
28
 
29
+ test("default error policy maps intent to presentation instead of status alone", () => {
30
+ const policy = createDefaultErrorPolicy();
31
+
32
+ assert.equal(policy({ intent: "resource-load", message: "Load failed" }).channel, "silent");
33
+ assert.equal(policy({ intent: "action-feedback", message: "Save failed" }).channel, "snackbar");
34
+ assert.equal(policy({ intent: "app-recoverable", message: "Offline" }).channel, "banner");
35
+ assert.equal(policy({ intent: "blocking", message: "Fatal" }).channel, "dialog");
36
+ assert.equal(policy({ blocking: true, message: "Fatal" }).channel, "dialog");
37
+ assert.equal(policy({ status: 500, message: "Server failed" }).channel, "snackbar");
38
+ assert.equal(
39
+ policy({ intent: "resource-load", channel: "banner", message: "Load failed" }).channel,
40
+ "banner"
41
+ );
42
+ });
43
+
44
+ test("error runtime treats resource load errors as silent by default", () => {
45
+ const calls = [];
46
+ const runtime = createErrorRuntime({
47
+ presenters: [
48
+ createPresenter("module.presenter", { calls })
49
+ ],
50
+ moduleDefaultPresenterId: "module.presenter"
51
+ });
52
+
53
+ const result = runtime.report({
54
+ kind: "resource-load",
55
+ message: "Unable to load records.",
56
+ status: 500,
57
+ action: {
58
+ label: "Retry",
59
+ handler() {}
60
+ }
61
+ });
62
+
63
+ assert.equal(result.skipped, true);
64
+ assert.equal(result.reason, "silent");
65
+ assert.equal(result.event.intent, "resource-load");
66
+ assert.equal(result.decision.channel, "silent");
67
+ assert.equal(calls.length, 0);
68
+ });
69
+
28
70
  test("error runtime prefers policy presenter over app and module defaults", () => {
29
71
  const calls = [];
30
72
  const runtime = createErrorRuntime({
@@ -32,8 +32,13 @@ test("shell-web exports generic link-item components for app-owned shell wrapper
32
32
  assert.match(clientIndexSource, /ShellMenuLinkItem/);
33
33
  assert.match(clientIndexSource, /ShellSurfaceAwareMenuLinkItem/);
34
34
  assert.match(clientIndexSource, /ShellTabLinkItem/);
35
+ assert.match(clientIndexSource, /ShellRouteTransition/);
35
36
 
36
37
  const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
38
+ assert.equal(
39
+ packageJson?.exports?.["./client/components/ShellRouteTransition"],
40
+ "./src/client/components/ShellRouteTransition.vue"
41
+ );
37
42
  assert.equal(
38
43
  packageJson?.exports?.["./client/components/ShellMenuLinkItem"],
39
44
  "./src/client/components/ShellMenuLinkItem.vue"
@@ -150,8 +155,10 @@ test("shell-web generic link items support the expected shared route and icon be
150
155
  assert.match(shellSurfaceAwareSource, /:exact="props\.exact"/);
151
156
  assert.match(shellTabSource, /icon:\s*\{/);
152
157
  assert.match(shellTabSource, /resolveMenuLinkIcon/);
153
- assert.match(shellTabSource, /<v-list-item/);
154
- assert.match(shellTabSource, /:prepend-icon="resolvedIcon \|\| undefined"/);
158
+ assert.match(shellTabSource, /<v-btn/);
159
+ assert.match(shellTabSource, /stacked/);
160
+ assert.match(shellTabSource, /min-height:\s*48px/);
161
+ assert.match(shellTabSource, /<v-icon v-if="resolvedIcon" :icon="resolvedIcon" \/>/);
155
162
  });
156
163
 
157
164
  test("shell-web binds the local link-item wrapper tokens into MainClientProvider", () => {
@@ -108,6 +108,43 @@ test("web placement runtime resolves semantic targets through topology variants"
108
108
  assert.equal(mediumEntries[0].componentToken, "component.menu");
109
109
  });
110
110
 
111
+ test("web placement runtime accepts append-only topology objects", () => {
112
+ const app = createAppStub({
113
+ tokens: {
114
+ "component.menu": () => null
115
+ }
116
+ });
117
+
118
+ const runtime = createWebPlacementRuntime({ app });
119
+ runtime.replacePlacementTopology({
120
+ placements: [
121
+ semanticTopologyEntry({
122
+ id: "shell.primary-nav",
123
+ compactRenderer: "component.menu",
124
+ mediumRenderer: "component.menu",
125
+ expandedRenderer: "component.menu"
126
+ })
127
+ ]
128
+ });
129
+ runtime.replacePlacements([
130
+ definePlacement({
131
+ id: "test.home",
132
+ target: "shell.primary-nav",
133
+ kind: "link",
134
+ surfaces: ["app"],
135
+ order: 10
136
+ })
137
+ ]);
138
+ runtime.setContext(createPlacementContext());
139
+
140
+ const entries = runtime.getPlacements({
141
+ surface: "app",
142
+ target: "shell-layout:primary-menu",
143
+ layoutClass: "expanded"
144
+ });
145
+ assert.deepEqual(entries.map((entry) => entry.id), ["test.home"]);
146
+ });
147
+
111
148
  test("web placement runtime filters by surface/host/position, resolves component tokens, and sorts by order", () => {
112
149
  const app = createAppStub({
113
150
  tokens: {
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createPinia } from "pinia";
4
- import { ShellWebClientProvider } from "../src/client/providers/ShellWebClientProvider.js";
4
+ import {
5
+ ShellWebClientProvider,
6
+ resolveAppPlacementTopologyExport
7
+ } from "../src/client/providers/ShellWebClientProvider.js";
5
8
  import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
6
9
  const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
7
10
 
@@ -14,7 +17,7 @@ function setClientAppConfig(source = {}) {
14
17
  return normalized;
15
18
  }
16
19
 
17
- function createAppDouble({ surfaceRuntime = null } = {}) {
20
+ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
18
21
  const singletons = new Map();
19
22
  const singletonInstances = new Map();
20
23
  const provided = [];
@@ -61,6 +64,9 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
61
64
  if (token === "jskit.client.surface.runtime") {
62
65
  return Boolean(surfaceRuntime);
63
66
  }
67
+ if (token === "jskit.client.query-client") {
68
+ return Boolean(queryClient);
69
+ }
64
70
  return singletons.has(token) || singletonInstances.has(token);
65
71
  },
66
72
  make(token) {
@@ -73,6 +79,9 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
73
79
  if (token === "jskit.client.surface.runtime" && surfaceRuntime) {
74
80
  return surfaceRuntime;
75
81
  }
82
+ if (token === "jskit.client.query-client" && queryClient) {
83
+ return queryClient;
84
+ }
76
85
  if (singletonInstances.has(token)) {
77
86
  return singletonInstances.get(token);
78
87
  }
@@ -125,6 +134,32 @@ async function withFetchImplementation(fetchImplementation, callback) {
125
134
  }
126
135
  }
127
136
 
137
+ test("shell web client provider preserves append-only placement topology object exports", () => {
138
+ const warnings = [];
139
+ const topology = {
140
+ placements: [
141
+ {
142
+ id: "shell.primary-nav",
143
+ surfaces: ["*"],
144
+ variants: {
145
+ compact: { outlet: "shell-layout:primary-menu" },
146
+ medium: { outlet: "shell-layout:primary-menu" },
147
+ expanded: { outlet: "shell-layout:primary-menu" }
148
+ }
149
+ }
150
+ ]
151
+ };
152
+
153
+ const resolved = resolveAppPlacementTopologyExport(topology, {
154
+ warn(payload, message) {
155
+ warnings.push({ payload, message });
156
+ }
157
+ });
158
+
159
+ assert.equal(resolved, topology);
160
+ assert.deepEqual(warnings, []);
161
+ });
162
+
128
163
  test("shell web client provider binds runtime and injects it into Vue app", async () => {
129
164
  await withFetchStub({ surfaceAccess: {} }, async () => {
130
165
  const app = createAppDouble();
@@ -134,15 +169,15 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
134
169
  assert.equal(app.singletons.has("runtime.web-placement.client"), true);
135
170
  assert.equal(app.singletons.has("runtime.web-error.client"), true);
136
171
  assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
172
+ assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
137
173
 
138
174
  await provider.boot(app);
139
- assert.equal(app.plugins.length, 1);
140
- assert.equal(typeof app.plugins[0].plugin.install, "function");
141
- assert.equal(typeof app.plugins[0].options?.queryClient, "object");
175
+ assert.equal(app.plugins.length, 0);
142
176
 
143
177
  const providedByKey = new Map(app.provided.map((entry) => [entry.key, entry.value]));
144
178
 
145
179
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
180
+ assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
146
181
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
147
182
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
148
183
 
@@ -156,6 +191,9 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
156
191
  assert.equal(typeof errorRuntime.report, "function");
157
192
  assert.equal(typeof errorRuntime.configure, "function");
158
193
 
194
+ const refreshRuntime = providedByKey.get("jskit.shell-web.runtime.web-refresh.client");
195
+ assert.equal(typeof refreshRuntime.refresh, "function");
196
+
159
197
  const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
160
198
  assert.equal(typeof errorStore.getState, "function");
161
199
  assert.equal(typeof errorStore.present, "function");
@@ -168,6 +206,60 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
168
206
  });
169
207
  });
170
208
 
209
+ test("shell refresh runtime refreshes bootstrap and active queries by default", async () => {
210
+ await withFetchStub({ surfaceAccess: { home: true } }, async () => {
211
+ const refetchCalls = [];
212
+ const queryClient = {
213
+ async refetchQueries(filters = {}, options = {}) {
214
+ refetchCalls.push({ filters, options });
215
+ }
216
+ };
217
+ const app = createAppDouble({ queryClient });
218
+ const provider = new ShellWebClientProvider();
219
+ provider.register(app);
220
+
221
+ const refreshRuntime = app.make("runtime.web-refresh.client");
222
+ const result = await refreshRuntime.refresh("test-refresh");
223
+
224
+ assert.equal(result.reason, "test-refresh");
225
+ assert.equal(result.bootstrapRefreshed, true);
226
+ assert.equal(result.queriesRefetched, true);
227
+ assert.equal(refetchCalls.length, 1);
228
+ assert.equal(refetchCalls[0].filters.type, "active");
229
+ assert.equal(refetchCalls[0].options.throwOnError, false);
230
+ assert.equal(refetchCalls[0].filters.predicate({}), true);
231
+ assert.equal(refetchCalls[0].filters.predicate({ meta: {} }), true);
232
+ assert.equal(refetchCalls[0].filters.predicate({ meta: { jskit: { refreshOnPull: true } } }), true);
233
+ assert.equal(refetchCalls[0].filters.predicate({ meta: { jskitRefresh: "pull" } }), true);
234
+ assert.equal(refetchCalls[0].filters.predicate({ meta: { jskit: { refreshOnPull: false } } }), false);
235
+ assert.equal(refetchCalls[0].filters.predicate({ meta: { jskitRefresh: false } }), false);
236
+ });
237
+ });
238
+
239
+ test("shell refresh runtime reports recoverable retry errors as banners", async () => {
240
+ await withFetchStub({ surfaceAccess: { home: true } }, async () => {
241
+ const queryClient = {
242
+ async refetchQueries() {
243
+ throw new Error("Network unavailable");
244
+ }
245
+ };
246
+ const app = createAppDouble({ queryClient });
247
+ const provider = new ShellWebClientProvider();
248
+ provider.register(app);
249
+
250
+ const refreshRuntime = app.make("runtime.web-refresh.client");
251
+ const result = await refreshRuntime.refresh("test-refresh");
252
+
253
+ assert.equal(result.error instanceof Error, true);
254
+
255
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
256
+ const state = errorStore.getState();
257
+ assert.equal(state.channels.banner.length, 1);
258
+ assert.equal(state.channels.banner[0].message, "Unable to refresh. Check the connection and try again.");
259
+ assert.equal(state.channels.banner[0].action.label, "Retry");
260
+ });
261
+ });
262
+
171
263
  test("shell web client provider resolves surface config from client app config", async () => {
172
264
  setClientAppConfig({
173
265
  tenancyMode: "workspaces",