Răsfoiți Sursa

Fixes #129 Add support for XEP-0156.

Only XML is supported for now.
JC Brand 6 ani în urmă
părinte
comite
9d77a4ef97

+ 7 - 0
CHANGES.md

@@ -2,11 +2,18 @@
 
 ## 6.0.0 (Unreleased)
 
+- #129: Add support for XEP-0156: Disovering Alternative XMPP Connection Methods. Only XML is supported for now.
 - #1691 Fix `collection.chatbox is undefined` errors
 - Prevent editing of sent file uploads.
 
 ### Breaking changes
 
+- In order to add support for XEP-0156, the XMPP connection needs to be created
+  only once we know the JID of the user that's logging in. This means that the
+  [connectionInitialized](https://conversejs.org/docs/html/api/-_converse.html#event:connectionInitialized)
+  event now fires much later than before. Plugins that rely on `connectionInitialized`
+  being triggered before the user's JID has been provided will need to be updated.
+
 - The following API methods now return promises:
   * `_converse.api.chats.get`
   * `_converse.api.chats.create`

+ 18 - 2
docs/source/configuration.rst

@@ -639,6 +639,23 @@ The default chat status that the user wil have. If you for example set this to
 ``'chat'``, then Converse will send out a presence stanza with ``"show"``
 set to ``'chat'`` as soon as you've been logged in.
 
+
+discover_connection_methods
+---------------------------
+
+* Default: ``false``
+
+Use `XEP-0156 <https://xmpp.org/extensions/xep-0156.html>`_ to discover whether
+the XMPP host for the current user advertises any Websocket or BOSH connection
+URLs that can be used.
+
+If this is set to ``false``, then a `websocket_url`_ or `bosh_service_url`_ need to be
+set.
+
+Currently only the XML encoded host-meta resource is supported as shown in
+`Example 2 under section 3.3 <https://xmpp.org/extensions/xep-0156.html#httpexamples>`_.
+
+
 domain_placeholder
 ------------------
 
@@ -647,8 +664,6 @@ domain_placeholder
 The placeholder text shown in the domain input on the registration form.
 
 
-
-
 emoji_image_path
 ----------------
 
@@ -1624,6 +1639,7 @@ Allows you to show or hide buttons on the chatboxes' toolbars.
 
 .. _`websocket-url`:
 
+
 websocket_url
 -------------
 

+ 24 - 61
spec/bookmarks.js

@@ -404,8 +404,8 @@
 
 
         it("can be retrieved from the XMPP server", mock.initConverse(
-            {'connection': ['send']}, ['chatBoxesFetched', 'roomsPanelRendered', 'rosterGroupsFetched'], {},
-            async function (done, _converse) {
+                null, ['chatBoxesFetched', 'roomsPanelRendered', 'rosterGroupsFetched'], {},
+                async function (done, _converse) {
 
             await test_utils.waitUntilDiscoConfirmed(
                 _converse, _converse.bare_jid,
@@ -421,25 +421,12 @@
              *  </pubsub>
              *  </iq>
              */
-            let IQ_id;
-            const call = await u.waitUntil(() =>
-                _.filter(
-                    _converse.connection.send.calls.all(),
-                    call => {
-                        const stanza = call.args[0];
-                        if (!(stanza instanceof Element) || stanza.nodeName !== 'iq') {
-                            return;
-                        }
-                        if (sizzle('items[node="storage:bookmarks"]', stanza).length) {
-                            IQ_id = stanza.getAttribute('id');
-                            return true;
-                        }
-                    }
-                ).pop()
-            );
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            const sent_stanza = await u.waitUntil(
+                () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
 
-            expect(Strophe.serialize(call.args[0])).toBe(
-                `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="get" xmlns="jabber:client">`+
+            expect(Strophe.serialize(sent_stanza)).toBe(
+                `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
                 '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
                     '<items node="storage:bookmarks"/>'+
                 '</pubsub>'+
@@ -469,7 +456,7 @@
             expect(_converse.bookmarks.models.length).toBe(0);
 
             spyOn(_converse.bookmarks, 'onBookmarksReceived').and.callThrough();
-            var stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':IQ_id})
+            var stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
                 .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
                     .c('items', {'node': 'storage:bookmarks'})
                         .c('item', {'id': 'current'})
@@ -495,7 +482,7 @@
         describe("The rooms panel", function () {
 
             it("shows a list of bookmarks", mock.initConverse(
-                {'connection': ['send']}, ['rosterGroupsFetched'], {},
+                null, ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
                 await test_utils.waitUntilDiscoConfirmed(
@@ -505,31 +492,19 @@
                 );
                 test_utils.openControlBox();
 
-                let IQ_id;
-                const call = await u.waitUntil(() =>
-                    _.filter(
-                        _converse.connection.send.calls.all(),
-                        call => {
-                            const stanza = call.args[0];
-                            if (!(stanza instanceof Element) || stanza.nodeName !== 'iq') {
-                                return;
-                            }
-                            if (sizzle('items[node="storage:bookmarks"]', stanza).length) {
-                                IQ_id = stanza.getAttribute('id');
-                                return true;
-                            }
-                        }
-                    ).pop()
-                );
-                expect(Strophe.serialize(call.args[0])).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="get" xmlns="jabber:client">`+
+                const IQ_stanzas = _converse.connection.IQ_stanzas;
+                const sent_stanza = await u.waitUntil(
+                    () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+
+                expect(Strophe.serialize(sent_stanza)).toBe(
+                    `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
                     '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
                         '<items node="storage:bookmarks"/>'+
                     '</pubsub>'+
                     '</iq>'
                 );
 
-                const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':IQ_id})
+                const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
                     .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
                         .c('items', {'node': 'storage:bookmarks'})
                             .c('item', {'id': 'current'})
@@ -583,7 +558,7 @@
 
 
             it("remembers the toggle state of the bookmarks list", mock.initConverse(
-                {'connection': ['send']}, ['rosterGroupsFetched'], {},
+                null, ['rosterGroupsFetched'], {},
                 async function (done, _converse) {
 
                 test_utils.openControlBox();
@@ -593,31 +568,19 @@
                     ['http://jabber.org/protocol/pubsub#publish-options']
                 );
 
-                let IQ_id;
-                const call = await u.waitUntil(() =>
-                    _.filter(
-                        _converse.connection.send.calls.all(),
-                        call => {
-                            const stanza = call.args[0];
-                            if (!(stanza instanceof Element) || stanza.nodeName !== 'iq') {
-                                return;
-                            }
-                            if (sizzle('items[node="storage:bookmarks"]', stanza).length) {
-                                IQ_id = stanza.getAttribute('id');
-                                return true;
-                            }
-                        }
-                    ).pop()
-                );
-                expect(Strophe.serialize(call.args[0])).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="get" xmlns="jabber:client">`+
+                const IQ_stanzas = _converse.connection.IQ_stanzas;
+                const sent_stanza = await u.waitUntil(
+                    () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+
+                expect(Strophe.serialize(sent_stanza)).toBe(
+                    `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
                     '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
                         '<items node="storage:bookmarks"/>'+
                     '</pubsub>'+
                     '</iq>'
                 );
 
-                const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':IQ_id})
+                const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
                     .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
                         .c('items', {'node': 'storage:bookmarks'})
                             .c('item', {'id': 'current'})

+ 2 - 2
spec/login.js

@@ -8,7 +8,7 @@
 
         it("contains a checkbox to indicate whether the computer is trusted or not",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   allow_registration: false },
                 async function (done, _converse) {
@@ -42,7 +42,7 @@
 
         it("checkbox can be set to false by default",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   trusted: false,
                   allow_registration: false },

+ 11 - 11
spec/register.js

@@ -10,7 +10,7 @@
 
         it("is not available unless allow_registration=true",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   allow_registration: false },
                 async function (done, _converse) {
@@ -24,7 +24,7 @@
 
         it("can be opened by clicking on the registration tab",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   allow_registration: true },
                 async function (done, _converse) {
@@ -45,18 +45,18 @@
 
         it("allows the user to choose an XMPP provider's domain",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   allow_registration: true },
                 async function (done, _converse) {
 
+            spyOn(Strophe.Connection.prototype, 'connect');
             await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
             test_utils.openControlBox();
             const cbview = _converse.chatboxviews.get('controlbox');
             const registerview = cbview.registerpanel;
             spyOn(registerview, 'onProviderChosen').and.callThrough();
             registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-            spyOn(_converse.connection, 'connect');
 
             // Open the register panel
             cbview.el.querySelector('.toggle-register-login').click();
@@ -75,17 +75,18 @@
             form.querySelector('input[name=domain]').value = 'conversejs.org';
             submit_button.click();
             expect(registerview.onProviderChosen).toHaveBeenCalled();
-            expect(_converse.connection.connect).toHaveBeenCalled();
+            await u.waitUntil(() => _converse.connection.connect.calls.count());
             done();
         }));
 
         it("will render a registration form as received from the XMPP provider",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   allow_registration: true },
                 async function (done, _converse) {
 
+            spyOn(Strophe.Connection.prototype, 'connect');
             await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
             test_utils.openControlBox();
             const cbview = _converse.chatboxviews.get('controlbox');
@@ -97,7 +98,6 @@
             spyOn(registerview, 'onRegistrationFields').and.callThrough();
             spyOn(registerview, 'renderRegistrationForm').and.callThrough();
             registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-            spyOn(_converse.connection, 'connect').and.callThrough();
 
             expect(registerview._registering).toBeFalsy();
             expect(_converse.connection.connected).toBeFalsy();
@@ -105,7 +105,7 @@
             registerview.el.querySelector('input[type=submit]').click();
             expect(registerview.onProviderChosen).toHaveBeenCalled();
             expect(registerview._registering).toBeTruthy();
-            expect(_converse.connection.connect).toHaveBeenCalled();
+            await u.waitUntil(() => _converse.connection.connect.calls.count());
 
             let stanza = new Strophe.Builder("stream:features", {
                         'xmlns:stream': "http://etherx.jabber.org/streams",
@@ -137,7 +137,7 @@
 
         it("will set form_type to legacy and submit it as legacy",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   allow_registration: true },
                 async function (done, _converse) {
@@ -194,7 +194,7 @@
 
         it("will set form_type to xform and submit it as xform",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   allow_registration: true },
                 async function (done, _converse) {
@@ -267,7 +267,7 @@
 
         it("renders the account registration form",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { auto_login: false,
                   view_mode: 'fullscreen',
                   allow_registration: true },

+ 2 - 4
spec/roomslist.js

@@ -53,7 +53,7 @@
 
         it("uses bookmarks to determine groupchat names",
             mock.initConverse(
-                {'connection': ['send']},
+                null,
                 ['rosterGroupsFetched', 'chatBoxesFetched', 'emojisInitialized'],
                 {'view_mode': 'fullscreen'},
                 async function (done, _converse) {
@@ -113,7 +113,7 @@
 
     describe("A groupchat shown in the groupchats list", function () {
 
-        it("is highlighted if its currently open", mock.initConverse(
+        it("is highlighted if it's currently open", mock.initConverse(
             null, ['rosterGroupsFetched', 'chatBoxesFetched', 'emojisInitialized'],
             { view_mode: 'fullscreen',
               allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
@@ -137,8 +137,6 @@
             expect(room_els.length).toBe(1);
             item = room_els[0];
             expect(item.textContent.trim()).toBe('balcony@chat.shakespeare.lit');
-            const conv_el = document.querySelector('#conversejs');
-            conv_el.parentElement.removeChild(conv_el);
             done();
         }));
 

+ 1 - 1
spec/smacks.js

@@ -11,7 +11,7 @@
 
         it("gets enabled with an <enable> stanza and resumed with a <resume> stanza",
             mock.initConverse(
-                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                null, ['chatBoxesInitialized'],
                 { 'auto_login': false,
                   'enable_smacks': true,
                   'show_controlbox_by_default': true,

+ 1 - 1
src/converse-chatview.js

@@ -1124,7 +1124,7 @@ converse.plugins.add('converse-chatview', {
                 if (Backbone.history.getFragment() === "converse/chat?jid="+this.model.get('jid')) {
                     _converse.router.navigate('');
                 }
-                if (_converse.connection.connected) {
+                if (_converse.api.connection.connected()) {
                     // Immediately sending the chat state, because the
                     // model is going to be destroyed afterwards.
                     this.model.setChatState(_converse.INACTIVE);

+ 15 - 13
src/converse-controlbox.js

@@ -154,6 +154,7 @@ converse.plugins.add('converse-controlbox', {
 
         _converse.api.promises.add('controlBoxInitialized');
 
+
         const addControlBox = () => _converse.chatboxes.add({'id': 'controlbox'});
 
         _converse.ControlBox = _converse.ChatBox.extend({
@@ -220,9 +221,9 @@ converse.plugins.add('converse-controlbox', {
                 } else {
                     this.hide();
                 }
-                if (!_converse.connection.connected ||
-                        !_converse.connection.authenticated ||
-                        _converse.connection.disconnecting) {
+
+                const connection = get(_converse, 'connection', {});
+                if (!connection.connected || !connection.authenticated || connection.disconnecting) {
                     this.renderLoginPanel();
                 } else if (this.model.get('connected')) {
                     this.renderControlBoxPane();
@@ -296,7 +297,8 @@ converse.plugins.add('converse-controlbox', {
                 if (_converse.sticky_controlbox) {
                     return;
                 }
-                if (_converse.connection.connected && !_converse.connection.disconnecting) {
+                const connection = get(_converse, 'connection', {});
+                if (connection.connected && !connection.disconnecting) {
                     this.model.save({'closed': true});
                 } else {
                     this.model.trigger('hide');
@@ -319,7 +321,8 @@ converse.plugins.add('converse-controlbox', {
                 }
                 u.addClass('hidden', this.el);
                 _converse.api.trigger('chatBoxClosed', this);
-                if (!_converse.connection.connected) {
+
+                if (!_converse.api.connection.connected()) {
                     _converse.controlboxtoggle.render();
                 }
                 _converse.controlboxtoggle.show(callback);
@@ -464,7 +467,7 @@ converse.plugins.add('converse-controlbox', {
                 if (["converse/login", "converse/register"].includes(Backbone.history.getFragment())) {
                     _converse.router.navigate('', {'replace': true});
                 }
-                _converse.connection.reset();
+                _converse.connection && _converse.connection.reset();
                 _converse.api.user.login(jid, password);
             }
         });
@@ -510,7 +513,7 @@ converse.plugins.add('converse-controlbox', {
                 // artifacts (i.e. on page load the toggle is shown only to then
                 // seconds later be hidden in favor of the controlbox).
                 this.el.innerHTML = tpl_controlbox_toggle({
-                    'label_toggle': _converse.connection.connected ? __('Chat Contacts') : __('Toggle chat')
+                    'label_toggle': _converse.api.connection.connected() ? __('Chat Contacts') : __('Toggle chat')
                 })
                 return this;
             },
@@ -529,7 +532,7 @@ converse.plugins.add('converse-controlbox', {
                 if (!controlbox) {
                     controlbox = addControlBox();
                 }
-                if (_converse.connection.connected) {
+                if (_converse.api.connection.connected()) {
                     controlbox.save({'closed': false});
                 } else {
                     controlbox.trigger('show');
@@ -540,7 +543,7 @@ converse.plugins.add('converse-controlbox', {
                 e.preventDefault();
                 if (u.isVisible(_converse.root.querySelector("#controlbox"))) {
                     const controlbox = _converse.chatboxes.get('controlbox');
-                    if (_converse.connection.connected) {
+                    if (_converse.api.connection.connected) {
                         controlbox.save({closed: true});
                     } else {
                         controlbox.trigger('hide');
@@ -582,10 +585,9 @@ converse.plugins.add('converse-controlbox', {
         });
 
 
-        Promise.all([
-            _converse.api.waitUntil('connectionInitialized'),
-            _converse.api.waitUntil('chatBoxViewsInitialized')
-        ]).then(addControlBox).catch(e => _converse.log(e, Strophe.LogLevel.FATAL));
+        _converse.api.waitUntil('chatBoxViewsInitialized')
+           .then(addControlBox)
+           .catch(e => _converse.log(e, Strophe.LogLevel.FATAL));
 
         _converse.api.listen.on('chatBoxesFetched', () => {
             const controlbox = _converse.chatboxes.get('controlbox') || addControlBox();

+ 1 - 1
src/converse-dragresize.js

@@ -351,7 +351,7 @@ converse.plugins.add('converse-dragresize', {
                         _converse.resizing.chatbox.width,
                         _converse.resizing.chatbox.model.get('default_width')
                 );
-                if (_converse.connection.connected) {
+                if (_converse.api.connection.connected()) {
                     _converse.resizing.chatbox.model.save({'height': height});
                     _converse.resizing.chatbox.model.save({'width': width});
                 } else {

+ 2 - 5
src/converse-minimize.js

@@ -318,7 +318,7 @@ converse.plugins.add('converse-minimize', {
              * @param { _converse.ChatBoxView|_converse.ChatRoomView|_converse.ControlBoxView|_converse.HeadlinesBoxView } [newchat]
              */
             async trimChats (newchat) {
-                if (_converse.no_trimming || !_converse.connection.connected || _converse.view_mode !== 'overlayed') {
+                if (_converse.no_trimming || !_converse.api.connection.connected() || _converse.view_mode !== 'overlayed') {
                     return;
                 }
                 const shown_chats = this.getShownChats();
@@ -556,10 +556,7 @@ converse.plugins.add('converse-minimize', {
         });
 
         /************************ BEGIN Event Handlers ************************/
-        Promise.all([
-            _converse.api.waitUntil('connectionInitialized'),
-            _converse.api.waitUntil('chatBoxViewsInitialized')
-        ]).then(() => {
+        _converse.api.waitUntil('chatBoxViewsInitialized').then(() => {
             _converse.minimized_chats = new _converse.MinimizedChats({
                 model: _converse.chatboxes
             });

+ 2 - 2
src/converse-omemo.js

@@ -240,8 +240,8 @@ converse.plugins.add('converse-omemo', {
         /* The initialize function gets called as soon as the plugin is
          * loaded by Converse.js's plugin machinery.
          */
-        const { _converse } = this,
-              { __ } = _converse;
+        const { _converse } = this;
+        const { __ } = _converse;
 
         _converse.api.settings.update({
             'omemo_default': false,

+ 4 - 3
src/converse-register.js

@@ -175,7 +175,7 @@ converse.plugins.add('converse-register', {
 
             initialize () {
                 this.reset();
-                this.registerHooks();
+                _converse.api.listen.on('connectionInitialized', () => this.registerHooks());
             },
 
             render () {
@@ -340,7 +340,7 @@ converse.plugins.add('converse-register', {
              * @method _converse.RegisterPanel#fetchRegistrationForm
              * @param { String } domain_name - XMPP server domain
              */
-            fetchRegistrationForm (domain_name) {
+            async fetchRegistrationForm (domain_name) {
                 if (!this.model.get('registration_form_rendered')) {
                     this.renderRegistrationRequest();
                 }
@@ -348,7 +348,8 @@ converse.plugins.add('converse-register', {
                     'domain': Strophe.getDomainFromJid(domain_name),
                     '_registering': true
                 });
-                _converse.connection.connect(this.domain, "", this.onConnectStatusChanged.bind(this));
+                await _converse.initConnection(this.domain);
+                _converse.connection.connect(this.domain, "", status => this.onConnectStatusChanged(status));
                 return false;
             },
 

+ 18 - 13
src/headless/converse-bosh.js

@@ -19,6 +19,10 @@ const BOSH_SESSION_ID = 'converse.bosh-session';
 
 converse.plugins.add('converse-bosh', {
 
+    enabled () {
+        return true;
+    },
+
     initialize () {
         const { _converse } = this;
 
@@ -35,9 +39,15 @@ converse.plugins.add('converse-bosh', {
                 _converse.bosh_session.browserStorage = new BrowserStorage.session(id);
                 await new Promise(resolve => _converse.bosh_session.fetch({'success': resolve, 'error': resolve}));
             }
-            if (_converse.jid && _converse.bosh_session.get('jid') === _converse.jid) {
-                _converse.bosh_session.clear({'silent': true });
-                _converse.bosh_session.save({'jid': _converse.jid, id});
+            if (_converse.jid) {
+                if (_converse.bosh_session.get('jid') !== _converse.jid) {
+                    const jid = await _converse.setUserJID(_converse.jid);
+                    _converse.bosh_session.clear({'silent': true });
+                    _converse.bosh_session.save({jid});
+                }
+            } else { // Keepalive
+                const jid = _converse.bosh_session.get('jid');
+                jid && await _converse.setUserJID();
             }
             return _converse.bosh_session;
         }
@@ -45,17 +55,17 @@ converse.plugins.add('converse-bosh', {
 
         _converse.startNewPreboundBOSHSession = function () {
             if (!_converse.prebind_url) {
-                throw new Error(
-                    "attemptPreboundSession: If you use prebind then you MUST supply a prebind_url");
+                throw new Error("startNewPreboundBOSHSession: If you use prebind then you MUST supply a prebind_url");
             }
             const xhr = new XMLHttpRequest();
             xhr.open('GET', _converse.prebind_url, true);
             xhr.setRequestHeader('Accept', 'application/json, text/javascript');
-            xhr.onload = function () {
+            xhr.onload = async function () {
                 if (xhr.status >= 200 && xhr.status < 400) {
                     const data = JSON.parse(xhr.responseText);
+                    const jid = await _converse.setUserJID(data.jid);
                     _converse.connection.attach(
-                        data.jid,
+                        jid,
                         data.sid,
                         data.rid,
                         _converse.onConnectStatusChanged
@@ -79,9 +89,6 @@ converse.plugins.add('converse-bosh', {
 
 
         _converse.restoreBOSHSession = async function () {
-            if (!_converse.api.connection.isType('bosh')) {
-                return false;
-            }
             const jid = (await initBOSHSession()).get('jid');
             if (jid) {
                 try {
@@ -119,9 +126,7 @@ converse.plugins.add('converse-bosh', {
             }
         });
 
-        _converse.api.listen.on('addClientFeatures',
-            () => _converse.api.disco.own.features.add(Strophe.NS.BOSH)
-        );
+        _converse.api.listen.on('addClientFeatures', () => _converse.api.disco.own.features.add(Strophe.NS.BOSH));
 
         /************************ END Event Handlers ************************/
 

+ 2 - 2
src/headless/converse-chatboxes.js

@@ -850,8 +850,8 @@ converse.plugins.add('converse-chatboxes', {
                             'to': this.get('jid'),
                             'type': 'chat'
                         }).c(this.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}).up()
-                          .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
-                          .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
+                        .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
+                        .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
                     );
                 }
             },

+ 144 - 65
src/headless/converse-core.js

@@ -230,6 +230,7 @@ _converse.default_settings = {
     csi_waiting_time: 0, // Support for XEP-0352. Seconds before client is considered idle and CSI is sent out.
     debug: false,
     default_state: 'online',
+    discover_connection_methods: false,
     geouri_regex: /https\:\/\/www.openstreetmap.org\/.*#map=[0-9]+\/([\-0-9.]+)\/([\-0-9.]+)\S*/g,
     geouri_replacement: 'https://www.openstreetmap.org/?mlat=$1&mlon=$2#map=18/$1/$2',
     idle_presence_timeout: 300, // Seconds after which an idle presence is sent
@@ -330,7 +331,7 @@ function addPromise (promise) {
 }
 
 _converse.isTestEnv = function () {
-    return _.get(_converse.connection, 'service') === 'jasmine tests';
+    return Strophe.Connection.name === 'MockConnection';
 }
 
 
@@ -457,7 +458,7 @@ async function attemptNonPreboundSession (credentials, automatic) {
         } else if (!_converse.isTestEnv() && window.PasswordCredential) {
             connect(await getLoginCredentialsFromBrowser());
         } else {
-            throw new Error("attemptNonPreboundSession: Could not find any credentials to log you in with!");
+            _converse.log("attemptNonPreboundSession: Could not find any credentials to log in with", Strophe.LogLevel.WARN);
         }
     } else if ([_converse.ANONYMOUS, _converse.EXTERNAL].includes(_converse.authentication) && (!automatic || _converse.auto_login)) {
         connect();
@@ -523,9 +524,7 @@ function reconnect () {
 const debouncedReconnect = _.debounce(reconnect, 2000);
 
 
-_converse.shouldClearCache = function () {
-    return !_converse.config.get('trusted') || _converse.isTestEnv();
-}
+_converse.shouldClearCache = () => (!_converse.config.get('trusted') || _converse.isTestEnv());
 
 function clearSession  () {
     if (_converse.session !== undefined) {
@@ -548,37 +547,84 @@ function clearSession  () {
 }
 
 
-/**
- * Creates a new Strophe.Connection instance and if applicable, attempt to
- * restore the BOSH session or if `auto_login` is true, attempt to log in.
+async function onDomainDiscovered (response) {
+    const text = await response.text();
+    const xrd = (new window.DOMParser()).parseFromString(text, "text/xml").firstElementChild;
+    if (xrd.nodeName != "XRD" || xrd.namespaceURI != "http://docs.oasis-open.org/ns/xri/xrd-1.0") {
+        return _converse.log("Could not discover XEP-0156 connection methods", Strophe.LogLevel.WARN);
+    }
+    const bosh_links = sizzle(`Link[rel="urn:xmpp:alt-connections:xbosh"]`, xrd);
+    const ws_links = sizzle(`Link[rel="urn:xmpp:alt-connections:websocket"]`, xrd);
+    const bosh_methods = bosh_links.map(el => el.getAttribute('href'));
+    const ws_methods = ws_links.map(el => el.getAttribute('href'));
+    // TODO: support multiple endpoints
+    _converse.websocket_url = ws_methods.pop();
+    _converse.bosh_service_url = bosh_methods.pop();
+    if (bosh_methods.length === 0 && ws_methods.length === 0) {
+        _converse.log(
+            "onDomainDiscovered: neither BOSH nor WebSocket connection methods have been specified with XEP-0156.",
+            Strophe.LogLevel.WARN
+        );
+    }
+}
+
+
+/* Use XEP-0156 to check whether this host advertises websocket or BOSH connection methods.
  */
-_converse.initConnection = async function () {
-    if (!_converse.connection) {
-        if (!_converse.bosh_service_url && ! _converse.websocket_url) {
-            throw new Error("initConnection: you must supply a value for either the bosh_service_url or websocket_url or both.");
+async function discoverConnectionMethods (domain) {
+    const options = {
+        'mode': 'cors',
+        'headers': {
+            'Accept': 'application/xrd+xml, text/xml'
         }
-        if (('WebSocket' in window || 'MozWebSocket' in window) && _converse.websocket_url) {
-            _converse.connection = new Strophe.Connection(
-                _converse.websocket_url,
-                Object.assign(_converse.default_connection_options, _converse.connection_options)
-            );
-        } else if (_converse.bosh_service_url) {
-            _converse.connection = new Strophe.Connection(
-                _converse.bosh_service_url,
-                Object.assign(
-                    _converse.default_connection_options,
-                    _converse.connection_options,
-                    {'keepalive': _converse.keepalive}
-                )
-            );
-        } else {
-            throw new Error("initConnection: this browser does not support "+
-                            "websockets and bosh_service_url wasn't specified.");
+    };
+    const url = `https://${domain}/.well-known/host-meta`;
+    let response;
+    try {
+        response = await fetch(url, options);
+    } catch (e) {
+        _converse.log(`Failed to discover alternative connection methods at ${url}`, Strophe.LogLevel.ERROR);
+        return _converse.log(e, Strophe.LogLevel.ERROR);
+    }
+    if (response.status >= 200 && response.status < 400) {
+        await onDomainDiscovered(response);
+    } else {
+        _converse.log("Could not discover XEP-0156 connection methods", Strophe.LogLevel.WARN);
+    }
+}
+
+
+_converse.initConnection = async function (domain) {
+    if (_converse.discover_connection_methods) {
+        await discoverConnectionMethods(domain);
+    }
+    if (! _converse.bosh_service_url) {
+        if (_converse.authentication === _converse.PREBIND) {
+            throw new Error("authentication is set to 'prebind' but we don't have a BOSH connection");
         }
-        if (_converse.auto_login || _converse.keepalive) {
-            await _converse.api.user.login(null, null, true);
+        if (! _converse.websocket_url) {
+            throw new Error("initConnection: you must supply a value for either the bosh_service_url or websocket_url or both.");
         }
     }
+
+    if (('WebSocket' in window || 'MozWebSocket' in window) && _converse.websocket_url) {
+        _converse.connection = new Strophe.Connection(
+            _converse.websocket_url,
+            Object.assign(_converse.default_connection_options, _converse.connection_options)
+        );
+    } else if (_converse.bosh_service_url) {
+        _converse.connection = new Strophe.Connection(
+            _converse.bosh_service_url,
+            Object.assign(
+                _converse.default_connection_options,
+                _converse.connection_options,
+                {'keepalive': _converse.keepalive}
+            )
+        );
+    } else {
+        throw new Error("initConnection: this browser does not support "+
+                        "websockets and bosh_service_url wasn't specified.");
+    }
     setUpXMLLogging();
     /**
      * Triggered once the `Strophe.Connection` constructor has been initialized, which
@@ -587,10 +633,10 @@ _converse.initConnection = async function () {
      * @event _converse#connectionInitialized
      */
     _converse.api.trigger('connectionInitialized');
-};
+}
 
 
-async function setUserJID (jid) {
+async function initSession (jid) {
     const bare_jid = Strophe.getBareJidFromJid(jid).toLowerCase();
     const id = `converse.session-${bare_jid}`;
     if (!_converse.session || _converse.session.get('id') !== id) {
@@ -612,23 +658,14 @@ async function setUserJID (jid) {
     } else {
         saveJIDtoSession(jid);
     }
-    /**
-     * Triggered whenever the user's JID has been updated
-     * @event _converse#setUserJID
-     */
-    _converse.api.trigger('setUserJID');
-    return jid;
 }
 
+
 function saveJIDtoSession (jid) {
     jid = _converse.session.get('jid') || jid;
     if (_converse.authentication !== _converse.ANONYMOUS && !Strophe.getResourceFromJid(jid)) {
         jid = jid.toLowerCase() + _converse.generateResource();
     }
-    // Set JID on the connection object so that when we call
-    // `connection.bind` the new resource is found by Strophe.js
-    // and sent to the XMPP server.
-    _converse.connection.jid = jid;
     _converse.jid = jid;
     _converse.bare_jid = Strophe.getBareJidFromJid(jid);
     _converse.resource = Strophe.getResourceFromJid(jid);
@@ -640,6 +677,37 @@ function saveJIDtoSession (jid) {
        'domain': _converse.domain,
        'active': true
     });
+    // Set JID on the connection object so that when we call `connection.bind`
+    // the new resource is found by Strophe.js and sent to the XMPP server.
+    _converse.connection.jid = jid;
+}
+
+
+/**
+ * Stores the passed in JID for the current user, potentially creating a
+ * resource if the JID is bare.
+ *
+ * Given that we can only create an XMPP connection if we know the domain of
+ * the server connect to and we only know this once we know the JID, we also
+ * call {@link _converse.initConnection } (if necessary) to make sure that the
+ * connection is set up.
+ *
+ * @method _converse#setUserJID
+ * @emits _converse#setUserJID
+ * @params { String } jid
+ */
+_converse.setUserJID = async function (jid) {
+    if (!_converse.connection || !u.isSameDomain(_converse.connection.jid, jid)) {
+        const domain = Strophe.getDomainFromJid(jid)
+        await _converse.initConnection(domain);
+    }
+    await initSession(jid);
+    /**
+     * Triggered whenever the user's JID has been updated
+     * @event _converse#setUserJID
+     */
+    _converse.api.trigger('setUserJID');
+    return jid;
 }
 
 
@@ -649,7 +717,7 @@ async function onConnected (reconnecting) {
      */
     delete _converse.connection.reconnecting;
     _converse.connection.flush(); // Solves problem of returned PubSub BOSH response not received by browser
-    await setUserJID(_converse.connection.jid);
+    await _converse.setUserJID(_converse.connection.jid);
     /**
      * Synchronous event triggered after we've sent an IQ to bind the
      * user's JID resource for this session.
@@ -681,9 +749,9 @@ function setUpXMLLogging () {
 async function finishInitialization () {
     initClientConfig();
     initPlugins();
-    await _converse.initConnection();
     _converse.registerGlobalEventHandlers();
-    if (!Backbone.history.started) {
+
+    if (!Backbone.History.started) {
         Backbone.history.start();
     }
     if (_converse.idle_presence_timeout > 0) {
@@ -691,6 +759,10 @@ async function finishInitialization () {
             _converse.api.disco.own.features.add(Strophe.NS.IDLE);
         });
     }
+    if (_converse.auto_login ||
+            _converse.keepalive && _.invoke(_converse.pluggable.plugins['converse-bosh'], 'enabled')) {
+        await _converse.api.user.login(null, null, true);
+    }
 }
 
 
@@ -706,6 +778,7 @@ function finishDisconnection () {
     _converse.connection.reset();
     tearDown();
     clearSession();
+    delete _converse.connection;
     /**
      * Triggered after converse.js has disconnected from the XMPP server.
      * @event _converse#disconnected
@@ -725,7 +798,7 @@ function fetchLoginCredentials (wait=0) {
             xhr.onload = () => {
                 if (xhr.status >= 200 && xhr.status < 400) {
                     const data = JSON.parse(xhr.responseText);
-                    setUserJID(data.jid).then(() => {
+                    _converse.setUserJID(data.jid).then(() => {
                         resolve({
                             jid: data.jid,
                             password: data.password
@@ -761,7 +834,7 @@ async function getLoginCredentials () {
 async function getLoginCredentialsFromBrowser () {
     const creds = await navigator.credentials.get({'password': true});
     if (creds && creds.type == 'password' && u.isValidJID(creds.id)) {
-        await setUserJID(creds.id);
+        await _converse.setUserJID(creds.id);
         return {'jid': creds.id, 'password': creds.password};
     }
 }
@@ -782,18 +855,20 @@ function cleanup () {
     if (_converse.chatboxviews) {
         delete _converse.chatboxviews;
     }
+    if (_converse.connection) {
+        _converse.connection.reset();
+    }
     _converse.stopListening();
     _converse.off();
 }
 
 
 _converse.initialize = async function (settings, callback) {
+    cleanup();
+
     settings = settings !== undefined ? settings : {};
     const init_promise = u.getResolveablePromise();
     PROMISES.forEach(addPromise);
-    if (_converse.connection !== undefined) {
-        cleanup();
-    }
 
     if ('onpagehide' in window) {
         // Pagehide gets thrown in more cases than unload. Specifically it
@@ -877,7 +952,7 @@ _converse.initialize = async function (settings, callback) {
         if (_converse.idle_seconds > 0) {
             _converse.idle_seconds = 0;
         }
-        if (!_converse.connection.authenticated) {
+        if (!_.get(_converse.connection, 'authenticated')) {
             // We can't send out any stanzas when there's no authenticated connection.
             // This can happen when the connection reconnects.
             return;
@@ -901,7 +976,7 @@ _converse.initialize = async function (settings, callback) {
         /* An interval handler running every second.
          * Used for CSI and the auto_away and auto_xa features.
          */
-        if (!_converse.connection.authenticated) {
+        if (!_.get(_converse.connection, 'authenticated')) {
             // We can't send out any stanzas when there's no authenticated connection.
             // This can happen when the connection reconnects.
             return;
@@ -1378,7 +1453,7 @@ _converse.api = {
          * @returns {boolean} Whether there is an established connection or not.
          */
         connected () {
-            return (_converse.connection && _converse.connection.connected) || false;
+            return _.get(_converse, 'connection', {}).connected && true;
         },
 
         /**
@@ -1417,7 +1492,7 @@ _converse.api = {
                 // We also call `_proto._doDisconnect` so that connection event handlers
                 // for the old transport are removed.
                 if (_converse.api.connection.isType('websocket') && _converse.bosh_service_url) {
-                    await setUserJID(_converse.bare_jid);
+                    await _converse.setUserJID(_converse.bare_jid);
                     _converse.connection._proto._doDisconnect();
                     _converse.connection._proto = new Strophe.Bosh(_converse.connection);
                     _converse.connection.service = _converse.bosh_service_url;
@@ -1426,9 +1501,9 @@ _converse.api = {
                         // When reconnecting anonymously, we need to connect with only
                         // the domain, not the full JID that we had in our previous
                         // (now failed) session.
-                        await setUserJID(_converse.settings.jid);
+                        await _converse.setUserJID(_converse.settings.jid);
                     } else {
-                        await setUserJID(_converse.bare_jid);
+                        await _converse.setUserJID(_converse.bare_jid);
                     }
                     _converse.connection._proto._doDisconnect();
                     _converse.connection._proto = new Strophe.Websocket(_converse.connection);
@@ -1439,7 +1514,7 @@ _converse.api = {
                 // When reconnecting anonymously, we need to connect with only
                 // the domain, not the full JID that we had in our previous
                 // (now failed) session.
-                await setUserJID(_converse.settings.jid);
+                await _converse.setUserJID(_converse.settings.jid);
             }
             if (_converse.connection.reconnecting) {
                 debouncedReconnect();
@@ -1527,20 +1602,19 @@ _converse.api = {
          *  fails to restore a previous auth'd session.
          */
         async login (jid, password, automatic=false) {
-            if (_converse.api.connection.isType('bosh')) {
+            if (jid || _converse.jid) {
+                jid = await _converse.setUserJID(jid || _converse.jid);
+            }
+
+            // See whether there is a BOSH session to re-attach to
+            if (_.invoke(_converse.pluggable.plugins['converse-bosh'], 'enabled')) {
                 if (await _converse.restoreBOSHSession()) {
                     return;
                 } else if (_converse.authentication === _converse.PREBIND && (!automatic || _converse.auto_login)) {
                     return _converse.startNewPreboundBOSHSession();
                 }
-            } else if (_converse.authentication === _converse.PREBIND) {
-                throw new Error("authentication is set to 'prebind' but we don't have a BOSH connection");
             }
 
-            if (jid || _converse.jid) {
-                // Reassign because we might have gained a resource
-                jid = await setUserJID(jid || _converse.jid);
-            }
             password = password || _converse.password;
             const credentials = (jid && password) ? { jid, password } : null;
             attemptNonPreboundSession(credentials, automatic);
@@ -1866,6 +1940,11 @@ _converse.api = {
      * _converse.api.send(msg);
      */
     send (stanza) {
+        if (!_converse.api.connection.connected()) {
+            _converse.log("Not sending stanza because we're not connected!", Strophe.LogLevel.WARN);
+            _converse.log(Strophe.serialize(stanza), Strophe.LogLevel.WARN);
+            return;
+        }
         if (_.isString(stanza)) {
             stanza = u.toStanza(stanza);
         }

+ 1 - 1
src/headless/converse-muc.js

@@ -633,7 +633,7 @@ converse.plugins.add('converse-muc', {
                         disco_entity.destroy();
                     }
                 }
-                if (_converse.connection.connected) {
+                if (_converse.api.connection.connected()) {
                     this.sendUnavailablePresence(exit_msg);
                 }
                 u.safeSave(this, {'connection_status': converse.ROOMSTATUS.DISCONNECTED});

+ 9 - 0
src/headless/utils/core.js

@@ -107,6 +107,15 @@ u.isSameBareJID = function (jid1, jid2) {
             Strophe.getBareJidFromJid(jid2).toLowerCase();
 };
 
+
+u.isSameDomain = function (jid1, jid2) {
+    if (!_.isString(jid1) || !_.isString(jid2)) {
+        return false;
+    }
+    return Strophe.getDomainFromJid(jid1).toLowerCase() ===
+            Strophe.getDomainFromJid(jid2).toLowerCase();
+};
+
 u.isNewMessage = function (message) {
     /* Given a stanza, determine whether it's a new
      * message, i.e. not a MAM archived one.

+ 71 - 76
tests/mock.js

@@ -9,7 +9,6 @@
     const Strophe = converse.env.Strophe;
     const dayjs = converse.env.dayjs;
     const $iq = converse.env.$iq;
-    const u = converse.env.utils;
 
     window.libsignal = {
         'SignalProtocolAddress': function (name, device_id) {
@@ -32,7 +31,7 @@
                 return Promise.resolve(key_and_tag);
             }
         },
-        'SessionBuilder': function (storage, remote_address) {
+        'SessionBuilder': function (storage, remote_address) { // eslint-disable-line no-unused-vars
             this.processPreKey = function () {
                 return Promise.resolve();
             }
@@ -116,95 +115,87 @@
         'preventDefault': function () {}
     };
 
-    mock.mock_connection = function ()  {  // eslint-disable-line wrap-iife
-        return function () {
-            Strophe.Bosh.prototype._processRequest = function () {}; // Don't attempt to send out stanzas
-            const c = new Strophe.Connection('jasmine tests');
-            const sendIQ = c.sendIQ;
 
-            c.IQ_stanzas = [];
-            c.IQ_ids = [];
-            c.sendIQ = function (iq, callback, errback) {
-                if (!_.isElement(iq)) {
-                    iq = iq.nodeTree;
-                }
-                this.IQ_stanzas.push(iq);
-                const id = sendIQ.bind(this)(iq, callback, errback);
-                this.IQ_ids.push(id);
-                return id;
+    const OriginalConnection = Strophe.Connection;
+
+    function MockConnection (service, options) {
+        OriginalConnection.call(this, service, options);
+
+        Strophe.Bosh.prototype._processRequest = function () {}; // Don't attempt to send out stanzas
+        const sendIQ = this.sendIQ;
+
+        this.IQ_stanzas = [];
+        this.IQ_ids = [];
+        this.sendIQ = function (iq, callback, errback) {
+            if (!_.isElement(iq)) {
+                iq = iq.nodeTree;
             }
+            this.IQ_stanzas.push(iq);
+            const id = sendIQ.bind(this)(iq, callback, errback);
+            this.IQ_ids.push(id);
+            return id;
+        }
 
-            const send = c.send;
-            c.sent_stanzas = [];
-            c.send = function (stanza) {
-                if (_.isElement(stanza)) {
-                    this.sent_stanzas.push(stanza);
-                } else {
-                    this.sent_stanzas.push(stanza.nodeTree);
-                }
-                return send.apply(this, arguments);
+        const send = this.send;
+        this.sent_stanzas = [];
+        this.send = function (stanza) {
+            if (_.isElement(stanza)) {
+                this.sent_stanzas.push(stanza);
+            } else {
+                this.sent_stanzas.push(stanza.nodeTree);
             }
+            return send.apply(this, arguments);
+        }
 
-            c.features = Strophe.xmlHtmlNode(
-                '<stream:features xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client">'+
-                    '<ver xmlns="urn:xmpp:features:rosterver"/>'+
-                    '<csi xmlns="urn:xmpp:csi:0"/>'+
-                    '<c xmlns="http://jabber.org/protocol/caps" ver="UwBpfJpEt3IoLYfWma/o/p3FFRo=" hash="sha-1" node="http://prosody.im"/>'+
-                    '<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">'+
-                        '<required/>'+
-                    '</bind>'+
-                    `<sm xmlns='urn:xmpp:sm:3'/>`+
-                    '<session xmlns="urn:ietf:params:xml:ns:xmpp-session">'+
-                        '<optional/>'+
-                    '</session>'+
-                '</stream:features>').firstChild;
+        this.features = Strophe.xmlHtmlNode(
+            '<stream:features xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client">'+
+                '<ver xmlns="urn:xmpp:features:rosterver"/>'+
+                '<csi xmlns="urn:xmpp:csi:0"/>'+
+                '<this xmlns="http://jabber.org/protocol/caps" ver="UwBpfJpEt3IoLYfWma/o/p3FFRo=" hash="sha-1" node="http://prosody.im"/>'+
+                '<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">'+
+                    '<required/>'+
+                '</bind>'+
+                `<sm xmlns='urn:xmpp:sm:3'/>`+
+                '<session xmlns="urn:ietf:params:xml:ns:xmpp-session">'+
+                    '<optional/>'+
+                '</session>'+
+            '</stream:features>').firstChild;
 
-            c._proto._connect = function () {
-                c.connected = true;
-                c.mock = true;
-                c.jid = 'romeo@montague.lit/orchard';
-                c._changeConnectStatus(Strophe.Status.BINDREQUIRED);
-            };
+        this._proto._connect = () => {
+            this.connected = true;
+            this.mock = true;
+            this.jid = 'romeo@montague.lit/orchard';
+            this._changeConnectStatus(Strophe.Status.BINDREQUIRED);
+        };
 
-            c.bind = function () {
-                c.authenticated = true;
-                this.authenticated = true;
-                c._changeConnectStatus(Strophe.Status.CONNECTED);
-            };
+        this.bind = () => {
+            this.authenticated = true;
+            this.authenticated = true;
+            this._changeConnectStatus(Strophe.Status.CONNECTED);
+        };
 
-            c._proto._disconnect = function () {
-                c._onDisconnectTimeout();
-            }
+        this._proto._disconnect = () => this._onDisconnectTimeout();
+        this._proto._onDisconnectTimeout = _.noop;
+    }
 
-            c._proto._onDisconnectTimeout = _.noop;
-            return c;
-        };
-    }();
+    MockConnection.prototype = Object.create(OriginalConnection.prototype);
+    Strophe.Connection = MockConnection;
 
-    async function initConverse (settings, spies={}, promises) {
+
+    async function initConverse (settings, spies={}) {
         window.localStorage.clear();
         window.sessionStorage.clear();
-        const el = document.querySelector('#conversejs');
-        if (el) {
-            el.parentElement.removeChild(el);
-        }
-
-        const connection = mock.mock_connection();
-        if (spies && spies.connection) {
-            spies.connection.forEach(method => spyOn(connection, method));
-        }
 
         const _converse = await converse.initialize(Object.assign({
-            'i18n': 'en',
+            'animate': false,
             'auto_subscribe': false,
-            'play_sounds': false,
             'bosh_service_url': 'montague.lit/http-bind',
-            'connection': connection,
-            'animate': false,
-            'use_emojione': false,
+            'debug': false,
+            'i18n': 'en',
             'no_trimming': true,
+            'play_sounds': false,
+            'use_emojione': false,
             'view_mode': mock.view_mode,
-            'debug': false
         }, settings || {}));
 
         if (spies && spies._converse) {
@@ -214,7 +205,7 @@
         _converse.ChatBoxViews.prototype.trimChat = function () {};
 
         _converse.api.vcard.get = function (model, force) {
-            return new Promise((resolve, reject) => {
+            return new Promise(resolve => {
                 let jid;
                 if (_.isString(model)) {
                     jid = model;
@@ -263,9 +254,13 @@
         return async done => {
             const _converse = await initConverse(settings, spies);
             async function _done () {
-                await _converse.api.user.logout();
+                if (_converse.api.connection.connected()) {
+                    await _converse.api.user.logout();
+                }
                 const el = document.querySelector('#conversejs');
-                el.parentElement.removeChild(el);
+                if (el) {
+                    el.parentElement.removeChild(el);
+                }
                 done();
             }
             await Promise.all((promise_names || []).map(_converse.api.waitUntil));