@excom/kit-utils 0.1.0

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.
@@ -0,0 +1,471 @@
1
+ import { Queue, QueueManager } from "../../queue-manager";
2
+ import {
3
+ describe,
4
+ expect,
5
+ it,
6
+ vi,
7
+ } from "@excom/heft-rig/node_modules/vitest";
8
+
9
+ const doNotCall = vi.fn(() => {
10
+ // must not run
11
+ throw new Error("This should not be called");
12
+ });
13
+
14
+ /* Settling is driven by hand rather than by a timer: `settle()` reacts in
15
+ * microtasks, so nothing here depends on wall-clock time. */
16
+ const deferred = <T = unknown>() => {
17
+ let resolve!: (value: T) => void;
18
+ let reject!: (reason: unknown) => void;
19
+ const promise = new Promise<T>((res, rej) => {
20
+ resolve = res;
21
+ reject = rej;
22
+ });
23
+ return { promise, resolve, reject };
24
+ };
25
+
26
+ /* `settle()` hops `.then` → `.catch`, so a rejection needs two turns. */
27
+ const flush = async () => {
28
+ for (let i = 0; i < 4; i++) await Promise.resolve();
29
+ };
30
+
31
+ describe("queue-manager", () => {
32
+ it("QueueManager", () => {
33
+ const queueManager = new QueueManager();
34
+ expect(queueManager).toBeDefined();
35
+
36
+ // createQueue
37
+ const queue = queueManager.createQueue("testQueue2", {
38
+ initialValue: "initial",
39
+ });
40
+ expect(queue.state.status).toBe("pending");
41
+ expect(queue.state.value).toBe("initial");
42
+ expect(queue.config.name).toBe("testQueue2");
43
+ expect(queue.config.initialValue).toBe("initial");
44
+
45
+ // getQueue (new queue)
46
+ expect(queueManager.getQueue("testQueue")).toBeDefined();
47
+ expect(queueManager.getQueue("testQueue").state.status).toBe("pending");
48
+ expect(queueManager.getQueue("testQueue").state.value).toBeUndefined();
49
+ expect(queueManager.getQueue("testQueue").config.name).toBe("testQueue");
50
+ expect(
51
+ queueManager.getQueue("testQueue").config.initialValue
52
+ ).toBeUndefined();
53
+
54
+ // getQueue (existing queue)
55
+ const existingQueue = queueManager.getQueue("testQueue2");
56
+ expect(existingQueue).toBeDefined();
57
+ expect(existingQueue.state.status).toBe("pending");
58
+ expect(existingQueue.state.value).toBe("initial");
59
+ expect(existingQueue.config.name).toBe("testQueue2");
60
+ expect(existingQueue.config.initialValue).toBe("initial");
61
+
62
+ // deleteQueue
63
+ expect(queueManager.deleteQueue("testQueue2")).toBe(true);
64
+ expect(queueManager.queues["testQueue2"]).toBeUndefined();
65
+ });
66
+ it("Queue happypath", async () => {
67
+ const queueManager = new QueueManager();
68
+ const queue = queueManager.getQueue("testQueue", { initialValue: "foo" });
69
+
70
+ // starts pending
71
+ expect(queue.state.status).toBe("pending");
72
+ expect(queue.state.value).toBe("foo");
73
+ expect(queue.config.name).toBe("testQueue");
74
+ expect(queue.config.initialValue).toBe("foo");
75
+
76
+ const resolveCb = vi.fn(() => {});
77
+
78
+ queue.onResolved(resolveCb);
79
+ queue.onRejected(doNotCall);
80
+ queue.onCanceled(doNotCall);
81
+
82
+ queue.resolve("success");
83
+ expect(resolveCb).toHaveBeenCalledTimes(1);
84
+ expect(resolveCb).toHaveBeenCalledWith(
85
+ {
86
+ status: "resolved",
87
+ value: "success",
88
+ },
89
+ expect.anything()
90
+ );
91
+ expect(queue.state.status).toBe("resolved");
92
+ expect(queue.state.value).toBe("success");
93
+
94
+ // already resolved: later pending cbs do not run
95
+ queue.reject(new Error("fail"));
96
+ queue.cancel(new Error("canceled"));
97
+
98
+ // register while already in that state: run now
99
+ const newResolvedCallback = vi.fn(() => {});
100
+ queue.onResolved(newResolvedCallback);
101
+ expect(newResolvedCallback).toHaveBeenCalledTimes(1);
102
+ expect(newResolvedCallback).toHaveBeenCalledWith(
103
+ {
104
+ status: "resolved",
105
+ value: "success",
106
+ },
107
+ {
108
+ name: "testQueue",
109
+ initialValue: "foo",
110
+ }
111
+ );
112
+ queue.offResolved(newResolvedCallback);
113
+
114
+ // reset
115
+ queue.reset();
116
+ queue.offResolved(resolveCb);
117
+ queue.offRejected(doNotCall);
118
+ queue.offCanceled(doNotCall);
119
+ expect(queue.state.status).toBe("pending");
120
+
121
+ // reject
122
+ const rejectCb = vi.fn(() => {});
123
+ queue.onResolved(doNotCall);
124
+ queue.onCanceled(doNotCall);
125
+ queue.onRejected(rejectCb);
126
+
127
+ queue.reject(new Error("fail"));
128
+ expect(rejectCb).toHaveBeenCalledTimes(1);
129
+ expect(rejectCb).toHaveBeenCalledWith(
130
+ {
131
+ status: "rejected",
132
+ value: new Error("fail"),
133
+ },
134
+ expect.anything()
135
+ );
136
+ expect(queue.state.status).toBe("rejected");
137
+ expect(queue.state.value.message).toBe("fail");
138
+ queue.offRejected(rejectCb);
139
+ queue.offResolved(doNotCall);
140
+ queue.offCanceled(doNotCall);
141
+
142
+ // settle promise (resolve)
143
+ queue.reset();
144
+ const resolveCb2 = vi.fn(() => {});
145
+ queue.onResolved(resolveCb2);
146
+ queue.onRejected(doNotCall);
147
+ queue.onCanceled(doNotCall);
148
+ const succeeding = deferred<string>();
149
+ queue.settle(succeeding.promise);
150
+ expect(resolveCb2).toHaveBeenCalledTimes(0);
151
+ succeeding.resolve("final success");
152
+ await flush();
153
+ expect(resolveCb2).toHaveBeenCalledWith(
154
+ {
155
+ status: "resolved",
156
+ value: "final success",
157
+ },
158
+ expect.anything()
159
+ );
160
+ expect(queue.state.status).toBe("resolved");
161
+ expect(queue.state.value).toBe("final success");
162
+ queue.offResolved(resolveCb2);
163
+ queue.offRejected(doNotCall);
164
+ queue.offCanceled(doNotCall);
165
+
166
+ // settle promise (reject)
167
+ queue.reset();
168
+ const rejectCb2 = vi.fn(() => {});
169
+ queue.onResolved(doNotCall);
170
+ queue.onCanceled(doNotCall);
171
+ queue.onRejected(rejectCb2);
172
+ const failing = deferred<string>();
173
+ queue.settle(failing.promise);
174
+ expect(rejectCb2).toHaveBeenCalledTimes(0);
175
+ failing.reject(new Error("final fail"));
176
+ await flush();
177
+ expect(rejectCb2).toHaveBeenCalledWith(
178
+ {
179
+ status: "rejected",
180
+ value: new Error("final fail"),
181
+ },
182
+ expect.anything()
183
+ );
184
+ expect(queue.state.status).toBe("rejected");
185
+ expect(queue.state.value.message).toBe("final fail");
186
+ queue.offRejected(rejectCb2);
187
+ queue.offResolved(doNotCall);
188
+ queue.offCanceled(doNotCall);
189
+
190
+ // settle promise (cancel)
191
+ queue.reset();
192
+ const cancelCb2 = vi.fn(() => {});
193
+ queue.onResolved(doNotCall);
194
+ queue.onRejected(doNotCall);
195
+ queue.onCanceled(cancelCb2);
196
+ const abandoned = deferred<string>();
197
+ queue.settle(abandoned.promise);
198
+ expect(cancelCb2).toHaveBeenCalledTimes(0);
199
+ queue.cancel(new Error("canceled"));
200
+ expect(cancelCb2).toHaveBeenCalledTimes(1);
201
+ // the abandoned promise settles after the cancel and must not win
202
+ abandoned.resolve("actually not");
203
+ await flush();
204
+ expect(cancelCb2).toHaveBeenCalledWith(
205
+ {
206
+ status: "canceled",
207
+ value: new Error("canceled"),
208
+ },
209
+ expect.anything()
210
+ );
211
+ expect(queue.state.status).toBe("canceled");
212
+ expect(queue.state.value.message).toBe("canceled");
213
+ queue.offCanceled(cancelCb2);
214
+ queue.offResolved(doNotCall);
215
+ queue.offRejected(doNotCall);
216
+
217
+ // `once()`
218
+ queue.reset();
219
+ const onceResolvedCb = vi.fn(() => {});
220
+ queue.onceResolved(onceResolvedCb);
221
+ queue.resolve("once success");
222
+ expect(onceResolvedCb).toHaveBeenCalledTimes(1);
223
+ expect(onceResolvedCb).toHaveBeenCalledWith(
224
+ {
225
+ status: "resolved",
226
+ value: "once success",
227
+ },
228
+ expect.anything()
229
+ );
230
+ expect(queue.state.status).toBe("resolved");
231
+ expect(queue.state.value).toBe("once success");
232
+ queue.reset();
233
+ queue.resolve("should not call again");
234
+ expect(onceResolvedCb).toHaveBeenCalledTimes(1);
235
+ queue.offResolved(onceResolvedCb);
236
+
237
+ // cancel
238
+ queue.reset();
239
+ const cancelCb = vi.fn(() => {});
240
+ queue.onResolved(doNotCall);
241
+ queue.onRejected(doNotCall);
242
+ queue.onCanceled(cancelCb);
243
+ queue.cancel(new Error("canceled"));
244
+ expect(cancelCb).toHaveBeenCalledTimes(1);
245
+ expect(cancelCb).toHaveBeenCalledWith(
246
+ {
247
+ status: "canceled",
248
+ value: new Error("canceled"),
249
+ },
250
+ expect.anything()
251
+ );
252
+ expect(queue.state.status).toBe("canceled");
253
+ expect(queue.state.value.message).toBe("canceled");
254
+ queue.offCanceled(cancelCb);
255
+ queue.offResolved(doNotCall);
256
+ queue.offRejected(doNotCall);
257
+ });
258
+
259
+ it("Queue pending callbacks fire on init and reset, once() only once", () => {
260
+ const queue = new Queue({ initialValue: "start" });
261
+ const pendingCb = vi.fn(() => {});
262
+ queue.onPending(pendingCb);
263
+ // already pending: invoked immediately
264
+ expect(pendingCb).toHaveBeenCalledTimes(1);
265
+ expect(pendingCb).toHaveBeenCalledWith(
266
+ { status: "pending", value: "start" },
267
+ { name: undefined, initialValue: "start" }
268
+ );
269
+ queue.resolve("done");
270
+ queue.reset();
271
+ expect(pendingCb).toHaveBeenCalledTimes(2);
272
+ queue.offPending(pendingCb);
273
+ queue.resolve("done");
274
+ queue.reset({ initialValue: "again" });
275
+ expect(pendingCb).toHaveBeenCalledTimes(2);
276
+
277
+ const oncePending = vi.fn(() => {});
278
+ queue.oncePending(oncePending);
279
+ expect(oncePending).toHaveBeenCalledTimes(1);
280
+ expect(oncePending).toHaveBeenCalledWith(
281
+ { status: "pending", value: "again" },
282
+ expect.anything()
283
+ );
284
+ queue.resolve("x");
285
+ queue.reset();
286
+ expect(oncePending).toHaveBeenCalledTimes(1);
287
+ });
288
+
289
+ it("Queue once callbacks for rejected, canceled and settling", () => {
290
+ const queue = new Queue();
291
+ const onceRejected = vi.fn(() => {});
292
+ queue.onceRejected(onceRejected);
293
+ queue.reject("bad");
294
+ queue.reset();
295
+ queue.reject("bad again");
296
+ expect(onceRejected).toHaveBeenCalledTimes(1);
297
+ expect(onceRejected).toHaveBeenCalledWith(
298
+ { status: "rejected", value: "bad" },
299
+ expect.anything()
300
+ );
301
+
302
+ queue.reset();
303
+ const onceCanceled = vi.fn(() => {});
304
+ queue.onceCanceled(onceCanceled);
305
+ queue.cancel("stop");
306
+ queue.reset();
307
+ queue.cancel("stop again");
308
+ expect(onceCanceled).toHaveBeenCalledTimes(1);
309
+ expect(onceCanceled).toHaveBeenCalledWith(
310
+ { status: "canceled", value: "stop" },
311
+ expect.anything()
312
+ );
313
+
314
+ queue.reset();
315
+ const onceSettling = vi.fn(() => {});
316
+ queue.onceSettling(onceSettling);
317
+ const first = Promise.resolve("a");
318
+ queue.settle(first);
319
+ expect(onceSettling).toHaveBeenCalledTimes(1);
320
+ expect(onceSettling).toHaveBeenCalledWith(
321
+ { status: "settling", value: first },
322
+ expect.anything()
323
+ );
324
+ queue.settle(Promise.resolve("b"));
325
+ expect(onceSettling).toHaveBeenCalledTimes(1);
326
+ });
327
+
328
+ it("Queue settling callbacks can be added, invoked immediately and removed", async () => {
329
+ const queue = new Queue();
330
+ const settlingCb = vi.fn(() => {});
331
+ const inFlight = deferred<string>();
332
+ queue.settle(inFlight.promise);
333
+ expect(queue.state.status).toBe("settling");
334
+ queue.onSettling(settlingCb);
335
+ expect(settlingCb).toHaveBeenCalledTimes(1);
336
+ queue.offSettling(settlingCb);
337
+ inFlight.resolve("v");
338
+ await flush();
339
+ expect(queue.state.status).toBe("resolved");
340
+ expect(queue.state.value).toBe("v");
341
+ queue.reset();
342
+ queue.settle(Promise.resolve("w"));
343
+ expect(settlingCb).toHaveBeenCalledTimes(1);
344
+ });
345
+
346
+ it("Queue settle with a plain value resolves unless already finished", () => {
347
+ const queue = new Queue();
348
+ const resolved = vi.fn(() => {});
349
+ queue.onResolved(resolved);
350
+ expect(queue.settle("sync")).toBe(queue);
351
+ expect(queue.state).toEqual({ status: "resolved", value: "sync" });
352
+ expect(resolved).toHaveBeenCalledTimes(1);
353
+ // finished: a second settle is ignored
354
+ expect(queue.settle("later")).toBe(queue);
355
+ expect(queue.state.value).toBe("sync");
356
+ expect(resolved).toHaveBeenCalledTimes(1);
357
+ queue.reset();
358
+ queue.cancel();
359
+ expect(queue.settle("after cancel")).toBe(queue);
360
+ expect(queue.state.status).toBe("canceled");
361
+ });
362
+
363
+ it("Queue settle with a promise is ignored once finished", async () => {
364
+ const queue = new Queue();
365
+ const settlingCb = vi.fn(() => {});
366
+ const resolvedCb = vi.fn(() => {});
367
+ queue.onSettling(settlingCb);
368
+ queue.resolve("first");
369
+ queue.onResolved(resolvedCb);
370
+ expect(resolvedCb).toHaveBeenCalledTimes(1);
371
+ expect(queue.settle(Promise.resolve("second"))).toBe(queue);
372
+ expect(settlingCb).not.toHaveBeenCalled();
373
+ expect(queue.state.status).toBe("resolved");
374
+ await flush();
375
+ expect(queue.state.value).toBe("first");
376
+ expect(resolvedCb).toHaveBeenCalledTimes(1);
377
+ });
378
+
379
+ it("Queue replacing an in-flight promise ignores the stale result", async () => {
380
+ const queue = new Queue();
381
+ const resolvedCb = vi.fn(() => {});
382
+ const rejectedCb = vi.fn(() => {});
383
+ queue.onResolved(resolvedCb);
384
+ queue.onRejected(rejectedCb);
385
+ const stale = deferred<string>();
386
+ const staleRejecting = deferred<string>();
387
+ const fresh = deferred<string>();
388
+ queue.settle(stale.promise);
389
+ queue.settle(staleRejecting.promise);
390
+ queue.settle(fresh.promise);
391
+ expect(queue.state).toEqual({ status: "settling", value: fresh.promise });
392
+ // both replaced promises settle while `fresh` is still in flight
393
+ stale.resolve("stale");
394
+ staleRejecting.reject(new Error("stale failure"));
395
+ await flush();
396
+ expect(queue.state.status).toBe("settling");
397
+ expect(resolvedCb).not.toHaveBeenCalled();
398
+ expect(rejectedCb).not.toHaveBeenCalled();
399
+ fresh.resolve("fresh");
400
+ await flush();
401
+ expect(queue.state).toEqual({ status: "resolved", value: "fresh" });
402
+ expect(resolvedCb).toHaveBeenCalledTimes(1);
403
+ });
404
+
405
+ it("Queue cancel during a rejecting promise wins", async () => {
406
+ const queue = new Queue();
407
+ const canceledCb = vi.fn(() => {});
408
+ const rejectedCb = vi.fn(() => {});
409
+ queue.onCanceled(canceledCb);
410
+ queue.onRejected(rejectedCb);
411
+ const failing = deferred();
412
+ queue.settle(failing.promise);
413
+ queue.cancel("abort");
414
+ expect(canceledCb).toHaveBeenCalledTimes(1);
415
+ // the rejection lands after the cancel and must be ignored
416
+ failing.reject(new Error("late"));
417
+ await flush();
418
+ expect(queue.state).toEqual({ status: "canceled", value: "abort" });
419
+ expect(rejectedCb).not.toHaveBeenCalled();
420
+ });
421
+
422
+ it("Queue does not register the same callback twice", () => {
423
+ const queue = new Queue();
424
+ const cb = vi.fn(() => {});
425
+ queue.onResolved(cb);
426
+ queue.onResolved([cb, cb]);
427
+ expect(queue.callbacks.resolved).toHaveLength(1);
428
+ queue.resolve("v");
429
+ expect(cb).toHaveBeenCalledTimes(1);
430
+ // removing an unknown callback is a no-op
431
+ queue.offResolved(() => {});
432
+ expect(queue.callbacks.resolved).toHaveLength(1);
433
+ queue.offResolved(cb);
434
+ expect(queue.callbacks.resolved).toHaveLength(0);
435
+ });
436
+
437
+ it("Queue callbacks may unsubscribe themselves during dispatch", () => {
438
+ const queue = new Queue();
439
+ const order: string[] = [];
440
+ const first = () => {
441
+ order.push("first");
442
+ queue.offResolved(first);
443
+ };
444
+ const second = () => order.push("second");
445
+ queue.onResolved([first, second]);
446
+ queue.resolve();
447
+ expect(order).toEqual(["first", "second"]);
448
+ queue.reset();
449
+ queue.resolve();
450
+ expect(order).toEqual(["first", "second", "second"]);
451
+ });
452
+
453
+ it("QueueManager cancels a queue it replaces and reports missing deletes", () => {
454
+ const queueManager = new QueueManager();
455
+ const original = queueManager.createQueue("q");
456
+ const canceled = vi.fn(() => {});
457
+ original.onCanceled(canceled);
458
+ const replacement = queueManager.createQueue("q", { initialValue: 1 });
459
+ expect(replacement).not.toBe(original);
460
+ expect(canceled).toHaveBeenCalledTimes(1);
461
+ expect(original.state.status).toBe("canceled");
462
+ expect(queueManager.getQueue("q")).toBe(replacement);
463
+ expect(queueManager.getQueue("q").state.value).toBe(1);
464
+ expect(queueManager.deleteQueue("nope")).toBe(false);
465
+ const deleted = vi.fn(() => {});
466
+ replacement.onCanceled(deleted);
467
+ expect(queueManager.deleteQueue("q")).toBe(true);
468
+ expect(deleted).toHaveBeenCalledTimes(1);
469
+ expect(queueManager.getQueue("q", { initialValue: 2 }).state.value).toBe(2);
470
+ });
471
+ });
@@ -0,0 +1,111 @@
1
+ import * as utils from "../../url";
2
+ import { describe, expect, it } from "@excom/heft-rig/node_modules/vitest";
3
+
4
+ describe("url", () => {
5
+ it("mergeSearchParamsIntoUrl: merges search params into URL", () => {
6
+ const url = "http://example.com/api";
7
+ const params = new URLSearchParams({ foo: "bar", baz: "qux" });
8
+ const result = utils.mergeSearchParamsIntoUrl(url, params);
9
+ expect(result).to.equal("http://example.com/api?foo=bar&baz=qux");
10
+ });
11
+
12
+ it("mergeSearchParamsIntoUrl: handles existing search params in URL", () => {
13
+ const url = "http://example.com/api?existing=param";
14
+ const params = new URLSearchParams({ foo: "bar", baz: "qux" });
15
+ const result = utils.mergeSearchParamsIntoUrl(url, params);
16
+ expect(result).to.equal(
17
+ "http://example.com/api?existing=param&foo=bar&baz=qux"
18
+ );
19
+ });
20
+
21
+ it("mergeSearchParamsIntoUrl: returns the same URL if no params are provided", () => {
22
+ const url = "http://example.com/api";
23
+ const result = utils.mergeSearchParamsIntoUrl(url, new URLSearchParams());
24
+ expect(result).to.equal(url);
25
+ });
26
+ it("jsonToSearchParams: converts JSON to URLSearchParams", () => {
27
+ const json = { foo: "bar", baz: "qux" };
28
+ const result = utils.jsonToSearchParams(json);
29
+ expect(result instanceof URLSearchParams).toBe(true);
30
+ expect(result.get("foo")).toBe("bar");
31
+ expect(result.get("baz")).toBe("qux");
32
+ });
33
+
34
+ it("jsonToSearchParams: returns empty URLSearchParams for empty object", () => {
35
+ const json = {};
36
+ const result = utils.jsonToSearchParams(json);
37
+ expect(result instanceof URLSearchParams).toBe(true);
38
+ expect(result.toString()).toBe("");
39
+ });
40
+
41
+ it("jsonToSearchParams: returns empty param for empty value", () => {
42
+ const json = { foo: "", baz: "qux" };
43
+ const result = utils.jsonToSearchParams(json).toString();
44
+ expect(result).toBe("foo=&baz=qux");
45
+ });
46
+
47
+ it("jsonToSearchParams: stringifies and encodes nested objects", () => {
48
+ const json = { foo: { bar: "baz" }, qux: "quux", arrayKey: [1, 2, 3] };
49
+ const result = utils.jsonToSearchParams(json).toString();
50
+ expect(result).toBe(
51
+ "foo=%7B%22bar%22%3A%22baz%22%7D&qux=quux&arrayKey=%5B1%2C2%2C3%5D"
52
+ );
53
+ });
54
+
55
+ it("jsonToSearchParams: stringifies and encodes in a way that can be parsed back", () => {
56
+ const json = {
57
+ foo: "bar",
58
+ baz: "qux",
59
+ nested: { a: 1, b: 2 },
60
+ arrayKey: [1, 2, 3],
61
+ };
62
+ const params = utils.jsonToSearchParams(json);
63
+ const result = new URLSearchParams(params.toString());
64
+ expect(result.get("foo")).toBe("bar");
65
+ expect(result.get("baz")).toBe("qux");
66
+ expect(result.get("nested")).toBe('{"a":1,"b":2}');
67
+
68
+ const url = "http://example.com/api?" + params.toString();
69
+ const parsedUrl = new URL(url);
70
+ expect(parsedUrl.searchParams.get("foo")).toBe("bar");
71
+ expect(parsedUrl.searchParams.get("baz")).toBe("qux");
72
+ expect(parsedUrl.searchParams.get("nested")).toBe('{"a":1,"b":2}');
73
+ expect(parsedUrl.searchParams.get("arrayKey")).toBe("[1,2,3]");
74
+ });
75
+
76
+ it("jsonToSearchParams: null and undefined become empty values", () => {
77
+ const result = utils.jsonToSearchParams({
78
+ a: null,
79
+ b: undefined,
80
+ c: 0,
81
+ d: false,
82
+ });
83
+ expect(result.toString()).toBe("a=&b=&c=0&d=false");
84
+ });
85
+
86
+ it("mergeSearchParams: overrides existing keys and appends new ones", () => {
87
+ const base = new URLSearchParams("a=1&b=2&b=3");
88
+ const incoming = new URLSearchParams("b=9&c=4");
89
+ const merged = utils.mergeSearchParams(base, incoming);
90
+ expect(merged.toString()).toBe("a=1&b=9&c=4");
91
+ // inputs are untouched
92
+ expect(base.toString()).toBe("a=1&b=2&b=3");
93
+ expect(incoming.toString()).toBe("b=9&c=4");
94
+ });
95
+
96
+ it("mergeSearchParamsIntoUrl: overrides an existing param in the URL", () => {
97
+ const result = utils.mergeSearchParamsIntoUrl(
98
+ "http://example.com/api?page=1&sort=asc",
99
+ new URLSearchParams({ page: "2" })
100
+ );
101
+ expect(result).toBe("http://example.com/api?page=2&sort=asc");
102
+ });
103
+
104
+ it("mergeSearchParamsIntoUrl: resolves relative URLs against the page origin", () => {
105
+ const result = utils.mergeSearchParamsIntoUrl(
106
+ "/api/items?x=1",
107
+ new URLSearchParams({ y: "2" })
108
+ );
109
+ expect(result).toBe(`${window.location.origin}/api/items?x=1&y=2`);
110
+ });
111
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "@excom/heft-rig/profiles/default/config/tsconfig.json",
3
+ "include": ["./*.ts"],
4
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
5
+ }
package/url.ts ADDED
@@ -0,0 +1,39 @@
1
+ export function jsonToSearchParams(json) {
2
+ const params = new URLSearchParams();
3
+ Object.entries(json).forEach(([key, value]) => {
4
+ if ([undefined, null].includes(value as undefined)) {
5
+ params.append(key, "");
6
+ } else if (typeof value === "object") {
7
+ // URLSearchParams encodes on toString(); no extra encode here
8
+ params.append(key, JSON.stringify(value));
9
+ } else {
10
+ params.append(key, String(value));
11
+ }
12
+ });
13
+ return params;
14
+ }
15
+
16
+ export function mergeSearchParams(
17
+ baseParams: URLSearchParams,
18
+ newParams: URLSearchParams
19
+ ) {
20
+ const merged = new URLSearchParams(baseParams);
21
+ for (const [key, value] of newParams.entries()) {
22
+ if (merged.has(key)) {
23
+ merged.set(key, value);
24
+ } else {
25
+ merged.append(key, value);
26
+ }
27
+ }
28
+ return merged;
29
+ }
30
+
31
+ export function mergeSearchParamsIntoUrl(
32
+ url: string,
33
+ newParams: URLSearchParams
34
+ ): string {
35
+ const urlObj = new URL(url, window.location.origin);
36
+ const mergedParams = mergeSearchParams(urlObj.searchParams, newParams);
37
+ urlObj.search = mergedParams.toString();
38
+ return urlObj.toString();
39
+ }