@ezez/utils 4.8.1 → 4.8.2

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 (179) hide show
  1. package/.claude/settings.local.json +8 -1
  2. package/CHANGELOG.md +14 -0
  3. package/dist/get.d.ts.map +1 -1
  4. package/dist/get.js +1 -1
  5. package/dist/get.js.map +1 -1
  6. package/dist/mostFrequent.d.ts +1 -1
  7. package/dist/mostFrequent.d.ts.map +1 -1
  8. package/dist/mostFrequent.js +2 -5
  9. package/dist/mostFrequent.js.map +1 -1
  10. package/dist/samples.d.ts.map +1 -1
  11. package/dist/samples.js +2 -1
  12. package/dist/samples.js.map +1 -1
  13. package/dist/scale.d.ts.map +1 -1
  14. package/dist/scale.js +3 -0
  15. package/dist/scale.js.map +1 -1
  16. package/dist/sortByMultiple.d.ts.map +1 -1
  17. package/dist/sortByMultiple.js.map +1 -1
  18. package/dist/throttle.d.ts +3 -3
  19. package/dist/throttle.d.ts.map +1 -1
  20. package/dist/throttle.js +2 -3
  21. package/dist/throttle.js.map +1 -1
  22. package/dist/trimEnd.d.ts.map +1 -1
  23. package/dist/trimEnd.js +3 -0
  24. package/dist/trimEnd.js.map +1 -1
  25. package/dist/trimStart.d.ts.map +1 -1
  26. package/dist/trimStart.js +3 -0
  27. package/dist/trimStart.js.map +1 -1
  28. package/dist/waitFor.d.ts.map +1 -1
  29. package/dist/waitFor.js +19 -11
  30. package/dist/waitFor.js.map +1 -1
  31. package/docs/documents/Changelog.html +70 -55
  32. package/docs/functions/index.assertProps.html +2 -2
  33. package/docs/functions/index.cap.html +2 -2
  34. package/docs/functions/index.capitalize.html +2 -2
  35. package/docs/functions/index.coalesce.html +2 -2
  36. package/docs/functions/index.compareArrays.html +2 -2
  37. package/docs/functions/index.compareProps.html +2 -2
  38. package/docs/functions/index.deserialize.html +2 -2
  39. package/docs/functions/index.ensureArray.html +2 -2
  40. package/docs/functions/index.ensureDate.html +2 -2
  41. package/docs/functions/index.ensureError.html +2 -2
  42. package/docs/functions/index.ensurePrefix.html +2 -2
  43. package/docs/functions/index.ensureSuffix.html +2 -2
  44. package/docs/functions/index.ensureTimestamp.html +2 -2
  45. package/docs/functions/index.escapeRegExp.html +2 -2
  46. package/docs/functions/index.formatDate.html +2 -2
  47. package/docs/functions/index.formatHash.html +2 -2
  48. package/docs/functions/index.get.html +3 -3
  49. package/docs/functions/index.getMultiple.html +2 -2
  50. package/docs/functions/index.hasProps.html +2 -2
  51. package/docs/functions/index.ignore.html +2 -2
  52. package/docs/functions/index.insertSeparator.html +2 -2
  53. package/docs/functions/index.isEmpty.html +2 -2
  54. package/docs/functions/index.isNumericString.html +2 -2
  55. package/docs/functions/index.isPlainObject.html +2 -2
  56. package/docs/functions/index.last.html +2 -2
  57. package/docs/functions/index.later-1.html +2 -2
  58. package/docs/functions/index.mapAsync.html +2 -2
  59. package/docs/functions/index.mapValues.html +2 -2
  60. package/docs/functions/index.match.html +2 -2
  61. package/docs/functions/index.memoize.html +2 -2
  62. package/docs/functions/index.merge.html +2 -2
  63. package/docs/functions/index.mostFrequent.html +2 -2
  64. package/docs/functions/index.noop.html +2 -2
  65. package/docs/functions/index.occurrences.html +2 -2
  66. package/docs/functions/index.omit.html +2 -2
  67. package/docs/functions/index.pick.html +2 -2
  68. package/docs/functions/index.pull.html +2 -2
  69. package/docs/functions/index.race.html +2 -2
  70. package/docs/functions/index.remove.html +2 -2
  71. package/docs/functions/index.removeCommonProperties.html +2 -2
  72. package/docs/functions/index.replace.html +2 -2
  73. package/docs/functions/index.replaceDeep.html +2 -2
  74. package/docs/functions/index.rethrow.html +2 -2
  75. package/docs/functions/index.retry.html +2 -2
  76. package/docs/functions/index.round.html +2 -2
  77. package/docs/functions/index.safe.html +2 -2
  78. package/docs/functions/index.sample.html +2 -2
  79. package/docs/functions/index.samples.html +2 -2
  80. package/docs/functions/index.scale.html +3 -2
  81. package/docs/functions/index.seq.html +2 -2
  82. package/docs/functions/index.seqEarlyBreak.html +2 -2
  83. package/docs/functions/index.serialize.html +2 -2
  84. package/docs/functions/index.serializeToBuffer.html +2 -2
  85. package/docs/functions/index.set.html +2 -2
  86. package/docs/functions/index.setImmutable.html +2 -2
  87. package/docs/functions/index.shuffle.html +2 -2
  88. package/docs/functions/index.sortBy.html +2 -2
  89. package/docs/functions/index.sortByMultiple.html +3 -3
  90. package/docs/functions/index.sortProps.html +2 -2
  91. package/docs/functions/index.stripPrefix.html +2 -2
  92. package/docs/functions/index.stripSuffix.html +2 -2
  93. package/docs/functions/index.throttle.html +3 -3
  94. package/docs/functions/index.toggle.html +2 -2
  95. package/docs/functions/index.trim.html +2 -2
  96. package/docs/functions/index.trimEnd.html +2 -2
  97. package/docs/functions/index.trimStart.html +2 -2
  98. package/docs/functions/index.truthy.html +2 -2
  99. package/docs/functions/index.unique.html +2 -2
  100. package/docs/functions/index.unserializeFromBuffer.html +2 -2
  101. package/docs/functions/index.wait.html +2 -2
  102. package/docs/functions/index.waitFor.html +2 -2
  103. package/docs/functions/index.waitSync.html +2 -2
  104. package/docs/index.html +2 -2
  105. package/docs/interfaces/index.ComparePropsOptions.html +3 -3
  106. package/docs/interfaces/index.GetMultipleSource.html +2 -2
  107. package/docs/interfaces/index.GetSource.html +2 -2
  108. package/docs/interfaces/index.IsNumericStringOptions.html +2 -2
  109. package/docs/interfaces/index.OccurencesOptions.html +2 -2
  110. package/docs/interfaces/index.SetImmutableSource.html +2 -2
  111. package/docs/interfaces/index.SetSource.html +2 -2
  112. package/docs/interfaces/index.ThrottleOptions.html +3 -3
  113. package/docs/interfaces/index.ThrottledFunctionExtras.html +3 -3
  114. package/docs/modules/index.html +1 -1
  115. package/docs/modules.html +1 -1
  116. package/docs/types/index.CustomDeserializers.html +1 -1
  117. package/docs/types/index.CustomSerializers.html +1 -1
  118. package/docs/types/index.Later.html +2 -2
  119. package/docs/types/index.MapValuesFn.html +2 -2
  120. package/docs/types/index.MatchCallback.html +1 -1
  121. package/docs/types/index.MergeTwo.html +2 -2
  122. package/docs/types/index.SeqEarlyBreaker.html +2 -2
  123. package/docs/types/index.SeqFn.html +2 -2
  124. package/docs/types/index.SeqFunctions.html +2 -2
  125. package/docs/types/index.SetImmutablePath.html +2 -2
  126. package/docs/types/index.ThrottledFunction.html +1 -1
  127. package/docs/variables/index.mapValuesUNSET.html +2 -2
  128. package/docs/variables/index.mergeUNSET.html +2 -2
  129. package/esm/get.d.ts.map +1 -1
  130. package/esm/get.js +1 -1
  131. package/esm/get.js.map +1 -1
  132. package/esm/mostFrequent.d.ts +1 -1
  133. package/esm/mostFrequent.d.ts.map +1 -1
  134. package/esm/mostFrequent.js +2 -5
  135. package/esm/mostFrequent.js.map +1 -1
  136. package/esm/samples.d.ts.map +1 -1
  137. package/esm/samples.js +2 -1
  138. package/esm/samples.js.map +1 -1
  139. package/esm/scale.d.ts.map +1 -1
  140. package/esm/scale.js +3 -0
  141. package/esm/scale.js.map +1 -1
  142. package/esm/sortByMultiple.d.ts.map +1 -1
  143. package/esm/sortByMultiple.js.map +1 -1
  144. package/esm/throttle.d.ts +3 -3
  145. package/esm/throttle.d.ts.map +1 -1
  146. package/esm/throttle.js +2 -3
  147. package/esm/throttle.js.map +1 -1
  148. package/esm/trimEnd.d.ts.map +1 -1
  149. package/esm/trimEnd.js +3 -0
  150. package/esm/trimEnd.js.map +1 -1
  151. package/esm/trimStart.d.ts.map +1 -1
  152. package/esm/trimStart.js +3 -0
  153. package/esm/trimStart.js.map +1 -1
  154. package/esm/waitFor.d.ts.map +1 -1
  155. package/esm/waitFor.js +19 -11
  156. package/esm/waitFor.js.map +1 -1
  157. package/package.json +12 -13
  158. package/pnpm-workspace.yaml +2 -0
  159. package/src/get.ts +1 -1
  160. package/src/mostFrequent.spec.ts +2 -1
  161. package/src/mostFrequent.ts +3 -7
  162. package/src/race.spec.ts +42 -0
  163. package/src/race.ts +0 -2
  164. package/src/retry.spec.ts +87 -0
  165. package/src/retry.ts +0 -2
  166. package/src/samples.spec.ts +9 -0
  167. package/src/samples.ts +4 -1
  168. package/src/scale.spec.ts +18 -0
  169. package/src/scale.ts +4 -0
  170. package/src/sortByMultiple.ts +1 -0
  171. package/src/throttle.spec.ts +35 -0
  172. package/src/throttle.ts +7 -8
  173. package/src/trimEnd.spec.ts +5 -0
  174. package/src/trimEnd.ts +3 -0
  175. package/src/trimStart.spec.ts +5 -0
  176. package/src/trimStart.ts +3 -0
  177. package/src/waitFor.spec.ts +61 -0
  178. package/src/waitFor.ts +33 -15
  179. package/src/waitSync.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ezez/utils",
3
- "version": "4.8.1",
3
+ "version": "4.8.2",
4
4
  "repository": "https://github.com/dzek69/bottom-line.git",
5
5
  "author": "Jacek Nowacki @dzek69 <git-public@dzek.eu>",
6
6
  "license": "MIT",
@@ -16,23 +16,23 @@
16
16
  "module": "./esm/index.js",
17
17
  "type": "module",
18
18
  "devDependencies": {
19
- "@babel/core": "^7.26.0",
20
- "@babel/preset-env": "^7.26.0",
21
- "@babel/preset-typescript": "^7.26.0",
22
- "@ezez/eslint": "^9.39.3",
19
+ "@babel/core": "^7.29.7",
20
+ "@babel/preset-env": "^7.29.7",
21
+ "@babel/preset-typescript": "^7.29.7",
22
+ "@ezez/eslint": "^9.39.4",
23
23
  "@types/jest": "^29.5.14",
24
- "@types/lodash": "^4.17.20",
25
- "@types/node": "^20.8.3",
24
+ "@types/lodash": "^4.17.24",
25
+ "@types/node": "^20.19.43",
26
26
  "babel-plugin-module-extension": "^0.1.3",
27
- "fs-extra": "^11.3.0",
27
+ "fs-extra": "^11.3.5",
28
28
  "husky": "^8.0.3",
29
- "jest": "^30.2.0",
30
- "lodash": "^4.17.23",
29
+ "jest": "^30.4.2",
30
+ "lodash": "^4.18.1",
31
31
  "must": "^0.13.4",
32
- "prettier": "^3.4.2",
32
+ "prettier": "^3.8.4",
33
33
  "resolve-tspaths": "^0.8.23",
34
34
  "typedoc": "0.27.6",
35
- "typescript": "^5.7.3"
35
+ "typescript": "^5.9.3"
36
36
  },
37
37
  "husky": {
38
38
  "hooks": {
@@ -45,7 +45,6 @@
45
45
  "fixDefaultForCommonJS": false,
46
46
  "jsx": false
47
47
  },
48
- "dependencies": {},
49
48
  "scripts": {
50
49
  "test": "NODE_ENV=test jest",
51
50
  "test:lodashImports": "node test/lodash-tree.cjs",
@@ -0,0 +1,2 @@
1
+ allowBuilds:
2
+ unrs-resolver: false
package/src/get.ts CHANGED
@@ -30,7 +30,7 @@ interface Source { [key: string]: unknown }
30
30
  * // else `5` will be returned
31
31
  * @returns {*} - found value or default value
32
32
  */
33
- const get = (source: Source, property: string | string[], defaultValue: unknown = undefined): unknown => {
33
+ const get = (source: Source, property: string | string[], defaultValue?: unknown): unknown => {
34
34
  const properties = typeof property === "string" ? property.split(".") : [...property];
35
35
 
36
36
  let result: unknown = source;
@@ -16,7 +16,8 @@ describe("mostFrequent", () => {
16
16
  });
17
17
 
18
18
  it("returns undefined if array empty", () => {
19
- must(mostFrequent([])).equal(undefined);
19
+ const result = mostFrequent<number>([]);
20
+ must(result).equal(undefined);
20
21
  });
21
22
 
22
23
  it("compares strictly", () => {
@@ -2,24 +2,20 @@
2
2
  * Finds most frequent value in array
3
3
  * @param {Array} array
4
4
  */
5
- const mostFrequent = <T>(array: T[]): T => {
5
+ const mostFrequent = <T>(array: T[]): T | undefined => {
6
6
  let top = 0,
7
7
  topValue = array[0];
8
8
 
9
9
  const map = new Map<T, number>();
10
10
  array.forEach(value => {
11
- if (!map.has(value)) {
12
- map.set(value, 0);
13
- }
14
- const next = map.get(value)! + 1;
15
- map.set(value, map.get(value)! + 1);
11
+ const next = (map.get(value) ?? 0) + 1;
12
+ map.set(value, next);
16
13
  if (next > top) {
17
14
  top = next;
18
15
  topValue = value;
19
16
  }
20
17
  });
21
18
 
22
- // @ts-expect-error - idk if there is a good workaround for this with `noUncheckedIndexedAccess`
23
19
  return topValue;
24
20
  };
25
21
 
@@ -0,0 +1,42 @@
1
+ import must from "must"; // eslint-disable-line @typescript-eslint/no-shadow
2
+
3
+ import { race } from "./race";
4
+ import { wait } from "./wait";
5
+
6
+ describe("race", () => {
7
+ it("resolves with the promise value when it settles before the timeout", async () => {
8
+ const start = Date.now();
9
+ const result = await race(Promise.resolve(42), 100);
10
+ must(result).equal(42);
11
+ must(Date.now() - start).be.below(90);
12
+ });
13
+
14
+ it("rejects with the promise error when it rejects before the timeout", async () => {
15
+ const start = Date.now();
16
+ await race(Promise.reject(new Error("boom")), 100).then(() => {
17
+ throw new Error("Should not resolve");
18
+ }, (e: unknown) => {
19
+ must((e as Error).message).equal("boom");
20
+ must(Date.now() - start).be.below(90);
21
+ });
22
+ });
23
+
24
+ it("rejects with a timeout error when the promise is too slow", async () => {
25
+ const slow = wait(200).then(() => "late");
26
+ await race(slow, 50).then(() => {
27
+ throw new Error("Should not resolve");
28
+ }, (e: unknown) => {
29
+ must(e).instanceOf(Error);
30
+ must((e as Error).message).equal("Race: Timeout");
31
+ });
32
+ });
33
+
34
+ it("uses a custom timeout message", async () => {
35
+ const slow = wait(200).then(() => "late");
36
+ await race(slow, 50, "too slow").then(() => {
37
+ throw new Error("Should not resolve");
38
+ }, (e: unknown) => {
39
+ must((e as Error).message).equal("too slow");
40
+ });
41
+ });
42
+ });
package/src/race.ts CHANGED
@@ -26,5 +26,3 @@ const race = <T>(promise: Promise<T>, timeout: number, message = "Race: Timeout"
26
26
  export {
27
27
  race,
28
28
  };
29
-
30
- // TODO unit tests
@@ -0,0 +1,87 @@
1
+ import must from "must"; // eslint-disable-line @typescript-eslint/no-shadow
2
+
3
+ import { retry } from "./retry";
4
+
5
+ describe("retry", () => {
6
+ it("returns the result on first success without retrying", async () => {
7
+ let calls = 0;
8
+ const result = await retry(async () => {
9
+ calls++;
10
+ return "ok";
11
+ });
12
+ must(result).equal("ok");
13
+ must(calls).equal(1);
14
+ });
15
+
16
+ it("retries until the function succeeds", async () => {
17
+ let calls = 0;
18
+ const result = await retry(async () => {
19
+ calls++;
20
+ if (calls < 3) {
21
+ throw new Error("fail");
22
+ }
23
+ return calls;
24
+ });
25
+ must(result).equal(3);
26
+ must(calls).equal(3);
27
+ });
28
+
29
+ it("with maxRetries 0 runs once and rethrows the error", async () => {
30
+ let calls = 0;
31
+ await retry(async () => {
32
+ calls++;
33
+ throw new Error("nope");
34
+ }, { maxRetries: 0 }).then(() => {
35
+ throw new Error("Should not resolve");
36
+ }, (e: unknown) => {
37
+ must((e as Error).message).equal("nope");
38
+ });
39
+ must(calls).equal(1);
40
+ });
41
+
42
+ it("with maxRetries N runs N+1 times before giving up", async () => {
43
+ let calls = 0;
44
+ await retry(async () => {
45
+ calls++;
46
+ throw new Error("nope");
47
+ }, { maxRetries: 2 }).catch(() => { /* expected to reject */ });
48
+ must(calls).equal(3);
49
+ });
50
+
51
+ it("stops early when earlyBreak returns true", async () => {
52
+ let calls = 0;
53
+ const seenCounts: number[] = [];
54
+ await retry(async () => {
55
+ calls++;
56
+ throw new Error("stop");
57
+ }, {
58
+ maxRetries: 10,
59
+ earlyBreak: (_e, count) => {
60
+ seenCounts.push(count);
61
+ return calls === 2;
62
+ },
63
+ }).catch(() => { /* expected to reject */ });
64
+ must(calls).equal(2);
65
+ must(seenCounts).eql([0, 1]);
66
+ });
67
+
68
+ it("waits between retries, passing the retry count to the wait function", async () => {
69
+ let calls = 0;
70
+ const waitCounts: number[] = [];
71
+ const result = await retry(async () => {
72
+ calls++;
73
+ if (calls < 3) {
74
+ throw new Error("fail");
75
+ }
76
+ return "done";
77
+ }, {
78
+ waitBetween: (count) => {
79
+ waitCounts.push(count);
80
+ return 10;
81
+ },
82
+ });
83
+ must(result).equal("done");
84
+ must(calls).equal(3);
85
+ must(waitCounts).eql([1, 2]);
86
+ });
87
+ });
package/src/retry.ts CHANGED
@@ -49,5 +49,3 @@ const retry = async <T>(fn: () => Promise<T>, options?: Options): Promise<T> =>
49
49
  export {
50
50
  retry,
51
51
  };
52
-
53
- // TODO unit tests
@@ -47,4 +47,13 @@ describe("samples", () => {
47
47
  const elements = [1, 2, 3];
48
48
  must(() => samples(elements, -1)).throw();
49
49
  });
50
+
51
+ it("should not pad with undefined when allowShuffle and elementsToPick exceed length", async () => {
52
+ const elements = [1, 2, 3];
53
+ // BUG: with allowShuffle the early-return is skipped and the loop runs `elementsToPick`
54
+ // times; once the keys are exhausted it pushes `array[NaN]` (undefined) as padding.
55
+ const result = samples(elements, 5, true);
56
+ must(result).have.length(3);
57
+ must(result).not.include(undefined);
58
+ });
50
59
  });
package/src/samples.ts CHANGED
@@ -21,10 +21,13 @@ const samples = <T>(array: T[], elementsToPick: number, allowShuffle = false): T
21
21
  }
22
22
 
23
23
  const keys = Object.keys(array);
24
+ // when shuffling we may be asked for more than we have - never pick more than exists, or we'd
25
+ // run out of keys and pad the result with `undefined`
26
+ const toPick = Math.min(elementsToPick, keys.length);
24
27
  let picked = 0;
25
28
  const result: T[] = [];
26
29
 
27
- while (picked < elementsToPick) {
30
+ while (picked < toPick) {
28
31
  const indexOfKey = Math.floor(Math.random() * keys.length);
29
32
  const indexOfArray = Number(keys[indexOfKey]);
30
33
  const element = array[indexOfArray]!;
@@ -0,0 +1,18 @@
1
+ import must from "must"; // eslint-disable-line @typescript-eslint/no-shadow
2
+
3
+ import { scale } from "./scale";
4
+
5
+ describe("scale", () => {
6
+ it("maps a value from one range to another", async () => {
7
+ must(scale(0, 10, 0, 100, 5)).equal(50);
8
+ must(scale(10, 20, 0, 200, 15)).equal(100);
9
+ });
10
+
11
+ it("handles inverted target ranges", async () => {
12
+ must(scale(0, 10, 100, 0, 5)).equal(50);
13
+ });
14
+
15
+ it("throws when fromMin equals fromMax", async () => {
16
+ must(() => scale(5, 5, 0, 100, 5)).throw(/fromMin and fromMax must not be equal/u);
17
+ });
18
+ });
package/src/scale.ts CHANGED
@@ -8,8 +8,12 @@
8
8
  * @param {number} toMin
9
9
  * @param {number} toMax
10
10
  * @param {number} number
11
+ * @throws {Error} when `fromMin` equals `fromMax` (the source range has zero width)
11
12
  */
12
13
  const scale = (fromMin: number, fromMax: number, toMin: number, toMax: number, number: number): number => {
14
+ if (fromMin === fromMax) {
15
+ throw new Error("[scale] fromMin and fromMax must not be equal");
16
+ }
13
17
  return toMin + ((number - fromMin) / (fromMax - fromMin) * (toMax - toMin));
14
18
  };
15
19
 
@@ -23,6 +23,7 @@
23
23
  * { height: 480, bitrate: 300 },
24
24
  * { height: 720, bitrate: 200 },
25
25
  * { height: 720 }, // bitrate assumed to be 150
26
+ * { height: 720, bitrate: undefined }, // bitrate assumed to be 150
26
27
  * { height: 720, bitrate: 100 },
27
28
  * ]
28
29
  * ```
@@ -133,4 +133,39 @@ describe("throttle", function() {
133
133
  diffs[2].must.be.gte(300);
134
134
  diffs[2].must.be.lt(350);
135
135
  });
136
+
137
+ describe("flush", function() {
138
+ it("runs the pending trailing call immediately and only once", async function() {
139
+ let calls = 0;
140
+ const fn = () => {
141
+ calls++;
142
+ return calls;
143
+ };
144
+
145
+ const throttled = throttle(fn, 100, { leading: false, trailing: true });
146
+ throttled(); // schedules a trailing call ~100ms from now
147
+ throttled.flush(); // should run that planned call right now
148
+
149
+ calls.must.equal(1);
150
+
151
+ // flush must NOT leave (or schedule) another planned call behind
152
+ await wait(200);
153
+ calls.must.equal(1);
154
+ });
155
+
156
+ it("is a no-op returning the last result when nothing is pending", async function() {
157
+ let calls = 0;
158
+ const fn = () => {
159
+ calls++;
160
+ return calls;
161
+ };
162
+
163
+ const throttled = throttle(fn, 100, { leading: true, trailing: true });
164
+ throttled(); // leading call runs immediately, nothing is pending afterwards
165
+ calls.must.equal(1);
166
+
167
+ throttled.flush();
168
+ calls.must.equal(1); // flush did not call fn again
169
+ });
170
+ });
136
171
  });
package/src/throttle.ts CHANGED
@@ -9,15 +9,15 @@ interface Opts {
9
9
  trailing?: boolean;
10
10
  }
11
11
 
12
- interface Extras {
12
+ interface Extras<R = unknown> {
13
13
  /**
14
14
  * Stops any planned calls (and resets the `time` array progress)
15
15
  */
16
16
  cancel: () => void;
17
17
  /**
18
- * Immediately runs planned call.
18
+ * Immediately runs planned call, returning its result (or the last cached result if nothing is planned).
19
19
  */
20
- flush: () => void;
20
+ flush: () => R | undefined;
21
21
  }
22
22
 
23
23
  const defaultOptions: Required<Opts> = {
@@ -48,7 +48,7 @@ type CanReturnUndefined<F extends (...args: any[]) => any> = (...args: Parameter
48
48
  */
49
49
  const throttle = <RT, F extends (...args: any[]) => RT>( // eslint-disable-line max-lines-per-function, @typescript-eslint/no-explicit-any
50
50
  fn: F, time: number | [number, ...number[]] = 0, options?: Opts,
51
- ): CanReturnUndefined<F> & Extras => {
51
+ ): CanReturnUndefined<F> & Extras<RT> => {
52
52
  const opts: Required<Opts> = {
53
53
  leading: options?.leading ?? defaultOptions.leading,
54
54
  trailing: options?.trailing ?? defaultOptions.trailing,
@@ -109,7 +109,7 @@ const throttle = <RT, F extends (...args: any[]) => RT>( // eslint-disable-line
109
109
  }, lastRun ? (lastTime - diffLastRun + 1) : lastTime);
110
110
 
111
111
  return lastResult;
112
- }) as (CanReturnUndefined<F> & Extras);
112
+ }) as (CanReturnUndefined<F> & Extras<RT>);
113
113
 
114
114
  throttledFn.cancel = () => {
115
115
  timeoutId !== null && clearTimeout(timeoutId);
@@ -130,11 +130,10 @@ const throttle = <RT, F extends (...args: any[]) => RT>( // eslint-disable-line
130
130
  };
131
131
  throttledFn.flush = () => {
132
132
  if (timeoutId !== null) {
133
- lastRun = Date.now();
134
- lastResult = fn(...lastArgs);
135
133
  clearTimeout(timeoutId);
136
134
  timeoutId = null;
137
- return throttledFn(...lastArgs);
135
+ lastRun = Date.now();
136
+ lastResult = fn(...lastArgs);
138
137
  }
139
138
  return lastResult;
140
139
  };
@@ -19,4 +19,9 @@ describe("trimEnd", () => {
19
19
  must(trimEnd("abc", "abc")).equal("");
20
20
  must(trimEnd("a", "a")).equal("");
21
21
  });
22
+
23
+ it("should return the source unchanged when characters is empty", async () => {
24
+ must(trimEnd("hello", "")).equal("hello");
25
+ must(trimEnd("", "")).equal("");
26
+ });
22
27
  });
package/src/trimEnd.ts CHANGED
@@ -10,6 +10,9 @@
10
10
  * trimEnd("!aaa!!", "!"); // "!aaa"
11
11
  */
12
12
  const trimEnd = (source: string, characters: string): string => {
13
+ if (characters === "") {
14
+ return source;
15
+ }
13
16
  let s = source;
14
17
  while (s.endsWith(characters)) {
15
18
  s = s.slice(0, -characters.length);
@@ -19,4 +19,9 @@ describe("trimStart", () => {
19
19
  must(trimStart("abc", "abc")).equal("");
20
20
  must(trimStart("aaa", "a")).equal("");
21
21
  });
22
+
23
+ it("should return the source unchanged when characters is empty", async () => {
24
+ must(trimStart("hello", "")).equal("hello");
25
+ must(trimStart("", "")).equal("");
26
+ });
22
27
  });
package/src/trimStart.ts CHANGED
@@ -10,6 +10,9 @@
10
10
  * trimStart("!!aaa!", "!"); // "aaa!"
11
11
  */
12
12
  const trimStart = (source: string, characters: string): string => {
13
+ if (characters === "") {
14
+ return source;
15
+ }
13
16
  let s = source;
14
17
  while (s.startsWith(characters)) {
15
18
  s = s.slice(characters.length);
@@ -53,6 +53,19 @@ describe("waitFor", () => {
53
53
  must(Date.now() - start).be.gte(300);
54
54
  });
55
55
 
56
+ it("falls back to the default interval when other options are given without `interval`", async () => {
57
+ const spy = createSpy(() => false);
58
+
59
+ // only `timeout` is provided, `interval` is omitted -> should still use the 50ms default.
60
+ // BUG: implementation reads the raw `options.interval` (undefined) instead of the merged
61
+ // `opts.interval`, so setTimeout fires at 0ms and the check busy-loops.
62
+ waitFor(spy, { timeout: 250 }).catch(() => { /* expected to reject on timeout */ });
63
+ await wait(120);
64
+
65
+ // with the default 50ms interval checks land at ~0/50/100ms => ~3 calls by now
66
+ must(spy.__spy.calls.length).be.lte(4);
67
+ });
68
+
56
69
  it("time outs after given time", async () => {
57
70
  const spy = createSpy(() => false);
58
71
  // eslint-disable-next-line @typescript-eslint/await-thenable
@@ -100,6 +113,54 @@ describe("waitFor", () => {
100
113
  });
101
114
  });
102
115
 
116
+ describe("error stack traces point at the caller", () => {
117
+ // Helper with a recognizable name so we can assert it appears in the stack.
118
+ const callerOfWaitFor = <T>(...args: Parameters<typeof waitFor<T>>): Promise<T> => {
119
+ return waitFor<T>(...args);
120
+ };
121
+
122
+ it("on timeout", async () => {
123
+ await callerOfWaitFor(() => false, { interval: 40, timeout: 100 }).then(() => {
124
+ throw new Error("Should not resolve");
125
+ }, (e: unknown) => {
126
+ must((e as Error).message).equal("[waitFor] Timeout");
127
+ must((e as Error).stack).include("callerOfWaitFor");
128
+ });
129
+ });
130
+
131
+ it("on max tries reached", async () => {
132
+ await callerOfWaitFor(() => false, { interval: 40, maxTries: 2 }).then(() => {
133
+ throw new Error("Should not resolve");
134
+ }, (e: unknown) => {
135
+ must((e as Error).message).equal("[waitFor] Max tries reached");
136
+ must((e as Error).stack).include("callerOfWaitFor");
137
+ });
138
+ });
139
+
140
+ it("on invalid maxTries", async () => {
141
+ await callerOfWaitFor(() => null, { maxTries: 0 }).then(() => {
142
+ throw new Error("Should not resolve");
143
+ }, (e: unknown) => {
144
+ must(e).instanceOf(TypeError);
145
+ must((e as Error).stack).include("callerOfWaitFor");
146
+ });
147
+ });
148
+
149
+ it("on check function throwing, and preserves original error as cause", async () => {
150
+ const original = new Error("boom");
151
+ await callerOfWaitFor(() => {
152
+ throw original;
153
+ }, { interval: 40 }).then(() => {
154
+ throw new Error("Should not resolve");
155
+ }, (e: unknown) => {
156
+ must((e as Error).message).equal("[waitFor] check function threw an error");
157
+ must((e as Error).stack).include("callerOfWaitFor");
158
+ must((e as Error & { cause?: unknown }).cause).equal(original);
159
+ must((e as Error & { details?: { error?: unknown } }).details!.error).equal(original);
160
+ });
161
+ });
162
+ });
163
+
103
164
  describe("treats most falsy values as succeeded check", () => {
104
165
  it("numeric zero", async () => {
105
166
  const result = await waitFor(() => 0);
package/src/waitFor.ts CHANGED
@@ -26,6 +26,18 @@ const defaultOptions: Required<Options> = {
26
26
  maxTries: Infinity,
27
27
  };
28
28
 
29
+ /**
30
+ * Appends the synchronously-captured caller frames onto an error created later (on a timer or
31
+ * microtask), so its stack trace points at whoever called `waitFor` instead of just node internals.
32
+ */
33
+ const graftCallerStack = (error: Error, callSite: Error): void => {
34
+ const callerFrames = callSite.stack?.replace(/^.*\n/u, "");
35
+ if (callerFrames) {
36
+ // eslint-disable-next-line no-param-reassign
37
+ error.stack = error.stack ? `${error.stack}\n${callerFrames}` : callerFrames;
38
+ }
39
+ };
40
+
29
41
  /**
30
42
  * Runs the callback function every specified interval and returns a Promise that resolves when the callback returns
31
43
  * any other value than `null`, `undefined` or `false`.
@@ -39,26 +51,35 @@ const defaultOptions: Required<Options> = {
39
51
  * @param fn - callback function
40
52
  * @param options - options object
41
53
  */
42
- const waitFor = <T>(fn: () => MaybePromise<T>, options: Options = defaultOptions): Promise<T> => {
54
+ const waitFor = <T>(fn: () => MaybePromise<T>, options: Options = defaultOptions): Promise<T> => { // eslint-disable-line max-lines-per-function
55
+ // Captured synchronously on the caller's stack: once execution hops onto a timer or
56
+ // microtask those frames are gone, so we graft them back onto any rejected error (see
57
+ // `graftCallerStack`) to keep production stack traces pointing at the actual caller.
58
+ const callSite = new Error();
59
+
43
60
  return new Promise<T>((resolve, reject) => {
44
61
  let intervalTimer: TTimeout, failTimer: TTimeout;
45
62
 
63
+ const fail = (error: Error): void => {
64
+ graftCallerStack(error, callSite);
65
+ clearTimeout(failTimer);
66
+ clearTimeout(intervalTimer);
67
+ reject(error);
68
+ };
69
+
46
70
  const opts = { ...defaultOptions, ...options };
47
71
  if (typeof opts.maxTries === "number" && opts.maxTries < 1) {
48
- reject(new TypeError("[waitFor] maxTries must be greater than 0"));
72
+ fail(new TypeError("[waitFor] maxTries must be greater than 0"));
49
73
  return;
50
74
  }
51
75
 
52
76
  if (Number.isFinite(opts.timeout)) {
53
77
  failTimer = setTimeout(() => {
54
- reject(new Error("[waitFor] Timeout"));
55
- clearTimeout(intervalTimer);
78
+ fail(new Error("[waitFor] Timeout"));
56
79
  }, opts.timeout);
57
80
  }
58
81
 
59
82
  let tries = 0;
60
-
61
- // eslint-disable-next-line max-statements
62
83
  const tryFn = (async () => {
63
84
  try {
64
85
  tries++;
@@ -71,24 +92,21 @@ const waitFor = <T>(fn: () => MaybePromise<T>, options: Options = defaultOptions
71
92
  }
72
93
  else {
73
94
  if (Number.isFinite(opts.maxTries) && tries >= opts.maxTries) {
74
- clearTimeout(failTimer);
75
- clearTimeout(intervalTimer);
76
- reject(new Error("[waitFor] Max tries reached"));
95
+ fail(new Error("[waitFor] Max tries reached"));
77
96
  return;
78
97
  }
79
98
 
80
99
  intervalTimer = setTimeout(() => {
81
100
  tryFn().catch(noop);
82
- }, options.interval);
101
+ }, opts.interval);
83
102
  }
84
103
  }
85
104
  catch (error: unknown) {
86
- clearTimeout(failTimer);
87
- clearTimeout(intervalTimer);
88
-
89
- const e: Error & { details?: unknown } = new Error("[waitFor] check function threw an error");
105
+ const e: Error & { details?: unknown } = new Error(
106
+ "[waitFor] check function threw an error", { cause: error },
107
+ );
90
108
  e.details = { error };
91
- reject(e);
109
+ fail(e);
92
110
  }
93
111
  });
94
112
  tryFn().catch(noop);
package/src/waitSync.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Synchronously wait for a given time, blocking the event loop [!]
3
- * @param {number} timeMs - time to wait
4
- * @returns {Promise<void>}
3
+ * @param timeMs - time to wait
4
+ * @returns
5
5
  */
6
6
  const waitSync = (timeMs = 0): void => {
7
7
  const s = Date.now();