summaryrefslogtreecommitdiffstats
path: root/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests
diff options
context:
space:
mode:
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests')
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js195
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js54
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js537
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js1365
4 files changed, 2151 insertions, 0 deletions
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js
new file mode 100644
index 0000000..a809cd7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js
@@ -0,0 +1,195 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+
+describe("The 'Add Contact' widget", function () {
+
+ it("opens up an add modal when you click on it",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all');
+ await mock.openControlBox(_converse);
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null);
+
+ const input_jid = modal.querySelector('input[name="jid"]');
+ const input_name = modal.querySelector('input[name="name"]');
+ input_jid.value = 'someone@';
+
+ const evt = new Event('input');
+ input_jid.dispatchEvent(evt);
+ expect(modal.querySelector('.suggestion-box li').textContent).toBe('someone@montague.lit');
+ input_jid.value = 'someone@montague.lit';
+ input_name.value = 'Someone';
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+
+ `</iq>`);
+ }));
+
+ it("can be configured to not provide search suggestions",
+ mock.initConverse([], {'autocomplete_add_contact': false}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete).toBe(undefined);
+
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null);
+ const input_jid = modal.querySelector('input[name="jid"]');
+ input_jid.value = 'someone@montague.lit';
+ modal.querySelector('button[type="submit"]').click();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(
+ () => IQ_stanzas.filter(s => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, s).length).pop()
+ );
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+
+ `</iq>`
+ );
+ }));
+
+ it("integrates with xhr_user_search_url to search for contacts",
+ mock.initConverse([], { 'xhr_user_search_url': 'http://example.org/?' },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+
+ class MockXHR extends XMLHttpRequest {
+ open () {} // eslint-disable-line
+ responseText = ''
+ send () {
+ this.responseText = JSON.stringify([
+ {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+ {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+ ]);
+ this.onload();
+ }
+ }
+ const XMLHttpRequestBackup = window.XMLHttpRequest;
+ window.XMLHttpRequest = MockXHR;
+
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ // We only have autocomplete for the name input
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete instanceof _converse.AutoComplete).toBe(true);
+
+ const input_el = modal.querySelector('input[name="name"]');
+ input_el.value = 'marty';
+ input_el.dispatchEvent(new Event('input'));
+ await u.waitUntil(() => modal.querySelector('.suggestion-box li'), 1000);
+ expect(modal.querySelectorAll('.suggestion-box li').length).toBe(1);
+ const suggestion = modal.querySelector('.suggestion-box li');
+ expect(suggestion.textContent).toBe('Marty McFly');
+
+ // Mock selection
+ modal.name_auto_complete.select(suggestion);
+
+ expect(input_el.value).toBe('Marty McFly');
+ expect(modal.querySelector('input[name="jid"]').value).toBe('marty@mcfly.net');
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+ `</iq>`);
+ window.XMLHttpRequest = XMLHttpRequestBackup;
+ }));
+
+ it("can be configured to not provide search suggestions for XHR search results",
+ mock.initConverse([],
+ { 'autocomplete_add_contact': false,
+ 'xhr_user_search_url': 'http://example.org/?' },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all');
+ await mock.openControlBox(_converse);
+
+ class MockXHR extends XMLHttpRequest {
+ open () {} // eslint-disable-line
+ responseText = ''
+ send () {
+ const value = modal.querySelector('input[name="name"]').value;
+ if (value === 'existing') {
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ this.responseText = JSON.stringify([{"jid": contact_jid, "fullname": mock.cur_names[0]}]);
+ } else if (value === 'romeo') {
+ this.responseText = JSON.stringify([{"jid": "romeo@montague.lit", "fullname": "Romeo Montague"}]);
+ } else if (value === 'ambiguous') {
+ this.responseText = JSON.stringify([
+ {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+ {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+ ]);
+ } else if (value === 'insufficient') {
+ this.responseText = JSON.stringify([]);
+ } else {
+ this.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]);
+ }
+ this.onload();
+ }
+ }
+
+ const XMLHttpRequestBackup = window.XMLHttpRequest;
+ window.XMLHttpRequest = MockXHR;
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete).toBe(undefined);
+
+ const input_el = modal.querySelector('input[name="name"]');
+ input_el.value = 'ambiguous';
+ modal.querySelector('button[type="submit"]').click();
+ let feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+ feedback_el.textContent = '';
+
+ input_el.value = 'insufficient';
+ modal.querySelector('button[type="submit"]').click();
+ feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+ feedback_el.textContent = '';
+
+ input_el.value = 'existing';
+ modal.querySelector('button[type="submit"]').click();
+ feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('This contact has already been added');
+
+ input_el.value = 'Marty McFly';
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+ `</iq>`);
+ window.XMLHttpRequest = XMLHttpRequestBackup;
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js
new file mode 100644
index 0000000..2ef07a0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js
@@ -0,0 +1,54 @@
+/*global mock, converse */
+
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("A sent presence stanza", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ it("includes the saved status message",
+ mock.initConverse([], {}, async (_converse) => {
+
+ const { u, Strophe } = converse.env;
+ mock.openControlBox(_converse);
+ spyOn(_converse.connection, 'send').and.callThrough();
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ const change_status_el = await u.waitUntil(() => cbview.querySelector('.change-status'));
+ change_status_el.click()
+ let modal = _converse.api.modal.get('converse-chat-status-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const msg = 'My custom status';
+ modal.querySelector('input[name="status_message"]').value = msg;
+ modal.querySelector('[type="submit"]').click();
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ let sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
+ expect(Strophe.serialize(sent_presence))
+ .toBe(`<presence xmlns="jabber:client">`+
+ `<status>My custom status</status>`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`)
+ await u.waitUntil(() => modal.getAttribute('aria-hidden') === "true");
+ await u.waitUntil(() => !u.isVisible(modal));
+
+ cbview.querySelector('.change-status').click()
+ modal = _converse.api.modal.get('converse-chat-status-modal');
+ await u.waitUntil(() => modal.getAttribute('aria-hidden') === "false", 1000);
+ modal.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
+ modal.querySelector('[type="submit"]').click();
+
+ await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).length === 2);
+ sent_presence = sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop();
+ expect(Strophe.serialize(sent_presence))
+ .toBe(
+ `<presence xmlns="jabber:client">`+
+ `<show>dnd</show>`+
+ `<status>My custom status</status>`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`)
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js
new file mode 100644
index 0000000..01439c5
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js
@@ -0,0 +1,537 @@
+/*global mock, converse */
+
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+const { Strophe, stx } = converse.env;
+
+describe("The Protocol", function () {
+
+ beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+ describe("Integration of Roster Items and Presence Subscriptions", function () {
+ /* Some level of integration between roster items and presence
+ * subscriptions is normally expected by an instant messaging user
+ * regarding the user's subscriptions to and from other contacts. This
+ * section describes the level of integration that MUST be supported
+ * within an XMPP instant messaging applications.
+ *
+ * There are four primary subscription states:
+ *
+ * None -- the user does not have a subscription to the contact's
+ * presence information, and the contact does not have a subscription
+ * to the user's presence information
+ * To -- the user has a subscription to the contact's presence
+ * information, but the contact does not have a subscription to the
+ * user's presence information
+ * From -- the contact has a subscription to the user's presence
+ * information, but the user does not have a subscription to the
+ * contact's presence information
+ * Both -- both the user and the contact have subscriptions to each
+ * other's presence information (i.e., the union of 'from' and 'to')
+ *
+ * Each of these states is reflected in the roster of both the user and
+ * the contact, thus resulting in durable subscription states.
+ *
+ * The 'from' and 'to' addresses are OPTIONAL in roster pushes; if
+ * included, their values SHOULD be the full JID of the resource for
+ * that session. A client MUST acknowledge each roster push with an IQ
+ * stanza of type "result".
+ */
+ it("Subscribe to contact, contact accepts and subscribes back",
+ mock.initConverse([], { roster_groups: false }, async function (_converse) {
+
+ const { u, $iq, $pres, sizzle, Strophe } = converse.env;
+ let stanza;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'), 300);
+ /* The process by which a user subscribes to a contact, including
+ * the interaction between roster items and subscription states.
+ */
+ mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+
+ spyOn(_converse.roster, "addAndSubscribe").and.callThrough();
+ spyOn(_converse.roster, "addContactToRoster").and.callThrough();
+ spyOn(_converse.roster, "sendContactAddIQ").and.callThrough();
+ spyOn(_converse.api.vcard, "get").and.callThrough();
+
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ modal.delegateEvents();
+
+ // Fill in the form and submit
+ const form = modal.querySelector('form.add-xmpp-contact');
+ form.querySelector('input[name="jid"]').value = 'contact@example.org';
+ form.querySelector('input[name="name"]').value = 'Chris Contact';
+ form.querySelector('input[name="group"]').value = 'My Buddies';
+ form.querySelector('[type="submit"]').click();
+
+ /* In preparation for being able to render the contact in the
+ * user's client interface and for the server to keep track of the
+ * subscription, the user's client SHOULD perform a "roster set"
+ * for the new roster item.
+ */
+ expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
+ expect(_converse.roster.addContactToRoster).toHaveBeenCalled();
+
+ /* The request consists of sending an IQ
+ * stanza of type='set' containing a <query/> element qualified by
+ * the 'jabber:iq:roster' namespace, which in turn contains an
+ * <item/> element that defines the new roster item; the <item/>
+ * element MUST possess a 'jid' attribute, MAY possess a 'name'
+ * attribute, MUST NOT possess a 'subscription' attribute, and MAY
+ * contain one or more <group/> child elements:
+ *
+ * <iq type='set' id='set1'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ await mock.waitForRoster(_converse, 'all', 0);
+ expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const roster_set_stanza = IQ_stanzas.filter(s => sizzle('query[xmlns="jabber:iq:roster"]', s)).pop();
+
+ expect(Strophe.serialize(roster_set_stanza)).toBe(
+ `<iq id="${roster_set_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="contact@example.org" name="Chris Contact">`+
+ `<group>My Buddies</group>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`
+ );
+
+ const sent_stanzas = [];
+ let sent_stanza;
+ spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
+ sent_stanza = stanza;
+ sent_stanzas.push(stanza);
+ });
+
+ /* As a result, the user's server (1) MUST initiate a roster push
+ * for the new roster item to all available resources associated
+ * with the user that have requested the roster, setting the
+ * 'subscription' attribute to a value of "none"; and (2) MUST
+ * reply to the sending resource with an IQ result indicating the
+ * success of the roster set:
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set'})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'name': 'Chris Contact'
+ }).c('group').t('My Buddies')
+ ));
+
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'result', 'id': roster_set_stanza.getAttribute('id')})
+ ));
+
+ await u.waitUntil(() => _converse.roster.length === 1);
+
+ // A contact should now have been created
+ const contact = _converse.roster.at(0);
+ expect(contact.get('jid')).toBe('contact@example.org');
+ expect(contact.get('nickname')).toBe('Chris Contact');
+ expect(contact.get('groups')).toEqual(['My Buddies']);
+ await u.waitUntil(() => contact.initialized);
+
+ /* To subscribe to the contact's presence information,
+ * the user's client MUST send a presence stanza of
+ * type='subscribe' to the contact:
+ *
+ * <presence to='contact@example.org' type='subscribe'/>
+ */
+ const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence')).pop());
+ expect(sent_presence).toEqualStanza(stx`
+ <presence to="contact@example.org" type="subscribe" xmlns="jabber:client">
+ <nick xmlns="http://jabber.org/protocol/nick">Romeo</nick>
+ <priority>0</priority>
+ <c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>
+ </presence>
+ `);
+
+ /* As a result, the user's server MUST initiate a second roster
+ * push to all of the user's available resources that have
+ * requested the roster, setting the contact to the pending
+ * sub-state of the 'none' subscription state; The pending
+ * sub-state is denoted by the inclusion of the ask='subscribe'
+ * attribute in the roster item:
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * ask='subscribe'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set', 'from': _converse.bare_jid})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'ask': 'subscribe',
+ 'name': 'Chris Contact'
+ }).c('group').t('My Buddies')
+ ));
+
+ const rosterview = document.querySelector('converse-roster');
+
+ // Check that the user is now properly shown as a pending contact in the roster.
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("Pending contacts")', rosterview).pop();
+ const contacts = Array.from(header?.parentElement.querySelectorAll('li') ?? []).filter(u.isVisible);
+ return contacts.length;
+ }, 600);
+
+ let header = sizzle('a:contains("Pending contacts")', rosterview).pop();
+ let contacts = header.parentElement.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ expect(u.isVisible(contacts[0])).toBe(true);
+ sent_stanza = ""; // Reset
+
+ spyOn(contact, "ackSubscribe").and.callThrough();
+
+ /* Here we assume the "happy path" that the contact
+ * approves the subscription request
+ *
+ * <presence
+ * to='user@example.com'
+ * from='contact@example.org'
+ * type='subscribed'/>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'subscribed'
+ })
+ ));
+
+ /* Upon receiving the presence stanza of type "subscribed",
+ * the user SHOULD acknowledge receipt of that
+ * subscription state notification by sending a presence
+ * stanza of type "subscribe".
+ */
+ expect(contact.ackSubscribe).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
+ `<presence to="contact@example.org" type="subscribe" xmlns="jabber:client"/>`
+ );
+
+ /* The user's server MUST initiate a roster push to all of the user's
+ * available resources that have requested the roster,
+ * containing an updated roster item for the contact with
+ * the 'subscription' attribute set to a value of "to";
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='to'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ const IQ_id = _converse.connection.getUniqueId('roster');
+ stanza = $iq({'type': 'set', 'id': IQ_id})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'to',
+ 'name': 'Nicky'});
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Check that the IQ set was acknowledged.
+ expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
+ `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="result" xmlns="jabber:client"/>`
+ );
+
+ // The contact should now be visible as an existing contact (but still offline).
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("My contacts")', rosterview).pop();
+ return sizzle('li', header?.parentNode).filter(l => u.isVisible(l)).length;
+ }, 600);
+ header = sizzle('a:contains("My contacts")', rosterview);
+ expect(header.length).toBe(1);
+ expect(u.isVisible(header[0])).toBeTruthy();
+ contacts = header[0].parentNode.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ // Check that it has the right classes and text
+ expect(u.hasClass('to', contacts[0])).toBeTruthy();
+ expect(u.hasClass('both', contacts[0])).toBeFalsy();
+ expect(u.hasClass('current-xmpp-contact', contacts[0])).toBeTruthy();
+
+ await u.waitUntil(() => contacts[0].textContent.trim() === 'Nicky');
+
+ expect(contact.presence.get('show')).toBe('offline');
+
+ /* <presence
+ * from='contact@example.org/resource'
+ * to='user@example.com/resource'/>
+ */
+ stanza = $pres({'to': _converse.bare_jid, 'from': 'contact@example.org/resource'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Now the contact should also be online.
+ expect(contact.presence.get('show')).toBe('online');
+
+ /* Section 8.3. Creating a Mutual Subscription
+ *
+ * If the contact wants to create a mutual subscription,
+ * the contact MUST send a subscription request to the
+ * user.
+ *
+ * <presence from='contact@example.org' to='user@example.com' type='subscribe'/>
+ */
+ spyOn(contact, 'authorize').and.callThrough();
+ spyOn(_converse.roster, 'handleIncomingSubscription').and.callThrough();
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org/resource',
+ 'type': 'subscribe'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(_converse.roster.handleIncomingSubscription).toHaveBeenCalled();
+
+ /* The user's client MUST send a presence stanza of type
+ * "subscribed" to the contact in order to approve the
+ * subscription request.
+ *
+ * <presence to='contact@example.org' type='subscribed'/>
+ */
+ expect(contact.authorize).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<presence to="contact@example.org" type="subscribed" xmlns="jabber:client"/>`
+ );
+
+ /* As a result, the user's server MUST initiate a
+ * roster push containing a roster item for the
+ * contact with the 'subscription' attribute set to
+ * a value of "both".
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='both'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'both',
+ 'name': 'contact@example.org'})
+ ));
+
+ // The class on the contact will now have switched.
+ await u.waitUntil(() => !u.hasClass('to', contacts[0]));
+ expect(u.hasClass('both', contacts[0])).toBe(true);
+
+ }));
+
+ it("Alternate Flow: Contact Declines Subscription Request",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const { $iq, $pres } = converse.env;
+ /* The process by which a user subscribes to a contact, including
+ * the interaction between roster items and subscription states.
+ */
+ var contact, stanza, sent_stanza, sent_IQ;
+ await mock.waitForRoster(_converse, 'current', 0);
+ mock.openControlBox(_converse);
+ // Add a new roster contact via roster push
+ stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'ask': 'subscribe',
+ 'name': 'contact@example.org'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // A pending contact should now exist.
+ contact = _converse.roster.get('contact@example.org');
+ expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
+ spyOn(contact, "ackUnsubscribe").and.callThrough();
+
+ spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza });
+ spyOn(_converse.connection, 'sendIQ').and.callFake(iq => { sent_IQ = iq });
+ /* We now assume the contact declines the subscription
+ * requests.
+ *
+ * Upon receiving the presence stanza of type "unsubscribed"
+ * addressed to the user, the user's server (1) MUST deliver
+ * that presence stanza to the user and (2) MUST initiate a
+ * roster push to all of the user's available resources that
+ * have requested the roster, containing an updated roster
+ * item for the contact with the 'subscription' attribute
+ * set to a value of "none" and with no 'ask' attribute:
+ *
+ * <presence
+ * from='contact@example.org'
+ * to='user@example.com'
+ * type='unsubscribed'/>
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ // FIXME: also add the <iq>
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'unsubscribed'
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ /* Upon receiving the presence stanza of type "unsubscribed",
+ * the user SHOULD acknowledge receipt of that subscription
+ * state notification through either "affirming" it by
+ * sending a presence stanza of type "unsubscribe
+ */
+ expect(contact.ackUnsubscribe).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<presence to="contact@example.org" type="unsubscribe" xmlns="jabber:client"/>`
+ );
+
+ /* _converse.js will then also automatically remove the
+ * contact from the user's roster.
+ */
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="contact@example.org" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`
+ );
+ }));
+
+ it("Unsubscribe to a contact when subscription is mutual",
+ mock.initConverse([], { roster_groups: false }, async function (_converse) {
+
+ const { u, $iq, sizzle, Strophe } = converse.env;
+ const jid = 'abram@montague.lit';
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ // We now have a contact we want to remove
+ expect(_converse.roster.get(jid) instanceof _converse.RosterContact).toBeTruthy();
+
+ const rosterview = document.querySelector('converse-roster');
+ const header = sizzle('a:contains("My contacts")', rosterview).pop();
+ await u.waitUntil(() => header.parentElement.querySelectorAll('li').length);
+
+ // remove the first user
+ header.parentElement.querySelector('li .remove-xmpp-contact').click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+
+ /* Section 8.6 Removing a Roster Item and Cancelling All
+ * Subscriptions
+ *
+ * First the user is removed from the roster
+ * Because there may be many steps involved in completely
+ * removing a roster item and cancelling subscriptions in
+ * both directions, the roster management protocol includes
+ * a "shortcut" method for doing so. The process may be
+ * initiated no matter what the current subscription state
+ * is by sending a roster set containing an item for the
+ * contact with the 'subscription' attribute set to a value
+ * of "remove":
+ *
+ * <iq type='set' id='remove1'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item jid='contact@example.org' subscription='remove'/>
+ * </query>
+ * </iq>
+ */
+ const iq_stanzas = _converse.connection.IQ_stanzas;
+ await u.waitUntil(() => Strophe.serialize(iq_stanzas.at(-1)) ===
+ `<iq id="${iq_stanzas.at(-1).getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="abram@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+ const sent_iq = iq_stanzas.at(-1);
+
+ // Receive confirmation from the contact's server
+ // <iq type='result' id='remove1'/>
+ const stanza = $iq({'type': 'result', 'id': sent_iq.getAttribute('id')});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Our contact has now been removed
+ await u.waitUntil(() => typeof _converse.roster.get(jid) === "undefined");
+ }));
+
+ it("Receiving a subscription request", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const { u, $pres, sizzle, Strophe } = converse.env;
+ spyOn(_converse.api, "trigger").and.callThrough();
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ /* <presence
+ * from='user@example.com'
+ * to='contact@example.org'
+ * type='subscribe'/>
+ */
+ const stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'subscribe'
+ }).c('nick', {
+ 'xmlns': Strophe.NS.NICK,
+ }).t('Clint Contact');
+
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("Contact requests")', rosterview).pop();
+ return Array.from(header?.parentElement.querySelectorAll('li') ?? []).filter(u.isVisible)?.length;
+ }, 500);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('contactRequest', jasmine.any(Object));
+
+ const header = sizzle('a:contains("Contact requests")', rosterview).pop();
+ expect(u.isVisible(header)).toBe(true);
+ const contacts = header.nextElementSibling.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js
new file mode 100644
index 0000000..e52c833
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js
@@ -0,0 +1,1365 @@
+/*global mock, converse, _ */
+
+const $iq = converse.env.$iq;
+const $pres = converse.env.$pres;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+const checkHeaderToggling = async function (group) {
+ const toggle = group.querySelector('a.group-toggle');
+ expect(u.isVisible(group)).toBeTruthy();
+ expect(group.querySelectorAll('ul.collapsed').length).toBe(0);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+ toggle.click();
+
+ await u.waitUntil(() => group.querySelectorAll('ul.collapsed').length === 1);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeTruthy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeFalsy();
+ toggle.click();
+ await u.waitUntil(() => group.querySelectorAll('li').length === _.filter(group.querySelectorAll('li'), u.isVisible).length);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+};
+
+
+describe("The Contacts Roster", function () {
+
+ it("verifies the origin of roster pushes", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ // See: https://gultsch.de/gajim_roster_push_and_message_interception.html
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.waitForRoster(_converse, 'current', 1);
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+
+ spyOn(converse.env.log, 'warn');
+ let roster_push = u.toStanza(`
+ <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
+ <query xmlns='jabber:iq:roster'>
+ <item subscription="remove" jid="${contact_jid}"/>
+ </query>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(converse.env.log.warn.calls.count()).toBe(1);
+ expect(converse.env.log.warn).toHaveBeenCalledWith(
+ `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
+ );
+ roster_push = u.toStanza(`
+ <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
+ <query xmlns='jabber:iq:roster'>
+ <item subscription="both" jid="eve@siacs.eu" name="${mock.cur_names[0]}" />
+ </query>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(converse.env.log.warn.calls.count()).toBe(2);
+ expect(converse.env.log.warn).toHaveBeenCalledWith(
+ `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
+ );
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+ }));
+
+ it("is populated once we have registered a presence handler", mock.initConverse([], {}, async function (_converse) {
+ const IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(
+ () => _.filter(IQs, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+ const result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ }).c('query', {
+ 'xmlns': 'jabber:iq:roster'
+ }).c('item', {'jid': 'nurse@example.com'}).up()
+ .c('item', {'jid': 'romeo@example.com'})
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => _converse.promises['rosterContactsFetched'].isResolved === true);
+ }));
+
+ it("supports roster versioning", mock.initConverse([], {}, async function (_converse) {
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ let stanza = await u.waitUntil(
+ () => _.filter(IQ_stanzas, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()
+ );
+ expect(_converse.roster.data.get('version')).toBeUndefined();
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+ let result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ }).c('query', {
+ 'xmlns': 'jabber:iq:roster',
+ 'ver': 'ver7'
+ }).c('item', {'jid': 'nurse@example.com'}).up()
+ .c('item', {'jid': 'romeo@example.com'})
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ await u.waitUntil(() => _converse.roster.models.length > 1);
+ expect(_converse.roster.data.get('version')).toBe('ver7');
+ expect(_converse.roster.models.length).toBe(2);
+
+ _converse.roster.fetchFromServer();
+ stanza = _converse.connection.IQ_stanzas.pop();
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query ver="ver7" xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+
+ result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ });
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ const roster_push = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'set',
+ }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
+ .c('item', {'jid': 'romeo@example.com', 'subscription': 'remove'});
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(_converse.roster.data.get('version')).toBe('ver34');
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe('nurse@example.com');
+ }));
+
+ it("also contains contacts with subscription of none", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.net',
+ name: 'Mercutio',
+ subscription: 'from'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'lord.capulet@example.net',
+ name: 'Lord Capulet',
+ subscription:'none'
+ }).c('group').t('Acquaintences')));
+
+ while (sent_IQs.length) sent_IQs.pop();
+
+ await u.waitUntil(() => _converse.roster.length === 3);
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'mercutio@example.net', 'lord.capulet@example.net']);
+ expect(_converse.roster.get('lord.capulet@example.net').get('subscription')).toBe('none');
+ }));
+
+ it("can be refreshed", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.net',
+ name: 'Mercutio',
+ subscription:'from'
+ }).c('group').t('Friends')));
+
+ while (sent_IQs.length) sent_IQs.pop();
+
+ await u.waitUntil(() => _converse.roster.length === 2);
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'mercutio@example.net']);
+
+ const rosterview = document.querySelector('converse-roster');
+ const sync_button = rosterview.querySelector('.sync-contacts');
+ sync_button.click();
+
+ stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'lord.capulet@example.net',
+ name: 'Lord Capulet',
+ subscription:'from'
+ }).c('group').t('Acquaintences')));
+
+ await u.waitUntil(() => _converse.roster.pluck('jid').includes('lord.capulet@example.net'));
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'lord.capulet@example.net']);
+ }));
+
+ it("will also show contacts added afterwards", mock.initConverse([], {}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-filter');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 800);
+ filter.value = "la";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 4), 800);
+
+ // Five roster contact is now visible
+ const visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(4);
+ let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(4);
+ expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
+ expect(visible_groups[1].textContent.trim()).toBe('Family');
+ expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[3].textContent.trim()).toBe('ænemies');
+
+ _converse.roster.create({
+ jid: 'lad@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: ['newgroup'],
+ fullname: 'Lad'
+ });
+ await u.waitUntil(() => sizzle('.roster-group[data-group="newgroup"] li', roster).length, 300);
+ visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(5);
+ expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
+ expect(visible_groups[1].textContent.trim()).toBe('Family');
+ expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[3].textContent.trim()).toBe('newgroup');
+ expect(visible_groups[4].textContent.trim()).toBe('ænemies');
+ expect(roster.querySelectorAll('.roster-group').length).toBe(5);
+ }));
+
+ describe("The live filter", function () {
+
+ it("will only appear when roster contacts flow over the visible area",
+ mock.initConverse([], {}, async function (_converse) {
+
+ expect(document.querySelector('converse-roster')).toBe(null);
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const view = _converse.chatboxviews.get('controlbox');
+ const flyout = view.querySelector('.box-flyout');
+ const panel = flyout.querySelector('.controlbox-pane');
+ function hasScrollBar (el) {
+ return el.isConnected && flyout.offsetHeight < panel.scrollHeight;
+ }
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-filter');
+ const el = rosterview.querySelector('.roster-contacts');
+ await u.waitUntil(() => hasScrollBar(el) ? u.isVisible(filter) : !u.isVisible(filter), 900);
+ }));
+
+ it("can be used to filter the contacts shown",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ let filter = rosterview.querySelector('.roster-filter');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ filter.value = "juliet";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 1), 600);
+ // Only one roster contact is now visible
+ let visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(1);
+ expect(visible_contacts.pop().textContent.trim()).toBe('Juliet Capulet');
+ // Only one foster group is still visible
+ expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(1);
+ const visible_group = sizzle('.roster-group', roster).filter(u.isVisible).pop();
+ expect(visible_group.querySelector('a.group-toggle').textContent.trim()).toBe('friends & acquaintences');
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = "j";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 2), 700);
+
+ visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(2);
+
+ let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(2);
+ expect(visible_groups[0].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[1].textContent.trim()).toBe('Ungrouped');
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = "xxx";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 0), 600);
+ visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(0);
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = "";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ }));
+
+ it("can be used to filter the groups shown", mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ const button = rosterview.querySelector('converse-icon[data-type="groups"]');
+ button.click();
+
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+ expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(5);
+
+ let filter = rosterview.querySelector('.roster-filter');
+ filter.value = "colleagues";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+
+ await u.waitUntil(() => (sizzle('div.roster-group:not(.collapsed)', roster).length === 1), 600);
+ expect(sizzle('div.roster-group:not(.collapsed)', roster).pop().firstElementChild.textContent.trim()).toBe('Colleagues');
+ expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(u.isVisible).length).toBe(6);
+ // Check that all contacts under the group are shown
+ expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(l => !u.isVisible(l)).length).toBe(0);
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = "xxx";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+
+ await u.waitUntil(() => (roster.querySelectorAll('.roster-group').length === 0), 700);
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = ""; // Check that groups are shown again, when the filter string is cleared.
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (roster.querySelectorAll('div.roster-group.collapsed').length === 0), 700);
+ expect(sizzle('div.roster-group', roster).length).toBe(0);
+ }));
+
+ it("has a button with which its contents can be cleared",
+ mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-filter');
+ filter.value = "xxx";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ expect(_.includes(filter.classList, "x")).toBeFalsy();
+ expect(u.hasClass('hidden', rosterview.querySelector('.roster-filter-form .clear-input'))).toBeTruthy();
+
+ const isHidden = (el) => u.hasClass('hidden', el);
+ await u.waitUntil(() => !isHidden(rosterview.querySelector('.roster-filter-form .clear-input')), 900);
+ rosterview.querySelector('.clear-input').click();
+ await u.waitUntil(() => document.querySelector('.roster-filter').value == '');
+ }));
+
+ // Disabling for now, because since recently this test consistently
+ // fails on Travis and I couldn't get it to pass there.
+ xit("can be used to filter contacts by their chat state",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ mock.waitForRoster(_converse, 'all');
+ let jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ jid = mock.cur_names[4].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ const button = rosterview.querySelector('span[data-type="state"]');
+ button.click();
+ const roster = rosterview.querySelector('.roster-contacts');
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 15, 900);
+ const filter = rosterview.querySelector('.state-type');
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ filter.value = "online";
+ u.triggerEvent(filter, 'change');
+
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 1, 900);
+ expect(sizzle('li', roster).filter(u.isVisible).pop().textContent.trim()).toBe('Lord Montague');
+ await u.waitUntil(() => sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length === 1, 900);
+ const ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
+ expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('friends & acquaintences');
+ filter.value = "dnd";
+ u.triggerEvent(filter, 'change');
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().textContent.trim() === 'Friar Laurence', 900);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(1);
+ }));
+ });
+
+ describe("A Roster Group", function () {
+
+ it("is created to show contacts with unread messages",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await mock.createContacts(_converse, 'requesting');
+
+ // Check that the groups appear alphabetically and that
+ // requesting and pending contacts are last.
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ let group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped",
+ ]);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = await _converse.api.contacts.get(contact_jid);
+ contact.save({'num_unread': 5});
+
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 7);
+ group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+
+ expect(group_titles).toEqual([
+ "New messages",
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped"
+ ]);
+ const contacts = sizzle('.roster-group[data-group="New messages"] li', rosterview);
+ expect(contacts.length).toBe(1);
+ expect(contacts[0].querySelector('.contact-name').textContent).toBe("Mercutio");
+ expect(contacts[0].querySelector('.msgs-indicator').textContent).toBe("5");
+
+ contact.save({'num_unread': 0});
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped"
+ ]);
+ }));
+
+
+ it("can be used to organize existing contacts",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await mock.createContacts(_converse, 'requesting');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ const group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped",
+ ]);
+ // Check that usernames appear alphabetically per group
+ Object.keys(mock.groups).forEach(name => {
+ const contacts = sizzle('.roster-group[data-group="'+name+'"] ul', rosterview);
+ const names = contacts.map(o => o.textContent.trim());
+ expect(names).toEqual(_.clone(names).sort());
+ });
+ }));
+
+ it("gets created when a contact's \"groups\" attribute changes",
+ mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+
+ _converse.roster.create({
+ jid: 'groupchanger@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: ['firstgroup'],
+ fullname: 'George Groupchanger'
+ });
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 1);
+ let group_titles = await u.waitUntil(() => {
+ const toggles = sizzle('.roster-group a.group-toggle', rosterview);
+ if (toggles.reduce((result, t) => result && u.isVisible(t), true)) {
+ return toggles.map(o => o.textContent.trim());
+ } else {
+ return false;
+ }
+ }, 1000);
+ expect(group_titles).toEqual(['firstgroup']);
+
+ const contact = _converse.roster.get('groupchanger@montague.lit');
+ contact.set({'groups': ['secondgroup']});
+ await u.waitUntil(() => sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', rosterview).length);
+ group_titles = await u.waitUntil(() => {
+ const toggles = sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', rosterview);
+ if (toggles.reduce((result, t) => result && u.isVisible(t), true)) {
+ return toggles.map(o => o.textContent.trim());
+ } else {
+ return false;
+ }
+ }, 1000);
+ expect(group_titles).toEqual(['secondgroup']);
+ }));
+
+ it("can share contacts with other roster groups",
+ mock.initConverse( [], {'roster_groups': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const groups = ['Colleagues', 'friends'];
+ await mock.openControlBox(_converse);
+ for (let i=0; i<mock.cur_names.length; i++) {
+ _converse.roster.create({
+ jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: groups,
+ fullname: mock.cur_names[i]
+ });
+ }
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => (sizzle('li', rosterview).filter(u.isVisible).length === 30));
+ // Check that usernames appear alphabetically per group
+ groups.forEach(name => {
+ const contacts = sizzle('.roster-group[data-group="'+name+'"] ul li', rosterview);
+ const names = contacts.map(o => o.textContent.trim());
+ expect(names).toEqual(_.clone(names).sort());
+ expect(names.length).toEqual(mock.cur_names.length);
+ });
+ }));
+
+ it("remembers whether it is closed or opened",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+
+ let i=0, j=0;
+ const groups = {
+ 'Colleagues': 3,
+ 'friends & acquaintences': 3,
+ 'Ungrouped': 2
+ };
+ Object.keys(groups).forEach(function (name) {
+ j = i;
+ for (i=j; i<j+groups[name]; i++) {
+ _converse.roster.create({
+ jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: name === 'ungrouped'? [] : [name],
+ fullname: mock.cur_names[i]
+ });
+ }
+ });
+
+ const state = _converse.roster.state;
+ expect(state.get('collapsed_groups')).toEqual([]);
+ const rosterview = document.querySelector('converse-roster');
+ const toggle = await u.waitUntil(() => rosterview.querySelector('a.group-toggle'));
+ toggle.click();
+ await u.waitUntil(() => state.get('collapsed_groups').length);
+ expect(state.get('collapsed_groups')).toEqual(['Colleagues']);
+ toggle.click();
+ expect(state.get('collapsed_groups')).toEqual([]);
+ }));
+ });
+
+ describe("Pending Contacts", function () {
+
+ it("can be collapsed under their own header (if roster_groups is false)",
+ mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
+ await checkHeaderToggling.apply(_converse, [rosterview.querySelector('[data-group="Pending contacts"]')]);
+ }));
+
+ it("can be added to the roster",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ _converse.roster.create({
+ jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: mock.pend_names[0]
+ });
+ expect(u.isVisible(rosterview)).toBe(true);
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length === 1);
+ }));
+
+ it("are shown in the roster when hide_offline_users",
+ mock.initConverse(
+ [], {'hide_offline_users': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'pending');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500)
+ expect(u.isVisible(rosterview)).toBe(true);
+ expect(sizzle('li', rosterview).filter(u.isVisible).length).toBe(3);
+ expect(sizzle('ul.roster-group-contacts', rosterview).filter(u.isVisible).length).toBe(1);
+ }));
+
+ it("can be removed by the user", mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const name = mock.pend_names[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle(`.pending-xmpp-contact .contact-name:contains("${name}")`, rosterview).length, 500);
+ let sent_IQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
+ sent_IQ = iq;
+ callback();
+ });
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ await u.waitUntil(() => !sizzle(`.pending-xmpp-contact .contact-name:contains("${name}")`, rosterview).length, 500);
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+ }));
+
+ it("do not have a header if there aren't any",
+ mock.initConverse(
+ ['VCardsInitialized'], {'roster_groups': false},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const name = mock.pend_names[0];
+ _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => {
+ const el = rosterview.querySelector(`ul[data-group="Pending contacts"]`);
+ return u.isVisible(el) && Array.from(el.querySelectorAll('li')).filter(li => u.isVisible(li)).length;
+ }, 700)
+
+ const remove_el = await u.waitUntil(() => sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop());
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ remove_el.click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+
+ const iq_stanzas = _converse.connection.IQ_stanzas;
+ await u.waitUntil(() => Strophe.serialize(iq_stanzas.at(-1)) ===
+ `<iq id="${iq_stanzas.at(-1).getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+
+ const iq = iq_stanzas.at(-1);
+ const stanza = u.toStanza(`<iq id="${iq.getAttribute('id')}" to="romeo@montague.lit/orchard" type="result"/>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`) === null);
+ }));
+
+ it("can be removed by the user",
+ mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ await u.waitUntil(() => _converse.roster.at(0).vcard.get('fullname'))
+ const rosterview = document.querySelector('converse-roster');
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ for (let i=0; i<mock.pend_names.length; i++) {
+ const name = mock.pend_names[i];
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ }
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`) === null);
+ }));
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {'roster_groups': false},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ let i;
+ for (i=0; i<mock.pend_names.length; i++) {
+ _converse.roster.create({
+ jid: mock.pend_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: mock.pend_names[i]
+ });
+ }
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview.querySelector(`ul[data-group="Pending contacts"]`)).filter(u.isVisible).length);
+ // Check that they are sorted alphabetically
+ const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`));
+ const spans = el.querySelectorAll('.pending-xmpp-contact span');
+
+ await u.waitUntil(
+ () => Array.from(spans).reduce((result, value) => result + value.textContent?.trim(), '') ===
+ mock.pend_names.slice(0,i+1).sort().join('')
+ );
+ expect(true).toBe(true);
+ }));
+ });
+
+ describe("Existing Contacts", function () {
+ async function _addContacts (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ }
+
+ it("can be collapsed under their own header",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500);
+ await checkHeaderToggling.apply(_converse, [rosterview.querySelector('.roster-group')]);
+ }));
+
+ it("will be hidden when appearing under a collapsed group",
+ mock.initConverse(
+ [], {'roster_groups': false},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500);
+ rosterview.querySelector('.group-toggle').click();
+ const name = "Romeo Montague";
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.create({
+ ask: null,
+ fullname: name,
+ jid: jid,
+ requesting: false,
+ subscription: 'both'
+ });
+ await u.waitUntil(() => u.hasClass('collapsed', rosterview.querySelector(`ul[data-group="My contacts"]`)) === true);
+ expect(true).toBe(true);
+ }));
+
+ it("will have their online statuses shown correctly",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const icon_el = document.querySelector('converse-roster-contact converse-icon');
+ expect(icon_el.getAttribute('color')).toBe('var(--subdued-color)');
+
+ let pres = $pres({from: 'mercutio@montague.lit/resource'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-online)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'away');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-away)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'xa');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--subdued-color)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'dnd');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-busy)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource', type: 'unavailable'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--subdued-color)');
+ }));
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await Promise.all(mock.cur_names.map(name => {
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ fullname: name
+ });
+ return u.waitUntil(() => contact.initialized);
+ }));
+ await u.waitUntil(() => sizzle('li', rosterview).length);
+ // Check that they are sorted alphabetically
+ const els = sizzle('.current-xmpp-contact.offline a.open-chat', rosterview)
+ const t = els.reduce((result, value) => (result + value.textContent.trim()), '');
+ expect(t).toEqual(mock.cur_names.slice(0,mock.cur_names.length).sort().join(''));
+ }));
+
+ it("can be removed by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('li').length);
+ const name = mock.cur_names[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+
+ let sent_IQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake((iq, callback) => {
+ sent_IQ = iq;
+ callback();
+ });
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => sent_IQ);
+
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="mercutio@montague.lit" subscription="remove"/></query>`+
+ `</iq>`);
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ await u.waitUntil(() => sizzle(".open-chat:contains('"+name+"')", rosterview).length === 0);
+ }));
+
+ it("do not have a header if there aren't any",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const name = mock.cur_names[0];
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ fullname: name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+ spyOn(_converse.connection, 'sendIQ').and.callFake((iq, callback) => callback?.());
+ expect(u.isVisible(rosterview.querySelector('.roster-group'))).toBe(true);
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => _converse.connection.sendIQ.calls.count());
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length === 0);
+ }));
+
+ it("can change their status to online and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.online a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent?.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to busy and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.dnd a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to away and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'away');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.away a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to xa and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'xa');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.xa a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContenc?.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to unavailable and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 500)
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'unavailable');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.unavailable a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("are ordered according to status: online, busy, away, xa, unavailable, offline",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ let i, jid;
+ for (i=0; i<3; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ }
+ for (i=3; i<6; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ }
+ for (i=6; i<9; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'away');
+ }
+ for (i=9; i<12; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'xa');
+ }
+ for (i=12; i<15; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'unavailable');
+ }
+
+ await u.waitUntil(() => u.isVisible(rosterview.querySelector('li:first-child')), 900);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+
+ const group = groups[0];
+ const els = Array.from(group.querySelectorAll('.current-xmpp-contact'));
+ await u.waitUntil(() => els.map(e => e.getAttribute('data-status')).join(" ") === "online online away xa xa xa");
+
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = Array.from(group.querySelectorAll('.current-xmpp-contact'));
+ expect(els.length).toBe(mock.groups_map[groupname].length);
+
+ if (groupname === "Colleagues") {
+
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online online away xa xa xa");
+ expect(status_classes.join(" ")).toBe("online online away xa xa xa");
+ expect(subscription_classes.join(" ")).toBe("both both both both both both");
+ } else if (groupname === "friends & acquaintences") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online online dnd dnd away unavailable");
+ expect(status_classes.join(" ")).toBe("online online dnd dnd away unavailable");
+ expect(subscription_classes.join(" ")).toBe("both both both both both both");
+ } else if (groupname === "Family") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online dnd");
+ expect(status_classes.join(" ")).toBe("online dnd");
+ expect(subscription_classes.join(" ")).toBe("both both");
+ } else if (groupname === "ænemies") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("away");
+ expect(status_classes.join(" ")).toBe("away");
+ expect(subscription_classes.join(" ")).toBe("both");
+ } else if (groupname === "Ungrouped") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("unavailable unavailable");
+ expect(status_classes.join(" ")).toBe("unavailable unavailable");
+ expect(subscription_classes.join(" ")).toBe("both both");
+ }
+ }
+ }));
+ });
+
+ describe("Requesting Contacts", function () {
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, "current", 0);
+ await mock.openControlBox(_converse);
+ let names = [];
+ const addName = function (item) {
+ if (!u.hasClass('request-actions', item)) {
+ names.push(item.textContent.replace(/^\s+|\s+$/g, ''));
+ }
+ };
+ const rosterview = document.querySelector('converse-roster');
+ await Promise.all(mock.req_names.map(name => {
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: null,
+ requesting: true,
+ nickname: name
+ });
+ return u.waitUntil(() => contact.initialized);
+ }));
+ await u.waitUntil(() => rosterview.querySelectorAll(`ul[data-group="Contact requests"] li`).length, 700);
+ // Check that they are sorted alphabetically
+ const children = rosterview.querySelector(`ul[data-group="Contact requests"]`).querySelectorAll('.requesting-xmpp-contact span');
+ names = [];
+ Array.from(children).forEach(addName);
+ expect(names.join('')).toEqual(mock.req_names.slice(0,mock.req_names.length+1).sort().join(''));
+ }));
+
+ it("do not have a header if there aren't any", mock.initConverse([], {}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, "current", 0);
+ const name = mock.req_names[0];
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ _converse.roster.create({
+ 'jid': name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ 'subscription': 'none',
+ 'ask': null,
+ 'requesting': true,
+ 'nickname': name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).length, 900);
+ expect(u.isVisible(rosterview.querySelector(`ul[data-group="Contact requests"]`))).toEqual(true);
+ expect(sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length).toBe(1);
+ sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li .decline-xmpp-request'))[0].click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Contact requests"]`) === null);
+ }));
+
+ it("can be collapsed under their own header", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 0);
+ mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).length, 700);
+ const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Contact requests"]`));
+ await checkHeaderToggling.apply(_converse, [el.parentElement]);
+ }));
+
+ it("can have their requests accepted by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ const name = mock.req_names.sort()[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(contact, 'authorize').and.callFake(() => contact);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length)
+ // TODO: Testing can be more thorough here, the user is
+ // actually not accepted/authorized because of
+ // mock_connection.
+ spyOn(_converse.roster, 'sendContactAddIQ').and.callFake(() => Promise.resolve());
+ const req_contact = sizzle(`.req-contact-name:contains("${contact.getDisplayName()}")`, rosterview).pop();
+ req_contact.parentElement.parentElement.querySelector('.accept-xmpp-request').click();
+ expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
+ await u.waitUntil(() => contact.authorize.calls.count());
+ expect(contact.authorize).toHaveBeenCalled();
+ }));
+
+ it("can have their requests denied by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const name = mock.req_names.sort()[1];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+ const req_contact = await u.waitUntil(() => sizzle(".req-contact-name:contains('"+name+"')", rosterview).pop());
+ req_contact.parentElement.parentElement.querySelector('.decline-xmpp-request').click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => contact.unauthorize.calls.count());
+ // There should now be one less contact
+ expect(_converse.roster.length).toEqual(mock.req_names.length-1);
+ }));
+
+ it("are persisted even if other contacts' change their presence ", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ // Taken from the spec
+ // https://xmpp.org/rfcs/rfc3921.html#rfc.section.7.3
+ const result = $iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.org',
+ name: 'Mercutio',
+ subscription:'from'
+ }).c('group').t('Friends').up().up()
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ const pres = $pres({from: 'data@enterprise/resource', type: 'subscribe'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+
+ expect(_converse.roster.pluck('jid').length).toBe(1);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('a:contains("Contact requests")', rosterview).length, 700);
+ expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
+
+ const roster_push = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'set',
+ }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
+ .c('item', {
+ jid: 'benvolio@example.org',
+ name: 'Benvolio',
+ subscription:'both'
+ }).c('group').t('Friends');
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(_converse.roster.data.get('version')).toBe('ver34');
+ expect(_converse.roster.models.length).toBe(4);
+ expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
+ }));
+ });
+
+ describe("All Contacts", function () {
+
+ it("are saved to, and can be retrieved from browserStorage",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ var new_attrs, old_attrs, attrs;
+ var num_contacts = _converse.roster.length;
+ var new_roster = new _converse.RosterContacts();
+ // Roster items are yet to be fetched from browserStorage
+ expect(new_roster.length).toEqual(0);
+ new_roster.browserStorage = _converse.roster.browserStorage;
+ await new Promise(success => new_roster.fetch({success}));
+ expect(new_roster.length).toEqual(num_contacts);
+ // Check that the roster items retrieved from browserStorage
+ // have the same attributes values as the original ones.
+ attrs = ['jid', 'fullname', 'subscription', 'ask'];
+ for (var i=0; i<attrs.length; i++) {
+ new_attrs = new_roster.models.map(m => m.attributes[attrs[i]]); // eslint-disable-line
+ old_attrs = _converse.roster.models.map(m => m.attributes[attrs[i]]); // eslint-disable-line
+ // Roster items in storage are not necessarily sorted,
+ // so we have to sort them here to do a proper
+ // comparison
+ expect(new_attrs.sort()).toEqual(old_attrs.sort());
+ }
+ }));
+
+ it("will show fullname and jid properties on tooltip",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 'all');
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ await Promise.all(mock.cur_names.map(async name => {
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop());
+ const child = el.firstElementChild.firstElementChild;
+ expect(child.textContent.trim()).toBe(name);
+ expect(child.getAttribute('title')).toContain(name);
+ expect(child.getAttribute('title')).toContain(jid);
+ }));
+ await Promise.all(mock.req_names.map(async name => {
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop());
+ const child = el.firstElementChild.firstElementChild;
+ expect(child.textContent.trim()).toBe(name);
+ expect(child.firstElementChild.getAttribute('title')).toContain(jid);
+ }));
+ }));
+ });
+});