@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 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.20.0",
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.10.0",
33
+ "@quenty/maid": "3.11.0",
34
34
  "@quenty/math": "2.7.5",
35
- "@quenty/nevermore-test-runner": "1.4.0",
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": "647acfad93dca41c38f59cda247d2bfc0fff36d4"
42
+ "gitHead": "88b03743718172ff79cd62ade64a86565fcac17d"
43
43
  }
@@ -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
- if self._unconsumedException and self._rejected.n > 0 then
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, 8) ~= "table: 0x" then
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 type(onRejected) == "function" then
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
- local method = allFulfilled and "Resolve" or "Reject"
180
- returnPromise[method](returnPromise, results)
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)