protocol.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. (function (root, factory) {
  2. define([
  3. "jasmine",
  4. "jquery",
  5. "converse-core",
  6. "mock",
  7. "test-utils"], factory);
  8. } (this, function (jasmine, $, converse, mock, test_utils) {
  9. "use strict";
  10. var Strophe = converse.env.Strophe;
  11. var $iq = converse.env.$iq;
  12. var $pres = converse.env.$pres;
  13. var _ = converse.env._;
  14. // See:
  15. // https://xmpp.org/rfcs/rfc3921.html
  16. describe("The Protocol", function () {
  17. describe("Integration of Roster Items and Presence Subscriptions", function () {
  18. // Stub the trimChat method. It causes havoc when running with
  19. // phantomJS.
  20. /* Some level of integration between roster items and presence
  21. * subscriptions is normally expected by an instant messaging user
  22. * regarding the user's subscriptions to and from other contacts. This
  23. * section describes the level of integration that MUST be supported
  24. * within an XMPP instant messaging applications.
  25. *
  26. * There are four primary subscription states:
  27. *
  28. * None -- the user does not have a subscription to the contact's
  29. * presence information, and the contact does not have a subscription
  30. * to the user's presence information
  31. * To -- the user has a subscription to the contact's presence
  32. * information, but the contact does not have a subscription to the
  33. * user's presence information
  34. * From -- the contact has a subscription to the user's presence
  35. * information, but the user does not have a subscription to the
  36. * contact's presence information
  37. * Both -- both the user and the contact have subscriptions to each
  38. * other's presence information (i.e., the union of 'from' and 'to')
  39. *
  40. * Each of these states is reflected in the roster of both the user and
  41. * the contact, thus resulting in durable subscription states.
  42. *
  43. * The 'from' and 'to' addresses are OPTIONAL in roster pushes; if
  44. * included, their values SHOULD be the full JID of the resource for
  45. * that session. A client MUST acknowledge each roster push with an IQ
  46. * stanza of type "result".
  47. */
  48. it("Subscribe to contact, contact accepts and subscribes back",
  49. mock.initConverseWithPromises(
  50. null, ['rosterGroupsFetched'],
  51. { roster_groups: false },
  52. function (done, _converse) {
  53. var contact, sent_stanza, IQ_id, stanza;
  54. test_utils.waitUntilFeatureSupportConfirmed(_converse, 'vcard-temp')
  55. .then(function () {
  56. return test_utils.waitUntil(function () {
  57. return _converse.xmppstatus.get('fullname');
  58. }, 300);
  59. }).then(function () {
  60. /* The process by which a user subscribes to a contact, including
  61. * the interaction between roster items and subscription states.
  62. */
  63. test_utils.openControlBox(_converse);
  64. var panel = _converse.chatboxviews.get('controlbox').contactspanel;
  65. spyOn(panel, "addContactFromForm").and.callThrough();
  66. spyOn(_converse.roster, "addAndSubscribe").and.callThrough();
  67. spyOn(_converse.roster, "addContact").and.callThrough();
  68. spyOn(_converse.roster, "sendContactAddIQ").and.callThrough();
  69. spyOn(_converse.api.vcard, "get").and.callThrough();
  70. var sendIQ = _converse.connection.sendIQ;
  71. spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
  72. sent_stanza = iq;
  73. IQ_id = sendIQ.bind(this)(iq, callback, errback);
  74. });
  75. panel.delegateEvents(); // Rebind all events so that our spy gets called
  76. /* Add a new contact through the UI */
  77. var form = panel.el.querySelector('form.add-xmpp-contact');
  78. expect(_.isNull(form)).toBeTruthy();
  79. // Click the "Add a contact" link.
  80. panel.$('.toggle-xmpp-contact-form').click();
  81. // Check that the form appears
  82. form = panel.el.querySelector('form.add-xmpp-contact');
  83. expect(form.parentElement.offsetHeight).not.toBe(0);
  84. expect(_.includes(form.parentElement.classList, 'collapsed')).toBeFalsy();
  85. // Fill in the form and submit
  86. $(form).find('input').val('contact@example.org');
  87. $(form).submit();
  88. /* In preparation for being able to render the contact in the
  89. * user's client interface and for the server to keep track of the
  90. * subscription, the user's client SHOULD perform a "roster set"
  91. * for the new roster item.
  92. */
  93. expect(panel.addContactFromForm).toHaveBeenCalled();
  94. expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
  95. expect(_converse.roster.addContact).toHaveBeenCalled();
  96. // The form should not be visible anymore (by virtue of its
  97. // parent being collapsed)
  98. expect(form.parentElement.offsetHeight).toBe(0);
  99. expect(_.includes(form.parentElement.classList, 'collapsed')).toBeTrue;
  100. /* _converse request consists of sending an IQ
  101. * stanza of type='set' containing a <query/> element qualified by
  102. * the 'jabber:iq:roster' namespace, which in turn contains an
  103. * <item/> element that defines the new roster item; the <item/>
  104. * element MUST possess a 'jid' attribute, MAY possess a 'name'
  105. * attribute, MUST NOT possess a 'subscription' attribute, and MAY
  106. * contain one or more <group/> child elements:
  107. *
  108. * <iq type='set' id='set1'>
  109. * <query xmlns='jabber:iq:roster'>
  110. * <item
  111. * jid='contact@example.org'
  112. * name='MyContact'>
  113. * <group>MyBuddies</group>
  114. * </item>
  115. * </query>
  116. * </iq>
  117. */
  118. expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
  119. expect(sent_stanza.toLocaleString()).toBe(
  120. "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
  121. "<query xmlns='jabber:iq:roster'>"+
  122. "<item jid='contact@example.org' name='contact@example.org'/>"+
  123. "</query>"+
  124. "</iq>"
  125. );
  126. /* As a result, the user's server (1) MUST initiate a roster push
  127. * for the new roster item to all available resources associated
  128. * with _converse user that have requested the roster, setting the
  129. * 'subscription' attribute to a value of "none"; and (2) MUST
  130. * reply to the sending resource with an IQ result indicating the
  131. * success of the roster set:
  132. *
  133. * <iq type='set'>
  134. * <query xmlns='jabber:iq:roster'>
  135. * <item
  136. * jid='contact@example.org'
  137. * subscription='none'
  138. * name='MyContact'>
  139. * <group>MyBuddies</group>
  140. * </item>
  141. * </query>
  142. * </iq>
  143. */
  144. var create = _converse.roster.create;
  145. var sent_stanzas = [];
  146. spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
  147. sent_stanza = stanza;
  148. sent_stanzas.push(stanza.toLocaleString());
  149. });
  150. spyOn(_converse.roster, 'create').and.callFake(function () {
  151. contact = create.apply(_converse.roster, arguments);
  152. spyOn(contact, 'subscribe').and.callThrough();
  153. return contact;
  154. });
  155. stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
  156. .c('item', {
  157. 'jid': 'contact@example.org',
  158. 'subscription': 'none',
  159. 'name': 'contact@example.org'});
  160. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  161. /*
  162. * <iq type='result' id='set1'/>
  163. */
  164. stanza = $iq({'type': 'result', 'id':IQ_id});
  165. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  166. // A contact should now have been created
  167. expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
  168. expect(contact.get('jid')).toBe('contact@example.org');
  169. expect(_converse.api.vcard.get).toHaveBeenCalled();
  170. /* To subscribe to the contact's presence information,
  171. * the user's client MUST send a presence stanza of
  172. * type='subscribe' to the contact:
  173. *
  174. * <presence to='contact@example.org' type='subscribe'/>
  175. */
  176. return test_utils.waitUntil(function () {
  177. return sent_stanzas.length == 1;
  178. }, 300);
  179. }).then(function () {
  180. expect(contact.subscribe).toHaveBeenCalled();
  181. expect(sent_stanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec)
  182. "<presence to='contact@example.org' type='subscribe' xmlns='jabber:client'>"+
  183. "<nick xmlns='http://jabber.org/protocol/nick'>Max Mustermann</nick>"+
  184. "</presence>"
  185. );
  186. /* As a result, the user's server MUST initiate a second roster
  187. * push to all of the user's available resources that have
  188. * requested the roster, setting the contact to the pending
  189. * sub-state of the 'none' subscription state; _converse pending
  190. * sub-state is denoted by the inclusion of the ask='subscribe'
  191. * attribute in the roster item:
  192. *
  193. * <iq type='set'>
  194. * <query xmlns='jabber:iq:roster'>
  195. * <item
  196. * jid='contact@example.org'
  197. * subscription='none'
  198. * ask='subscribe'
  199. * name='MyContact'>
  200. * <group>MyBuddies</group>
  201. * </item>
  202. * </query>
  203. * </iq>
  204. */
  205. spyOn(_converse.roster, "updateContact").and.callThrough();
  206. stanza = $iq({'type': 'set', 'from': 'dummy@localhost'})
  207. .c('query', {'xmlns': 'jabber:iq:roster'})
  208. .c('item', {
  209. 'jid': 'contact@example.org',
  210. 'subscription': 'none',
  211. 'ask': 'subscribe',
  212. 'name': 'contact@example.org'});
  213. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  214. expect(_converse.roster.updateContact).toHaveBeenCalled();
  215. // Check that the user is now properly shown as a pending
  216. // contact in the roster.
  217. return test_utils.waitUntil(function () {
  218. var $header = $('a:contains("Pending contacts")');
  219. var $contacts = $header.parent().find('li:visible');
  220. return $contacts.length;
  221. }, 600);
  222. }).then(function () {
  223. var $header = $('a:contains("Pending contacts")');
  224. var $contacts = $header.parent().find('li');
  225. expect($contacts.length).toBe(1);
  226. expect($contacts.is(':visible')).toBeTruthy();
  227. spyOn(contact, "ackSubscribe").and.callThrough();
  228. /* Here we assume the "happy path" that the contact
  229. * approves the subscription request
  230. *
  231. * <presence
  232. * to='user@example.com'
  233. * from='contact@example.org'
  234. * type='subscribed'/>
  235. */
  236. stanza = $pres({
  237. 'to': _converse.bare_jid,
  238. 'from': 'contact@example.org',
  239. 'type': 'subscribed'
  240. });
  241. sent_stanza = ""; // Reset
  242. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  243. /* Upon receiving the presence stanza of type "subscribed",
  244. * the user SHOULD acknowledge receipt of that
  245. * subscription state notification by sending a presence
  246. * stanza of type "subscribe".
  247. */
  248. expect(contact.ackSubscribe).toHaveBeenCalled();
  249. expect(sent_stanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec)
  250. "<presence type='subscribe' to='contact@example.org' xmlns='jabber:client'/>"
  251. );
  252. /* The user's server MUST initiate a roster push to all of the user's
  253. * available resources that have requested the roster,
  254. * containing an updated roster item for the contact with
  255. * the 'subscription' attribute set to a value of "to";
  256. *
  257. * <iq type='set'>
  258. * <query xmlns='jabber:iq:roster'>
  259. * <item
  260. * jid='contact@example.org'
  261. * subscription='to'
  262. * name='MyContact'>
  263. * <group>MyBuddies</group>
  264. * </item>
  265. * </query>
  266. * </iq>
  267. */
  268. IQ_id = _converse.connection.getUniqueId('roster');
  269. stanza = $iq({'type': 'set', 'id': IQ_id})
  270. .c('query', {'xmlns': 'jabber:iq:roster'})
  271. .c('item', {
  272. 'jid': 'contact@example.org',
  273. 'subscription': 'to',
  274. 'name': 'contact@example.org'});
  275. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  276. // Check that the IQ set was acknowledged.
  277. expect(sent_stanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec)
  278. "<iq type='result' id='"+IQ_id+"' from='dummy@localhost/resource' xmlns='jabber:client'/>"
  279. );
  280. expect(_converse.roster.updateContact).toHaveBeenCalled();
  281. // The contact should now be visible as an existing
  282. // contact (but still offline).
  283. return test_utils.waitUntil(function () {
  284. var $header = $('a:contains("My contacts")');
  285. var $contacts = $header.parent().find('li:visible');
  286. return $contacts.length;
  287. }, 600);
  288. }).then(function () {
  289. var $header = $('a:contains("My contacts")');
  290. expect($header.length).toBe(1);
  291. expect($header.is(":visible")).toBeTruthy();
  292. var $contacts = $header.parent().find('li');
  293. expect($contacts.length).toBe(1);
  294. // Check that it has the right classes and text
  295. expect($contacts.hasClass('to')).toBeTruthy();
  296. expect($contacts.hasClass('both')).toBeFalsy();
  297. expect($contacts.hasClass('current-xmpp-contact')).toBeTruthy();
  298. expect($contacts.text().trim()).toBe('Contact');
  299. expect(contact.get('chat_status')).toBe('offline');
  300. /* <presence
  301. * from='contact@example.org/resource'
  302. * to='user@example.com/resource'/>
  303. */
  304. stanza = $pres({'to': _converse.bare_jid, 'from': 'contact@example.org/resource'});
  305. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  306. // Now the contact should also be online.
  307. expect(contact.get('chat_status')).toBe('online');
  308. /* Section 8.3. Creating a Mutual Subscription
  309. *
  310. * If the contact wants to create a mutual subscription,
  311. * the contact MUST send a subscription request to the
  312. * user.
  313. *
  314. * <presence from='contact@example.org' to='user@example.com' type='subscribe'/>
  315. */
  316. spyOn(contact, 'authorize').and.callThrough();
  317. spyOn(_converse.roster, 'handleIncomingSubscription').and.callThrough();
  318. stanza = $pres({
  319. 'to': _converse.bare_jid,
  320. 'from': 'contact@example.org/resource',
  321. 'type': 'subscribe'});
  322. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  323. expect(_converse.roster.handleIncomingSubscription).toHaveBeenCalled();
  324. /* The user's client MUST send a presence stanza of type
  325. * "subscribed" to the contact in order to approve the
  326. * subscription request.
  327. *
  328. * <presence to='contact@example.org' type='subscribed'/>
  329. */
  330. expect(contact.authorize).toHaveBeenCalled();
  331. expect(sent_stanza.toLocaleString()).toBe(
  332. "<presence to='contact@example.org' type='subscribed' xmlns='jabber:client'/>"
  333. );
  334. /* As a result, the user's server MUST initiate a
  335. * roster push containing a roster item for the
  336. * contact with the 'subscription' attribute set to
  337. * a value of "both".
  338. *
  339. * <iq type='set'>
  340. * <query xmlns='jabber:iq:roster'>
  341. * <item
  342. * jid='contact@example.org'
  343. * subscription='both'
  344. * name='MyContact'>
  345. * <group>MyBuddies</group>
  346. * </item>
  347. * </query>
  348. * </iq>
  349. */
  350. stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
  351. .c('item', {
  352. 'jid': 'contact@example.org',
  353. 'subscription': 'both',
  354. 'name': 'contact@example.org'});
  355. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  356. expect(_converse.roster.updateContact).toHaveBeenCalled();
  357. // The class on the contact will now have switched.
  358. expect($contacts.hasClass('to')).toBeFalsy();
  359. expect($contacts.hasClass('both')).toBeTruthy();
  360. done();
  361. });
  362. }));
  363. it("Alternate Flow: Contact Declines Subscription Request",
  364. mock.initConverseWithPromises(
  365. null, ['rosterGroupsFetched'], {},
  366. function (done, _converse) {
  367. /* The process by which a user subscribes to a contact, including
  368. * the interaction between roster items and subscription states.
  369. */
  370. var contact, stanza, sent_stanza, sent_IQ;
  371. test_utils.openControlBox(_converse);
  372. // Add a new roster contact via roster push
  373. stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
  374. .c('item', {
  375. 'jid': 'contact@example.org',
  376. 'subscription': 'none',
  377. 'ask': 'subscribe',
  378. 'name': 'contact@example.org'});
  379. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  380. // A pending contact should now exist.
  381. contact = _converse.roster.get('contact@example.org');
  382. expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
  383. spyOn(contact, "ackUnsubscribe").and.callThrough();
  384. spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
  385. sent_stanza = stanza;
  386. });
  387. spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
  388. sent_IQ = iq;
  389. });
  390. /* We now assume the contact declines the subscription
  391. * requests.
  392. *
  393. * Upon receiving the presence stanza of type "unsubscribed"
  394. * addressed to the user, the user's server (1) MUST deliver
  395. * that presence stanza to the user and (2) MUST initiate a
  396. * roster push to all of the user's available resources that
  397. * have requested the roster, containing an updated roster
  398. * item for the contact with the 'subscription' attribute
  399. * set to a value of "none" and with no 'ask' attribute:
  400. *
  401. * <presence
  402. * from='contact@example.org'
  403. * to='user@example.com'
  404. * type='unsubscribed'/>
  405. *
  406. * <iq type='set'>
  407. * <query xmlns='jabber:iq:roster'>
  408. * <item
  409. * jid='contact@example.org'
  410. * subscription='none'
  411. * name='MyContact'>
  412. * <group>MyBuddies</group>
  413. * </item>
  414. * </query>
  415. * </iq>
  416. */
  417. // FIXME: also add the <iq>
  418. stanza = $pres({
  419. 'to': _converse.bare_jid,
  420. 'from': 'contact@example.org',
  421. 'type': 'unsubscribed'
  422. });
  423. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  424. /* Upon receiving the presence stanza of type "unsubscribed",
  425. * the user SHOULD acknowledge receipt of that subscription
  426. * state notification through either "affirming" it by
  427. * sending a presence stanza of type "unsubscribe
  428. */
  429. expect(contact.ackUnsubscribe).toHaveBeenCalled();
  430. expect(sent_stanza.toLocaleString()).toBe(
  431. "<presence type='unsubscribe' to='contact@example.org' xmlns='jabber:client'/>"
  432. );
  433. /* _converse.js will then also automatically remove the
  434. * contact from the user's roster.
  435. */
  436. expect(sent_IQ.toLocaleString()).toBe(
  437. "<iq type='set' xmlns='jabber:client'>"+
  438. "<query xmlns='jabber:iq:roster'>"+
  439. "<item jid='contact@example.org' subscription='remove'/>"+
  440. "</query>"+
  441. "</iq>"
  442. );
  443. done();
  444. }));
  445. it("Unsubscribe to a contact when subscription is mutual",
  446. mock.initConverseWithPromises(
  447. null, ['rosterGroupsFetched'],
  448. { roster_groups: false },
  449. function (done, _converse) {
  450. var sent_IQ, IQ_id, jid = 'annegreet.gomez@localhost';
  451. test_utils.openControlBox(_converse);
  452. test_utils.createContacts(_converse, 'current');
  453. spyOn(window, 'confirm').and.returnValue(true);
  454. // We now have a contact we want to remove
  455. expect(_converse.roster.get(jid) instanceof _converse.RosterContact).toBeTruthy();
  456. var sendIQ = _converse.connection.sendIQ;
  457. spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
  458. sent_IQ = iq;
  459. IQ_id = sendIQ.bind(this)(iq, callback, errback);
  460. });
  461. return test_utils.waitUntil(function () {
  462. var $header = $('a:contains("My contacts")');
  463. var $contacts = $header.parent().find('li');
  464. return $contacts.length;
  465. }, 600).then(function () {
  466. var $header = $('a:contains("My contacts")');
  467. // remove the first user
  468. $($header.parent().find('li .remove-xmpp-contact').get(0)).click();
  469. expect(window.confirm).toHaveBeenCalled();
  470. /* Section 8.6 Removing a Roster Item and Cancelling All
  471. * Subscriptions
  472. *
  473. * First the user is removed from the roster
  474. * Because there may be many steps involved in completely
  475. * removing a roster item and cancelling subscriptions in
  476. * both directions, the roster management protocol includes
  477. * a "shortcut" method for doing so. The process may be
  478. * initiated no matter what the current subscription state
  479. * is by sending a roster set containing an item for the
  480. * contact with the 'subscription' attribute set to a value
  481. * of "remove":
  482. *
  483. * <iq type='set' id='remove1'>
  484. * <query xmlns='jabber:iq:roster'>
  485. * <item jid='contact@example.org' subscription='remove'/>
  486. * </query>
  487. * </iq>
  488. */
  489. expect(sent_IQ.toLocaleString()).toBe(
  490. "<iq type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
  491. "<query xmlns='jabber:iq:roster'>"+
  492. "<item jid='annegreet.gomez@localhost' subscription='remove'/>"+
  493. "</query>"+
  494. "</iq>");
  495. // Receive confirmation from the contact's server
  496. // <iq type='result' id='remove1'/>
  497. var stanza = $iq({'type': 'result', 'id':IQ_id});
  498. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  499. // Our contact has now been removed
  500. expect(typeof _converse.roster.get(jid) === "undefined").toBeTruthy();
  501. done();
  502. });
  503. }));
  504. it("Receiving a subscription request", mock.initConverseWithPromises(
  505. null, ['rosterGroupsFetched'], {},
  506. function (done, _converse) {
  507. spyOn(_converse, "emit");
  508. test_utils.openControlBox(_converse);
  509. test_utils.createContacts(_converse, 'current'); // Create some contacts so that we can test positioning
  510. /* <presence
  511. * from='user@example.com'
  512. * to='contact@example.org'
  513. * type='subscribe'/>
  514. */
  515. var stanza = $pres({
  516. 'to': _converse.bare_jid,
  517. 'from': 'contact@example.org',
  518. 'type': 'subscribe'
  519. }).c('nick', {
  520. 'xmlns': Strophe.NS.NICK,
  521. }).t('Clint Contact');
  522. _converse.connection._dataRecv(test_utils.createRequest(stanza));
  523. return test_utils.waitUntil(function () {
  524. var $header = $('a:contains("Contact requests")');
  525. var $contacts = $header.parent().find('li:visible');
  526. return $contacts.length;
  527. }, 600).then(function () {
  528. expect(_converse.emit).toHaveBeenCalledWith('contactRequest', jasmine.any(Object));
  529. var $header = $('a:contains("Contact requests")');
  530. expect($header.length).toBe(1);
  531. expect($header.is(":visible")).toBeTruthy();
  532. var $contacts = $header.parent().find('li');
  533. expect($contacts.length).toBe(1);
  534. done();
  535. });
  536. }));
  537. });
  538. });
  539. }));