anear-js-api 0.3.4 → 0.3.7
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.
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict"
|
|
2
2
|
const Ably = require('ably/promises')
|
|
3
3
|
const AnearApi = require('../api/AnearApi')
|
|
4
|
+
const ParticipantTimer = require('../utils/ParticipantTimer')
|
|
4
5
|
const logger = require('../utils/Logger')
|
|
5
6
|
const { Mutex } = require('async-mutex')
|
|
6
7
|
|
|
@@ -25,7 +26,6 @@ const AblyLogLevel = process.env.ANEARAPP_ABLY_LOG_LEVEL || 0 // 0 - no logging,
|
|
|
25
26
|
const AnearCreateEventChannelName = `anear:${AppId}:e`
|
|
26
27
|
|
|
27
28
|
class AnearMessaging {
|
|
28
|
-
|
|
29
29
|
constructor(AnearEventClass, AnearParticipantClass) {
|
|
30
30
|
this.api = new AnearApi()
|
|
31
31
|
this.AnearEventClass = AnearEventClass
|
|
@@ -86,31 +86,64 @@ class AnearMessaging {
|
|
|
86
86
|
return this.realtime.channels.get(channelName, channelParams)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
ensureParticipantTimer(anearEvent, participant, timeoutMsecs) {
|
|
90
|
+
// this is called when a new timer is being started for a privateMessage
|
|
91
|
+
// sent to a participant, or public message to all participants
|
|
92
|
+
// If the timer already exists and is paused, it is resumed with the timeRemaining
|
|
93
|
+
// [a starter function, timeRemaining] is returned
|
|
94
|
+
let timeRemaining = timeoutMsecs
|
|
95
|
+
let timerStarter = () => {}
|
|
96
|
+
|
|
97
|
+
if (timeoutMsecs > 0) {
|
|
98
|
+
let timer = this.participantTimers[participant.id] || createTimer(anearEvent, participant, timeoutMsecs)
|
|
99
|
+
|
|
100
|
+
if (timer.isPaused) {
|
|
101
|
+
timerRemaining = this.timeRemaining
|
|
102
|
+
timerStarter = () => timer.resume()
|
|
103
|
+
} else {
|
|
104
|
+
timerStarter = () => timer.start(timeoutMsecs)
|
|
105
|
+
}
|
|
94
106
|
}
|
|
107
|
+
return [timerStarter, timeRemaining]
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
|
|
98
|
-
const
|
|
110
|
+
createTimer(anearEvent, participant, timeoutMsecs) {
|
|
111
|
+
const timer = new ParticipantTimer(
|
|
112
|
+
participant.id,
|
|
113
|
+
async () => await this.timerExpired(anearEvent, participant, timeoutMsecs)
|
|
114
|
+
)
|
|
115
|
+
this.participantTimers[participant.id] = timer
|
|
99
116
|
|
|
100
|
-
|
|
117
|
+
return timer
|
|
118
|
+
}
|
|
101
119
|
|
|
102
|
-
|
|
120
|
+
destroyParticipantTimer(participantId) {
|
|
121
|
+
// participant will not be receiving any more display messages
|
|
122
|
+
// so we close out and delete the ParticipantTimer
|
|
123
|
+
const timer = this.participantTimers[participantId]
|
|
103
124
|
|
|
104
|
-
|
|
125
|
+
if (timer) {
|
|
126
|
+
timer.reset()
|
|
127
|
+
delete this.participantTimers[participantId]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
131
|
+
pauseParticipantTimer(participantId) {
|
|
132
|
+
const timer = this.participantTimers[particpantId]
|
|
133
|
+
|
|
134
|
+
if (timer && timer.isRunning) timer.pause()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
resetParticipantTimer(participantId) {
|
|
138
|
+
// called after participant takes Action before timer expires
|
|
139
|
+
const timer = this.participantTimers[particpantId]
|
|
140
|
+
if (timer) timer.reset()
|
|
110
141
|
}
|
|
111
142
|
|
|
112
|
-
async timerExpired(anearEvent, participant,
|
|
113
|
-
logger.debug(`participant (${anearEvent.id}, ${participant.id}) TIMED OUT after ${
|
|
143
|
+
async timerExpired(anearEvent, participant, timeoutMsecs) {
|
|
144
|
+
logger.debug(`participant (${anearEvent.id}, ${participant.id}) TIMED OUT after ${timeoutMsecs} msecs`)
|
|
145
|
+
|
|
146
|
+
delete this.participantTimers[participant.id]
|
|
114
147
|
|
|
115
148
|
await anearEvent.participantTimedOut(participant)
|
|
116
149
|
await anearEvent.update()
|
|
@@ -222,7 +255,7 @@ class AnearMessaging {
|
|
|
222
255
|
}
|
|
223
256
|
|
|
224
257
|
async refreshActiveParticipants(anearEvent) {
|
|
225
|
-
const allParticipants = anearEvent.participants.active
|
|
258
|
+
const allParticipants = anearEvent.participants.active
|
|
226
259
|
|
|
227
260
|
return Promise.all(
|
|
228
261
|
allParticipants.map(
|
|
@@ -406,12 +439,16 @@ class AnearMessaging {
|
|
|
406
439
|
const participantId = message.data.participantId
|
|
407
440
|
|
|
408
441
|
logger.debug(`**** LEAVE PARTICIPANT **** participantLeaveMessagingCallback(user: ${participantId})`)
|
|
442
|
+
|
|
443
|
+
// pause the participant timer if one was active. The participant may return shortly
|
|
444
|
+
// and we can resume this timer
|
|
445
|
+
this.pauseParticipantTimer(participantId)
|
|
409
446
|
}
|
|
410
447
|
|
|
411
448
|
async closeParticipant(anearEvent, participantId, callback) {
|
|
412
449
|
logger.debug(`closeParticipant(${participantId})`)
|
|
413
450
|
|
|
414
|
-
this.
|
|
451
|
+
this.destroyParticipantTimer(participantId)
|
|
415
452
|
|
|
416
453
|
const participant = await this.getAnearParticipantFromStorage(participantId)
|
|
417
454
|
|
|
@@ -425,9 +462,8 @@ class AnearMessaging {
|
|
|
425
462
|
}
|
|
426
463
|
}
|
|
427
464
|
|
|
428
|
-
async detachParticipantPrivateChannel(
|
|
465
|
+
async detachParticipantPrivateChannel(eventId, participant) {
|
|
429
466
|
const userId = participant.userId
|
|
430
|
-
const eventId = anearEvent.id
|
|
431
467
|
const channel = this.eventChannels[eventId].privates[userId]
|
|
432
468
|
|
|
433
469
|
if (channel) {
|
|
@@ -446,12 +482,12 @@ class AnearMessaging {
|
|
|
446
482
|
// actionEventName => "reviewResponse"
|
|
447
483
|
// actionPayload => {questionId: "ab88373ccf", decision:"approved"}
|
|
448
484
|
//
|
|
449
|
-
const payload = message.data.payload
|
|
450
485
|
const participantId = message.data.participantId
|
|
486
|
+
const payload = message.data.payload
|
|
451
487
|
|
|
452
|
-
|
|
488
|
+
this.resetParticipantTimer(participantId) // participant responded in time, reset any running timer
|
|
453
489
|
|
|
454
|
-
|
|
490
|
+
logger.debug(`participantActionMessagingCallback(${anearEvent.id}, ${participantId})`)
|
|
455
491
|
|
|
456
492
|
const actionJSON = JSON.parse(payload)
|
|
457
493
|
const [actionEventName, actionPayload] = Object.entries(actionJSON)[0]
|
|
@@ -528,39 +564,46 @@ class AnearMessaging {
|
|
|
528
564
|
}
|
|
529
565
|
}
|
|
530
566
|
|
|
531
|
-
async
|
|
532
|
-
const
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
567
|
+
async publishEventSpectatorsMessage(anearEvent, css, message, messageType = PublicDisplayMessageType) {
|
|
568
|
+
const channel = this.eventChannels[anearEvent.id].spectators
|
|
569
|
+
const payload = {
|
|
570
|
+
css: css,
|
|
571
|
+
content: message
|
|
572
|
+
}
|
|
536
573
|
|
|
537
|
-
await this.
|
|
538
|
-
channel,
|
|
539
|
-
PublicDisplayMessageType,
|
|
540
|
-
css,
|
|
541
|
-
message,
|
|
542
|
-
timeoutMilliseconds,
|
|
543
|
-
setTimerFunction,
|
|
544
|
-
timeoutCallback
|
|
545
|
-
)
|
|
574
|
+
await this.publishChannelMessage(channel, messageType, payload)
|
|
546
575
|
}
|
|
547
576
|
|
|
548
|
-
setMultipleParticipantTimers(anearEvent, participants,
|
|
549
|
-
if (
|
|
577
|
+
setMultipleParticipantTimers(anearEvent, participants, timeoutMsecs) {
|
|
578
|
+
if (timeoutMsecs === 0) return [() => {}, 0]
|
|
579
|
+
|
|
580
|
+
const participantTimers = []
|
|
550
581
|
|
|
551
582
|
participants.forEach(
|
|
552
|
-
participant =>
|
|
583
|
+
participant => {
|
|
584
|
+
const [startTimer, _timeRemaining] = this.ensureParticipantTimer(anearEvent, participant, timeoutMsecs)
|
|
585
|
+
participantTimers.push(startTimer)
|
|
586
|
+
}
|
|
553
587
|
)
|
|
588
|
+
const startTimers = () => participantTimers.forEach(startTimer => startTimer())
|
|
589
|
+
return [startTimers, timeoutMsecs]
|
|
554
590
|
}
|
|
555
591
|
|
|
556
|
-
async
|
|
557
|
-
const
|
|
558
|
-
const
|
|
559
|
-
css: css,
|
|
560
|
-
content: message
|
|
561
|
-
}
|
|
592
|
+
async publishEventParticipantsMessage(anearEvent, participants, css, message, timeoutMsecs=0) {
|
|
593
|
+
const eventId = anearEvent.id
|
|
594
|
+
const channel = this.eventChannels[eventId].participants
|
|
562
595
|
|
|
563
|
-
|
|
596
|
+
const [startTimers, timeRemaining] = this.setMultipleParticipantTimers(eventId, participants, timeoutMsecs)
|
|
597
|
+
|
|
598
|
+
await this.publishMessage(
|
|
599
|
+
channel,
|
|
600
|
+
PublicDisplayMessageType,
|
|
601
|
+
css,
|
|
602
|
+
message,
|
|
603
|
+
timeoutMsecs,
|
|
604
|
+
timeRemaining
|
|
605
|
+
)
|
|
606
|
+
startTimers()
|
|
564
607
|
}
|
|
565
608
|
|
|
566
609
|
async publishEventPrivateMessage(
|
|
@@ -569,44 +612,37 @@ class AnearMessaging {
|
|
|
569
612
|
messageType,
|
|
570
613
|
css,
|
|
571
614
|
message,
|
|
572
|
-
|
|
573
|
-
timeoutCallback=null) {
|
|
615
|
+
timeoutMsecs=0) {
|
|
574
616
|
|
|
575
617
|
const userId = participant.userId
|
|
576
618
|
const channel = this.eventChannels[anearEvent.id].privates[userId]
|
|
577
619
|
if (!channel) throw new Error(`private channel not found. invalid user id ${userId}`)
|
|
578
620
|
|
|
579
|
-
const
|
|
621
|
+
const [startTimer, timeRemaining] = this.ensureParticipantTimer(anearEvent, participant, timeoutMsecs)
|
|
580
622
|
|
|
581
|
-
await this.
|
|
623
|
+
await this.publishMessage(
|
|
582
624
|
channel,
|
|
583
625
|
messageType,
|
|
584
626
|
css,
|
|
585
627
|
message,
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
timeoutCallback
|
|
628
|
+
timeoutMsecs,
|
|
629
|
+
timeRemaining
|
|
589
630
|
)
|
|
631
|
+
startTimer()
|
|
590
632
|
}
|
|
591
633
|
|
|
592
|
-
async
|
|
634
|
+
async publishMessage(
|
|
593
635
|
channel,
|
|
594
636
|
messageType,
|
|
595
637
|
css,
|
|
596
638
|
message,
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
timeoutCallback=null) {
|
|
600
|
-
|
|
601
|
-
const timerCallback = async () => {
|
|
602
|
-
if (timeoutMilliseconds > 0) setTimerFunction()
|
|
603
|
-
if (timeoutCallback) await timeoutCallback()
|
|
604
|
-
}
|
|
639
|
+
timeoutMsecs,
|
|
640
|
+
timeRemaining) {
|
|
605
641
|
|
|
606
642
|
const payload = {
|
|
607
643
|
css: css,
|
|
608
644
|
content: message,
|
|
609
|
-
timeout:
|
|
645
|
+
timeout: { timeoutMsecs, timeRemaining }
|
|
610
646
|
}
|
|
611
647
|
|
|
612
648
|
await this.publishChannelMessage(
|
|
@@ -614,7 +650,6 @@ class AnearMessaging {
|
|
|
614
650
|
messageType,
|
|
615
651
|
payload
|
|
616
652
|
)
|
|
617
|
-
await timerCallback()
|
|
618
653
|
}
|
|
619
654
|
|
|
620
655
|
async publishEventTransitionMessage(anearEvent, newState) {
|
package/lib/models/AnearEvent.js
CHANGED
|
@@ -128,7 +128,7 @@ class AnearEvent extends JsonApiResource {
|
|
|
128
128
|
throw new Error('You must implement an async participantEnterEventCallback() in your AnearEvent sub-class');
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
async participantRefreshEventCallback(participant) {
|
|
131
|
+
async participantRefreshEventCallback(participant, remainingTimeout = null) {
|
|
132
132
|
// You may implement an async participantRefreshEventCallback() in your AnearEvent sub-class
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -163,14 +163,13 @@ class AnearEvent extends JsonApiResource {
|
|
|
163
163
|
await this.spectatorRefreshEventCallback()
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
async publishEventParticipantsMessage(message, timeoutMilliseconds=0
|
|
166
|
+
async publishEventParticipantsMessage(message, timeoutMilliseconds=0) {
|
|
167
167
|
await this.messaging.publishEventParticipantsMessage(
|
|
168
168
|
this,
|
|
169
|
-
this.
|
|
169
|
+
this.participants.active,
|
|
170
170
|
this.css,
|
|
171
171
|
message,
|
|
172
|
-
timeoutMilliseconds
|
|
173
|
-
timeoutCallback
|
|
172
|
+
timeoutMilliseconds
|
|
174
173
|
)
|
|
175
174
|
}
|
|
176
175
|
|
|
@@ -178,15 +177,14 @@ class AnearEvent extends JsonApiResource {
|
|
|
178
177
|
await this.messaging.publishEventSpectatorsMessage(this, this.css, message)
|
|
179
178
|
}
|
|
180
179
|
|
|
181
|
-
async publishEventPrivateMessage(participant, message, timeoutMilliseconds=0
|
|
180
|
+
async publishEventPrivateMessage(participant, message, timeoutMilliseconds=0) {
|
|
182
181
|
await this.messaging.publishEventPrivateMessage(
|
|
183
182
|
this,
|
|
184
183
|
participant,
|
|
185
184
|
PrivateDisplayMessageType,
|
|
186
185
|
this.css,
|
|
187
186
|
message,
|
|
188
|
-
timeoutMilliseconds
|
|
189
|
-
timeoutCallback
|
|
187
|
+
timeoutMilliseconds
|
|
190
188
|
)
|
|
191
189
|
}
|
|
192
190
|
|
|
@@ -252,7 +250,7 @@ class AnearEvent extends JsonApiResource {
|
|
|
252
250
|
logger.debug(`AnearEvent: transitionEvent(${eventName})`)
|
|
253
251
|
|
|
254
252
|
try {
|
|
255
|
-
const responseAttributes = await this.messaging.api.transitionEvent(this, eventName)
|
|
253
|
+
const responseAttributes = await this.messaging.api.transitionEvent(this.id, eventName)
|
|
256
254
|
const newState = responseAttributes.state
|
|
257
255
|
await this.messaging.publishEventTransitionMessage(this, newState)
|
|
258
256
|
} catch(err) {
|
|
@@ -94,7 +94,7 @@ const DefaultOptionsFunc = anearEvent => {
|
|
|
94
94
|
},
|
|
95
95
|
services: {
|
|
96
96
|
joinEventHandler: (context, event) => anearEvent.participantEnterEventCallback(event.participant),
|
|
97
|
-
refreshEventHandler: (context, event) => anearEvent.participantRefreshEventCallback(event.participant),
|
|
97
|
+
refreshEventHandler: (context, event) => anearEvent.participantRefreshEventCallback(event.participant, event.remainingTimeout),
|
|
98
98
|
closeEventHandler: (context, event) => anearEvent.participantCloseEventCallback(event.participant),
|
|
99
99
|
timeoutEventHandler: (context, event) => anearEvent.participantTimedOutEventCallback(event.participant),
|
|
100
100
|
actionEventHandler: (context, event) => anearEvent.participantActionEventCallback(event.participant, event.type, event.payload)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const logger = require('./Logger')
|
|
4
|
+
|
|
5
|
+
const Off = "off"
|
|
6
|
+
const Running = "running"
|
|
7
|
+
const Paused = "paused"
|
|
8
|
+
const Expired = "expired"
|
|
9
|
+
|
|
10
|
+
class ParticipantTimer {
|
|
11
|
+
constructor(participantId, expireCallback) {
|
|
12
|
+
this.participantId = participantId
|
|
13
|
+
this.expireCallback = expireCallback
|
|
14
|
+
|
|
15
|
+
this.turnOff()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
start(timeoutMsecs, now = Date.now()) {
|
|
19
|
+
this.startedAt = now
|
|
20
|
+
logger.debug(`starting ${timeoutMsecs} msec timer for participant ${this.participantId}`)
|
|
21
|
+
this.runTimer(timeoutMsecs)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
runTimer(timeoutMsecs) {
|
|
25
|
+
const timerExpired = () => {
|
|
26
|
+
this.state = Expired
|
|
27
|
+
this.expireCallback()
|
|
28
|
+
}
|
|
29
|
+
this.state = Running
|
|
30
|
+
this.id = setTimeout(timerExpired, timeoutMsecs)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pause(now = Date.now()) {
|
|
34
|
+
// if running, stop the timer
|
|
35
|
+
if (!this.isRunning) throw new Error("timer not running")
|
|
36
|
+
|
|
37
|
+
clearTimeout(this.id)
|
|
38
|
+
this.id = null
|
|
39
|
+
this.timeRemaining = now - this.startedAt
|
|
40
|
+
|
|
41
|
+
logger.debug(`pausing timer for participant ${this.participantId}. Time remaining: ${this.timeRemaining}`)
|
|
42
|
+
this.state = Paused
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
resume() {
|
|
46
|
+
// if paused, restarts the timer with the timeRemaining
|
|
47
|
+
if (!this.isPaused) throw new Error("timer not paused")
|
|
48
|
+
|
|
49
|
+
if (this.timeRemaining > 0) {
|
|
50
|
+
logger.debug(`resuming ${this.timeRemaining} msec timer for participant ${this.participantId}`)
|
|
51
|
+
this.runTimer(this.timeRemaining)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
reset() {
|
|
56
|
+
// if running, stops the timer and/or sets the timer state to Off
|
|
57
|
+
logger.debug(`resetting timer for participant ${this.participantId}`)
|
|
58
|
+
|
|
59
|
+
if (this.id) clearTimeout(this.id)
|
|
60
|
+
|
|
61
|
+
this.turnOff()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
turnOff() {
|
|
65
|
+
this.id = null
|
|
66
|
+
this.state = Off
|
|
67
|
+
this.startedAt = null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get isRunning() {
|
|
71
|
+
return this.state === Running
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get isPaused() {
|
|
75
|
+
return this.state === Paused
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get isOff() {
|
|
79
|
+
return this.state === Off
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get isExpired() {
|
|
83
|
+
return this.state === Expired
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = ParticipantTimer
|
package/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const ParticipantTimer = require('../lib/utils/ParticipantTimer')
|
|
4
|
+
const ParticipantId = "machvee"
|
|
5
|
+
const Now = Date.now()
|
|
6
|
+
|
|
7
|
+
jest.useFakeTimers();
|
|
8
|
+
|
|
9
|
+
afterEach(() => jest.clearAllTimers)
|
|
10
|
+
|
|
11
|
+
test('constructor with callback basic usage', () => {
|
|
12
|
+
const callback = jest.fn()
|
|
13
|
+
const t = new ParticipantTimer(ParticipantId, callback)
|
|
14
|
+
expect(t).toBeDefined()
|
|
15
|
+
expect(t.isRunning).toBe(false)
|
|
16
|
+
expect(t.isPaused).toBe(false)
|
|
17
|
+
t.start(500, Now)
|
|
18
|
+
expect(t.isRunning).toBe(true)
|
|
19
|
+
jest.runAllTimers()
|
|
20
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
21
|
+
expect(t.isExpired).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('start and then pause does not invoke callback', () => {
|
|
25
|
+
const callback = jest.fn()
|
|
26
|
+
const t = new ParticipantTimer(ParticipantId, callback)
|
|
27
|
+
t.start(1000, Now)
|
|
28
|
+
jest.advanceTimersByTime(500);
|
|
29
|
+
t.pause(Now + 500)
|
|
30
|
+
expect(t.isPaused).toBe(true)
|
|
31
|
+
expect(t.timeRemaining).toEqual(500)
|
|
32
|
+
jest.advanceTimersByTime(501);
|
|
33
|
+
expect(callback).toHaveBeenCalledTimes(0)
|
|
34
|
+
expect(t.isPaused).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('start, pause, then resume', () => {
|
|
38
|
+
const callback = jest.fn()
|
|
39
|
+
const t = new ParticipantTimer(ParticipantId, callback)
|
|
40
|
+
t.start(1000, Now)
|
|
41
|
+
jest.advanceTimersByTime(500);
|
|
42
|
+
t.pause(Now + 500)
|
|
43
|
+
expect(t.isPaused).toBe(true)
|
|
44
|
+
expect(t.timeRemaining).toEqual(500)
|
|
45
|
+
|
|
46
|
+
t.resume(Now + 1001)
|
|
47
|
+
jest.advanceTimersByTime(501);
|
|
48
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
49
|
+
})
|