@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 +4 -0
- package/deploy.nevermore.json +10 -0
- package/package.json +5 -3
- package/src/Shared/PromiseRetryUtils.lua +10 -0
- package/src/Shared/PromiseRetryUtils.spec.lua +109 -0
- package/src/Shared/PromiseTestUtils.lua +86 -0
- package/src/Shared/PromiseTestUtils.spec.lua +75 -0
- package/src/Shared/PromiseUtils.lua +16 -3
- package/src/jest.config.lua +3 -0
- package/test/default.project.json +25 -0
- package/test/scripts/Server/ServerMain.server.lua +14 -0
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/promise",
|
|
3
|
-
"version": "10.
|
|
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/
|
|
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": "
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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,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)
|