@quenty/promise 10.20.0 → 10.22.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 +13 -0
- package/package.json +4 -4
- package/src/Shared/Promise.lua +30 -10
- package/src/Shared/Promise.spec.lua +564 -0
- package/src/Shared/PromiseRetryUtils.spec.lua +0 -3
- package/src/Shared/PromiseUtils.lua +17 -3
- package/src/Shared/PromiseUtils.spec.lua +275 -0
- package/src/Shared/Utility/PendingPromiseTracker.spec.lua +69 -0
- package/src/Shared/Utility/PromiseInstanceUtils.spec.lua +41 -0
- package/src/Shared/Utility/promiseChild.spec.lua +64 -0
- package/src/Shared/Utility/promisePropertyValue.spec.lua +53 -0
- package/src/Shared/Utility/promiseWait.spec.lua +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
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.22.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.21.0...@quenty/promise@10.22.0) (2026-07-23)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- Even more stuff ([fea7e95](https://github.com/Quenty/NevermoreEngine/commit/fea7e9587e7195bbfa7f4753c130a3d597f9a34b))
|
|
11
|
+
|
|
12
|
+
# [10.21.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.20.0...@quenty/promise@10.21.0) (2026-07-23)
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- Add baseline player-mock and support across Nevermore for mocked players. ([567d121](https://github.com/Quenty/NevermoreEngine/commit/567d121ffc014b42391554088189a1a6296dda83))
|
|
17
|
+
- Add promise tests ([ff0a2a6](https://github.com/Quenty/NevermoreEngine/commit/ff0a2a6f72bcd63dbecf1af0a57870bac62c77ca))
|
|
18
|
+
|
|
6
19
|
# [10.20.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.19.0...@quenty/promise@10.20.0) (2026-07-18)
|
|
7
20
|
|
|
8
21
|
**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.22.0",
|
|
4
4
|
"description": "Promise implementation for Roblox",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@quenty/deferred": "2.3.2",
|
|
32
32
|
"@quenty/loader": "10.11.0",
|
|
33
|
-
"@quenty/maid": "3.
|
|
33
|
+
"@quenty/maid": "3.11.0",
|
|
34
34
|
"@quenty/math": "2.7.5",
|
|
35
|
-
"@quenty/nevermore-test-runner": "1.
|
|
35
|
+
"@quenty/nevermore-test-runner": "1.5.0",
|
|
36
36
|
"@quenty/signal": "7.13.1",
|
|
37
37
|
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
|
-
"gitHead": "
|
|
42
|
+
"gitHead": "88b03743718172ff79cd62ade64a86565fcac17d"
|
|
43
43
|
}
|
package/src/Shared/Promise.lua
CHANGED
|
@@ -188,7 +188,8 @@ end
|
|
|
188
188
|
|
|
189
189
|
--[=[
|
|
190
190
|
Yields until the promise is complete, and errors if an error
|
|
191
|
-
exists, otherwise returns the fulfilled results.
|
|
191
|
+
exists, otherwise returns the fulfilled results. Raising the rejection to the caller consumes it —
|
|
192
|
+
no uncaught-exception warning fires for a rejection surfaced here.
|
|
192
193
|
|
|
193
194
|
@yields
|
|
194
195
|
@return T
|
|
@@ -197,6 +198,7 @@ function Promise.Wait<T...>(self: Promise<T...>): T...
|
|
|
197
198
|
if self._fulfilled then
|
|
198
199
|
return table.unpack(self._fulfilled, 1, self._fulfilled.n)
|
|
199
200
|
elseif self._rejected then
|
|
201
|
+
self._unconsumedException = false
|
|
200
202
|
error(tostring(self._rejected[1]), 2)
|
|
201
203
|
else
|
|
202
204
|
local waitingCoroutine = coroutine.running()
|
|
@@ -227,7 +229,8 @@ end
|
|
|
227
229
|
|
|
228
230
|
--[=[
|
|
229
231
|
Yields until the promise is complete, then returns a boolean indicating
|
|
230
|
-
the result, followed by the values from the promise.
|
|
232
|
+
the result, followed by the values from the promise. Reading the outcome consumes a rejection — the
|
|
233
|
+
caller has inspected it, so no uncaught-exception warning fires for it.
|
|
231
234
|
|
|
232
235
|
@yields
|
|
233
236
|
@return boolean, T
|
|
@@ -236,6 +239,7 @@ function Promise.Yield<T...>(self: Promise<T...>): (boolean, T...)
|
|
|
236
239
|
if self._fulfilled then
|
|
237
240
|
return true, table.unpack(self._fulfilled, 1, self._fulfilled.n)
|
|
238
241
|
elseif self._rejected then
|
|
242
|
+
self._unconsumedException = false
|
|
239
243
|
return false, table.unpack(self._rejected, 1, self._rejected.n)
|
|
240
244
|
else
|
|
241
245
|
local waitingCoroutine = coroutine.running()
|
|
@@ -346,7 +350,7 @@ function Promise.Reject<T...>(self: Promise<T...>, ...: any): ()
|
|
|
346
350
|
self:_reject(table.pack(...))
|
|
347
351
|
end
|
|
348
352
|
|
|
349
|
-
function Promise._reject<T...>(self: Promise<T...>, values: { any }): ()
|
|
353
|
+
function Promise._reject<T...>(self: Promise<T...>, values: { n: number, [number]: any }): ()
|
|
350
354
|
if not self._pendingExecuteList then
|
|
351
355
|
return
|
|
352
356
|
end
|
|
@@ -359,8 +363,11 @@ function Promise._reject<T...>(self: Promise<T...>, values: { any }): ()
|
|
|
359
363
|
self:_executeThen(unpack(data))
|
|
360
364
|
end
|
|
361
365
|
|
|
362
|
-
-- Check for uncaught exceptions
|
|
363
|
-
|
|
366
|
+
-- Check for uncaught exceptions. A single empty table carries no more information than an
|
|
367
|
+
-- empty rejection (aggregators may reject with an empty results table), so it is not
|
|
368
|
+
-- reported either.
|
|
369
|
+
local isEmptyTableRejection = values.n == 1 and type(values[1]) == "table" and next(values[1]) == nil
|
|
370
|
+
if self._unconsumedException and values.n > 0 and not isEmptyTableRejection then
|
|
364
371
|
task.defer(function()
|
|
365
372
|
-- Yield to end of frame, giving control back to Roblox.
|
|
366
373
|
-- This is the equivalent of giving something back to a task manager.
|
|
@@ -381,8 +388,10 @@ end
|
|
|
381
388
|
|
|
382
389
|
function Promise._toHumanReadable<T...>(_self: Promise<T...>, data: any): string
|
|
383
390
|
if type(data) == "table" then
|
|
391
|
+
-- A custom __tostring is the best human-readable form; only address-form tables
|
|
392
|
+
-- ("table: 0x...") fall through to JSON encoding.
|
|
384
393
|
local converted = tostring(data)
|
|
385
|
-
if string.sub(converted, 1,
|
|
394
|
+
if string.sub(converted, 1, 9) ~= "table: 0x" then
|
|
386
395
|
return converted
|
|
387
396
|
end
|
|
388
397
|
|
|
@@ -424,15 +433,24 @@ function Promise.Then<T...>(
|
|
|
424
433
|
onFulfilled: ((T...) -> ...any)?,
|
|
425
434
|
onRejected: ((...any) -> ...any)?
|
|
426
435
|
): Promise<...any>
|
|
427
|
-
if
|
|
436
|
+
if self._pendingExecuteList then
|
|
437
|
+
-- Attaching any continuation delegates this promise's rejection to the returned
|
|
438
|
+
-- child promise, which becomes the new carrier of the unconsumed exception. So the
|
|
439
|
+
-- parent is consumed even without an onRejected here: a rejection flowing through a
|
|
440
|
+
-- handler-less Then is forwarded, not dropped, and the warning surfaces only at the
|
|
441
|
+
-- tail of the chain if nobody ever handles it.
|
|
428
442
|
self._unconsumedException = false
|
|
429
|
-
end
|
|
430
443
|
|
|
431
|
-
if self._pendingExecuteList then
|
|
432
444
|
local promise: Promise<T...> = Promise.new()
|
|
433
445
|
self._pendingExecuteList[#self._pendingExecuteList + 1] = { onFulfilled, onRejected, promise } :: { any }
|
|
434
446
|
return promise
|
|
435
447
|
else
|
|
448
|
+
-- Already settled: _executeThen returns self when there is no matching handler, so
|
|
449
|
+
-- self stays the carrier. Only an actual onRejected consumes the rejection here.
|
|
450
|
+
if type(onRejected) == "function" then
|
|
451
|
+
self._unconsumedException = false
|
|
452
|
+
end
|
|
453
|
+
|
|
436
454
|
return self:_executeThen(onFulfilled, onRejected, nil)
|
|
437
455
|
end
|
|
438
456
|
end
|
|
@@ -501,7 +519,8 @@ function Promise.Destroy<T...>(self: Promise<T...>): ()
|
|
|
501
519
|
end
|
|
502
520
|
|
|
503
521
|
--[=[
|
|
504
|
-
Returns the results from the promise.
|
|
522
|
+
Returns the results from the promise. Reading the outcome consumes a rejection — the caller has
|
|
523
|
+
inspected it, so no uncaught-exception warning fires for it.
|
|
505
524
|
|
|
506
525
|
:::warning
|
|
507
526
|
This API surface will error if the promise is still pending.
|
|
@@ -512,6 +531,7 @@ end
|
|
|
512
531
|
]=]
|
|
513
532
|
function Promise.GetResults<T...>(self: Promise<T...>): (boolean, T...)
|
|
514
533
|
if self._rejected then
|
|
534
|
+
self._unconsumedException = false
|
|
515
535
|
return false, table.unpack(self._rejected, 1, self._rejected.n)
|
|
516
536
|
elseif self._fulfilled then
|
|
517
537
|
return true, table.unpack(self._fulfilled, 1, self._fulfilled.n)
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
@class Promise.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
|
+
describe("Promise.isPromise", function()
|
|
17
|
+
it("returns true for a promise", function()
|
|
18
|
+
expect(Promise.isPromise(Promise.new())).toEqual(true)
|
|
19
|
+
expect(Promise.isPromise(Promise.resolved(1))).toEqual(true)
|
|
20
|
+
end)
|
|
21
|
+
|
|
22
|
+
it("returns false for non-promise values", function()
|
|
23
|
+
expect(Promise.isPromise(nil)).toEqual(false)
|
|
24
|
+
expect(Promise.isPromise(5)).toEqual(false)
|
|
25
|
+
expect(Promise.isPromise("Promise")).toEqual(false)
|
|
26
|
+
expect(Promise.isPromise({})).toEqual(false)
|
|
27
|
+
expect(Promise.isPromise({ ClassName = "NotAPromise" })).toEqual(false)
|
|
28
|
+
end)
|
|
29
|
+
end)
|
|
30
|
+
|
|
31
|
+
describe("Promise.new", function()
|
|
32
|
+
it("starts pending with no executor", function()
|
|
33
|
+
local promise = Promise.new()
|
|
34
|
+
expect(promise:IsPending()).toEqual(true)
|
|
35
|
+
expect(promise:IsFulfilled()).toEqual(false)
|
|
36
|
+
expect(promise:IsRejected()).toEqual(false)
|
|
37
|
+
end)
|
|
38
|
+
|
|
39
|
+
it("resolves synchronously when the executor calls resolve", function()
|
|
40
|
+
local promise = Promise.new(function(resolve)
|
|
41
|
+
resolve("value")
|
|
42
|
+
end)
|
|
43
|
+
|
|
44
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
45
|
+
expect(outcome).toEqual("resolved")
|
|
46
|
+
expect(value).toEqual("value")
|
|
47
|
+
end)
|
|
48
|
+
|
|
49
|
+
it("rejects when the executor calls reject", function()
|
|
50
|
+
local promise = Promise.new(function(_resolve, reject)
|
|
51
|
+
reject("boom")
|
|
52
|
+
end)
|
|
53
|
+
|
|
54
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(promise)
|
|
55
|
+
expect(outcome).toEqual("rejected")
|
|
56
|
+
expect(err).toEqual("boom")
|
|
57
|
+
end)
|
|
58
|
+
end)
|
|
59
|
+
|
|
60
|
+
describe("Promise.spawn", function()
|
|
61
|
+
it("resolves from a spawned executor", function()
|
|
62
|
+
local promise = Promise.spawn(function(resolve)
|
|
63
|
+
resolve("spawned")
|
|
64
|
+
end)
|
|
65
|
+
|
|
66
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
67
|
+
expect(outcome).toEqual("resolved")
|
|
68
|
+
expect(value).toEqual("spawned")
|
|
69
|
+
end)
|
|
70
|
+
end)
|
|
71
|
+
|
|
72
|
+
describe("Promise.delay", function()
|
|
73
|
+
it("resolves after the delay elapses", function()
|
|
74
|
+
local promise = Promise.delay(0, function(resolve)
|
|
75
|
+
resolve("delayed")
|
|
76
|
+
end)
|
|
77
|
+
|
|
78
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
79
|
+
expect(outcome).toEqual("resolved")
|
|
80
|
+
expect(value).toEqual("delayed")
|
|
81
|
+
end)
|
|
82
|
+
|
|
83
|
+
it("is pending before the delay elapses", function()
|
|
84
|
+
local promise = Promise.delay(10, function(resolve)
|
|
85
|
+
resolve("late")
|
|
86
|
+
end)
|
|
87
|
+
expect(promise:IsPending()).toEqual(true)
|
|
88
|
+
end)
|
|
89
|
+
end)
|
|
90
|
+
|
|
91
|
+
describe("Promise.defer", function()
|
|
92
|
+
it("resolves from a deferred executor", function()
|
|
93
|
+
local promise = Promise.defer(function(resolve)
|
|
94
|
+
resolve("deferred")
|
|
95
|
+
end)
|
|
96
|
+
|
|
97
|
+
expect(promise:IsPending()).toEqual(true)
|
|
98
|
+
|
|
99
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
100
|
+
expect(outcome).toEqual("resolved")
|
|
101
|
+
expect(value).toEqual("deferred")
|
|
102
|
+
end)
|
|
103
|
+
end)
|
|
104
|
+
|
|
105
|
+
describe("Promise.resolved", function()
|
|
106
|
+
it("creates an already-fulfilled promise", function()
|
|
107
|
+
local promise = Promise.resolved("done")
|
|
108
|
+
expect(promise:IsFulfilled()).toEqual(true)
|
|
109
|
+
expect(promise:IsPending()).toEqual(false)
|
|
110
|
+
|
|
111
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
112
|
+
expect(outcome).toEqual("resolved")
|
|
113
|
+
expect(value).toEqual("done")
|
|
114
|
+
end)
|
|
115
|
+
|
|
116
|
+
it("reuses a single shared promise when resolving with no values", function()
|
|
117
|
+
expect(Promise.resolved()).toBe(Promise.resolved())
|
|
118
|
+
end)
|
|
119
|
+
|
|
120
|
+
it("returns the same promise when resolving an already-settled promise", function()
|
|
121
|
+
local inner = Promise.resolved("inner")
|
|
122
|
+
expect(Promise.resolved(inner)).toBe(inner)
|
|
123
|
+
end)
|
|
124
|
+
end)
|
|
125
|
+
|
|
126
|
+
describe("Promise.rejected", function()
|
|
127
|
+
it("creates an already-rejected promise", function()
|
|
128
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(Promise.rejected("nope"))
|
|
129
|
+
expect(outcome).toEqual("rejected")
|
|
130
|
+
expect(err).toEqual("nope")
|
|
131
|
+
end)
|
|
132
|
+
|
|
133
|
+
it("reuses a single shared promise when rejecting with no values", function()
|
|
134
|
+
expect(Promise.rejected()).toBe(Promise.rejected())
|
|
135
|
+
end)
|
|
136
|
+
end)
|
|
137
|
+
|
|
138
|
+
describe("Promise:Resolve", function()
|
|
139
|
+
it("fulfills a pending promise", function()
|
|
140
|
+
local promise = Promise.new()
|
|
141
|
+
promise:Resolve(1, 2, 3)
|
|
142
|
+
|
|
143
|
+
local ok, a, b, c = promise:GetResults()
|
|
144
|
+
expect(ok).toEqual(true)
|
|
145
|
+
expect(a).toEqual(1)
|
|
146
|
+
expect(b).toEqual(2)
|
|
147
|
+
expect(c).toEqual(3)
|
|
148
|
+
end)
|
|
149
|
+
|
|
150
|
+
it("rejects with a TypeError when resolving to itself", function()
|
|
151
|
+
local promise = Promise.new()
|
|
152
|
+
promise:Resolve(promise)
|
|
153
|
+
|
|
154
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(promise)
|
|
155
|
+
expect(outcome).toEqual("rejected")
|
|
156
|
+
expect(string.find(tostring(err), "Resolved to self", 1, true) ~= nil).toEqual(true)
|
|
157
|
+
end)
|
|
158
|
+
|
|
159
|
+
it("adopts the state of a resolved promise", function()
|
|
160
|
+
local promise = Promise.new()
|
|
161
|
+
promise:Resolve(Promise.resolved("adopted"))
|
|
162
|
+
|
|
163
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
164
|
+
expect(outcome).toEqual("resolved")
|
|
165
|
+
expect(value).toEqual("adopted")
|
|
166
|
+
end)
|
|
167
|
+
|
|
168
|
+
it("adopts the rejection of a rejected promise", function()
|
|
169
|
+
local promise = Promise.new()
|
|
170
|
+
promise:Resolve(Promise.rejected("adopted rejection"))
|
|
171
|
+
|
|
172
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(promise)
|
|
173
|
+
expect(outcome).toEqual("rejected")
|
|
174
|
+
expect(err).toEqual("adopted rejection")
|
|
175
|
+
end)
|
|
176
|
+
|
|
177
|
+
it("adopts the state of a pending promise once it settles", function()
|
|
178
|
+
local inner = Promise.new()
|
|
179
|
+
local promise = Promise.new()
|
|
180
|
+
promise:Resolve(inner)
|
|
181
|
+
|
|
182
|
+
expect(promise:IsPending()).toEqual(true)
|
|
183
|
+
|
|
184
|
+
inner:Resolve("later")
|
|
185
|
+
|
|
186
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
187
|
+
expect(outcome).toEqual("resolved")
|
|
188
|
+
expect(value).toEqual("later")
|
|
189
|
+
end)
|
|
190
|
+
|
|
191
|
+
it("is a no-op once the promise has settled", function()
|
|
192
|
+
local promise = Promise.resolved("first")
|
|
193
|
+
promise:Resolve("second")
|
|
194
|
+
|
|
195
|
+
local _ok, value = promise:GetResults()
|
|
196
|
+
expect(value).toEqual("first")
|
|
197
|
+
end)
|
|
198
|
+
end)
|
|
199
|
+
|
|
200
|
+
describe("Promise:Reject", function()
|
|
201
|
+
it("rejects a pending promise", function()
|
|
202
|
+
local promise = Promise.new()
|
|
203
|
+
promise:Catch(function() end)
|
|
204
|
+
promise:Reject("rejected")
|
|
205
|
+
|
|
206
|
+
local ok, err = promise:GetResults()
|
|
207
|
+
expect(ok).toEqual(false)
|
|
208
|
+
expect(err).toEqual("rejected")
|
|
209
|
+
end)
|
|
210
|
+
|
|
211
|
+
it("is a no-op once the promise has settled", function()
|
|
212
|
+
local promise = Promise.resolved("value")
|
|
213
|
+
promise:Reject("too late")
|
|
214
|
+
expect(promise:IsFulfilled()).toEqual(true)
|
|
215
|
+
expect(promise:IsRejected()).toEqual(false)
|
|
216
|
+
end)
|
|
217
|
+
end)
|
|
218
|
+
|
|
219
|
+
describe("Promise:Wait", function()
|
|
220
|
+
it("returns the fulfilled values", function()
|
|
221
|
+
local a, b = Promise.resolved(1, 2):Wait()
|
|
222
|
+
expect(a).toEqual(1)
|
|
223
|
+
expect(b).toEqual(2)
|
|
224
|
+
end)
|
|
225
|
+
|
|
226
|
+
it("errors when the promise is rejected", function()
|
|
227
|
+
local promise = Promise.rejected("boom")
|
|
228
|
+
local ok, err = pcall(function()
|
|
229
|
+
promise:Wait()
|
|
230
|
+
end)
|
|
231
|
+
expect(ok).toEqual(false)
|
|
232
|
+
expect(string.find(tostring(err), "boom", 1, true) ~= nil).toEqual(true)
|
|
233
|
+
end)
|
|
234
|
+
|
|
235
|
+
it("yields until a pending promise resolves", function()
|
|
236
|
+
local promise = Promise.new()
|
|
237
|
+
task.defer(function()
|
|
238
|
+
promise:Resolve("eventually")
|
|
239
|
+
end)
|
|
240
|
+
expect(promise:Wait()).toEqual("eventually")
|
|
241
|
+
end)
|
|
242
|
+
end)
|
|
243
|
+
|
|
244
|
+
describe("Promise:Yield", function()
|
|
245
|
+
it("returns true and the values when fulfilled", function()
|
|
246
|
+
local ok, value = Promise.resolved("ok"):Yield()
|
|
247
|
+
expect(ok).toEqual(true)
|
|
248
|
+
expect(value).toEqual("ok")
|
|
249
|
+
end)
|
|
250
|
+
|
|
251
|
+
it("returns false and the error when rejected", function()
|
|
252
|
+
local promise = Promise.rejected("bad")
|
|
253
|
+
local ok, err = promise:Yield()
|
|
254
|
+
expect(ok).toEqual(false)
|
|
255
|
+
expect(err).toEqual("bad")
|
|
256
|
+
end)
|
|
257
|
+
|
|
258
|
+
it("yields until a pending promise settles", function()
|
|
259
|
+
local promise = Promise.new()
|
|
260
|
+
task.defer(function()
|
|
261
|
+
promise:Resolve("settled")
|
|
262
|
+
end)
|
|
263
|
+
local ok, value = promise:Yield()
|
|
264
|
+
expect(ok).toEqual(true)
|
|
265
|
+
expect(value).toEqual("settled")
|
|
266
|
+
end)
|
|
267
|
+
end)
|
|
268
|
+
|
|
269
|
+
describe("Promise:Then", function()
|
|
270
|
+
it("calls onFulfilled with the resolved value", function()
|
|
271
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(Promise.resolved(10):Then(function(n)
|
|
272
|
+
return n * 2
|
|
273
|
+
end))
|
|
274
|
+
expect(outcome).toEqual("resolved")
|
|
275
|
+
expect(value).toEqual(20)
|
|
276
|
+
end)
|
|
277
|
+
|
|
278
|
+
it("calls onRejected with the rejection reason", function()
|
|
279
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(Promise.rejected("err"):Then(nil, function(err)
|
|
280
|
+
return "recovered from " .. err
|
|
281
|
+
end))
|
|
282
|
+
expect(outcome).toEqual("resolved")
|
|
283
|
+
expect(value).toEqual("recovered from err")
|
|
284
|
+
end)
|
|
285
|
+
|
|
286
|
+
it("propagates fulfillment when onFulfilled is omitted", function()
|
|
287
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(Promise.resolved("passthrough"):Then(nil, function() end))
|
|
288
|
+
expect(outcome).toEqual("resolved")
|
|
289
|
+
expect(value).toEqual("passthrough")
|
|
290
|
+
end)
|
|
291
|
+
|
|
292
|
+
it("propagates rejection when onRejected is omitted", function()
|
|
293
|
+
local source = Promise.rejected("still bad")
|
|
294
|
+
-- Then with no onRejected does not consume the source rejection, so handle it separately to
|
|
295
|
+
-- avoid an uncaught-exception warning.
|
|
296
|
+
source:Catch(function() end)
|
|
297
|
+
|
|
298
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(source:Then(function() end))
|
|
299
|
+
expect(outcome).toEqual("rejected")
|
|
300
|
+
expect(err).toEqual("still bad")
|
|
301
|
+
end)
|
|
302
|
+
|
|
303
|
+
it("runs handlers attached while pending", function()
|
|
304
|
+
local promise = Promise.new()
|
|
305
|
+
local chained = promise:Then(function(n)
|
|
306
|
+
return n + 1
|
|
307
|
+
end)
|
|
308
|
+
|
|
309
|
+
task.defer(function()
|
|
310
|
+
promise:Resolve(41)
|
|
311
|
+
end)
|
|
312
|
+
|
|
313
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(chained)
|
|
314
|
+
expect(outcome).toEqual("resolved")
|
|
315
|
+
expect(value).toEqual(42)
|
|
316
|
+
end)
|
|
317
|
+
|
|
318
|
+
it("chains when the handler returns a promise", function()
|
|
319
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(Promise.resolved(1):Then(function()
|
|
320
|
+
return Promise.resolved("from inner promise")
|
|
321
|
+
end))
|
|
322
|
+
expect(outcome).toEqual("resolved")
|
|
323
|
+
expect(value).toEqual("from inner promise")
|
|
324
|
+
end)
|
|
325
|
+
end)
|
|
326
|
+
|
|
327
|
+
describe("Promise:Then unconsumed-exception propagation", function()
|
|
328
|
+
it("consumes a pending promise once any continuation is attached", function()
|
|
329
|
+
local root = Promise.new()
|
|
330
|
+
expect((root :: any)._unconsumedException).toEqual(true)
|
|
331
|
+
|
|
332
|
+
root:Then(function() end)
|
|
333
|
+
|
|
334
|
+
expect((root :: any)._unconsumedException).toEqual(false)
|
|
335
|
+
end)
|
|
336
|
+
|
|
337
|
+
it("transfers the unconsumed exception to the returned child", function()
|
|
338
|
+
local root = Promise.new()
|
|
339
|
+
local child = root:Then(function() end)
|
|
340
|
+
|
|
341
|
+
expect((root :: any)._unconsumedException).toEqual(false)
|
|
342
|
+
expect((child :: any)._unconsumedException).toEqual(true)
|
|
343
|
+
|
|
344
|
+
root:Reject("boom")
|
|
345
|
+
|
|
346
|
+
-- Consume the child before the deferred uncaught-exception warning fires.
|
|
347
|
+
child:Catch(function() end)
|
|
348
|
+
end)
|
|
349
|
+
|
|
350
|
+
it("propagates a rejection through handler-less Thens to a downstream handler", function()
|
|
351
|
+
local root = Promise.new()
|
|
352
|
+
local tail = root:Then(function() end):Then(function() end):Then(nil, function(err)
|
|
353
|
+
return "recovered from " .. err
|
|
354
|
+
end)
|
|
355
|
+
|
|
356
|
+
root:Reject("boom")
|
|
357
|
+
|
|
358
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(tail)
|
|
359
|
+
expect(outcome).toEqual("resolved")
|
|
360
|
+
expect(value).toEqual("recovered from boom")
|
|
361
|
+
end)
|
|
362
|
+
|
|
363
|
+
it("consumes every intermediate hop so none of them warn", function()
|
|
364
|
+
local root = Promise.new()
|
|
365
|
+
local hop1 = root:Then(function() end)
|
|
366
|
+
local hop2 = hop1:Then(function() end)
|
|
367
|
+
hop2:Then(nil, function(err)
|
|
368
|
+
return err
|
|
369
|
+
end)
|
|
370
|
+
|
|
371
|
+
root:Reject("boom")
|
|
372
|
+
|
|
373
|
+
expect((root :: any)._unconsumedException).toEqual(false)
|
|
374
|
+
expect((hop1 :: any)._unconsumedException).toEqual(false)
|
|
375
|
+
expect((hop2 :: any)._unconsumedException).toEqual(false)
|
|
376
|
+
end)
|
|
377
|
+
|
|
378
|
+
it("leaves an unhandled tail flagged so the error is not silently dropped", function()
|
|
379
|
+
local root = Promise.new()
|
|
380
|
+
local tail = root:Then(function() end):Then(function() end)
|
|
381
|
+
|
|
382
|
+
root:Reject("boom")
|
|
383
|
+
|
|
384
|
+
expect(tail:IsRejected()).toEqual(true)
|
|
385
|
+
expect((tail :: any)._unconsumedException).toEqual(true)
|
|
386
|
+
|
|
387
|
+
-- Consume it before the deferred uncaught-exception warning fires.
|
|
388
|
+
tail:Catch(function() end)
|
|
389
|
+
expect((tail :: any)._unconsumedException).toEqual(false)
|
|
390
|
+
end)
|
|
391
|
+
|
|
392
|
+
it("gives each fan-out branch its own unconsumed carrier", function()
|
|
393
|
+
local root = Promise.new()
|
|
394
|
+
local branchA = root:Then(function() end)
|
|
395
|
+
local branchB = root:Then(function() end)
|
|
396
|
+
|
|
397
|
+
root:Reject("boom")
|
|
398
|
+
|
|
399
|
+
expect((branchA :: any)._unconsumedException).toEqual(true)
|
|
400
|
+
expect((branchB :: any)._unconsumedException).toEqual(true)
|
|
401
|
+
|
|
402
|
+
branchA:Catch(function() end)
|
|
403
|
+
branchB:Catch(function() end)
|
|
404
|
+
end)
|
|
405
|
+
|
|
406
|
+
it("does not consume an already-rejected source when onRejected is omitted", function()
|
|
407
|
+
local source = Promise.rejected("nope")
|
|
408
|
+
-- Settled + no onRejected returns self, so self stays the carrier and must remain flagged.
|
|
409
|
+
local same = source:Then(function() end)
|
|
410
|
+
|
|
411
|
+
expect(same == source).toEqual(true)
|
|
412
|
+
expect((source :: any)._unconsumedException).toEqual(true)
|
|
413
|
+
|
|
414
|
+
source:Catch(function() end)
|
|
415
|
+
expect((source :: any)._unconsumedException).toEqual(false)
|
|
416
|
+
end)
|
|
417
|
+
|
|
418
|
+
it("runs the tail error handler for the datastore graceful-close shape", function()
|
|
419
|
+
local outer = Promise.new()
|
|
420
|
+
local root = Promise.new()
|
|
421
|
+
|
|
422
|
+
root:Then(function()
|
|
423
|
+
return "unused"
|
|
424
|
+
end)
|
|
425
|
+
:Then(function()
|
|
426
|
+
return "unused"
|
|
427
|
+
end)
|
|
428
|
+
:Then(function(value)
|
|
429
|
+
outer:Resolve(value)
|
|
430
|
+
end, function(err)
|
|
431
|
+
outer:Reject(string.format("locked: %s", err))
|
|
432
|
+
end)
|
|
433
|
+
|
|
434
|
+
root:Reject("still locked")
|
|
435
|
+
|
|
436
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(outer)
|
|
437
|
+
expect(outcome).toEqual("rejected")
|
|
438
|
+
expect(err).toEqual("locked: still locked")
|
|
439
|
+
|
|
440
|
+
expect((root :: any)._unconsumedException).toEqual(false)
|
|
441
|
+
end)
|
|
442
|
+
end)
|
|
443
|
+
|
|
444
|
+
describe("Promise:Catch", function()
|
|
445
|
+
it("handles a rejection", function()
|
|
446
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(Promise.rejected("caught"):Catch(function(err)
|
|
447
|
+
return "handled " .. err
|
|
448
|
+
end))
|
|
449
|
+
expect(outcome).toEqual("resolved")
|
|
450
|
+
expect(value).toEqual("handled caught")
|
|
451
|
+
end)
|
|
452
|
+
|
|
453
|
+
it("does not fire for a fulfilled promise", function()
|
|
454
|
+
local called = { value = false }
|
|
455
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(Promise.resolved("fine"):Catch(function()
|
|
456
|
+
called.value = true
|
|
457
|
+
end))
|
|
458
|
+
expect(outcome).toEqual("resolved")
|
|
459
|
+
expect(value).toEqual("fine")
|
|
460
|
+
expect(called.value).toEqual(false)
|
|
461
|
+
end)
|
|
462
|
+
end)
|
|
463
|
+
|
|
464
|
+
describe("Promise:Tap", function()
|
|
465
|
+
it("passes the original value through regardless of the handler return", function()
|
|
466
|
+
local seen = {}
|
|
467
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(Promise.resolved("original"):Tap(function(v)
|
|
468
|
+
seen.value = v
|
|
469
|
+
return "ignored"
|
|
470
|
+
end))
|
|
471
|
+
expect(outcome).toEqual("resolved")
|
|
472
|
+
expect(value).toEqual("original")
|
|
473
|
+
expect(seen.value).toEqual("original")
|
|
474
|
+
end)
|
|
475
|
+
end)
|
|
476
|
+
|
|
477
|
+
describe("Promise:Finally", function()
|
|
478
|
+
it("runs on fulfillment", function()
|
|
479
|
+
local ran = { value = false }
|
|
480
|
+
Promise.resolved("x"):Finally(function()
|
|
481
|
+
ran.value = true
|
|
482
|
+
end)
|
|
483
|
+
expect(ran.value).toEqual(true)
|
|
484
|
+
end)
|
|
485
|
+
|
|
486
|
+
it("runs on rejection", function()
|
|
487
|
+
local ran = { value = false }
|
|
488
|
+
Promise.rejected("x"):Finally(function()
|
|
489
|
+
ran.value = true
|
|
490
|
+
end)
|
|
491
|
+
expect(ran.value).toEqual(true)
|
|
492
|
+
end)
|
|
493
|
+
end)
|
|
494
|
+
|
|
495
|
+
describe("Promise:Destroy", function()
|
|
496
|
+
it("rejects the promise", function()
|
|
497
|
+
local promise = Promise.new()
|
|
498
|
+
promise:Catch(function() end)
|
|
499
|
+
promise:Destroy()
|
|
500
|
+
expect(promise:IsRejected()).toEqual(true)
|
|
501
|
+
end)
|
|
502
|
+
end)
|
|
503
|
+
|
|
504
|
+
describe("Promise:GetResults", function()
|
|
505
|
+
it("errors while pending", function()
|
|
506
|
+
local ok, err = pcall(function()
|
|
507
|
+
Promise.new():GetResults()
|
|
508
|
+
end)
|
|
509
|
+
expect(ok).toEqual(false)
|
|
510
|
+
expect(string.find(tostring(err), "pending", 1, true) ~= nil).toEqual(true)
|
|
511
|
+
end)
|
|
512
|
+
|
|
513
|
+
it("returns true and the values when fulfilled", function()
|
|
514
|
+
local ok, a, b = Promise.resolved("a", "b"):GetResults()
|
|
515
|
+
expect(ok).toEqual(true)
|
|
516
|
+
expect(a).toEqual("a")
|
|
517
|
+
expect(b).toEqual("b")
|
|
518
|
+
end)
|
|
519
|
+
|
|
520
|
+
it("returns false and the error when rejected", function()
|
|
521
|
+
local promise = Promise.rejected("failure")
|
|
522
|
+
local ok, err = promise:GetResults()
|
|
523
|
+
expect(ok).toEqual(false)
|
|
524
|
+
expect(err).toEqual("failure")
|
|
525
|
+
end)
|
|
526
|
+
|
|
527
|
+
it("consumes the rejection", function()
|
|
528
|
+
local promise = Promise.rejected("failure")
|
|
529
|
+
promise:GetResults()
|
|
530
|
+
expect((promise :: any)._unconsumedException).toEqual(false)
|
|
531
|
+
end)
|
|
532
|
+
end)
|
|
533
|
+
|
|
534
|
+
describe("Promise._toHumanReadable", function()
|
|
535
|
+
local promise = Promise.new()
|
|
536
|
+
|
|
537
|
+
it("stringifies non-table values", function()
|
|
538
|
+
expect(promise:_toHumanReadable("failure")).toEqual("failure")
|
|
539
|
+
expect(promise:_toHumanReadable(5)).toEqual("5")
|
|
540
|
+
expect(promise:_toHumanReadable(nil)).toEqual("nil")
|
|
541
|
+
expect(promise:_toHumanReadable(false)).toEqual("false")
|
|
542
|
+
end)
|
|
543
|
+
|
|
544
|
+
it("uses a table's custom __tostring when present", function()
|
|
545
|
+
local data = setmetatable({}, {
|
|
546
|
+
__tostring = function()
|
|
547
|
+
return "CustomError<oops>"
|
|
548
|
+
end,
|
|
549
|
+
})
|
|
550
|
+
expect(promise:_toHumanReadable(data)).toEqual("CustomError<oops>")
|
|
551
|
+
end)
|
|
552
|
+
|
|
553
|
+
it("JSON-encodes a plain table instead of returning its address", function()
|
|
554
|
+
expect(promise:_toHumanReadable({ code = 500 })).toEqual('{"code":500}')
|
|
555
|
+
expect(promise:_toHumanReadable({})).toEqual("[]")
|
|
556
|
+
end)
|
|
557
|
+
|
|
558
|
+
it("falls back to tostring when the table cannot be JSON-encoded", function()
|
|
559
|
+
local data = {}
|
|
560
|
+
data.cycle = data
|
|
561
|
+
local result = promise:_toHumanReadable(data)
|
|
562
|
+
expect(string.sub(result, 1, 9)).toEqual("table: 0x")
|
|
563
|
+
end)
|
|
564
|
+
end)
|
|
@@ -20,8 +20,6 @@ local FAST_OPTIONS = {
|
|
|
20
20
|
printWarning = false,
|
|
21
21
|
}
|
|
22
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
23
|
local function newFlakyCallback(failureCount: number, errorMessage: string, value: any)
|
|
26
24
|
local attempts = { count = 0 }
|
|
27
25
|
local function callback()
|
|
@@ -103,7 +101,6 @@ describe("PromiseRetryUtils.retry", function()
|
|
|
103
101
|
|
|
104
102
|
expect(outcome).toEqual("rejected")
|
|
105
103
|
expect(attempts.count).toEqual(3)
|
|
106
|
-
-- Consulted after every failure, including the final attempt.
|
|
107
104
|
expect(consulted.count).toEqual(3)
|
|
108
105
|
end)
|
|
109
106
|
end)
|
|
@@ -145,6 +145,11 @@ end
|
|
|
145
145
|
--[=[
|
|
146
146
|
Combines the result of promises together
|
|
147
147
|
|
|
148
|
+
On failure, rejects with the results table when any input rejected with a value (the
|
|
149
|
+
error is stored under that input's key). When every failing input rejected with no
|
|
150
|
+
values (i.e. was cancelled), rejects with no values so the cancellation stays a
|
|
151
|
+
cancellation instead of becoming a valued rejection of partial results.
|
|
152
|
+
|
|
148
153
|
@param stateTable any
|
|
149
154
|
@return Promise<any>
|
|
150
155
|
]=]
|
|
@@ -168,16 +173,25 @@ function PromiseUtils.combine(stateTable: any): Promise.Promise<any>
|
|
|
168
173
|
|
|
169
174
|
local returnPromise = Promise.new()
|
|
170
175
|
local allFulfilled = true
|
|
176
|
+
local sawRejectionValue = false
|
|
171
177
|
|
|
172
178
|
local function syncronize(key, isFullfilled)
|
|
173
|
-
return function(value)
|
|
179
|
+
return function(value, ...)
|
|
174
180
|
allFulfilled = allFulfilled and isFullfilled
|
|
181
|
+
if not isFullfilled and (value ~= nil or select("#", ...) > 0) then
|
|
182
|
+
sawRejectionValue = true
|
|
183
|
+
end
|
|
175
184
|
results[key] = value
|
|
176
185
|
remainingCount = remainingCount - 1
|
|
177
186
|
|
|
178
187
|
if remainingCount == 0 then
|
|
179
|
-
|
|
180
|
-
|
|
188
|
+
if allFulfilled then
|
|
189
|
+
returnPromise:Resolve(results)
|
|
190
|
+
elseif sawRejectionValue then
|
|
191
|
+
returnPromise:Reject(results)
|
|
192
|
+
else
|
|
193
|
+
returnPromise:Reject()
|
|
194
|
+
end
|
|
181
195
|
end
|
|
182
196
|
end
|
|
183
197
|
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
@class PromiseUtils.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
|
+
local PromiseUtils = require("PromiseUtils")
|
|
12
|
+
local Signal = require("Signal")
|
|
13
|
+
|
|
14
|
+
local describe = Jest.Globals.describe
|
|
15
|
+
local expect = Jest.Globals.expect
|
|
16
|
+
local it = Jest.Globals.it
|
|
17
|
+
|
|
18
|
+
describe("PromiseUtils.any / race", function()
|
|
19
|
+
it("is aliased as race", function()
|
|
20
|
+
expect(PromiseUtils.race).toBe(PromiseUtils.any)
|
|
21
|
+
end)
|
|
22
|
+
|
|
23
|
+
it("resolves with the first promise to resolve", function()
|
|
24
|
+
local a = Promise.new()
|
|
25
|
+
local b = Promise.resolved("b wins")
|
|
26
|
+
|
|
27
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(PromiseUtils.any({ a, b }))
|
|
28
|
+
expect(outcome).toEqual("resolved")
|
|
29
|
+
expect(value).toEqual("b wins")
|
|
30
|
+
|
|
31
|
+
a:Resolve("ignored")
|
|
32
|
+
end)
|
|
33
|
+
|
|
34
|
+
it("rejects if the first to settle rejects", function()
|
|
35
|
+
local outcome, err =
|
|
36
|
+
PromiseTestUtils.awaitOutcome(PromiseUtils.any({ Promise.new(), Promise.rejected("boom") }))
|
|
37
|
+
expect(outcome).toEqual("rejected")
|
|
38
|
+
expect(err).toEqual("boom")
|
|
39
|
+
end)
|
|
40
|
+
end)
|
|
41
|
+
|
|
42
|
+
describe("PromiseUtils.delayed", function()
|
|
43
|
+
it("resolves after the delay", function()
|
|
44
|
+
local outcome = PromiseTestUtils.awaitOutcome(PromiseUtils.delayed(0))
|
|
45
|
+
expect(outcome).toEqual("resolved")
|
|
46
|
+
end)
|
|
47
|
+
|
|
48
|
+
it("is pending before the delay elapses", function()
|
|
49
|
+
local promise = PromiseUtils.delayed(10)
|
|
50
|
+
expect(promise:IsPending()).toEqual(true)
|
|
51
|
+
end)
|
|
52
|
+
end)
|
|
53
|
+
|
|
54
|
+
describe("PromiseUtils.all", function()
|
|
55
|
+
it("resolves an empty list immediately", function()
|
|
56
|
+
expect(PromiseUtils.all({}):IsFulfilled()).toEqual(true)
|
|
57
|
+
end)
|
|
58
|
+
|
|
59
|
+
it("returns the same promise for a single-element list", function()
|
|
60
|
+
local only = Promise.resolved("only")
|
|
61
|
+
expect(PromiseUtils.all({ only })).toBe(only)
|
|
62
|
+
end)
|
|
63
|
+
|
|
64
|
+
it("resolves with every value once all resolve", function()
|
|
65
|
+
local combined = PromiseUtils.all({ Promise.resolved("a"), Promise.resolved("b"), Promise.resolved("c") })
|
|
66
|
+
|
|
67
|
+
expect(PromiseTestUtils.awaitSettled(combined)).toEqual(true)
|
|
68
|
+
local ok, a, b, c = combined:GetResults()
|
|
69
|
+
expect(ok).toEqual(true)
|
|
70
|
+
expect(a).toEqual("a")
|
|
71
|
+
expect(b).toEqual("b")
|
|
72
|
+
expect(c).toEqual("c")
|
|
73
|
+
end)
|
|
74
|
+
|
|
75
|
+
it("rejects if any promise rejects", function()
|
|
76
|
+
-- all() rejects with the positional results array (resolved values and rejection reasons mixed
|
|
77
|
+
-- by index), so only the rejected outcome itself is asserted here.
|
|
78
|
+
local outcome =
|
|
79
|
+
PromiseTestUtils.awaitOutcome(PromiseUtils.all({ Promise.resolved("a"), Promise.rejected("nope") }))
|
|
80
|
+
expect(outcome).toEqual("rejected")
|
|
81
|
+
end)
|
|
82
|
+
|
|
83
|
+
it("waits for pending promises before resolving", function()
|
|
84
|
+
local pending = Promise.new()
|
|
85
|
+
local combined = PromiseUtils.all({ Promise.resolved("a"), pending })
|
|
86
|
+
expect(combined:IsPending()).toEqual(true)
|
|
87
|
+
|
|
88
|
+
pending:Resolve("b")
|
|
89
|
+
expect(PromiseTestUtils.awaitSettled(combined)).toEqual(true)
|
|
90
|
+
local ok, a, b = combined:GetResults()
|
|
91
|
+
expect(ok).toEqual(true)
|
|
92
|
+
expect(a).toEqual("a")
|
|
93
|
+
expect(b).toEqual("b")
|
|
94
|
+
end)
|
|
95
|
+
end)
|
|
96
|
+
|
|
97
|
+
describe("PromiseUtils.firstSuccessOrLastFailure", function()
|
|
98
|
+
it("resolves with the first success", function()
|
|
99
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(
|
|
100
|
+
PromiseUtils.firstSuccessOrLastFailure({ Promise.rejected("bad"), Promise.resolved("good") })
|
|
101
|
+
)
|
|
102
|
+
expect(outcome).toEqual("resolved")
|
|
103
|
+
expect(value).toEqual("good")
|
|
104
|
+
end)
|
|
105
|
+
|
|
106
|
+
it("rejects with the last failure when all fail", function()
|
|
107
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(
|
|
108
|
+
PromiseUtils.firstSuccessOrLastFailure({ Promise.rejected("first"), Promise.rejected("last") })
|
|
109
|
+
)
|
|
110
|
+
expect(outcome).toEqual("rejected")
|
|
111
|
+
expect(err).toEqual("last")
|
|
112
|
+
end)
|
|
113
|
+
|
|
114
|
+
it("returns the same promise for a single-element list", function()
|
|
115
|
+
local only = Promise.resolved("only")
|
|
116
|
+
expect(PromiseUtils.firstSuccessOrLastFailure({ only })).toBe(only)
|
|
117
|
+
end)
|
|
118
|
+
end)
|
|
119
|
+
|
|
120
|
+
describe("PromiseUtils.combine", function()
|
|
121
|
+
it("passes through a table with no promises", function()
|
|
122
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(PromiseUtils.combine({ a = 1, b = 2 }))
|
|
123
|
+
expect(outcome).toEqual("resolved")
|
|
124
|
+
expect(value.a).toEqual(1)
|
|
125
|
+
expect(value.b).toEqual(2)
|
|
126
|
+
end)
|
|
127
|
+
|
|
128
|
+
it("resolves with a table mixing promises and plain values", function()
|
|
129
|
+
local combined = PromiseUtils.combine({ a = Promise.resolved("A"), b = "B" })
|
|
130
|
+
|
|
131
|
+
expect(PromiseTestUtils.awaitSettled(combined)).toEqual(true)
|
|
132
|
+
local ok, results = combined:GetResults()
|
|
133
|
+
expect(ok).toEqual(true)
|
|
134
|
+
expect(results.a).toEqual("A")
|
|
135
|
+
expect(results.b).toEqual("B")
|
|
136
|
+
end)
|
|
137
|
+
|
|
138
|
+
it("rejects if any promise rejects", function()
|
|
139
|
+
local outcome = PromiseTestUtils.awaitOutcome(PromiseUtils.combine({ a = Promise.rejected("bad"), b = "B" }))
|
|
140
|
+
expect(outcome).toEqual("rejected")
|
|
141
|
+
end)
|
|
142
|
+
|
|
143
|
+
it("rejects with the results table carrying the error under its key", function()
|
|
144
|
+
local combined = PromiseUtils.combine({
|
|
145
|
+
a = Promise.rejected("bad"),
|
|
146
|
+
b = Promise.resolved("B"),
|
|
147
|
+
})
|
|
148
|
+
combined:Catch(function() end)
|
|
149
|
+
|
|
150
|
+
expect(PromiseTestUtils.awaitSettled(combined)).toEqual(true)
|
|
151
|
+
local ok, results = combined:GetResults()
|
|
152
|
+
expect(ok).toEqual(false)
|
|
153
|
+
expect(results.a).toEqual("bad")
|
|
154
|
+
expect(results.b).toEqual("B")
|
|
155
|
+
end)
|
|
156
|
+
|
|
157
|
+
it("rejects with no values when the only failure is a cancellation", function()
|
|
158
|
+
local pending = Promise.new()
|
|
159
|
+
local combined = PromiseUtils.combine({
|
|
160
|
+
a = pending,
|
|
161
|
+
b = Promise.resolved("B"),
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
local rejectionCount = nil
|
|
165
|
+
combined:Then(nil, function(...)
|
|
166
|
+
rejectionCount = select("#", ...)
|
|
167
|
+
end)
|
|
168
|
+
|
|
169
|
+
pending:Reject() -- cancellation: rejection with no values
|
|
170
|
+
|
|
171
|
+
expect(PromiseTestUtils.awaitSettled(combined)).toEqual(true)
|
|
172
|
+
expect(combined:IsRejected()).toEqual(true)
|
|
173
|
+
expect(rejectionCount).toEqual(0)
|
|
174
|
+
end)
|
|
175
|
+
|
|
176
|
+
it("still rejects with the results table when a cancellation and a real error mix", function()
|
|
177
|
+
local cancelled = Promise.new()
|
|
178
|
+
local combined = PromiseUtils.combine({
|
|
179
|
+
a = cancelled,
|
|
180
|
+
b = Promise.rejected("bad"),
|
|
181
|
+
c = Promise.resolved("C"),
|
|
182
|
+
})
|
|
183
|
+
combined:Catch(function() end)
|
|
184
|
+
|
|
185
|
+
cancelled:Reject()
|
|
186
|
+
|
|
187
|
+
expect(PromiseTestUtils.awaitSettled(combined)).toEqual(true)
|
|
188
|
+
local ok, results = combined:GetResults()
|
|
189
|
+
expect(ok).toEqual(false)
|
|
190
|
+
expect(results.a).toEqual(nil)
|
|
191
|
+
expect(results.b).toEqual("bad")
|
|
192
|
+
expect(results.c).toEqual("C")
|
|
193
|
+
end)
|
|
194
|
+
end)
|
|
195
|
+
|
|
196
|
+
describe("PromiseUtils.invert", function()
|
|
197
|
+
it("turns a resolved promise into a rejection", function()
|
|
198
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(PromiseUtils.invert(Promise.resolved("was ok")))
|
|
199
|
+
expect(outcome).toEqual("rejected")
|
|
200
|
+
expect(err).toEqual("was ok")
|
|
201
|
+
end)
|
|
202
|
+
|
|
203
|
+
it("turns a rejected promise into a resolution", function()
|
|
204
|
+
local source = Promise.rejected("was bad")
|
|
205
|
+
|
|
206
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(PromiseUtils.invert(source))
|
|
207
|
+
expect(outcome).toEqual("resolved")
|
|
208
|
+
expect(value).toEqual("was bad")
|
|
209
|
+
end)
|
|
210
|
+
|
|
211
|
+
it("inverts a pending promise once it settles", function()
|
|
212
|
+
local source = Promise.new()
|
|
213
|
+
local inverted = PromiseUtils.invert(source)
|
|
214
|
+
expect(inverted:IsPending()).toEqual(true)
|
|
215
|
+
|
|
216
|
+
source:Resolve("later")
|
|
217
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(inverted)
|
|
218
|
+
expect(outcome).toEqual("rejected")
|
|
219
|
+
expect(err).toEqual("later")
|
|
220
|
+
end)
|
|
221
|
+
end)
|
|
222
|
+
|
|
223
|
+
describe("PromiseUtils.fromSignal", function()
|
|
224
|
+
it("resolves when the signal fires", function()
|
|
225
|
+
local signal = Signal.new()
|
|
226
|
+
local promise = PromiseUtils.fromSignal(signal)
|
|
227
|
+
expect(promise:IsPending()).toEqual(true)
|
|
228
|
+
|
|
229
|
+
signal:Fire("fired")
|
|
230
|
+
|
|
231
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
232
|
+
expect(outcome).toEqual("resolved")
|
|
233
|
+
expect(value).toEqual("fired")
|
|
234
|
+
|
|
235
|
+
signal:Destroy()
|
|
236
|
+
end)
|
|
237
|
+
|
|
238
|
+
it("disconnects the signal once settled", function()
|
|
239
|
+
local signal = Signal.new()
|
|
240
|
+
local promise = PromiseUtils.fromSignal(signal)
|
|
241
|
+
|
|
242
|
+
signal:Fire("first")
|
|
243
|
+
PromiseTestUtils.awaitSettled(promise)
|
|
244
|
+
|
|
245
|
+
-- A second fire must not change the already-resolved value.
|
|
246
|
+
signal:Fire("second")
|
|
247
|
+
local _ok, value = promise:GetResults()
|
|
248
|
+
expect(value).toEqual("first")
|
|
249
|
+
|
|
250
|
+
signal:Destroy()
|
|
251
|
+
end)
|
|
252
|
+
end)
|
|
253
|
+
|
|
254
|
+
describe("PromiseUtils.timeout", function()
|
|
255
|
+
it("returns the same promise when it is already settled", function()
|
|
256
|
+
local settled = Promise.resolved("done")
|
|
257
|
+
expect(PromiseUtils.timeout(1, settled)).toBe(settled)
|
|
258
|
+
end)
|
|
259
|
+
|
|
260
|
+
it("rejects once the timeout elapses", function()
|
|
261
|
+
local outcome = PromiseTestUtils.awaitOutcome(PromiseUtils.timeout(0, Promise.new()))
|
|
262
|
+
expect(outcome).toEqual("rejected")
|
|
263
|
+
end)
|
|
264
|
+
|
|
265
|
+
it("resolves with the underlying value if it settles before the timeout", function()
|
|
266
|
+
local source = Promise.new()
|
|
267
|
+
local promise = PromiseUtils.timeout(10, source)
|
|
268
|
+
expect(promise:IsPending()).toEqual(true)
|
|
269
|
+
|
|
270
|
+
source:Resolve("in time")
|
|
271
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
272
|
+
expect(outcome).toEqual("resolved")
|
|
273
|
+
expect(value).toEqual("in time")
|
|
274
|
+
end)
|
|
275
|
+
end)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
@class PendingPromiseTracker.spec.lua
|
|
4
|
+
]]
|
|
5
|
+
|
|
6
|
+
local require = require(script.Parent.loader).load(script)
|
|
7
|
+
|
|
8
|
+
local Jest = require("Jest")
|
|
9
|
+
local PendingPromiseTracker = require("PendingPromiseTracker")
|
|
10
|
+
local Promise = require("Promise")
|
|
11
|
+
|
|
12
|
+
local describe = Jest.Globals.describe
|
|
13
|
+
local expect = Jest.Globals.expect
|
|
14
|
+
local it = Jest.Globals.it
|
|
15
|
+
|
|
16
|
+
describe("PendingPromiseTracker", function()
|
|
17
|
+
it("starts empty", function()
|
|
18
|
+
local tracker = PendingPromiseTracker.new()
|
|
19
|
+
expect(#tracker:GetAll()).toEqual(0)
|
|
20
|
+
end)
|
|
21
|
+
|
|
22
|
+
it("tracks a pending promise", function()
|
|
23
|
+
local tracker = PendingPromiseTracker.new()
|
|
24
|
+
tracker:Add(Promise.new())
|
|
25
|
+
expect(#tracker:GetAll()).toEqual(1)
|
|
26
|
+
end)
|
|
27
|
+
|
|
28
|
+
it("ignores an already-settled promise", function()
|
|
29
|
+
local tracker = PendingPromiseTracker.new()
|
|
30
|
+
tracker:Add(Promise.resolved("done"))
|
|
31
|
+
expect(#tracker:GetAll()).toEqual(0)
|
|
32
|
+
end)
|
|
33
|
+
|
|
34
|
+
it("drops a promise once it resolves", function()
|
|
35
|
+
local tracker = PendingPromiseTracker.new()
|
|
36
|
+
local promise = Promise.new()
|
|
37
|
+
tracker:Add(promise)
|
|
38
|
+
expect(#tracker:GetAll()).toEqual(1)
|
|
39
|
+
|
|
40
|
+
promise:Resolve(true)
|
|
41
|
+
expect(#tracker:GetAll()).toEqual(0)
|
|
42
|
+
end)
|
|
43
|
+
|
|
44
|
+
it("drops a promise once it rejects", function()
|
|
45
|
+
local tracker = PendingPromiseTracker.new()
|
|
46
|
+
local promise = Promise.new()
|
|
47
|
+
promise:Catch(function() end)
|
|
48
|
+
tracker:Add(promise)
|
|
49
|
+
expect(#tracker:GetAll()).toEqual(1)
|
|
50
|
+
|
|
51
|
+
promise:Reject("boom")
|
|
52
|
+
expect(#tracker:GetAll()).toEqual(0)
|
|
53
|
+
end)
|
|
54
|
+
|
|
55
|
+
it("tracks multiple pending promises independently", function()
|
|
56
|
+
local tracker = PendingPromiseTracker.new()
|
|
57
|
+
local a = Promise.new()
|
|
58
|
+
local b = Promise.new()
|
|
59
|
+
tracker:Add(a)
|
|
60
|
+
tracker:Add(b)
|
|
61
|
+
expect(#tracker:GetAll()).toEqual(2)
|
|
62
|
+
|
|
63
|
+
a:Resolve(true)
|
|
64
|
+
expect(#tracker:GetAll()).toEqual(1)
|
|
65
|
+
|
|
66
|
+
b:Resolve(true)
|
|
67
|
+
expect(#tracker:GetAll()).toEqual(0)
|
|
68
|
+
end)
|
|
69
|
+
end)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
@class PromiseInstanceUtils.spec.lua
|
|
4
|
+
]]
|
|
5
|
+
|
|
6
|
+
local require = require(script.Parent.loader).load(script)
|
|
7
|
+
|
|
8
|
+
local Jest = require("Jest")
|
|
9
|
+
local PromiseInstanceUtils = require("PromiseInstanceUtils")
|
|
10
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
11
|
+
|
|
12
|
+
local Workspace = game:GetService("Workspace")
|
|
13
|
+
|
|
14
|
+
local describe = Jest.Globals.describe
|
|
15
|
+
local expect = Jest.Globals.expect
|
|
16
|
+
local it = Jest.Globals.it
|
|
17
|
+
|
|
18
|
+
describe("PromiseInstanceUtils.promiseRemoved", function()
|
|
19
|
+
it("is pending while the instance stays parented", function()
|
|
20
|
+
local folder = Instance.new("Folder")
|
|
21
|
+
folder.Parent = Workspace
|
|
22
|
+
|
|
23
|
+
local promise = PromiseInstanceUtils.promiseRemoved(folder)
|
|
24
|
+
expect(promise:IsPending()).toEqual(true)
|
|
25
|
+
|
|
26
|
+
folder:Destroy()
|
|
27
|
+
end)
|
|
28
|
+
|
|
29
|
+
it("resolves when the instance is removed from the game", function()
|
|
30
|
+
local folder = Instance.new("Folder")
|
|
31
|
+
folder.Parent = Workspace
|
|
32
|
+
|
|
33
|
+
local promise = PromiseInstanceUtils.promiseRemoved(folder)
|
|
34
|
+
folder.Parent = nil
|
|
35
|
+
|
|
36
|
+
expect(PromiseTestUtils.awaitSettled(promise)).toEqual(true)
|
|
37
|
+
expect(promise:IsFulfilled()).toEqual(true)
|
|
38
|
+
|
|
39
|
+
folder:Destroy()
|
|
40
|
+
end)
|
|
41
|
+
end)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
@class promiseChild.spec.lua
|
|
4
|
+
]]
|
|
5
|
+
|
|
6
|
+
local require = require(script.Parent.loader).load(script)
|
|
7
|
+
|
|
8
|
+
local Jest = require("Jest")
|
|
9
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
10
|
+
local promiseChild = require("promiseChild")
|
|
11
|
+
|
|
12
|
+
local Workspace = game:GetService("Workspace")
|
|
13
|
+
|
|
14
|
+
local describe = Jest.Globals.describe
|
|
15
|
+
local expect = Jest.Globals.expect
|
|
16
|
+
local it = Jest.Globals.it
|
|
17
|
+
|
|
18
|
+
describe("promiseChild", function()
|
|
19
|
+
it("resolves immediately when the child already exists", function()
|
|
20
|
+
local parent = Instance.new("Folder")
|
|
21
|
+
local child = Instance.new("Folder")
|
|
22
|
+
child.Name = "Target"
|
|
23
|
+
child.Parent = parent
|
|
24
|
+
parent.Parent = Workspace
|
|
25
|
+
|
|
26
|
+
local promise = promiseChild(parent, "Target")
|
|
27
|
+
expect(promise:IsFulfilled()).toEqual(true)
|
|
28
|
+
|
|
29
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
30
|
+
expect(outcome).toEqual("resolved")
|
|
31
|
+
expect(value).toEqual(child)
|
|
32
|
+
|
|
33
|
+
parent:Destroy()
|
|
34
|
+
end)
|
|
35
|
+
|
|
36
|
+
it("resolves once a matching child is added", function()
|
|
37
|
+
local parent = Instance.new("Folder")
|
|
38
|
+
parent.Parent = Workspace
|
|
39
|
+
|
|
40
|
+
local promise = promiseChild(parent, "Later")
|
|
41
|
+
expect(promise:IsPending()).toEqual(true)
|
|
42
|
+
|
|
43
|
+
local child = Instance.new("Folder")
|
|
44
|
+
child.Name = "Later"
|
|
45
|
+
child.Parent = parent
|
|
46
|
+
|
|
47
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
48
|
+
expect(outcome).toEqual("resolved")
|
|
49
|
+
expect(value).toEqual(child)
|
|
50
|
+
|
|
51
|
+
parent:Destroy()
|
|
52
|
+
end)
|
|
53
|
+
|
|
54
|
+
it("rejects when the child never appears before the timeout", function()
|
|
55
|
+
local parent = Instance.new("Folder")
|
|
56
|
+
parent.Parent = Workspace
|
|
57
|
+
|
|
58
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(promiseChild(parent, "Missing", 0.05))
|
|
59
|
+
expect(outcome).toEqual("rejected")
|
|
60
|
+
expect(string.find(tostring(err), "Timed out", 1, true) ~= nil).toEqual(true)
|
|
61
|
+
|
|
62
|
+
parent:Destroy()
|
|
63
|
+
end)
|
|
64
|
+
end)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
@class promisePropertyValue.spec.lua
|
|
4
|
+
]]
|
|
5
|
+
|
|
6
|
+
local require = require(script.Parent.loader).load(script)
|
|
7
|
+
|
|
8
|
+
local Jest = require("Jest")
|
|
9
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
10
|
+
local promisePropertyValue = require("promisePropertyValue")
|
|
11
|
+
|
|
12
|
+
local Workspace = game:GetService("Workspace")
|
|
13
|
+
|
|
14
|
+
local describe = Jest.Globals.describe
|
|
15
|
+
local expect = Jest.Globals.expect
|
|
16
|
+
local it = Jest.Globals.it
|
|
17
|
+
|
|
18
|
+
describe("promisePropertyValue", function()
|
|
19
|
+
it("resolves immediately when the property is already truthy", function()
|
|
20
|
+
local folder = Instance.new("Folder")
|
|
21
|
+
folder.Name = "HasAName"
|
|
22
|
+
folder.Parent = Workspace
|
|
23
|
+
|
|
24
|
+
local promise = promisePropertyValue(folder, "Name")
|
|
25
|
+
expect(promise:IsFulfilled()).toEqual(true)
|
|
26
|
+
|
|
27
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
28
|
+
expect(outcome).toEqual("resolved")
|
|
29
|
+
expect(value).toEqual("HasAName")
|
|
30
|
+
|
|
31
|
+
folder:Destroy()
|
|
32
|
+
end)
|
|
33
|
+
|
|
34
|
+
it("resolves once a falsy property becomes truthy", function()
|
|
35
|
+
local objectValue = Instance.new("ObjectValue")
|
|
36
|
+
objectValue.Parent = Workspace
|
|
37
|
+
expect(objectValue.Value).toEqual(nil)
|
|
38
|
+
|
|
39
|
+
local promise = promisePropertyValue(objectValue, "Value")
|
|
40
|
+
expect(promise:IsPending()).toEqual(true)
|
|
41
|
+
|
|
42
|
+
local target = Instance.new("Folder")
|
|
43
|
+
target.Parent = Workspace
|
|
44
|
+
objectValue.Value = target
|
|
45
|
+
|
|
46
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(promise)
|
|
47
|
+
expect(outcome).toEqual("resolved")
|
|
48
|
+
expect(value).toEqual(target)
|
|
49
|
+
|
|
50
|
+
objectValue:Destroy()
|
|
51
|
+
target:Destroy()
|
|
52
|
+
end)
|
|
53
|
+
end)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
@class promiseWait.spec.lua
|
|
4
|
+
]]
|
|
5
|
+
|
|
6
|
+
local require = require(script.Parent.loader).load(script)
|
|
7
|
+
|
|
8
|
+
local Jest = require("Jest")
|
|
9
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
10
|
+
local promiseWait = require("promiseWait")
|
|
11
|
+
|
|
12
|
+
local describe = Jest.Globals.describe
|
|
13
|
+
local expect = Jest.Globals.expect
|
|
14
|
+
local it = Jest.Globals.it
|
|
15
|
+
|
|
16
|
+
describe("promiseWait", function()
|
|
17
|
+
it("resolves after the delay elapses", function()
|
|
18
|
+
local outcome = PromiseTestUtils.awaitOutcome(promiseWait(0))
|
|
19
|
+
expect(outcome).toEqual("resolved")
|
|
20
|
+
end)
|
|
21
|
+
|
|
22
|
+
it("is pending before the delay elapses", function()
|
|
23
|
+
local promise = promiseWait(10)
|
|
24
|
+
expect(promise:IsPending()).toEqual(true)
|
|
25
|
+
end)
|
|
26
|
+
end)
|