protocol.js 28 KB

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