@jskit-ai/shell-web 0.1.162 → 0.1.164

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 (46) hide show
  1. package/fixtures/adaptive-shell/src/ScreenPage.vue +1 -1
  2. package/package.json +8 -253
  3. package/patterns/application-shell/PATTERN.md +66 -0
  4. package/{templates → patterns/application-shell/example}/expected-existing/src/pages/home/index.vue +8 -8
  5. package/{templates → patterns/application-shell/example}/src/pages/home/index.vue +8 -8
  6. package/{templates → patterns/application-shell/example}/src/pages/home/settings/general/index.vue +1 -1
  7. package/{templates → patterns/application-shell/example}/src/pages/home/settings.vue +8 -8
  8. package/patterns/page-and-placement/PATTERN.md +76 -0
  9. package/patterns/page-and-placement/example/packages/main/src/client/providers/MainClientProvider.js +14 -0
  10. package/patterns/page-and-placement/example/src/components/SyncStatusElement.vue +16 -0
  11. package/patterns/page-and-placement/example/src/pages/home/reports/activity.vue +8 -0
  12. package/patterns/page-and-placement/example/src/pages/home/reports/index.vue +7 -0
  13. package/patterns/page-and-placement/example/src/pages/home/reports/overview.vue +8 -0
  14. package/patterns/page-and-placement/example/src/pages/home/reports.vue +33 -0
  15. package/patterns/page-and-placement/example/src/placement.js +52 -0
  16. package/patterns/page-and-placement/example/src/placementTopology.js +29 -0
  17. package/src/client/bootstrap/bootstrapPayloadHandlerRegistry.js +30 -42
  18. package/src/client/bootstrap/index.js +2 -3
  19. package/src/client/index.js +2 -11
  20. package/src/client/placement/runtime.js +9 -12
  21. package/src/client/providers/ShellWebClientProvider.js +95 -129
  22. package/src/client/requestRecovery/runtime.js +9 -29
  23. package/src/client/runtime/bootstrapRuntime.js +18 -29
  24. package/src/test/adaptiveShellSmoke.js +3 -9
  25. package/test/bootstrapRuntime.test.js +10 -61
  26. package/test/pageAndPlacementPattern.test.js +38 -0
  27. package/test/placementRuntime.test.js +36 -50
  28. package/test/playwrightContract.test.js +2 -12
  29. package/test/provider.test.js +126 -737
  30. package/test/settingsPlacementContract.test.js +75 -126
  31. package/src/server/support/localLinkItemScaffolds.js +0 -80
  32. package/test/bootstrapClaimContract.test.js +0 -76
  33. package/test/linkItemScaffoldContract.test.js +0 -248
  34. /package/{templates → patterns/application-shell/example}/expected-existing/src/App.vue +0 -0
  35. /package/{templates → patterns/application-shell/example}/expected-existing/src/pages/home.vue +0 -0
  36. /package/{templates → patterns/application-shell/example}/src/App.vue +0 -0
  37. /package/{templates → patterns/application-shell/example}/src/components/ShellLayout.vue +0 -0
  38. /package/{templates → patterns/application-shell/example}/src/components/menus/MenuLinkItem.vue +0 -0
  39. /package/{templates → patterns/application-shell/example}/src/components/menus/SurfaceAwareMenuLinkItem.vue +0 -0
  40. /package/{templates → patterns/application-shell/example}/src/components/menus/TabLinkItem.vue +0 -0
  41. /package/{templates → patterns/application-shell/example}/src/error.js +0 -0
  42. /package/{templates → patterns/application-shell/example}/src/pages/home/settings/index.vue +0 -0
  43. /package/{templates → patterns/application-shell/example}/src/pages/home.vue +0 -0
  44. /package/{templates → patterns/application-shell/example}/src/placement.js +0 -0
  45. /package/{templates → patterns/application-shell/example}/src/placementTopology.js +0 -0
  46. /package/{templates → patterns/application-shell/example}/tests/e2e/adaptive-shell.spec.ts +0 -0
@@ -1,796 +1,185 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createPinia } from "pinia";
4
+ import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
5
+ import { createClientComponentRegistry } from "../../kernel/client/componentRegistry.js";
6
+ import { createSurfaceRuntime } from "@jskit-ai/kernel/shared/surface/runtime";
4
7
  import {
5
8
  ShellWebClientProvider,
6
9
  resolveAppPlacementTopologyExport
7
10
  } from "../src/client/providers/ShellWebClientProvider.js";
8
- import {
9
- isMissingDynamicModule
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";
17
- import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
11
+
18
12
  const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
19
13
 
20
- async function withTemporaryGlobal(name, value, callback) {
21
- const hadPrevious = Object.prototype.hasOwnProperty.call(globalThis, name);
22
- const previousValue = globalThis[name];
23
- globalThis[name] = value;
14
+ function response(payload = {}, { ok = true, status = 200 } = {}) {
15
+ return {
16
+ ok,
17
+ status,
18
+ async json() {
19
+ return payload;
20
+ }
21
+ };
22
+ }
24
23
 
24
+ async function withGlobal(name, value, callback) {
25
+ const hadPrevious = Object.hasOwn(globalThis, name);
26
+ const previous = globalThis[name];
27
+ globalThis[name] = value;
25
28
  try {
26
29
  return await callback();
27
30
  } finally {
28
31
  if (hadPrevious) {
29
- globalThis[name] = previousValue;
32
+ globalThis[name] = previous;
30
33
  } else {
31
34
  delete globalThis[name];
32
35
  }
33
36
  }
34
37
  }
35
38
 
36
- function setClientAppConfig(source = {}) {
37
- const normalized =
38
- source && typeof source === "object" && !Array.isArray(source) ? Object.freeze({ ...source }) : Object.freeze({});
39
- if (typeof globalThis === "object" && globalThis) {
40
- globalThis[CLIENT_APP_CONFIG_GLOBAL_KEY] = normalized;
41
- }
42
- return normalized;
43
- }
44
-
45
- function createAppDouble({ surfaceRuntime = null, queryClient = null, router = null } = {}) {
46
- const singletons = new Map();
47
- const singletonInstances = new Map();
48
- const provided = [];
49
- const plugins = [];
50
- const pinia = createPinia();
51
-
52
- const vueApp = {
53
- config: {
54
- globalProperties: {
55
- $pinia: pinia
56
- }
57
- },
58
- use(plugin, options) {
59
- plugins.push({ plugin, options });
60
- return this;
61
- },
62
- provide(key, value) {
63
- provided.push({ key, value });
64
- }
65
- };
66
-
39
+ function createVueApp() {
40
+ const provided = new Map();
67
41
  return {
68
- singletons,
42
+ config: {},
69
43
  provided,
70
- plugins,
71
- pinia,
72
- vueApp,
73
- _tags: new Map(),
74
- singleton(token, factory) {
75
- singletons.set(token, factory);
76
- },
77
- tag(token, tagName) {
78
- const current = this._tags.get(tagName) || [];
79
- current.push(token);
80
- this._tags.set(tagName, current);
81
- },
82
- has(token) {
83
- if (token === "jskit.client.vue.app") {
84
- return true;
85
- }
86
- if (token === "jskit.client.pinia") {
87
- return true;
88
- }
89
- if (token === "jskit.client.surface.runtime") {
90
- return Boolean(surfaceRuntime);
91
- }
92
- if (token === "jskit.client.query-client") {
93
- return Boolean(queryClient);
94
- }
95
- if (token === "jskit.client.router") {
96
- return Boolean(router);
97
- }
98
- return singletons.has(token) || singletonInstances.has(token);
99
- },
100
- make(token) {
101
- if (token === "jskit.client.vue.app") {
102
- return vueApp;
103
- }
104
- if (token === "jskit.client.pinia") {
105
- return pinia;
106
- }
107
- if (token === "jskit.client.surface.runtime" && surfaceRuntime) {
108
- return surfaceRuntime;
109
- }
110
- if (token === "jskit.client.query-client" && queryClient) {
111
- return queryClient;
112
- }
113
- if (token === "jskit.client.router" && router) {
114
- return router;
115
- }
116
- if (singletonInstances.has(token)) {
117
- return singletonInstances.get(token);
118
- }
119
- const factory = singletons.get(token);
120
- if (!factory) {
121
- throw new Error(`Unknown token ${String(token)}`);
122
- }
123
- const instance = factory(this);
124
- singletonInstances.set(token, instance);
125
- return instance;
44
+ provide(key, value) {
45
+ provided.set(key, value);
126
46
  },
127
- resolveTag(tagName) {
128
- return (this._tags.get(tagName) || []).map((token) => this.make(token));
47
+ use() {
48
+ return this;
129
49
  }
130
50
  };
131
51
  }
132
52
 
133
- async function withFetchStub(responsePayload, callback) {
134
- return withFetchImplementation(async () => ({
135
- ok: true,
136
- async json() {
137
- return responsePayload;
138
- }
139
- }), callback);
140
- }
141
-
142
- async function withFetchImplementation(fetchImplementation, callback) {
143
- return withTemporaryGlobal("fetch", fetchImplementation, callback);
144
- }
145
-
146
- async function withWindowDouble(windowObject, callback) {
147
- return withTemporaryGlobal("window", windowObject, callback);
148
- }
149
-
150
- function createWindowDouble({ href = "https://example.test/home" } = {}) {
151
- const listeners = new Map();
152
- const reloadCalls = [];
153
- const location = {
154
- href,
155
- reload() {
156
- reloadCalls.push(location.href);
157
- }
158
- };
159
-
53
+ function createRouter() {
54
+ const errorHandlers = [];
160
55
  return {
161
- listeners,
162
- reloadCalls,
163
- location,
164
- addEventListener(type, handler) {
165
- listeners.set(type, handler);
166
- },
167
- removeEventListener(type, handler) {
168
- if (listeners.get(type) === handler) {
169
- listeners.delete(type);
170
- }
56
+ currentRoute: { value: { path: "/" } },
57
+ onError(handler) {
58
+ errorHandlers.push(handler);
59
+ return () => {};
171
60
  }
172
61
  };
173
62
  }
174
63
 
175
- function createQueryCacheDouble(initialQueries = []) {
176
- const listeners = [];
177
- const queries = [...initialQueries];
178
-
179
- return {
180
- queries,
181
- getAll() {
182
- return queries;
64
+ function createSurfaceRuntimeFixture() {
65
+ return createSurfaceRuntime({
66
+ allMode: "all",
67
+ surfaces: {
68
+ app: { id: "app", pagesRoot: "app", enabled: true }
183
69
  },
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
- };
70
+ defaultSurfaceId: "app"
71
+ });
199
72
  }
200
73
 
201
- test("shell web client provider preserves append-only placement topology object exports", () => {
202
- const warnings = [];
203
- const topology = {
204
- placements: [
205
- {
206
- id: "shell.primary-nav",
207
- surfaces: ["*"],
208
- variants: {
209
- compact: { outlet: "shell-layout:primary-menu" },
210
- medium: { outlet: "shell-layout:primary-menu" },
211
- expanded: { outlet: "shell-layout:primary-menu" }
74
+ async function startShell({
75
+ bootstrapPayload = {},
76
+ bootstrapResponse = null,
77
+ queryClient = null
78
+ } = {}) {
79
+ let shell = null;
80
+ const Probe = defineProvider({
81
+ id: "test.shell.probe",
82
+ requires: { value: "client.shell" },
83
+ setup({ value }) {
84
+ shell = value;
85
+ }
86
+ });
87
+ const vueApp = createVueApp();
88
+ const pinia = createPinia();
89
+ const router = createRouter();
90
+ const components = createClientComponentRegistry();
91
+ const surfaceRuntime = createSurfaceRuntimeFixture();
92
+ const logger = { debug() {}, info() {}, warn() {}, error() {} };
93
+ const fetchImplementation = async () => bootstrapResponse || response(bootstrapPayload);
94
+
95
+ return withGlobal("fetch", fetchImplementation, async () =>
96
+ withGlobal(CLIENT_APP_CONFIG_GLOBAL_KEY, Object.freeze({}), async () => {
97
+ const runtime = createCapabilityRuntime({
98
+ profile: "test",
99
+ providers: [ShellWebClientProvider, Probe],
100
+ inputs: {
101
+ "client.components": components,
102
+ "client.logger": logger,
103
+ "client.pinia": pinia,
104
+ "client.query": queryClient,
105
+ "client.router": router,
106
+ "client.surface": surfaceRuntime,
107
+ "client.vue": vueApp
212
108
  }
213
- }
214
- ]
215
- };
109
+ });
110
+ await runtime.start();
111
+ return { components, pinia, router, runtime, shell, vueApp };
112
+ })
113
+ );
114
+ }
216
115
 
217
- const resolved = resolveAppPlacementTopologyExport(topology, {
218
- warn(payload, message) {
219
- warnings.push({ payload, message });
116
+ test("shell capability owns placement, bootstrap, refresh, errors, and recovery without a container", async () => {
117
+ const fixture = await startShell({
118
+ bootstrapPayload: {
119
+ surfaceAccess: {
120
+ app: { allowed: true }
121
+ }
220
122
  }
221
123
  });
222
124
 
223
- assert.equal(resolved, topology);
224
- assert.deepEqual(warnings, []);
225
- });
226
-
227
- test("shell web boot import classifier does not swallow fetched dynamic module failures", () => {
125
+ assert.equal(fixture.runtime.diagnostics().lifecycleState, "started");
126
+ assert.deepEqual(Object.keys(fixture.shell).sort(), [
127
+ "asyncModuleRecovery",
128
+ "bootstrap",
129
+ "bootstrapHandlers",
130
+ "error",
131
+ "errorPresentationStore",
132
+ "placement",
133
+ "refresh",
134
+ "requestRecovery"
135
+ ]);
136
+ assert.deepEqual(fixture.shell.placement.getContext().surfaceAccess, {
137
+ app: { allowed: true }
138
+ });
228
139
  assert.equal(
229
- isMissingDynamicModule(
230
- new Error("Cannot find module '/src/placement.js' imported from /src/main.js"),
231
- "/src/placement.js"
232
- ),
233
- true
140
+ fixture.vueApp.provided.get("jskit.shell-web.runtime.web-placement.client"),
141
+ fixture.shell.placement
234
142
  );
235
143
  assert.equal(
236
- isMissingDynamicModule(
237
- new Error("Failed to fetch dynamically imported module: http://localhost:5173/src/placement.js?t=123"),
238
- "/src/placement.js"
239
- ),
240
- false
144
+ fixture.vueApp.provided.get("jskit.shell-web.runtime.web-error.client"),
145
+ fixture.shell.error
241
146
  );
242
- });
243
-
244
- test("shell web client provider binds runtime and injects it into Vue app", async () => {
245
- await withFetchStub({ surfaceAccess: {} }, async () => {
246
- const app = createAppDouble();
247
- const provider = new ShellWebClientProvider();
248
-
249
- provider.register(app);
250
- assert.equal(app.singletons.has("runtime.web-placement.client"), true);
251
- assert.equal(app.singletons.has("runtime.web-error.client"), true);
252
- assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
253
- assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
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);
256
-
257
- await provider.boot(app);
258
- assert.equal(app.plugins.length, 0);
259
-
260
- const providedByKey = new Map(app.provided.map((entry) => [entry.key, entry.value]));
261
-
262
- assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
263
- assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
264
- assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
265
- assert.equal(providedByKey.has(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY), true);
266
- assert.equal(providedByKey.has(SHELL_REQUEST_RECOVERY_RUNTIME_KEY), true);
267
- assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
268
-
269
- const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
270
- assert.equal(typeof placementRuntime.getPlacements, "function");
271
- assert.equal(typeof placementRuntime.getContext, "function");
272
- assert.equal(typeof placementRuntime.setContext, "function");
273
- assert.equal(typeof placementRuntime.getContext().surfaceConfig, "object");
274
-
275
- const errorRuntime = providedByKey.get("jskit.shell-web.runtime.web-error.client");
276
- assert.equal(typeof errorRuntime.report, "function");
277
- assert.equal(typeof errorRuntime.configure, "function");
278
-
279
- const refreshRuntime = providedByKey.get("jskit.shell-web.runtime.web-refresh.client");
280
- assert.equal(typeof refreshRuntime.refresh, "function");
281
147
 
282
- const asyncModuleRecoveryRuntime = providedByKey.get(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY);
283
- assert.equal(typeof asyncModuleRecoveryRuntime.install, "function");
284
- assert.equal(typeof asyncModuleRecoveryRuntime.notify, "function");
285
- assert.equal(typeof asyncModuleRecoveryRuntime.reload, "function");
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
-
292
- const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
293
- assert.equal(typeof errorStore.getState, "function");
294
- assert.equal(typeof errorStore.present, "function");
295
-
296
- const errorPresentationStore = useShellErrorPresentationStore(app.pinia);
297
- assert.equal(errorPresentationStore.revision, 0);
298
- assert.equal(typeof errorPresentationStore.present, "function");
299
- errorStore.present("banner", { message: "Hello" });
300
- assert.equal(errorPresentationStore.channels.banner[0].message, "Hello");
301
- });
148
+ await fixture.runtime.shutdown();
149
+ assert.equal(fixture.runtime.diagnostics().lifecycleState, "stopped");
302
150
  });
303
151
 
304
- test("shell refresh runtime refreshes bootstrap and active queries by default", async () => {
305
- await withFetchStub({ surfaceAccess: { home: true } }, async () => {
306
- const refetchCalls = [];
307
- const queryClient = {
308
- async refetchQueries(filters = {}, options = {}) {
309
- refetchCalls.push({ filters, options });
310
- }
311
- };
312
- const app = createAppDouble({ queryClient });
313
- const provider = new ShellWebClientProvider();
314
- provider.register(app);
315
-
316
- const refreshRuntime = app.make("runtime.web-refresh.client");
317
- const result = await refreshRuntime.refresh("test-refresh");
318
-
319
- assert.equal(result.reason, "test-refresh");
320
- assert.equal(result.bootstrapRefreshed, true);
321
- assert.equal(result.queriesRefetched, true);
322
- assert.equal(refetchCalls.length, 1);
323
- assert.equal(refetchCalls[0].filters.type, "active");
324
- assert.equal(refetchCalls[0].options.throwOnError, false);
325
- assert.equal(refetchCalls[0].filters.predicate({}), true);
326
- assert.equal(refetchCalls[0].filters.predicate({ meta: {} }), true);
327
- assert.equal(refetchCalls[0].filters.predicate({ meta: { jskit: { refreshOnPull: true } } }), true);
328
- assert.equal(refetchCalls[0].filters.predicate({ meta: { jskitRefresh: "pull" } }), true);
329
- assert.equal(refetchCalls[0].filters.predicate({ meta: { jskit: { refreshOnPull: false } } }), false);
330
- assert.equal(refetchCalls[0].filters.predicate({ meta: { jskitRefresh: false } }), false);
331
- });
332
- });
333
-
334
- test("shell refresh runtime reports recoverable retry errors as banners", async () => {
335
- await withFetchStub({ surfaceAccess: { home: true } }, async () => {
336
- const queryClient = {
337
- async refetchQueries() {
338
- throw new Error("Network unavailable");
339
- }
340
- };
341
- const app = createAppDouble({ queryClient });
342
- const provider = new ShellWebClientProvider();
343
- provider.register(app);
344
-
345
- const refreshRuntime = app.make("runtime.web-refresh.client");
346
- const result = await refreshRuntime.refresh("test-refresh");
347
-
348
- assert.equal(result.error instanceof Error, true);
349
-
350
- const errorStore = app.make("runtime.web-error.presentation-store.client");
351
- const state = errorStore.getState();
352
- assert.equal(state.channels.banner.length, 1);
353
- assert.equal(state.channels.banner[0].message, "Unable to refresh. Check the connection and try again.");
354
- assert.equal(state.channels.banner[0].action.label, "Retry");
355
- });
356
- });
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
- const reportEvents = [];
375
- app.make("runtime.web-error.client").subscribe((event = {}) => {
376
- reportEvents.push(event);
377
- });
378
-
379
- const failedQuery = {
380
- queryHash: "[\"project-access\"]",
381
- queryKey: ["project-access"],
382
- meta: {
383
- jskit: {
384
- requestRecoveryLabel: "Project access",
385
- requestRecoverySource: "project-access.panel",
386
- requestRecoveryDedupeKey: "project-access",
387
- requestRecoveryMethod: "GET",
388
- requestRecoveryDedupeWindowMs: 100
389
- }
390
- },
391
- state: {
392
- status: "error",
393
- fetchStatus: "idle",
394
- error: {
395
- status: 0,
396
- message: "Network request failed."
397
- },
398
- errorUpdateCount: 1
399
- },
400
- isActive() {
401
- return true;
402
- }
403
- };
404
- queryCache.emit({ type: "updated", query: failedQuery });
405
-
406
- const errorStore = app.make("runtime.web-error.presentation-store.client");
407
- const state = errorStore.getState();
408
- assert.equal(state.channels.banner.length, 1);
409
- assert.equal(
410
- state.channels.banner[0].message,
411
- "Project access could not reach the server or network. Check the connection and try again."
412
- );
413
- assert.equal(state.channels.banner[0].action.label, "Retry");
414
- assert.equal(state.channels.banner[0].dedupeKey, "project-access");
415
- assert.equal(reportEvents[0]?.result?.event?.source, "project-access.panel");
416
- assert.equal(reportEvents[0]?.result?.decision?.dedupeWindowMs, 100);
417
-
418
- await state.channels.banner[0].action.handler();
419
- assert.equal(refetchCalls.length, 1);
420
- assert.deepEqual(refetchCalls[0].filters, {
421
- queryKey: ["project-access"],
422
- exact: true,
423
- type: "active"
424
- });
425
- assert.deepEqual(refetchCalls[0].options, {
426
- throwOnError: false
427
- });
428
- });
429
- });
430
-
431
- test("shell request recovery dismisses query presentations after recovery", async () => {
432
- await withFetchStub({ surfaceAccess: {} }, async () => {
433
- const queryCache = createQueryCacheDouble();
434
- const queryClient = {
152
+ test("shell refresh updates bootstrap state and active pull queries", async () => {
153
+ let refetchCount = 0;
154
+ const fixture = await startShell({
155
+ queryClient: {
435
156
  getQueryCache() {
436
- return queryCache;
437
- },
438
- async refetchQueries() {}
439
- };
440
- const app = createAppDouble({ queryClient });
441
- const provider = new ShellWebClientProvider();
442
- provider.register(app);
443
- await provider.boot(app);
444
-
445
- const query = {
446
- queryHash: "[\"project-access\"]",
447
- queryKey: ["project-access"],
448
- meta: {
449
- jskit: {
450
- requestRecoveryLabel: "Project access",
451
- requestRecoverySource: "project-access.panel",
452
- requestRecoveryDedupeKey: "project-access",
453
- requestRecoveryMethod: "GET"
454
- }
455
- },
456
- state: {
457
- status: "error",
458
- fetchStatus: "idle",
459
- error: {
460
- status: 0,
461
- message: "Network request failed."
462
- },
463
- errorUpdateCount: 1
157
+ return null;
464
158
  },
465
- isActive() {
466
- return true;
467
- }
468
- };
469
- queryCache.emit({ type: "updated", query });
470
-
471
- const errorStore = app.make("runtime.web-error.presentation-store.client");
472
- assert.equal(errorStore.getState().channels.banner.length, 1);
473
-
474
- query.state = {
475
- status: "success",
476
- fetchStatus: "idle",
477
- data: {
478
- ok: true
479
- },
480
- error: null,
481
- errorUpdateCount: 1
482
- };
483
- queryCache.emit({ type: "updated", query });
484
-
485
- assert.equal(errorStore.getState().channels.banner.length, 0);
486
- });
487
- });
488
-
489
- test("shell request recovery ignores query transport failures without a safe read method", async () => {
490
- await withFetchStub({ surfaceAccess: {} }, async () => {
491
- const queryCache = createQueryCacheDouble();
492
- const queryClient = {
493
- getQueryCache() {
494
- return queryCache;
495
- },
496
- async refetchQueries() {}
497
- };
498
- const app = createAppDouble({ queryClient });
499
- const provider = new ShellWebClientProvider();
500
- provider.register(app);
501
- await provider.boot(app);
502
-
503
- for (const [queryKey, meta] of [
504
- [["unmarked"], {}],
505
- [["unsafe"], {
506
- jskit: {
507
- requestRecoveryMethod: "POST"
508
- }
509
- }]
510
- ]) {
511
- queryCache.emit({
512
- type: "updated",
513
- query: {
514
- queryHash: JSON.stringify(queryKey),
515
- queryKey,
516
- meta,
517
- state: {
518
- status: "error",
519
- fetchStatus: "idle",
520
- error: {
521
- status: 0,
522
- message: "Network request failed."
523
- },
524
- errorUpdateCount: 1
525
- },
526
- isActive() {
527
- return true;
528
- }
529
- }
530
- });
531
- }
532
-
533
- const errorStore = app.make("runtime.web-error.presentation-store.client");
534
- assert.equal(errorStore.getState().channels.banner.length, 0);
535
- });
536
- });
537
-
538
- test("shell request recovery ignores ordinary active query validation failures", async () => {
539
- await withFetchStub({ surfaceAccess: {} }, async () => {
540
- const queryCache = createQueryCacheDouble();
541
- const queryClient = {
542
- getQueryCache() {
543
- return queryCache;
544
- },
545
- async refetchQueries() {}
546
- };
547
- const app = createAppDouble({ queryClient });
548
- const provider = new ShellWebClientProvider();
549
- provider.register(app);
550
- await provider.boot(app);
551
-
552
- queryCache.emit({
553
- type: "updated",
554
- query: {
555
- queryHash: "[\"project-access\"]",
556
- queryKey: ["project-access"],
557
- meta: {
558
- jskit: {
559
- requestRecoveryMethod: "GET"
560
- }
561
- },
562
- state: {
563
- status: "error",
564
- fetchStatus: "idle",
565
- error: {
566
- status: 422,
567
- message: "Invalid input."
568
- },
569
- errorUpdateCount: 1
570
- },
571
- isActive() {
572
- return true;
573
- }
574
- }
575
- });
576
-
577
- const errorStore = app.make("runtime.web-error.presentation-store.client");
578
- assert.equal(errorStore.getState().channels.banner.length, 0);
579
- });
580
- });
581
-
582
- test("shell bootstrap transport failures report through request recovery", async () => {
583
- let fetchCalls = 0;
584
- await withFetchImplementation(async () => {
585
- fetchCalls += 1;
586
- throw new TypeError("Failed to fetch");
587
- }, async () => {
588
- const app = createAppDouble();
589
- const provider = new ShellWebClientProvider();
590
- provider.register(app);
591
- await provider.boot(app);
592
-
593
- const errorStore = app.make("runtime.web-error.presentation-store.client");
594
- const state = errorStore.getState();
595
- assert.equal(state.channels.banner.length, 1);
596
- assert.equal(
597
- state.channels.banner[0].message,
598
- "App data could not reach the server or network. Check the connection and try again."
599
- );
600
- assert.equal(state.channels.banner[0].action.label, "Retry");
601
-
602
- await state.channels.banner[0].action.handler();
603
- assert.equal(fetchCalls, 2);
604
- });
605
- });
606
-
607
- test("shell web client provider reports dynamic import failures through async module recovery", async () => {
608
- await withFetchStub({ surfaceAccess: {} }, async () => {
609
- const routerErrorHandlers = [];
610
- const replacedPaths = [];
611
- const router = {
612
- onError(handler) {
613
- routerErrorHandlers.push(handler);
614
- return () => null;
615
- },
616
- replace(fullPath) {
617
- replacedPaths.push(fullPath);
159
+ async refetchQueries() {
160
+ refetchCount += 1;
618
161
  }
619
- };
620
- const app = createAppDouble({ router });
621
- const provider = new ShellWebClientProvider();
622
- provider.register(app);
623
- await provider.boot(app);
624
-
625
- assert.equal(routerErrorHandlers.length, 2);
626
-
627
- const chunkError = new Error("Failed to fetch dynamically imported module: /assets/page.js");
628
- for (const handler of routerErrorHandlers) {
629
- handler(chunkError, {
630
- fullPath: "/app/dashboard"
631
- });
632
162
  }
633
-
634
- const errorStore = app.make("runtime.web-error.presentation-store.client");
635
- const state = errorStore.getState();
636
- assert.equal(state.channels.banner.length, 1);
637
- assert.equal(
638
- state.channels.banner[0].message,
639
- "Page did not download. The app may have been updated, or the network request failed."
640
- );
641
- assert.equal(state.channels.banner[0].severity, "warning");
642
- assert.equal(state.channels.banner[0].action.label, "Reload");
643
- assert.deepEqual(replacedPaths, []);
644
- });
645
- });
646
-
647
- test("shell web async module recovery reload action checks the document before reloading", async () => {
648
- const windowObject = createWindowDouble();
649
- await withWindowDouble(windowObject, async () => {
650
- const fetchCalls = [];
651
- await withFetchImplementation(async (input, init = {}) => {
652
- fetchCalls.push({ input, init });
653
- return {
654
- ok: true,
655
- status: 200,
656
- async json() {
657
- return { surfaceAccess: {} };
658
- }
659
- };
660
- }, async () => {
661
- const routerErrorHandlers = [];
662
- const router = {
663
- onError(handler) {
664
- routerErrorHandlers.push(handler);
665
- return () => null;
666
- },
667
- replace() {}
668
- };
669
- const app = createAppDouble({ router });
670
- const provider = new ShellWebClientProvider();
671
- provider.register(app);
672
- await provider.boot(app);
673
-
674
- const chunkError = new Error("Failed to fetch dynamically imported module: /assets/page.js");
675
- routerErrorHandlers[0](chunkError, {
676
- fullPath: "/home/settings"
677
- });
678
-
679
- const errorStore = app.make("runtime.web-error.presentation-store.client");
680
- const banner = errorStore.getState().channels.banner[0];
681
- await banner.action.handler();
682
-
683
- assert.deepEqual(windowObject.reloadCalls, ["https://example.test/home"]);
684
- const reloadFetch = fetchCalls.find((entry) => entry.input === "https://example.test/home");
685
- assert.equal(reloadFetch?.init?.cache, "no-store");
686
- assert.equal(reloadFetch?.init?.credentials, "same-origin");
687
- });
688
163
  });
689
- });
690
-
691
- test("shell web async module recovery keeps the page alive when reload check fails", async () => {
692
- const windowObject = createWindowDouble();
693
- await withWindowDouble(windowObject, async () => {
694
- await withFetchImplementation(async (input) => {
695
- if (input === "https://example.test/home") {
696
- throw new TypeError("Failed to fetch");
697
- }
698
- return {
699
- ok: true,
700
- status: 200,
701
- async json() {
702
- return { surfaceAccess: {} };
703
- }
704
- };
705
- }, async () => {
706
- const app = createAppDouble();
707
- const provider = new ShellWebClientProvider();
708
- provider.register(app);
709
- await provider.boot(app);
710
-
711
- const recoveryRuntime = app.make("runtime.web-async-module-recovery.client");
712
- const reloaded = await recoveryRuntime.reload();
713
164
 
714
- assert.equal(reloaded, false);
715
- assert.deepEqual(windowObject.reloadCalls, []);
716
- const errorStore = app.make("runtime.web-error.presentation-store.client");
717
- const state = errorStore.getState();
718
- assert.equal(state.channels.banner.length, 1);
719
- assert.equal(
720
- state.channels.banner[0].message,
721
- "The app cannot reload because the app server is not reachable. Restart the server, then click Reload."
722
- );
723
- assert.equal(state.channels.banner[0].action.label, "Reload");
724
- });
725
- });
165
+ const result = await fixture.shell.refresh.refresh("test");
166
+ assert.equal(result.bootstrapRefreshed, true);
167
+ assert.equal(result.queriesRefetched, true);
168
+ assert.equal(refetchCount, 1);
169
+ await fixture.runtime.shutdown();
726
170
  });
727
171
 
728
- test("shell web client provider resolves surface config from client app config", async () => {
729
- setClientAppConfig({
730
- tenancyMode: "workspaces",
731
- surfaceAccessPolicies: {
732
- public: {}
733
- }
172
+ test("shell bootstrap clears surface access after an unauthenticated response", async () => {
173
+ const fixture = await startShell({
174
+ bootstrapResponse: response({}, { ok: false, status: 401 })
734
175
  });
735
-
736
- try {
737
- await withFetchStub({ surfaceAccess: {} }, async () => {
738
- const app = createAppDouble({
739
- surfaceRuntime: {
740
- DEFAULT_SURFACE_ID: "app",
741
- listEnabledSurfaceIds() {
742
- return ["app", "admin", "console"];
743
- },
744
- listSurfaceDefinitions() {
745
- return [
746
- { id: "app", pagesRoot: "w/[workspaceSlug]", requiresWorkspace: true, enabled: true },
747
- { id: "admin", pagesRoot: "w/[workspaceSlug]/admin", requiresWorkspace: true, enabled: true },
748
- { id: "console", pagesRoot: "console", requiresWorkspace: false, enabled: true }
749
- ];
750
- }
751
- }
752
- });
753
- const provider = new ShellWebClientProvider();
754
- provider.register(app);
755
-
756
- await provider.boot(app);
757
-
758
- const placementRuntime = app.make("runtime.web-placement.client");
759
- const context = placementRuntime.getContext();
760
- assert.equal(context.surfaceConfig.tenancyMode, "workspaces");
761
- assert.equal(context.surfaceConfig.defaultSurfaceId, "app");
762
- assert.deepEqual(context.surfaceConfig.enabledSurfaceIds, ["app", "admin", "console"]);
763
- assert.deepEqual(context.surfaceAccessPolicies, {
764
- public: {}
765
- });
766
- });
767
- } finally {
768
- setClientAppConfig({});
769
- }
176
+ assert.deepEqual(fixture.shell.placement.getContext().surfaceAccess, {});
177
+ await fixture.runtime.shutdown();
770
178
  });
771
179
 
772
- test("shell web client provider clears generic surface access on bootstrap 401", async () => {
773
- await withFetchImplementation(async () => ({
774
- ok: false,
775
- status: 401
776
- }), async () => {
777
- const app = createAppDouble();
778
- const provider = new ShellWebClientProvider();
779
- provider.register(app);
780
-
781
- const placementRuntime = app.make("runtime.web-placement.client");
782
- placementRuntime.setContext(
783
- {
784
- surfaceAccess: {
785
- consoleowner: true
786
- }
787
- },
788
- {
789
- source: "test.seed"
790
- }
791
- );
792
-
793
- await provider.boot(app);
794
- assert.deepEqual(placementRuntime.getContext().surfaceAccess, {});
795
- });
180
+ test("shell placement topology export accepts append-only objects and arrays", () => {
181
+ const logger = { warn() {} };
182
+ const topology = { mode: "append", placements: [] };
183
+ assert.equal(resolveAppPlacementTopologyExport(topology, logger), topology);
184
+ assert.deepEqual(resolveAppPlacementTopologyExport([], logger), []);
796
185
  });