@quenty/promise 10.18.1 → 10.19.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [10.19.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.18.1...@quenty/promise@10.19.0) (2026-07-18)
7
+
8
+ **Note:** Version bump only for package @quenty/promise
9
+
6
10
  ## [10.18.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.18.0...@quenty/promise@10.18.1) (2026-05-30)
7
11
 
8
12
  **Note:** Version bump only for package @quenty/promise
@@ -0,0 +1,10 @@
1
+ {
2
+ "targets": {
3
+ "test": {
4
+ "universeId": 9716264427,
5
+ "placeId": 102260698838672,
6
+ "project": "test/default.project.json",
7
+ "scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/promise",
3
- "version": "10.18.1",
3
+ "version": "10.19.0",
4
4
  "description": "Promise implementation for Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -32,10 +32,12 @@
32
32
  "@quenty/loader": "10.11.0",
33
33
  "@quenty/maid": "3.9.0",
34
34
  "@quenty/math": "2.7.5",
35
- "@quenty/signal": "7.13.1"
35
+ "@quenty/nevermore-test-runner": "1.4.0",
36
+ "@quenty/signal": "7.13.1",
37
+ "@quentystudios/jest-lua": "3.10.0-quenty.2"
36
38
  },
37
39
  "publishConfig": {
38
40
  "access": "public"
39
41
  },
40
- "gitHead": "598b2b62b36bdcbdbbd56f7db10c399831cc6eba"
42
+ "gitHead": "cbbb89635bfdcbf32f26a40422ac736292917cca"
41
43
  }
@@ -15,6 +15,9 @@ export type RetryOptions = {
15
15
  initialWaitTime: number,
16
16
  maxAttempts: number,
17
17
  printWarning: boolean,
18
+ -- Optional predicate consulted after each failure. Returning false stops retrying immediately and
19
+ -- rejects with that error (for failures that will not resolve by retrying).
20
+ shouldRetry: ((any) -> boolean)?,
18
21
  }
19
22
 
20
23
  --[=[
@@ -54,6 +57,13 @@ function PromiseRetryUtils.retry<T...>(callback: () -> Promise.Promise<T...>, op
54
57
  return
55
58
  end
56
59
 
60
+ -- Bail out early on a failure that will not resolve by retrying.
61
+ if options.shouldRetry and not options.shouldRetry(lastResults[2]) then
62
+ isLoopResolved = true
63
+ promise:Reject(table.unpack(lastResults, 2, lastResults.n))
64
+ return
65
+ end
66
+
57
67
  if attemptNumber ~= options.maxAttempts then
58
68
  local thisWaitTime = Math.jitter(waitTime * ((options.exponential or 2) ^ (attemptNumber - 1)))
59
69
  if options.printWarning then
@@ -0,0 +1,109 @@
1
+ --!nonstrict
2
+ --[[
3
+ @class PromiseRetryUtils.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Jest = require("Jest")
9
+ local Promise = require("Promise")
10
+ local PromiseRetryUtils = require("PromiseRetryUtils")
11
+ local PromiseTestUtils = require("PromiseTestUtils")
12
+
13
+ local describe = Jest.Globals.describe
14
+ local expect = Jest.Globals.expect
15
+ local it = Jest.Globals.it
16
+
17
+ local FAST_OPTIONS = {
18
+ initialWaitTime = 0,
19
+ maxAttempts = 3,
20
+ printWarning = false,
21
+ }
22
+
23
+ -- Returns a callback that rejects with `errorMessage` for its first `failureCount` attempts, then
24
+ -- resolves with `value`, and a counter table exposing how many attempts were made.
25
+ local function newFlakyCallback(failureCount: number, errorMessage: string, value: any)
26
+ local attempts = { count = 0 }
27
+ local function callback()
28
+ attempts.count += 1
29
+ if attempts.count <= failureCount then
30
+ -- Reject asynchronously so retry attaches a rejection handler (consuming the exception)
31
+ -- rather than reading an already-rejected promise, which would be flagged as uncaught.
32
+ return Promise.defer(function(_resolve, reject)
33
+ reject(errorMessage)
34
+ end)
35
+ end
36
+ return Promise.resolved(value)
37
+ end
38
+ return callback, attempts
39
+ end
40
+
41
+ describe("PromiseRetryUtils.retry", function()
42
+ it("resolves on the first attempt without retrying", function()
43
+ local callback, attempts = newFlakyCallback(0, "boom", "done")
44
+
45
+ local outcome, value = PromiseTestUtils.awaitOutcome(PromiseRetryUtils.retry(callback, FAST_OPTIONS))
46
+
47
+ expect(outcome).toEqual("resolved")
48
+ expect(value).toEqual("done")
49
+ expect(attempts.count).toEqual(1)
50
+ end)
51
+
52
+ it("retries after a failure and resolves once the callback succeeds", function()
53
+ local callback, attempts = newFlakyCallback(2, "boom", "done")
54
+
55
+ local outcome, value = PromiseTestUtils.awaitOutcome(PromiseRetryUtils.retry(callback, FAST_OPTIONS))
56
+
57
+ expect(outcome).toEqual("resolved")
58
+ expect(value).toEqual("done")
59
+ expect(attempts.count).toEqual(3)
60
+ end)
61
+
62
+ it("rejects after exhausting maxAttempts", function()
63
+ local callback, attempts = newFlakyCallback(math.huge, "always fails", nil)
64
+
65
+ local outcome, err = PromiseTestUtils.awaitOutcome(PromiseRetryUtils.retry(callback, FAST_OPTIONS))
66
+
67
+ expect(outcome).toEqual("rejected")
68
+ expect(string.find(tostring(err), "always fails", 1, true) ~= nil).toEqual(true)
69
+ expect(attempts.count).toEqual(3)
70
+ end)
71
+
72
+ it("stops immediately and rejects with the error when shouldRetry returns false", function()
73
+ local callback, attempts = newFlakyCallback(math.huge, "fatal 509", nil)
74
+
75
+ local outcome, err = PromiseTestUtils.awaitOutcome(PromiseRetryUtils.retry(callback, {
76
+ initialWaitTime = 0,
77
+ maxAttempts = 5,
78
+ printWarning = false,
79
+ shouldRetry = function()
80
+ return false
81
+ end,
82
+ }))
83
+
84
+ expect(outcome).toEqual("rejected")
85
+ expect(err).toEqual("fatal 509")
86
+ expect(attempts.count).toEqual(1)
87
+ end)
88
+
89
+ it("keeps retrying while shouldRetry returns true", function()
90
+ local callback, attempts = newFlakyCallback(math.huge, "retryable", nil)
91
+ local consulted = { count = 0 }
92
+
93
+ local outcome = PromiseTestUtils.awaitOutcome(PromiseRetryUtils.retry(callback, {
94
+ initialWaitTime = 0,
95
+ maxAttempts = 3,
96
+ printWarning = false,
97
+ shouldRetry = function(err)
98
+ consulted.count += 1
99
+ expect(err).toEqual("retryable")
100
+ return true
101
+ end,
102
+ }))
103
+
104
+ expect(outcome).toEqual("rejected")
105
+ expect(attempts.count).toEqual(3)
106
+ -- Consulted after every failure, including the final attempt.
107
+ expect(consulted.count).toEqual(3)
108
+ end)
109
+ end)
@@ -0,0 +1,86 @@
1
+ --!strict
2
+ --[=[
3
+ Test helpers for awaiting promises with a bounded timeout, so a hung promise fails the test
4
+ instead of freezing the runner. Awaiting races the promise against a timeout rather than polling
5
+ with `task.wait()`.
6
+
7
+ @class PromiseTestUtils
8
+ ]=]
9
+
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local Promise = require("Promise")
13
+ local PromiseUtils = require("PromiseUtils")
14
+
15
+ local PromiseTestUtils = {}
16
+
17
+ local DEFAULT_TIMEOUT = 5
18
+
19
+ --[=[
20
+ Yields until the promise settles (resolves or rejects) or the timeout elapses. Races the promise
21
+ against a timeout instead of polling.
22
+
23
+ @param promise Promise
24
+ @param timeout number? -- Defaults to 5 seconds
25
+ @return boolean -- true if the promise settled, false if it timed out
26
+ ]=]
27
+ function PromiseTestUtils.awaitSettled<T...>(promise: Promise.Promise<T...>, timeout: number?): boolean
28
+ if not promise:IsPending() then
29
+ -- Attach a handler so an already-rejected promise is not flagged as an uncaught exception.
30
+ promise:Catch(function() end)
31
+ return true
32
+ end
33
+
34
+ local settled = Promise.new()
35
+ local function markSettled()
36
+ settled:Resolve(true)
37
+ end
38
+ promise:Then(markSettled, markSettled)
39
+
40
+ local timedOut = PromiseUtils.delayed(timeout or DEFAULT_TIMEOUT):Then(function()
41
+ return false
42
+ end)
43
+
44
+ return (PromiseUtils.race({ settled, timedOut }):Wait())
45
+ end
46
+
47
+ --[=[
48
+ Yields until the predicate returns a truthy value or the timeout elapses. Used when the awaited
49
+ condition is observable state rather than a promise.
50
+
51
+ @param predicate () -> boolean
52
+ @param timeout number? -- Defaults to 5 seconds
53
+ @return boolean -- The final predicate result
54
+ ]=]
55
+ function PromiseTestUtils.awaitValue(predicate: () -> boolean, timeout: number?): boolean
56
+ local deadline = os.clock() + (timeout or DEFAULT_TIMEOUT)
57
+ while not predicate() and os.clock() < deadline do
58
+ task.wait()
59
+ end
60
+ return predicate()
61
+ end
62
+
63
+ --[=[
64
+ Attaches resolve/reject handlers synchronously (so the rejection is always handled and never
65
+ surfaces as an uncaught error) and yields for the outcome.
66
+
67
+ @param promise Promise
68
+ @param timeout number? -- Defaults to 5 seconds
69
+ @return "resolved" | "rejected" | "pending"
70
+ @return any -- The resolved value or rejection error
71
+ ]=]
72
+ function PromiseTestUtils.awaitOutcome<T...>(promise: Promise.Promise<T...>, timeout: number?): (string, any)
73
+ local outcome: string?
74
+ local payload: any
75
+ promise:Then(function(value)
76
+ outcome, payload = "resolved", value
77
+ end, function(err)
78
+ outcome, payload = "rejected", err
79
+ end)
80
+
81
+ PromiseTestUtils.awaitSettled(promise, timeout)
82
+
83
+ return outcome or "pending", payload
84
+ end
85
+
86
+ return PromiseTestUtils
@@ -0,0 +1,75 @@
1
+ --!nonstrict
2
+ --[[
3
+ @class PromiseTestUtils.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Jest = require("Jest")
9
+ local Promise = require("Promise")
10
+ local PromiseTestUtils = require("PromiseTestUtils")
11
+
12
+ local describe = Jest.Globals.describe
13
+ local expect = Jest.Globals.expect
14
+ local it = Jest.Globals.it
15
+
16
+ local SHORT_TIMEOUT = 0.1
17
+
18
+ describe("PromiseTestUtils.awaitSettled", function()
19
+ it("returns true for an already-resolved promise", function()
20
+ expect(PromiseTestUtils.awaitSettled(Promise.resolved(1))).toEqual(true)
21
+ end)
22
+
23
+ it("returns true for a rejected promise", function()
24
+ expect(PromiseTestUtils.awaitSettled(Promise.rejected("boom"))).toEqual(true)
25
+ end)
26
+
27
+ it("returns true once a pending promise resolves", function()
28
+ local promise = Promise.new()
29
+ task.defer(function()
30
+ promise:Resolve(true)
31
+ end)
32
+ expect(PromiseTestUtils.awaitSettled(promise)).toEqual(true)
33
+ end)
34
+
35
+ it("returns false when the promise never settles within the timeout", function()
36
+ expect(PromiseTestUtils.awaitSettled(Promise.new(), SHORT_TIMEOUT)).toEqual(false)
37
+ end)
38
+ end)
39
+
40
+ describe("PromiseTestUtils.awaitOutcome", function()
41
+ it("reports a resolved value", function()
42
+ local outcome, value = PromiseTestUtils.awaitOutcome(Promise.resolved(42))
43
+ expect(outcome).toEqual("resolved")
44
+ expect(value).toEqual(42)
45
+ end)
46
+
47
+ it("reports a rejection error", function()
48
+ local outcome, err = PromiseTestUtils.awaitOutcome(Promise.rejected("boom"))
49
+ expect(outcome).toEqual("rejected")
50
+ expect(err).toEqual("boom")
51
+ end)
52
+
53
+ it("reports pending when the promise never settles within the timeout", function()
54
+ local outcome = PromiseTestUtils.awaitOutcome(Promise.new(), SHORT_TIMEOUT)
55
+ expect(outcome).toEqual("pending")
56
+ end)
57
+ end)
58
+
59
+ describe("PromiseTestUtils.awaitValue", function()
60
+ it("returns true once the predicate becomes true", function()
61
+ local flag = { value = false }
62
+ task.defer(function()
63
+ flag.value = true
64
+ end)
65
+ expect(PromiseTestUtils.awaitValue(function()
66
+ return flag.value
67
+ end)).toEqual(true)
68
+ end)
69
+
70
+ it("returns false when the predicate never becomes true within the timeout", function()
71
+ expect(PromiseTestUtils.awaitValue(function()
72
+ return false
73
+ end, SHORT_TIMEOUT)).toEqual(false)
74
+ end)
75
+ end)
@@ -12,9 +12,11 @@ local Signal = require("Signal")
12
12
  local PromiseUtils = {}
13
13
 
14
14
  --[=[
15
- Returns the value of the first promise resolved
16
- @param promises { Promise<T> }
17
- @return Promise<T> -- Promise that resolves with first result
15
+ Settles with the first of the given promises to settle, resolving if it resolved or rejecting if
16
+ it rejected. Also available as [PromiseUtils.race].
17
+
18
+ @param promises { Promise<T...> }
19
+ @return Promise<T...> -- Promise that settles with the first result
18
20
  ]=]
19
21
  function PromiseUtils.any<T...>(promises: { Promise.Promise<T...> }): Promise.Promise<T...>
20
22
  local returnPromise = Promise.new()
@@ -34,6 +36,17 @@ function PromiseUtils.any<T...>(promises: { Promise.Promise<T...> }): Promise.Pr
34
36
  return returnPromise
35
37
  end
36
38
 
39
+ --[=[
40
+ Alias for [PromiseUtils.any]. Settles with the first of the given promises to settle, resolving
41
+ if it resolved or rejecting if it rejected.
42
+
43
+ @function race
44
+ @within PromiseUtils
45
+ @param promises { Promise<T...> }
46
+ @return Promise<T...>
47
+ ]=]
48
+ PromiseUtils.race = PromiseUtils.any
49
+
37
50
  --[=[
38
51
  Returns a promise that will resolve after the set amount of seconds
39
52
 
@@ -0,0 +1,3 @@
1
+ return {
2
+ testMatch = { "**/*.spec" },
3
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "PromiseTest",
3
+ "globIgnorePaths": [
4
+ "**/.package-lock.json",
5
+ "**/.pnpm",
6
+ "**/.pnpm-workspace-state-v1.json",
7
+ "**/.modules.yaml",
8
+ "**/.ignored",
9
+ "**/.ignored_*"
10
+ ],
11
+ "tree": {
12
+ "$className": "DataModel",
13
+ "ServerScriptService": {
14
+ "$properties": {
15
+ "LoadStringEnabled": true
16
+ },
17
+ "promise": {
18
+ "$path": ".."
19
+ },
20
+ "Script": {
21
+ "$path": "scripts/Server"
22
+ }
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,14 @@
1
+ --!nonstrict
2
+ --[[
3
+ @class ServerMain
4
+ ]]
5
+
6
+ local ServerScriptService = game:GetService("ServerScriptService")
7
+
8
+ local root = ServerScriptService.promise
9
+ local loader = root:FindFirstChild("LoaderUtils", true).Parent
10
+ local require = require(loader).bootstrapGame(root)
11
+
12
+ local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
13
+
14
+ NevermoreTestRunnerUtils.runTestsIfNeededAsync(root)