@jskit-ai/shell-web 0.1.251 → 0.1.252

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/main.js +6 -3
  2. package/package.json +2 -2
  3. package/patterns/application-shell/example/src/pages/home/index.vue +15 -5
  4. package/patterns/application-shell/example/tests/e2e/adaptive-shell.spec.ts +1 -1
  5. package/src/architecture/inspectVueArchitecture.js +1092 -336
  6. package/src/client/asyncModuleRecovery/runtime.js +116 -0
  7. package/src/client/components/ShellLayout.vue +18 -288
  8. package/src/client/components/ShellMenuLinkItem.vue +3 -21
  9. package/src/client/components/ShellOutlet.vue +2 -24
  10. package/src/client/components/ShellRouteTransition.vue +16 -114
  11. package/src/client/components/ShellSurfaceAwareMenuLinkItem.vue +5 -100
  12. package/src/client/components/ShellTabLinkItem.vue +5 -101
  13. package/src/client/composables/useShellNavigationLink.js +85 -0
  14. package/src/client/composables/useShellPullToRefresh.js +260 -0
  15. package/src/client/error/index.js +1 -1
  16. package/src/client/error/inject.js +4 -1
  17. package/src/client/error/policy.js +4 -13
  18. package/src/client/error/presenters.js +4 -9
  19. package/src/client/error/store.js +1 -1
  20. package/src/client/navigation/linkResolver.js +5 -27
  21. package/src/client/navigation/usePaths.js +1 -21
  22. package/src/client/placement/index.js +2 -0
  23. package/src/client/placement/inject.js +4 -1
  24. package/src/client/placement/runtime.js +2 -4
  25. package/src/client/placement/surfaceContext.js +11 -8
  26. package/src/client/placement/validators.js +18 -65
  27. package/src/client/providers/ShellWebClientProvider.js +100 -356
  28. package/src/client/requestRecovery/runtime.js +29 -77
  29. package/src/client/runtime/bootstrapRuntime.js +5 -4
  30. package/src/client/runtime/refreshRuntime.js +122 -0
  31. package/src/client/support/gestureTargets.js +26 -0
  32. package/src/client/support/menuLinkTarget.js +35 -19
  33. package/src/client/support/routeParams.js +26 -0
  34. package/src/client/support/routeSwipeGesture.js +88 -0
  35. package/src/client/support/routeTransitionKey.js +1 -3
  36. package/src/client/support/shellLayoutClass.js +32 -0
  37. package/test/bootstrapRuntime.test.js +98 -0
  38. package/test/errorActions.test.js +2 -1
  39. package/test/errorRuntime.test.js +30 -0
  40. package/test/linkResolver.test.js +23 -1
  41. package/test/menuLinkTarget.test.js +77 -0
  42. package/test/routeSwipeGesture.test.js +49 -0
  43. package/test/settingsPlacementContract.test.js +16 -6
  44. package/test/shellGestureSupport.test.js +46 -0
  45. package/test/surfaceContext.test.js +53 -0
  46. package/test/useShellPullToRefresh.test.js +123 -0
@@ -134,3 +134,101 @@ test("shell bootstrap runtime refreshes /api/bootstrap on init and applies regis
134
134
  consoleowner: true
135
135
  });
136
136
  });
137
+
138
+ test("shell bootstrap runtime reports a failing payload handler and keeps refreshing afterwards", async () => {
139
+ const placementRuntime = createPlacementRuntime({});
140
+ const calls = [];
141
+ const handledErrors = [];
142
+ const reported = [];
143
+ let failNextApply = true;
144
+ let failNextResolve = false;
145
+ const handlers = createBootstrapPayloadHandlerRegistry();
146
+ handlers.register(Object.freeze({
147
+ handlerId: "test.bootstrap.flaky",
148
+ order: 0,
149
+ resolveBootstrapRequest() {
150
+ if (failNextResolve) {
151
+ failNextResolve = false;
152
+ throw new Error("resolve failed");
153
+ }
154
+ return {};
155
+ },
156
+ applyBootstrapPayload() {
157
+ if (failNextApply) {
158
+ failNextApply = false;
159
+ throw new Error("apply failed");
160
+ }
161
+ },
162
+ handleBootstrapError({ error, request }) {
163
+ handledErrors.push({ message: error.message, path: request.path });
164
+ }
165
+ }));
166
+
167
+ const runtime = createShellBootstrapRuntime({
168
+ handlers,
169
+ placementRuntime,
170
+ requestRecoveryRuntime: {
171
+ report(error, options) {
172
+ reported.push({ message: error.message, source: options.source });
173
+ }
174
+ },
175
+ logger: { warn() {}, info() {}, error() {}, debug() {} },
176
+ fetchImplementation: async (url) => {
177
+ calls.push(String(url || ""));
178
+ return {
179
+ ok: true,
180
+ async json() {
181
+ return { ok: true };
182
+ }
183
+ };
184
+ }
185
+ });
186
+
187
+ assert.equal(await runtime.refresh("manual"), null);
188
+ assert.deepEqual(handledErrors, [{ message: "apply failed", path: "/api/bootstrap" }]);
189
+ assert.deepEqual(reported, [{ message: "apply failed", source: "shell-web.bootstrap" }]);
190
+
191
+ failNextResolve = true;
192
+ assert.equal(await runtime.refresh("manual"), null);
193
+ assert.deepEqual(handledErrors[1], { message: "resolve failed", path: "/api/bootstrap" });
194
+
195
+ assert.deepEqual(await runtime.refresh("manual"), { ok: true });
196
+ assert.deepEqual(calls, ["/api/bootstrap", "/api/bootstrap"]);
197
+ });
198
+
199
+ test("shell bootstrap runtime queue recovers after a refresh rejects", async () => {
200
+ const handlers = createBootstrapPayloadHandlerRegistry();
201
+ let failErrorHandler = true;
202
+ handlers.register(Object.freeze({
203
+ handlerId: "test.bootstrap.error-handler",
204
+ order: 0,
205
+ applyBootstrapPayload() {},
206
+ handleBootstrapError() {
207
+ if (failErrorHandler) {
208
+ failErrorHandler = false;
209
+ throw new Error("error handler failed");
210
+ }
211
+ }
212
+ }));
213
+ let fail = true;
214
+ const runtime = createShellBootstrapRuntime({
215
+ handlers,
216
+ placementRuntime: createPlacementRuntime({}),
217
+ logger: { warn() {}, info() {}, error() {}, debug() {} },
218
+ fetchImplementation: async () => {
219
+ if (fail) {
220
+ fail = false;
221
+ return { ok: false, status: 503 };
222
+ }
223
+ return {
224
+ ok: true,
225
+ async json() {
226
+ return { recovered: true };
227
+ }
228
+ };
229
+ }
230
+ });
231
+
232
+ await assert.rejects(runtime.refresh("manual"), /error handler failed/);
233
+ assert.deepEqual(await runtime.refresh("manual"), { recovered: true });
234
+ });
@@ -6,6 +6,7 @@ import { compileScript, parse } from "@vue/compiler-sfc";
6
6
  import { createRenderer, defineComponent, h, nextTick } from "vue";
7
7
  import { createPinia } from "pinia";
8
8
  import { createErrorRuntime } from "../src/client/error/runtime.js";
9
+ import { SHELL_WEB_ERROR_RUNTIME_KEY } from "../src/client/error/inject.js";
9
10
  import { createDefaultMaterialErrorPresenters } from "../src/client/error/presenters.js";
10
11
  import { createErrorPresentationStore } from "../src/client/error/store.js";
11
12
  import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
@@ -48,7 +49,7 @@ function fixture(t) {
48
49
  const pinia = createPinia();
49
50
  app.use(pinia);
50
51
  useShellErrorPresentationStore(pinia).attachRuntimeStore(store);
51
- app.provide("jskit.shell-web.runtime.web-error.client", runtime);
52
+ app.provide(SHELL_WEB_ERROR_RUNTIME_KEY, runtime);
52
53
  for (const name of ["VSnackbar", "VDialog", "VCard", "VCardTitle", "VCardText", "VCardActions", "VSpacer", "VAlert", "VBtn"]) {
53
54
  app.component(name, defineComponent({ setup: (_props, { slots }) => () =>
54
55
  h(name, {}, [slots.default?.(), slots.actions?.()]) }));
@@ -231,3 +231,33 @@ test("error runtime throws when no presenter supports channel", () => {
231
231
  /No error presenter supports channel "dialog"/,
232
232
  );
233
233
  });
234
+
235
+ test("error runtime honours an explicit persist on any channel and defaults by channel otherwise", async () => {
236
+ const { createErrorPresentationStore } = await import("../src/client/error/store.js");
237
+ const { createDefaultMaterialErrorPresenters } = await import("../src/client/error/presenters.js");
238
+ const store = createErrorPresentationStore({ now: () => 1000 });
239
+ const runtime = createErrorRuntime({
240
+ presenters: createDefaultMaterialErrorPresenters({ store }),
241
+ moduleDefaultPresenterId: "material.snackbar"
242
+ });
243
+
244
+ const persistentSnackbar = runtime.report({ message: "Keep me", channel: "snackbar", persist: true });
245
+ const transientSnackbar = runtime.report({ message: "Drop me", channel: "snackbar" });
246
+ const defaultBanner = runtime.report({ message: "Offline", intent: "app-recoverable" });
247
+ const transientDialog = runtime.report({ message: "Heads up", channel: "dialog", persist: false });
248
+
249
+ assert.equal(persistentSnackbar.decision.persist, true);
250
+ assert.equal(transientSnackbar.decision.persist, false);
251
+ assert.equal(defaultBanner.decision.persist, true);
252
+ assert.equal(transientDialog.decision.persist, false);
253
+ const { channels } = store.getState();
254
+ assert.deepEqual(channels.snackbar.map((entry) => [entry.message, entry.persist]), [["Keep me", true], ["Drop me", false]]);
255
+ assert.deepEqual(channels.banner.map((entry) => [entry.message, entry.persist]), [["Offline", true]]);
256
+ assert.deepEqual(channels.dialog.map((entry) => [entry.message, entry.persist]), [["Heads up", false]]);
257
+ });
258
+
259
+ test("default error policy decides only channel and severity", () => {
260
+ const policy = createDefaultErrorPolicy({ defaultSeverity: "warning" });
261
+
262
+ assert.deepEqual(policy({ message: "Saved?", action: { label: "Retry" } }), { channel: "snackbar", severity: "warning" });
263
+ });
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { resolveShellLinkPath } from "../src/client/navigation/linkResolver.js";
3
+ import { createApp } from "vue";
4
+ import { resolveShellLinkPath, useShellLinkResolver } from "../src/client/navigation/linkResolver.js";
4
5
 
5
6
  function createPlacementContext() {
6
7
  return {
@@ -110,3 +111,24 @@ test("resolveShellLinkPath uses deterministic surface fallback when context is m
110
111
 
111
112
  assert.equal(to, "/admin/contacts/5");
112
113
  });
114
+
115
+ test("resolveShellLinkPath uses the first value of a repeated route param", () => {
116
+ const to = resolveShellLinkPath({
117
+ context: createPlacementContext(),
118
+ surface: "app",
119
+ params: {
120
+ workspaceSlug: [" acme ", "other"]
121
+ },
122
+ relativePath: "/projects"
123
+ });
124
+
125
+ assert.equal(to, "/w/acme/projects");
126
+ });
127
+
128
+ test("useShellLinkResolver exposes only surface-relative resolve", () => {
129
+ const app = createApp({ render: () => null });
130
+ const resolver = app.runWithContext(() => useShellLinkResolver({ surface: "console" }));
131
+
132
+ assert.deepEqual(Object.keys(resolver), ["resolve"]);
133
+ assert.equal(resolver.resolve("/reports"), "/console/reports");
134
+ });
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { resolveMenuLinkTarget } from "../src/client/support/menuLinkTarget.js";
4
+
5
+ function createPlacementContext() {
6
+ return {
7
+ surfaceConfig: {
8
+ defaultSurfaceId: "home",
9
+ enabledSurfaceIds: ["home", "app"],
10
+ surfacesById: {
11
+ home: {
12
+ id: "home",
13
+ pagesRoot: "home",
14
+ routeBase: "/home",
15
+ enabled: true,
16
+ requiresWorkspace: false
17
+ },
18
+ app: {
19
+ id: "app",
20
+ pagesRoot: "w/[workspaceSlug]",
21
+ routeBase: "/w/:workspaceSlug",
22
+ enabled: true,
23
+ requiresWorkspace: true
24
+ }
25
+ }
26
+ }
27
+ };
28
+ }
29
+
30
+ test("menu link target returns an explicit absolute target without resolving the target surface", () => {
31
+ assert.equal(
32
+ resolveMenuLinkTarget({
33
+ to: "/w/acme/x",
34
+ surface: "app",
35
+ currentSurfaceId: "home",
36
+ placementContext: createPlacementContext(),
37
+ routeParams: {}
38
+ }),
39
+ "/w/acme/x"
40
+ );
41
+ });
42
+
43
+ test("menu link target resolves suffixes for missing and relative targets", () => {
44
+ const placementContext = createPlacementContext();
45
+ assert.equal(
46
+ resolveMenuLinkTarget({
47
+ surface: "app",
48
+ currentSurfaceId: "home",
49
+ placementContext,
50
+ scopedSuffix: "/projects",
51
+ routeParams: { workspaceSlug: "acme" }
52
+ }),
53
+ "/w/acme/projects"
54
+ );
55
+ assert.equal(
56
+ resolveMenuLinkTarget({
57
+ to: "./settings",
58
+ currentSurfaceId: "home",
59
+ placementContext,
60
+ unscopedSuffix: "/settings"
61
+ }),
62
+ "/home/settings"
63
+ );
64
+ });
65
+
66
+ test("menu link target still requires surface params when it must build the path", () => {
67
+ assert.throws(
68
+ () => resolveMenuLinkTarget({
69
+ surface: "app",
70
+ currentSurfaceId: "home",
71
+ placementContext: createPlacementContext(),
72
+ scopedSuffix: "/projects",
73
+ routeParams: {}
74
+ }),
75
+ /Missing required surface route params/
76
+ );
77
+ });
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createRouteSwipeGesture } from "../src/client/support/routeSwipeGesture.js";
4
+
5
+ function createClock() {
6
+ const clock = { time: 0, now: () => clock.time };
7
+ return clock;
8
+ }
9
+
10
+ test("route swipe gesture navigates on a fast horizontal swipe", () => {
11
+ const clock = createClock();
12
+ const gesture = createRouteSwipeGesture({ now: clock.now });
13
+ gesture.start({ pointerId: 1, clientX: 200, clientY: 100 });
14
+ clock.time = 50;
15
+ assert.deepEqual(gesture.move({ pointerId: 1, clientX: 185, clientY: 102 }), { claim: true, offset: 0 });
16
+ clock.time = 100;
17
+ assert.deepEqual(gesture.move({ pointerId: 1, clientX: 150, clientY: 104 }), { claim: true, offset: 1 });
18
+ assert.equal(gesture.end({ pointerId: 1, clientX: 150, clientY: 104 }), 0);
19
+ });
20
+
21
+ test("route swipe gesture resolves the direction on pointer end", () => {
22
+ const clock = createClock();
23
+ const gesture = createRouteSwipeGesture({ now: clock.now });
24
+ gesture.start({ pointerId: 1, clientX: 100, clientY: 100 });
25
+ clock.time = 100;
26
+ assert.equal(gesture.end({ pointerId: 1, clientX: 140, clientY: 100 }), -1);
27
+ assert.equal(gesture.end({ pointerId: 1, clientX: 140, clientY: 100 }), 0);
28
+ });
29
+
30
+ test("route swipe gesture stays dropped after vertical drift even when the finger turns sideways", () => {
31
+ const clock = createClock();
32
+ const gesture = createRouteSwipeGesture({ now: clock.now });
33
+ gesture.start({ pointerId: 1, clientX: 200, clientY: 100 });
34
+ clock.time = 40;
35
+ assert.deepEqual(gesture.move({ pointerId: 1, clientX: 200, clientY: 140 }), { claim: false, offset: 0 });
36
+ clock.time = 120;
37
+ assert.deepEqual(gesture.move({ pointerId: 1, clientX: 140, clientY: 110 }), { claim: false, offset: 0 });
38
+ assert.equal(gesture.end({ pointerId: 1, clientX: 140, clientY: 110 }), 0);
39
+ });
40
+
41
+ test("route swipe gesture ignores other pointers and cancelled pointers", () => {
42
+ const clock = createClock();
43
+ const gesture = createRouteSwipeGesture({ now: clock.now });
44
+ gesture.start({ pointerId: 1, clientX: 200, clientY: 100 });
45
+ clock.time = 100;
46
+ assert.deepEqual(gesture.move({ pointerId: 2, clientX: 100, clientY: 100 }), { claim: false, offset: 0 });
47
+ gesture.cancel({ pointerId: 1 });
48
+ assert.equal(gesture.end({ pointerId: 1, clientX: 100, clientY: 100 }), 0);
49
+ });
@@ -83,10 +83,17 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
83
83
  assert.match(source, /target="shell-layout:supporting-side-panel"/);
84
84
  assert.match(source, /data-testid="jskit-shell-supporting-bottom-sheet"/);
85
85
  assert.match(source, /data-testid="jskit-shell-supporting-side-panel"/);
86
- assert.match(source, /inject\("jskit\.shell-web\.runtime\.web-refresh\.client", null\)/);
87
- assert.match(source, /window\.addEventListener\("pointerdown", handlePullPointerDown/);
88
- assert.match(source, /window\.addEventListener\("touchmove", handlePullTouchMove/);
89
- assert.match(source, /refreshRuntime\.refresh\("pull-to-refresh"\)/);
86
+ assert.match(source, /inject\(SHELL_WEB_REFRESH_RUNTIME_KEY, null\)/);
87
+ const refreshRuntimeSource = await readFile(path.join(PACKAGE_DIR, "src", "client", "runtime", "refreshRuntime.js"), "utf8");
88
+ assert.match(refreshRuntimeSource, /SHELL_WEB_REFRESH_RUNTIME_KEY = "jskit\.shell-web\.runtime\.web-refresh\.client"/);
89
+ assert.match(source, /useShellPullToRefresh\(\{ enabled: isCompactLayout, refreshRuntime \}\)/);
90
+ const pullToRefreshSource = await readFile(
91
+ path.join(PACKAGE_DIR, "src", "client", "composables", "useShellPullToRefresh.js"),
92
+ "utf8"
93
+ );
94
+ assert.match(pullToRefreshSource, /window\.addEventListener\("pointerdown", handlePullPointerDown/);
95
+ assert.match(pullToRefreshSource, /window\.addEventListener\("touchmove", handlePullTouchMove/);
96
+ assert.match(pullToRefreshSource, /refreshRuntime\.refresh\("pull-to-refresh"\)/);
90
97
  assert.match(source, /data-testid="jskit-shell-pull-refresh"/);
91
98
  assert.match(source, /target="shell-layout:primary-menu"[\s\S]*default/);
92
99
  assert.doesNotMatch(source, /target="shell-layout:primary-bottom-nav"[\s\S]*default/);
@@ -255,9 +262,10 @@ test("shell-web route transition keeps mobile route motion placement-driven", as
255
262
  assert.match(source, /swipeEnabled:\s*\{[\s\S]*default:\s*true/);
256
263
  assert.match(source, /window\.addEventListener\("pointerdown", handlePointerDown/);
257
264
  assert.match(source, /document\.documentElement\.classList\.toggle\("shell-route-swipe-enabled", enabled\)/);
258
- assert.match(source, /navigateBySwipe\(deltaX < 0 \? 1 : -1\)/);
265
+ assert.match(source, /createRouteSwipeGesture\(\)/);
266
+ assert.match(source, /const offset = swipeGesture\.end\(event\);[\s\S]*navigateBySwipe\(offset\)/);
259
267
  assert.match(source, /router\.push\(nextEntry\.href\)/);
260
- assert.match(source, /isSwipeIgnoredTarget/);
268
+ assert.match(source, /isShellGestureIgnoredTarget\(event\.target\)/);
261
269
  assert.match(source, /touch-action:\s*pan-y/);
262
270
  assert.match(source, /\.shell-route-transition\s*\{[\s\S]*display:\s*flex/);
263
271
  assert.match(source, /\.shell-route-transition\s*\{[\s\S]*min-height:\s*0/);
@@ -571,6 +579,8 @@ test("shell-web pattern home page relies on adaptive navigation instead of dead
571
579
  assert.match(source, /--home-surface-panel-padding/);
572
580
  assert.doesNotMatch(source, /generated-ui/u);
573
581
  assert.match(source, /Service health/);
582
+ assert.match(source, /<v-skeleton-loader/u);
583
+ assert.doesNotMatch(source, /loading\.\.\.|v-progress-circular/u);
574
584
  assert.match(source, /to="\/home\/settings\/general"/);
575
585
  assert.doesNotMatch(source, /Use bottom navigation|Replace this content|Main public surface/);
576
586
  assert.doesNotMatch(source, /\/console/);
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { isPrimaryTouchPointer, isShellGestureIgnoredTarget } from "../src/client/support/gestureTargets.js";
4
+ import { resolveShellLayoutClass } from "../src/client/support/shellLayoutClass.js";
5
+
6
+ function createTarget(matchingSelectors = []) {
7
+ return {
8
+ closest(selectorList) {
9
+ const selectors = selectorList.split(",");
10
+ return matchingSelectors.some((selector) => selectors.includes(selector)) ? {} : null;
11
+ }
12
+ };
13
+ }
14
+
15
+ test("shell layout class follows the display breakpoint, then the viewport width", () => {
16
+ assert.equal(resolveShellLayoutClass("xs", 1600), "compact");
17
+ assert.equal(resolveShellLayoutClass("SM"), "compact");
18
+ assert.equal(resolveShellLayoutClass("md", 300), "medium");
19
+ assert.equal(resolveShellLayoutClass("xxl", 300), "expanded");
20
+ assert.equal(resolveShellLayoutClass("", 599), "compact");
21
+ assert.equal(resolveShellLayoutClass(undefined, 600), "medium");
22
+ assert.equal(resolveShellLayoutClass("", 1280), "expanded");
23
+ assert.equal(resolveShellLayoutClass("", 0), "expanded");
24
+ });
25
+
26
+ test("shell gestures follow primary touch and pen pointers only", () => {
27
+ assert.equal(isPrimaryTouchPointer({ isPrimary: true, button: 0, pointerType: "touch" }), true);
28
+ assert.equal(isPrimaryTouchPointer({ button: 0, pointerType: "pen" }), true);
29
+ assert.equal(isPrimaryTouchPointer({ isPrimary: true, button: 0, pointerType: "mouse" }), false);
30
+ assert.equal(isPrimaryTouchPointer({ isPrimary: false, button: 0, pointerType: "touch" }), false);
31
+ assert.equal(isPrimaryTouchPointer({ isPrimary: true, button: 2, pointerType: "touch" }), false);
32
+ });
33
+
34
+ test("shell gestures skip interactive targets and honour extra ignore selectors", () => {
35
+ assert.equal(isShellGestureIgnoredTarget(createTarget(["button"])), true);
36
+ assert.equal(isShellGestureIgnoredTarget(createTarget(["[data-shell-swipe-ignore]"])), true);
37
+ assert.equal(isShellGestureIgnoredTarget(createTarget(["[data-shell-pull-refresh-ignore]"])), false);
38
+ assert.equal(
39
+ isShellGestureIgnoredTarget(createTarget(["[data-shell-pull-refresh-ignore]"]), {
40
+ extraSelectors: ["[data-shell-pull-refresh-ignore]"]
41
+ }),
42
+ true
43
+ );
44
+ assert.equal(isShellGestureIgnoredTarget(createTarget([])), false);
45
+ assert.equal(isShellGestureIgnoredTarget(null), false);
46
+ });
@@ -3,6 +3,7 @@ import test from "node:test";
3
3
  import {
4
4
  buildSurfaceConfigContext,
5
5
  readPlacementSurfaceConfig,
6
+ resolveAllowedOriginsFromPlacementContext,
6
7
  joinSurfacePath,
7
8
  resolveSurfaceIdFromPlacementPathname,
8
9
  resolveSurfaceRootPathFromPlacementContext,
@@ -182,3 +183,55 @@ test("resolveSurfaceNavigationTargetFromPlacementContext resolves cross-origin t
182
183
  external: false
183
184
  });
184
185
  });
186
+
187
+ test("resolveAllowedOriginsFromPlacementContext collects current and surface origins", () => {
188
+ const originalWindow = globalThis.window;
189
+ globalThis.window = {
190
+ location: {
191
+ origin: "https://current.example.com"
192
+ }
193
+ };
194
+
195
+ try {
196
+ const origins = resolveAllowedOriginsFromPlacementContext({
197
+ surfaceConfig: {
198
+ surfacesById: {
199
+ home: { origin: "https://home.example.com" },
200
+ app: { origin: "https://app.example.com" },
201
+ duplicate: { origin: "https://home.example.com" },
202
+ invalid: { origin: "mailto:hello@example.com" }
203
+ }
204
+ }
205
+ });
206
+
207
+ assert.deepEqual(origins, [
208
+ "https://current.example.com",
209
+ "https://home.example.com",
210
+ "https://app.example.com"
211
+ ]);
212
+ } finally {
213
+ if (typeof originalWindow === "undefined") {
214
+ delete globalThis.window;
215
+ } else {
216
+ globalThis.window = originalWindow;
217
+ }
218
+ }
219
+ });
220
+
221
+ test("resolveAllowedOriginsFromPlacementContext reads surfaces through the placement surface config", () => {
222
+ const originalWindow = globalThis.window;
223
+ delete globalThis.window;
224
+ try {
225
+ assert.deepEqual(resolveAllowedOriginsFromPlacementContext(null), []);
226
+ assert.deepEqual(
227
+ resolveAllowedOriginsFromPlacementContext({
228
+ surfaceConfig: { surfacesById: { Admin: { origin: "https://ADMIN.example.com/x" } } }
229
+ }),
230
+ ["https://admin.example.com"]
231
+ );
232
+ } finally {
233
+ if (typeof originalWindow !== "undefined") {
234
+ globalThis.window = originalWindow;
235
+ }
236
+ }
237
+ });
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createRenderer, defineComponent, nextTick, ref } from "vue";
4
+ import { useShellPullToRefresh } from "../src/client/composables/useShellPullToRefresh.js";
5
+
6
+ function node(type) {
7
+ return { type, props: {}, children: [], parent: null, text: "" };
8
+ }
9
+
10
+ const renderer = createRenderer({
11
+ createElement: node,
12
+ createText: () => node("text"),
13
+ createComment: () => node("comment"),
14
+ setText() {},
15
+ setElementText() {},
16
+ patchProp() {},
17
+ parentNode: (item) => item.parent,
18
+ nextSibling: () => null,
19
+ insert(item, parent) {
20
+ item.parent = parent;
21
+ parent.children.push(item);
22
+ },
23
+ remove(item) {
24
+ item.parent?.children.splice(item.parent.children.indexOf(item), 1);
25
+ }
26
+ });
27
+
28
+ function installWindow() {
29
+ const listeners = new Map();
30
+ globalThis.window = {
31
+ scrollY: 0,
32
+ addEventListener(type, handler) {
33
+ listeners.set(type, handler);
34
+ },
35
+ removeEventListener(type, handler) {
36
+ if (listeners.get(type) === handler) {
37
+ listeners.delete(type);
38
+ }
39
+ }
40
+ };
41
+ globalThis.document = { documentElement: { scrollTop: 0 }, body: { scrollTop: 0 } };
42
+ return {
43
+ listeners,
44
+ dispatch(type, event) {
45
+ listeners.get(type)?.({ preventDefault() {}, cancelable: true, target: { closest: () => null }, ...event });
46
+ }
47
+ };
48
+ }
49
+
50
+ function mountPull({ enabled = true } = {}) {
51
+ const page = installWindow();
52
+ const refreshes = [];
53
+ const refreshRuntime = {
54
+ async refresh(reason) {
55
+ refreshes.push(reason);
56
+ }
57
+ };
58
+ let pull = null;
59
+ const app = renderer.createApp(defineComponent({
60
+ setup() {
61
+ pull = useShellPullToRefresh({ enabled: ref(enabled), refreshRuntime });
62
+ return () => null;
63
+ }
64
+ }));
65
+ app.mount(node("root"));
66
+ return { app, page, pull, refreshes };
67
+ }
68
+
69
+ const touchPointer = { isPrimary: true, button: 0, pointerType: "touch", pointerId: 7 };
70
+
71
+ test("pull to refresh refreshes after a long enough pull and resets", async () => {
72
+ const { app, page, pull, refreshes } = mountPull();
73
+
74
+ page.dispatch("pointerdown", { ...touchPointer, clientX: 100, clientY: 10 });
75
+ page.dispatch("pointermove", { ...touchPointer, clientX: 102, clientY: 210 });
76
+ assert.equal(pull.pullDistance.value, 110);
77
+ assert.equal(pull.pullRefreshLabel.value, "Release to refresh");
78
+ assert.equal(pull.pullIndicatorVisible.value, true);
79
+
80
+ page.dispatch("pointerup", { ...touchPointer });
81
+ assert.deepEqual(refreshes, ["pull-to-refresh"]);
82
+ await nextTick();
83
+ await nextTick();
84
+ assert.equal(pull.pullRefreshing.value, false);
85
+ assert.equal(pull.pullDistance.value, 0);
86
+
87
+ app.unmount();
88
+ assert.equal(page.listeners.size, 0);
89
+ });
90
+
91
+ test("pull to refresh follows the touch after the browser cancels the pointer", () => {
92
+ const { app, page, pull, refreshes } = mountPull();
93
+
94
+ page.dispatch("pointerdown", { ...touchPointer, clientX: 100, clientY: 10 });
95
+ page.dispatch("pointercancel", { ...touchPointer });
96
+ page.dispatch("touchmove", { touches: [{ identifier: 1, clientX: 100, clientY: 60 }] });
97
+ assert.equal(pull.pullDistance.value, 28);
98
+ assert.equal(pull.pullRefreshLabel.value, "Pull to refresh");
99
+
100
+ page.dispatch("touchend", { changedTouches: [{ identifier: 1 }] });
101
+ assert.deepEqual(refreshes, []);
102
+ assert.equal(pull.pullDistance.value, 0);
103
+ app.unmount();
104
+ });
105
+
106
+ test("pull to refresh stays off outside the compact layout and on ignored targets", () => {
107
+ const wide = mountPull({ enabled: false });
108
+ wide.page.dispatch("pointerdown", { ...touchPointer, clientX: 100, clientY: 10 });
109
+ wide.page.dispatch("pointermove", { ...touchPointer, clientX: 100, clientY: 210 });
110
+ assert.equal(wide.pull.pullDistance.value, 0);
111
+ wide.app.unmount();
112
+
113
+ const compact = mountPull();
114
+ compact.page.dispatch("pointerdown", {
115
+ ...touchPointer,
116
+ clientX: 100,
117
+ clientY: 10,
118
+ target: { closest: (selectors) => (selectors.includes("[data-shell-pull-refresh-ignore]") ? {} : null) }
119
+ });
120
+ compact.page.dispatch("pointermove", { ...touchPointer, clientX: 100, clientY: 210 });
121
+ assert.equal(compact.pull.pullDistance.value, 0);
122
+ compact.app.unmount();
123
+ });