@jskit-ai/http-web 0.1.1

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 (110) hide show
  1. package/fixtures/crud-list-date-filters/App.vue +107 -0
  2. package/fixtures/crud-list-date-filters/index.html +12 -0
  3. package/fixtures/crud-list-date-filters/main.js +24 -0
  4. package/fixtures/crud-list-date-filters/vite.config.mjs +18 -0
  5. package/package.json +150 -0
  6. package/src/client/bulkActions.js +47 -0
  7. package/src/client/components/CrudAddEditScreen.vue +186 -0
  8. package/src/client/components/CrudDeleteAction.vue +93 -0
  9. package/src/client/components/CrudListBulkActionSurface.vue +126 -0
  10. package/src/client/components/CrudListDateFilterControl.vue +188 -0
  11. package/src/client/components/CrudListFilterSurface.vue +388 -0
  12. package/src/client/components/CrudListRecordActionMenu.vue +120 -0
  13. package/src/client/components/CrudListScreen.vue +501 -0
  14. package/src/client/components/CrudViewScreen.vue +197 -0
  15. package/src/client/composables/crud/crudBindingSupport.js +75 -0
  16. package/src/client/composables/crud/crudHttpClientSupport.js +57 -0
  17. package/src/client/composables/crud/crudJsonApiTransportSupport.js +145 -0
  18. package/src/client/composables/crud/crudLookupFieldLabelSupport.js +151 -0
  19. package/src/client/composables/crud/crudLookupFieldRuntime.js +251 -0
  20. package/src/client/composables/crud/crudSchemaFormHelpers.js +464 -0
  21. package/src/client/composables/internal/crudListFilterLookupSupport.js +161 -0
  22. package/src/client/composables/internal/crudListParentTitleSupport.js +171 -0
  23. package/src/client/composables/internal/useOperationScope.js +144 -0
  24. package/src/client/composables/records/useAddEdit.js +224 -0
  25. package/src/client/composables/records/useCrudAddEdit.js +227 -0
  26. package/src/client/composables/records/useCrudList.js +101 -0
  27. package/src/client/composables/records/useCrudView.js +47 -0
  28. package/src/client/composables/records/useList.js +500 -0
  29. package/src/client/composables/records/useView.js +164 -0
  30. package/src/client/composables/runtime/addEditUiRuntime.js +130 -0
  31. package/src/client/composables/runtime/listUiRuntime.js +123 -0
  32. package/src/client/composables/runtime/modelStateHelpers.js +90 -0
  33. package/src/client/composables/runtime/operationAdapters.js +34 -0
  34. package/src/client/composables/runtime/operationUiHelpers.js +125 -0
  35. package/src/client/composables/runtime/operationValidationHelpers.js +51 -0
  36. package/src/client/composables/runtime/useAddEditCore.js +112 -0
  37. package/src/client/composables/runtime/useCommandCore.js +123 -0
  38. package/src/client/composables/runtime/useEndpointResource.js +199 -0
  39. package/src/client/composables/runtime/useFieldErrorBag.js +61 -0
  40. package/src/client/composables/runtime/useListCore.js +112 -0
  41. package/src/client/composables/runtime/useUiFeedback.js +98 -0
  42. package/src/client/composables/runtime/useViewCore.js +93 -0
  43. package/src/client/composables/runtime/viewUiRuntime.js +96 -0
  44. package/src/client/composables/support/errorMessageHelpers.js +66 -0
  45. package/src/client/composables/support/listQueryParamSupport.js +473 -0
  46. package/src/client/composables/support/listSearchSupport.js +70 -0
  47. package/src/client/composables/support/refValueHelpers.js +19 -0
  48. package/src/client/composables/support/requestQueryRuntimeSupport.js +141 -0
  49. package/src/client/composables/support/resourceLoadStateHelpers.js +208 -0
  50. package/src/client/composables/support/routeTemplateHelpers.js +285 -0
  51. package/src/client/composables/support/scopeHelpers.js +145 -0
  52. package/src/client/composables/useAccess.js +109 -0
  53. package/src/client/composables/useCommand.js +118 -0
  54. package/src/client/composables/useCrudAddEditScreen.js +88 -0
  55. package/src/client/composables/useCrudDeleteAction.js +160 -0
  56. package/src/client/composables/useCrudListBulkActions.js +147 -0
  57. package/src/client/composables/useCrudListFilterLookups.js +146 -0
  58. package/src/client/composables/useCrudListFilters.js +339 -0
  59. package/src/client/composables/useCrudListParentTitle.js +151 -0
  60. package/src/client/composables/useCrudListRowActions.js +144 -0
  61. package/src/client/composables/useCrudListScreen.js +237 -0
  62. package/src/client/composables/useCrudViewScreen.js +77 -0
  63. package/src/client/composables/usePagedCollection.js +181 -0
  64. package/src/client/composables/useRealtimeQueryInvalidation.js +156 -0
  65. package/src/client/composables/useScopeRuntime.js +135 -0
  66. package/src/client/filters.js +15 -0
  67. package/src/client/index.js +24 -0
  68. package/src/client/lib/httpClient.js +47 -0
  69. package/src/client/lib/permissions.js +34 -0
  70. package/src/client/rowActions.js +47 -0
  71. package/src/client/support/contractGuards.js +34 -0
  72. package/src/client/support/crudListDateFilterSupport.js +91 -0
  73. package/test/addEditUiRuntime.test.js +110 -0
  74. package/test/crudBindingSupport.test.js +110 -0
  75. package/test/crudHttpClientSupport.test.js +77 -0
  76. package/test/crudJsonApiTransportSupport.test.js +179 -0
  77. package/test/crudListBulkActionSurface.test.js +27 -0
  78. package/test/crudListDateFilterSupport.test.js +92 -0
  79. package/test/crudListDateFilterSurface.browser.test.js +182 -0
  80. package/test/crudListFilterSurface.test.js +90 -0
  81. package/test/crudLookupFieldRuntime.test.js +264 -0
  82. package/test/crudScreenComponents.test.js +110 -0
  83. package/test/errorIntentContract.test.js +31 -0
  84. package/test/errorMessageHelpers.test.js +28 -0
  85. package/test/exportsContract.test.js +68 -0
  86. package/test/listQueryParamSupport.test.js +223 -0
  87. package/test/listUiRuntime.test.js +102 -0
  88. package/test/operationValidationHelpers.test.js +64 -0
  89. package/test/packageBoundary.test.js +31 -0
  90. package/test/permissions.test.js +35 -0
  91. package/test/refValueHelpers.test.js +14 -0
  92. package/test/requestTransportOptions.test.js +457 -0
  93. package/test/resourceLoadStateHelpers.test.js +189 -0
  94. package/test/routeTemplateHelpers.test.js +85 -0
  95. package/test/scopeHelpers.test.js +57 -0
  96. package/test/useAddEditCore.test.js +124 -0
  97. package/test/useAddEditRequestQueryParams.test.js +190 -0
  98. package/test/useCrudAddEdit.test.js +415 -0
  99. package/test/useCrudDeleteAction.test.js +139 -0
  100. package/test/useCrudListBulkActions.test.js +65 -0
  101. package/test/useCrudListFilterLookups.test.js +114 -0
  102. package/test/useCrudListFilters.test.js +293 -0
  103. package/test/useCrudListParentTitle.test.js +262 -0
  104. package/test/useCrudListRowActions.test.js +77 -0
  105. package/test/useListRouteInitialization.test.js +337 -0
  106. package/test/useListSearchSupport.test.js +61 -0
  107. package/test/usePagedCollection.test.js +53 -0
  108. package/test/useViewRequestQueryParams.test.js +20 -0
  109. package/test/viewCoreLoading.test.js +44 -0
  110. package/test/viewUiRuntime.test.js +95 -0
@@ -0,0 +1,457 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
4
+ import { createSSRApp, h } from "vue";
5
+ import { renderToString } from "vue/server-renderer";
6
+
7
+ import {
8
+ buildEndpointReadRequestOptions,
9
+ buildEndpointWriteRequestOptions,
10
+ useEndpointResource
11
+ } from "../src/client/composables/runtime/useEndpointResource.js";
12
+ import { useCommand } from "../src/client/composables/useCommand.js";
13
+ import {
14
+ configureHttpWebClient,
15
+ resetHttpWebClientForTests
16
+ } from "../src/client/lib/httpClient.js";
17
+ import { buildListRequestOptions } from "../src/client/composables/runtime/useListCore.js";
18
+ import {
19
+ resolveOperationRealtimeOptions
20
+ } from "../src/client/composables/useRealtimeQueryInvalidation.js";
21
+
22
+ test("endpoint read request options include transport only when provided", () => {
23
+ assert.deepEqual(
24
+ buildEndpointReadRequestOptions({
25
+ method: "get"
26
+ }),
27
+ {
28
+ method: "GET"
29
+ }
30
+ );
31
+
32
+ assert.deepEqual(
33
+ buildEndpointReadRequestOptions({
34
+ method: "get",
35
+ transport: {
36
+ kind: "jsonapi-resource",
37
+ responseType: "user-settings",
38
+ responseKind: "record"
39
+ }
40
+ }),
41
+ {
42
+ method: "GET",
43
+ transport: {
44
+ kind: "jsonapi-resource",
45
+ responseType: "user-settings",
46
+ responseKind: "record"
47
+ }
48
+ }
49
+ );
50
+ });
51
+
52
+ test("endpoint write request options preserve body/options and append transport", () => {
53
+ assert.deepEqual(
54
+ buildEndpointWriteRequestOptions({
55
+ method: "patch",
56
+ body: {
57
+ theme: "dark"
58
+ },
59
+ options: {
60
+ headers: {
61
+ "x-demo": "1"
62
+ }
63
+ },
64
+ transport: {
65
+ kind: "jsonapi-resource",
66
+ requestType: "user-preferences",
67
+ responseType: "user-settings",
68
+ responseKind: "record"
69
+ }
70
+ }),
71
+ {
72
+ method: "PATCH",
73
+ headers: {
74
+ "x-demo": "1"
75
+ },
76
+ body: {
77
+ theme: "dark"
78
+ },
79
+ transport: {
80
+ kind: "jsonapi-resource",
81
+ requestType: "user-preferences",
82
+ responseType: "user-settings",
83
+ responseKind: "record"
84
+ }
85
+ }
86
+ );
87
+ });
88
+
89
+ test("list request options stay GET and carry transport when provided", () => {
90
+ assert.deepEqual(
91
+ buildListRequestOptions({
92
+ requestOptions: {
93
+ headers: {
94
+ "x-demo": "1"
95
+ }
96
+ },
97
+ pageParam: "cursor_2",
98
+ transport: {
99
+ kind: "jsonapi-resource",
100
+ responseType: "contacts",
101
+ responseKind: "collection"
102
+ }
103
+ }),
104
+ {
105
+ method: "GET",
106
+ headers: {
107
+ "x-demo": "1"
108
+ },
109
+ query: {
110
+ limit: 20,
111
+ cursor: "cursor_2"
112
+ },
113
+ transport: {
114
+ kind: "jsonapi-resource",
115
+ responseType: "contacts",
116
+ responseKind: "collection"
117
+ }
118
+ }
119
+ );
120
+ });
121
+
122
+ test("list request options preserve explicit limit values", () => {
123
+ assert.deepEqual(
124
+ buildListRequestOptions({
125
+ requestOptions: {
126
+ query: {
127
+ limit: 50
128
+ }
129
+ }
130
+ }),
131
+ {
132
+ method: "GET",
133
+ query: {
134
+ limit: 50
135
+ }
136
+ }
137
+ );
138
+
139
+ assert.deepEqual(
140
+ buildListRequestOptions({
141
+ requestOptions: {
142
+ query: {
143
+ "page[limit]": "75"
144
+ }
145
+ }
146
+ }),
147
+ {
148
+ method: "GET",
149
+ query: {
150
+ "page[limit]": "75"
151
+ }
152
+ }
153
+ );
154
+ });
155
+
156
+ test("operation realtime options use fallback events unless explicitly overridden", () => {
157
+ const fallbackRealtime = {
158
+ events: ["contacts.record.changed"]
159
+ };
160
+
161
+ assert.deepEqual(
162
+ resolveOperationRealtimeOptions({
163
+ fallbackRealtime
164
+ }),
165
+ {
166
+ events: ["contacts.record.changed"]
167
+ }
168
+ );
169
+
170
+ assert.deepEqual(
171
+ resolveOperationRealtimeOptions({
172
+ realtime: {
173
+ enabled: false
174
+ },
175
+ fallbackRealtime
176
+ }),
177
+ {
178
+ events: ["contacts.record.changed"],
179
+ enabled: false
180
+ }
181
+ );
182
+
183
+ assert.deepEqual(
184
+ resolveOperationRealtimeOptions({
185
+ realtime: {
186
+ event: "contacts.custom.changed"
187
+ },
188
+ fallbackRealtime
189
+ }),
190
+ {
191
+ event: "contacts.custom.changed"
192
+ }
193
+ );
194
+
195
+ assert.equal(
196
+ resolveOperationRealtimeOptions({
197
+ realtime: false,
198
+ fallbackRealtime
199
+ }),
200
+ null
201
+ );
202
+ });
203
+
204
+ test("endpoint resources invalidate their query key from configured realtime events", async () => {
205
+ const queryClient = new QueryClient();
206
+ const invalidations = [];
207
+ const socketHandlers = new Map();
208
+ const socket = {
209
+ on(eventName, handler) {
210
+ socketHandlers.set(eventName, handler);
211
+ },
212
+ off(eventName, handler) {
213
+ if (socketHandlers.get(eventName) === handler) {
214
+ socketHandlers.delete(eventName);
215
+ }
216
+ }
217
+ };
218
+ queryClient.invalidateQueries = async (options = {}) => {
219
+ invalidations.push(options);
220
+ };
221
+ const app = createSSRApp({
222
+ setup() {
223
+ useEndpointResource({
224
+ queryKey: ["today-workout-detail", "/api/today/workouts/2026-05-06"],
225
+ path: "/api/today/workouts/2026-05-06",
226
+ client: {
227
+ async request() {
228
+ return {};
229
+ }
230
+ },
231
+ realtime: {
232
+ event: "workout_set_logs.record.changed"
233
+ }
234
+ });
235
+ return () => h("div");
236
+ }
237
+ });
238
+ app.use(VueQueryPlugin, {
239
+ queryClient
240
+ });
241
+ app.provide("jskit.realtime.runtime.client.socket", socket);
242
+ await renderToString(app);
243
+
244
+ const handler = socketHandlers.get("workout_set_logs.record.changed");
245
+ assert.equal(typeof handler, "function");
246
+ handler({
247
+ entityId: "42"
248
+ });
249
+ await new Promise((resolve) => setImmediate(resolve));
250
+
251
+ assert.deepEqual(invalidations, [
252
+ {
253
+ queryKey: ["today-workout-detail", "/api/today/workouts/2026-05-06"]
254
+ }
255
+ ]);
256
+ });
257
+
258
+ test("endpoint resource reads attach request recovery metadata to query options", async () => {
259
+ const queryClient = new QueryClient();
260
+ const queryKey = ["project-access", "beepollen"];
261
+ const app = createSSRApp({
262
+ setup() {
263
+ useEndpointResource({
264
+ queryKey,
265
+ path: "/api/project-access",
266
+ client: {
267
+ async request() {
268
+ return {};
269
+ }
270
+ },
271
+ requestRecovery: {
272
+ label: "Project access",
273
+ source: "project-access.panel",
274
+ dedupeKey: "project-access"
275
+ }
276
+ });
277
+ return () => h("div");
278
+ }
279
+ });
280
+ app.use(VueQueryPlugin, {
281
+ queryClient
282
+ });
283
+ await renderToString(app);
284
+
285
+ const query = queryClient.getQueryCache().find({
286
+ queryKey
287
+ });
288
+ assert.deepEqual(query?.meta?.jskit, {
289
+ requestRecoveryLabel: "Project access",
290
+ requestRecoverySource: "project-access.panel",
291
+ requestRecoveryDedupeKey: "project-access",
292
+ requestRecoveryMethod: "GET"
293
+ });
294
+ });
295
+
296
+ test("endpoint resources use the configured http-web HTTP client by default", async () => {
297
+ const queryClient = new QueryClient();
298
+ const calls = [];
299
+ let resource = null;
300
+ configureHttpWebClient({
301
+ csrf: {
302
+ enabled: false
303
+ },
304
+ resolveRequestUrl(url) {
305
+ return url.replace(/^\/api\//u, "/api/app/beepollen/");
306
+ },
307
+ fetchImpl: async (url, options) => {
308
+ calls.push([url, options]);
309
+ return {
310
+ ok: true,
311
+ status: 200,
312
+ headers: {
313
+ get(name) {
314
+ return String(name || "").toLowerCase() === "content-type"
315
+ ? "application/json; charset=utf-8"
316
+ : "";
317
+ }
318
+ },
319
+ async json() {
320
+ return {
321
+ ok: true
322
+ };
323
+ }
324
+ };
325
+ }
326
+ });
327
+
328
+ try {
329
+ const app = createSSRApp({
330
+ setup() {
331
+ resource = useEndpointResource({
332
+ queryKey: ["vibe64-sessions"],
333
+ path: "/api/vibe64/sessions"
334
+ });
335
+ return () => h("div");
336
+ }
337
+ });
338
+ app.use(VueQueryPlugin, {
339
+ queryClient
340
+ });
341
+ await renderToString(app);
342
+
343
+ await resource.reload();
344
+
345
+ assert.equal(calls.at(-1)?.[0], "/api/app/beepollen/vibe64/sessions");
346
+ } finally {
347
+ resetHttpWebClientForTests();
348
+ }
349
+ });
350
+
351
+ test("commands use the configured http-web HTTP client by default", async () => {
352
+ const queryClient = new QueryClient();
353
+ const calls = [];
354
+ let command = null;
355
+ configureHttpWebClient({
356
+ csrf: {
357
+ enabled: false
358
+ },
359
+ resolveRequestUrl(url) {
360
+ return url.replace(/^\/api\//u, "/api/app/beepollen/");
361
+ },
362
+ fetchImpl: async (url, options) => {
363
+ calls.push([url, options]);
364
+ return {
365
+ ok: true,
366
+ status: 200,
367
+ headers: {
368
+ get(name) {
369
+ return String(name || "").toLowerCase() === "content-type"
370
+ ? "application/json; charset=utf-8"
371
+ : "";
372
+ }
373
+ },
374
+ async json() {
375
+ return {
376
+ ok: true
377
+ };
378
+ }
379
+ };
380
+ }
381
+ });
382
+
383
+ try {
384
+ const app = createSSRApp({
385
+ setup() {
386
+ command = useCommand({
387
+ access: "always",
388
+ apiSuffix: "/vibe64/sessions"
389
+ });
390
+ return () => h("div");
391
+ }
392
+ });
393
+ app.use(VueQueryPlugin, {
394
+ queryClient
395
+ });
396
+ await renderToString(app);
397
+
398
+ await command.run();
399
+
400
+ assert.equal(calls.at(-1)?.[0], "/api/app/beepollen/vibe64/sessions");
401
+ assert.equal(calls.at(-1)?.[1]?.method, "POST");
402
+ } finally {
403
+ resetHttpWebClientForTests();
404
+ }
405
+ });
406
+
407
+ test("commands use public CRUD resource access without a CSRF session preflight", async () => {
408
+ const queryClient = new QueryClient();
409
+ const calls = [];
410
+ let command = null;
411
+ configureHttpWebClient({
412
+ fetchImpl: async (url, options) => {
413
+ calls.push([url, options]);
414
+ return {
415
+ ok: true,
416
+ status: 204,
417
+ headers: {
418
+ get() {
419
+ return "";
420
+ }
421
+ },
422
+ async json() {
423
+ return {};
424
+ }
425
+ };
426
+ }
427
+ });
428
+
429
+ try {
430
+ const app = createSSRApp({
431
+ setup() {
432
+ command = useCommand({
433
+ access: "always",
434
+ apiSuffix: "/books/1",
435
+ writeMethod: "DELETE",
436
+ resource: {
437
+ apiAccess: "public"
438
+ }
439
+ });
440
+ return () => h("div");
441
+ }
442
+ });
443
+ app.use(VueQueryPlugin, {
444
+ queryClient
445
+ });
446
+ await renderToString(app);
447
+
448
+ await command.run();
449
+
450
+ assert.equal(calls.length, 1);
451
+ assert.equal(calls[0][0], "/api/books/1");
452
+ assert.equal(calls[0][1].method, "DELETE");
453
+ assert.equal(Object.hasOwn(calls[0][1], "csrf"), false);
454
+ } finally {
455
+ resetHttpWebClientForTests();
456
+ }
457
+ });
@@ -0,0 +1,189 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ref } from "vue";
4
+ import {
5
+ buildRequestRecoveryMeta,
6
+ hasResolvedQueryData,
7
+ mergeQueryMeta,
8
+ mergeRequestRecoveryQueryMeta
9
+ } from "../src/client/composables/support/resourceLoadStateHelpers.js";
10
+
11
+ test("hasResolvedQueryData returns true when the query succeeded", () => {
12
+ const query = {
13
+ isSuccess: ref(true)
14
+ };
15
+
16
+ assert.equal(hasResolvedQueryData({
17
+ query,
18
+ data: ref(null)
19
+ }), true);
20
+ });
21
+
22
+ test("hasResolvedQueryData returns true when data payload is available", () => {
23
+ const query = {
24
+ isSuccess: ref(false)
25
+ };
26
+
27
+ assert.equal(hasResolvedQueryData({
28
+ query,
29
+ data: ref({
30
+ id: 2971
31
+ })
32
+ }), true);
33
+ });
34
+
35
+ test("hasResolvedQueryData returns false when query and payload are unresolved", () => {
36
+ const query = {
37
+ isSuccess: ref(false)
38
+ };
39
+
40
+ assert.equal(hasResolvedQueryData({
41
+ query,
42
+ data: ref(null)
43
+ }), false);
44
+ });
45
+
46
+ test("mergeQueryMeta preserves caller metadata while adding JSKIT refresh policy", () => {
47
+ assert.deepEqual(
48
+ mergeQueryMeta(
49
+ {
50
+ staleTime: 1000,
51
+ meta: {
52
+ owner: "contacts",
53
+ jskit: {
54
+ feature: "list"
55
+ }
56
+ }
57
+ },
58
+ {
59
+ jskit: {
60
+ refreshOnPull: true
61
+ }
62
+ }
63
+ ),
64
+ {
65
+ staleTime: 1000,
66
+ meta: {
67
+ owner: "contacts",
68
+ jskit: {
69
+ feature: "list",
70
+ refreshOnPull: true
71
+ }
72
+ }
73
+ }
74
+ );
75
+ });
76
+
77
+ test("request recovery query metadata supports labels and disabling recovery", () => {
78
+ assert.deepEqual(
79
+ buildRequestRecoveryMeta("Project access"),
80
+ {
81
+ jskit: {
82
+ requestRecoveryLabel: "Project access"
83
+ }
84
+ }
85
+ );
86
+
87
+ assert.deepEqual(
88
+ buildRequestRecoveryMeta(false),
89
+ {
90
+ jskit: {
91
+ requestRecovery: false
92
+ }
93
+ }
94
+ );
95
+ });
96
+
97
+ test("mergeRequestRecoveryQueryMeta preserves caller metadata while adding recovery hints", () => {
98
+ assert.deepEqual(
99
+ mergeRequestRecoveryQueryMeta(
100
+ {
101
+ staleTime: 5000,
102
+ meta: {
103
+ owner: "project-access",
104
+ jskit: {
105
+ refreshOnPull: true
106
+ }
107
+ }
108
+ },
109
+ {
110
+ label: "Project access",
111
+ source: "app.project-access",
112
+ dedupeKey: "project-access",
113
+ method: "get",
114
+ dedupeWindowMs: 100
115
+ }
116
+ ),
117
+ {
118
+ staleTime: 5000,
119
+ meta: {
120
+ owner: "project-access",
121
+ jskit: {
122
+ refreshOnPull: true,
123
+ requestRecoveryLabel: "Project access",
124
+ requestRecoverySource: "app.project-access",
125
+ requestRecoveryDedupeKey: "project-access",
126
+ requestRecoveryMethod: "GET",
127
+ requestRecoveryDedupeWindowMs: 100
128
+ }
129
+ }
130
+ }
131
+ );
132
+ });
133
+
134
+ test("mergeRequestRecoveryQueryMeta does not overwrite explicit query recovery labels with defaults", () => {
135
+ assert.deepEqual(
136
+ mergeRequestRecoveryQueryMeta(
137
+ {
138
+ meta: {
139
+ jskit: {
140
+ requestRecoveryLabel: "Explicit label",
141
+ requestRecoveryMethod: "HEAD",
142
+ requestRecoveryDedupeWindowMs: 0
143
+ }
144
+ }
145
+ },
146
+ null,
147
+ {
148
+ label: "Default label",
149
+ method: "GET",
150
+ dedupeWindowMs: 5000
151
+ }
152
+ ),
153
+ {
154
+ meta: {
155
+ jskit: {
156
+ requestRecoveryLabel: "Explicit label",
157
+ requestRecoveryMethod: "HEAD",
158
+ requestRecoveryDedupeWindowMs: 0
159
+ }
160
+ }
161
+ }
162
+ );
163
+ });
164
+
165
+ test("mergeRequestRecoveryQueryMeta preserves disabled recovery metadata", () => {
166
+ assert.deepEqual(
167
+ mergeRequestRecoveryQueryMeta(
168
+ {
169
+ meta: {
170
+ jskit: {
171
+ requestRecovery: false
172
+ }
173
+ }
174
+ },
175
+ null,
176
+ {
177
+ label: "Default label",
178
+ method: "GET"
179
+ }
180
+ ),
181
+ {
182
+ meta: {
183
+ jskit: {
184
+ requestRecovery: false
185
+ }
186
+ }
187
+ }
188
+ );
189
+ });
@@ -0,0 +1,85 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ extractRouteParamNames,
5
+ resolveScopedRoutePathname,
6
+ resolveRouteParamNamesInOrder
7
+ } from "../src/client/composables/support/routeTemplateHelpers.js";
8
+
9
+ test("extractRouteParamNames reads dynamic params from route templates", () => {
10
+ assert.deepEqual(
11
+ extractRouteParamNames("/w/:workspaceSlug/admin/contacts/:contactId/addresses/:addressId"),
12
+ ["workspaceSlug", "contactId", "addressId"]
13
+ );
14
+ });
15
+
16
+ test("resolveRouteParamNamesInOrder prefers matched route templates", () => {
17
+ const route = {
18
+ matched: [
19
+ { path: "/w/:workspaceSlug/admin" },
20
+ { path: "/contacts/:contactId/addresses/:addressId" }
21
+ ],
22
+ params: {
23
+ workspaceSlug: "acme",
24
+ contactId: "7",
25
+ addressId: "42"
26
+ }
27
+ };
28
+
29
+ assert.deepEqual(resolveRouteParamNamesInOrder(route), ["workspaceSlug", "contactId", "addressId"]);
30
+ });
31
+
32
+ test("resolveScopedRoutePathname supports at/before/after anchors", () => {
33
+ const currentPathname = "/w/dogandgroom/admin/contacts/541841/pets/715528/edit/advanced";
34
+ const params = {
35
+ workspaceSlug: "dogandgroom",
36
+ contactId: "541841",
37
+ petId: "715528"
38
+ };
39
+ const orderedParamNames = ["workspaceSlug", "contactId", "petId"];
40
+
41
+ assert.equal(
42
+ resolveScopedRoutePathname({
43
+ currentPathname,
44
+ params,
45
+ orderedParamNames,
46
+ anchorParamName: "contactId",
47
+ anchorMode: "at"
48
+ }),
49
+ "/w/dogandgroom/admin/contacts/541841"
50
+ );
51
+ assert.equal(
52
+ resolveScopedRoutePathname({
53
+ currentPathname,
54
+ params,
55
+ orderedParamNames,
56
+ anchorParamName: "petId",
57
+ anchorMode: "before"
58
+ }),
59
+ "/w/dogandgroom/admin/contacts/541841/pets"
60
+ );
61
+ assert.equal(
62
+ resolveScopedRoutePathname({
63
+ currentPathname,
64
+ params,
65
+ orderedParamNames,
66
+ anchorParamName: "petId",
67
+ anchorMode: "after"
68
+ }),
69
+ "/w/dogandgroom/admin/contacts/541841/pets/715528/edit"
70
+ );
71
+ });
72
+
73
+ test("resolveScopedRoutePathname falls back to direct value matching", () => {
74
+ assert.equal(
75
+ resolveScopedRoutePathname({
76
+ currentPathname: "/contacts/abc%20123/notes",
77
+ params: {},
78
+ orderedParamNames: [],
79
+ anchorParamName: "contactId",
80
+ anchorParamValue: "abc 123",
81
+ anchorMode: "at"
82
+ }),
83
+ "/contacts/abc%20123"
84
+ );
85
+ });