Bug 798083 - fix various social chat overflow and related issues. r=jaws
authorMark Hammond <mhammond@skippinet.com.au>
Sat, 06 Oct 2012 15:23:50 -0700
changeset 112290 837ae727d237e061235d0fb891be68bc995bb847
parent 112289 4a5c43e5b7e28073725891390d159f11cba042e7
child 112291 cecff590fcd642d57ecc77018457373bf2dd027b
push id23809
push useremorley@mozilla.com
push dateMon, 05 Nov 2012 15:24:12 +0000
treeherdermozilla-central@358c9830d166 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs798083
milestone19.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 798083 - fix various social chat overflow and related issues. r=jaws
browser/base/content/socialchat.xml
browser/base/content/test/browser_social_chatwindow.js
--- a/browser/base/content/socialchat.xml
+++ b/browser/base/content/socialchat.xml
@@ -117,241 +117,351 @@
       ]]></handler>
     </handlers>
   </binding>
 
   <binding id="chatbar">
     <content>
       <xul:hbox align="end" pack="end" anonid="innerbox" class="chatbar-innerbox" mousethrough="always" flex="1">
         <xul:toolbarbutton anonid="nub" class="chatbar-button" type="menu" collapsed="true" mousethrough="never">
-          <xul:menupopup anonid="nubMenu" oncommand="document.getBindingParent(this).swapChat(event)"/>
+          <xul:menupopup anonid="nubMenu" oncommand="document.getBindingParent(this).showChat(event.target.chat)"/>
         </xul:toolbarbutton>
         <xul:spacer flex="1" anonid="spacer" class="chatbar-overflow-spacer"/>
         <children/>
       </xul:hbox>
     </content>
 
     <implementation implements="nsIDOMEventListener">
+      <constructor>
+        // to avoid reflows we cache the values for various widths.
+        this.cachedWidthOpen = 0;
+        this.cachedWidthMinimized = 0;
+        this.cachedWidthNub = 0;
+      </constructor>
 
       <field name="innerbox" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "innerbox");
       </field>
 
       <field name="menupopup" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "nubMenu");
       </field>
 
       <field name="nub" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "nub");
       </field>
 
-      <property name="emptyWidth">
-        <getter>
-          return document.getAnonymousElementByAttribute(this, "anonid", "spacer").boxObject.width;
-        </getter>
-      </property>
-
       <property name="selectedChat">
         <getter><![CDATA[
           return this._selectedChat;
         ]]></getter>
         <setter><![CDATA[
-          if (this._selectedChat)
-            this._selectedChat.removeAttribute("selected");
-          this._selectedChat = val;
+          // this is pretty horrible, but we:
+          // * want to avoid doing touching 'selected' attribute when the
+          //   specified chat is already selected.
+          // * remove 'activity' attribute on newly selected tab *even if*
+          //   newly selected is already selected.
+          // * need to handle either current or new being null.
+          if (this._selectedChat != val) {
+            if (this._selectedChat) {
+              this._selectedChat.removeAttribute("selected");
+            }
+            this._selectedChat = val;
+            if (val) {
+              this._selectedChat.setAttribute("selected", "true");
+            }
+          }
           if (val) {
-            this._selectedChat.setAttribute("selected", "true");
             this._selectedChat.removeAttribute("activity");
           }
         ]]></setter>
       </property>
 
       <field name="menuitemMap">new WeakMap()</field>
       <field name="chatboxForURL">new Map();</field>
 
-      <property name="firstCollapsedChild">
+      <property name="hasCollapsedChildren">
         <getter><![CDATA[
-          let child = this.lastChild;
-          while (child && !child.collapsed) {
-            child = child.previousSibling;
-          }
-          return child;
+          return !!this.querySelector("[collapsed]");
         ]]></getter>
       </property>
 
-      <property name="firstVisibleChild">
+      <property name="collapsedChildren">
         <getter><![CDATA[
-          let child = this.firstChild;
-          while (child && child.collapsed) {
-            child = child.nextSibling;
+          // A generator yielding all collapsed chatboxes, in the order in
+          // which they should be restored.
+          let child = this.lastChild;
+          while (child) {
+            if (child.collapsed)
+              yield child;
+            child = child.previousSibling;
           }
-          return child;
         ]]></getter>
       </property>
 
-      <property name="firstRemovableChild">
+      <property name="visibleChildren">
         <getter><![CDATA[
+          // A generator yielding all non-collapsed chatboxes.
           let child = this.firstChild;
-          // find the first visible non-focused chatbox, always keep one visible if we
-          // have enough width to do so.
-          while (child &&
-                (child.collapsed || child == this.selectedChat)) {
+          while (child) {
+            if (!child.collapsed)
+              yield child;
             child = child.nextSibling;
           }
-          if (!child && this.selectedChat) {
-            child = this.selectedChat;
-          }
-          return child;
         ]]></getter>
       </property>
 
-      <method name="resize">
-        <body><![CDATA[
-          let child = this.firstCollapsedChild;
-          if (child && this.emptyWidth > child.viewWidth) {
-            this.showChat(child);
-          }
-          if (!this.firstCollapsedChild) {
-            window.removeEventListener("resize", this);
-            this.menupopup.parentNode.collapsed = true;
-          }
-        ]]></body>
-      </method>
+      <property name="collapsibleChildren">
+        <getter><![CDATA[
+          // A generator yielding all children which are able to be collapsed
+          // in the order in which they should be collapsed.
+          // (currently this is all visible ones other than the selected one.)
+          for (let child of this.visibleChildren)
+            if (child != this.selectedChat)
+              yield child;
+        ]]></getter>
+      </property>
 
       <method name="updateTitlebar">
         <parameter name="aChatbox"/>
         <body><![CDATA[
           if (aChatbox.collapsed) {
             let menuitem = this.menuitemMap.get(aChatbox);
             if (aChatbox.getAttribute("activity")) {
               menuitem.setAttribute("activity", true);
               this.nub.setAttribute("activity", true);
             }
             menuitem.setAttribute("label", aChatbox.getAttribute("label"));
             menuitem.setAttribute("image", aChatbox.getAttribute("image"));
           }
         ]]></body>
       </method>
 
-      <method name="handleEvent">
-        <parameter name="aEvent"/>
+      <method name="calcTotalWidthOf">
+        <parameter name="aElement"/>
         <body><![CDATA[
-          if (aEvent.type == "resize") {
-            this.resize();
-          }
+          let cs = document.defaultView.getComputedStyle(aElement);
+          let margins = parseInt(cs.marginLeft) + parseInt(cs.marginRight);
+          return aElement.getBoundingClientRect().width + margins;
         ]]></body>
       </method>
 
-      <method name="swapChat">
-        <parameter name="aEvent"/>
+      <method name="getTotalChildWidth">
+        <parameter name="aChatbox"/>
         <body><![CDATA[
-          let menuitem = aEvent.target;
-          let newChat = menuitem.chat;
-          let oldChat = this.firstVisibleChild;
-          if (oldChat)
-            this.collapseChat(oldChat);
-          if (newChat) 
-            this.showChat(newChat);
+          // gets the width of a child, using/setting the cached value for
+          // children of this type.
+          // DOES NOT take collapsed into account - ie, this is the width
+          // of a child assuming it is *not* collapsed.  (collapsed chats
+          // have a width of zero as they are not shown).
+          if (aChatbox.minimized) {
+            if (!this.cachedWidthMinimized) {
+              if (aChatbox.collapsed)
+                throw new Error("can't calculate size of collapsed chat!");
+              this.cachedWidthMinimized = this.calcTotalWidthOf(aChatbox);
+            }
+            return this.cachedWidthMinimized;
+          }
+          if (!this.cachedWidthOpen) {
+            if (aChatbox.collapsed)
+              throw new Error("can't calculate size of collapsed chat!");
+            this.cachedWidthOpen = this.calcTotalWidthOf(aChatbox);
+          }
+          return this.cachedWidthOpen;
         ]]></body>
       </method>
 
       <method name="collapseChat">
         <parameter name="aChatbox"/>
         <body><![CDATA[
-          aChatbox.viewWidth = aChatbox.getBoundingClientRect().width;
+          // we ensure that the cached width for a child of this type is
+          // up-to-date so we can use it when resizing.
+          this.getTotalChildWidth(aChatbox);
           aChatbox.collapsed = true;
           aChatbox.isActive = false;
           let menu = document.createElementNS("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", "menuitem");
           menu.setAttribute("class", "menuitem-iconic");
           menu.setAttribute("label", aChatbox.iframe.contentDocument.title);
           menu.setAttribute("image", aChatbox.getAttribute("image"));
           menu.chat = aChatbox;
           this.menuitemMap.set(aChatbox, menu);
           this.menupopup.appendChild(menu);
-          this.menupopup.parentNode.collapsed = false;
+          this.nub.collapsed = false;
         ]]></body>
       </method>
 
       <method name="showChat">
         <parameter name="aChatbox"/>
         <body><![CDATA[
+          if (aChatbox.minimized)
+            aChatbox.minimized = false;
+          if (this.selectedChat != aChatbox)
+            this.selectedChat = aChatbox;
+          if (!aChatbox.collapsed)
+            return; // already showing - no more to do.
+          this._showChat(aChatbox);
+          // showing a collapsed chat might mean another needs to be collapsed
+          // to make room...
+          this.resize();
+        ]]></body>
+      </method>
+
+      <method name="_showChat">
+        <parameter name="aChatbox"/>
+        <body><![CDATA[
+          // the actual implementation - doesn't check for overflow, assumes
+          // collapsed, etc.
           let menuitem = this.menuitemMap.get(aChatbox);
           this.menuitemMap.delete(aChatbox);
           this.menupopup.removeChild(menuitem);
           aChatbox.collapsed = false;
           aChatbox.isActive = !aChatbox.minimized;
         ]]></body>
       </method>
 
       <method name="remove">
         <parameter name="aChatbox"/>
         <body><![CDATA[
+          this._remove(aChatbox);
+          // The removal of a chat may mean a collapsed one can spring up,
+          // or that the popup should be hidden.
+          this.resize();
+        ]]></body>
+      </method>
+
+      <method name="_remove">
+        <parameter name="aChatbox"/>
+        <body><![CDATA[
           if (this.selectedChat == aChatbox) {
             this.selectedChat = aChatbox.previousSibling ? aChatbox.previousSibling : aChatbox.nextSibling
           }
           this.removeChild(aChatbox);
-          this.resize();
+          // child might have been collapsed.
+          let menuitem = this.menuitemMap.get(aChatbox);
+          if (menuitem) {
+            this.menuitemMap.delete(aChatbox);
+            this.menupopup.removeChild(menuitem);
+          }
           this.chatboxForURL.delete(aChatbox.getAttribute('src'));
         ]]></body>
       </method>
 
       <method name="removeAll">
         <body><![CDATA[
           while (this.firstChild) {
-            this.removeChild(this.firstChild);
+            this._remove(this.firstChild);
           }
-          this.chatboxForURL = new Map();
+          // and the nub/popup must also die.
+          this.nub.collapsed = true;
         ]]></body>
       </method>
 
       <method name="openChat">
         <parameter name="aProvider"/>
         <parameter name="aURL"/>
         <parameter name="aCallback"/>
         <parameter name="aMode"/>
         <body><![CDATA[
           let cb = this.chatboxForURL.get(aURL);
           if (cb) {
             cb = cb.get();
             if (cb.parentNode) {
-              // ensure this chatbox is visible
-              if (this.selectedChat != cb)
-                this.selectedChat = cb;
-              if (cb.collapsed)
-                this.showChat(cb);
+              this.showChat(cb);
               if (aCallback)
                 aCallback(cb.iframe.contentWindow);
               return;
             }
             this.chatboxForURL.delete(aURL);
           }
           cb = document.createElementNS("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", "chatbox");
           if (aMode == "minimized")
             cb.setAttribute("minimized", "true");
           this.selectedChat = cb;
           this.insertBefore(cb, this.firstChild);
           cb.init(aProvider, aURL, aCallback);
           this.chatboxForURL.set(aURL, Cu.getWeakReference(cb));
+          this.resize();
+        ]]></body>
+      </method>
+
+      <method name="resize">
+        <body><![CDATA[
+        // Checks the current size against the collapsed state of children
+        // and collapses or expands as necessary such that as many as possible
+        // are shown.
+        // So 2 basic strategies:
+        // * Collapse/Expand one at a time until we can't collapse/expand any
+        //   more - but this is one reflow per change.
+        // * Calculate the dimensions ourself and choose how many to collapse
+        //   or expand based on this, then do them all in one go.  This is one
+        //   reflow regardless of how many we change.
+        // So we go the more complicated but more efficient second option...
+        let availWidth = this.getBoundingClientRect().width;
+        let currentWidth = 0;
+        if (!this.nub.collapsed) { // the nub is visible.
+          if (!this.cachedWidthNub)
+            this.cachedWidthNub = this.calcTotalWidthOf(this.nub);
+          currentWidth += this.cachedWidthNub;
+        }
+        for (let child of this.visibleChildren) {
+          currentWidth += this.getTotalChildWidth(child);
+        }
+
+        if (currentWidth > availWidth) {
+          // we need to collapse some.
+          let toCollapse = [];
+          for (let child of this.collapsibleChildren) {
+            if (currentWidth <= availWidth)
+              break;
+            toCollapse.push(child);
+            currentWidth -= this.getTotalChildWidth(child);
+          }
+          if (toCollapse.length) {
+            for (let child of toCollapse)
+              this.collapseChat(child);
+          }
+        } else if (currentWidth < availWidth) {
+          // we *might* be able to expand some - see how many.
+          // XXX - if this was clever, it could know when removing the nub
+          // leaves enough space to show all collapsed
+          let toShow = [];
+          for (let child of this.collapsedChildren) {
+            currentWidth += this.getTotalChildWidth(child);
+            if (currentWidth > availWidth)
+              break;
+            toShow.push(child);
+          }
+          for (let child of toShow)
+            this._showChat(child);
+
+          // If none remain collapsed remove the nub.
+          if (!this.hasCollapsedChildren) {
+            this.nub.collapsed = true;
+          }
+        }
+        // else: achievement unlocked - we are pixel-perfect!
+        ]]></body>
+      </method>
+
+      <method name="handleEvent">
+        <parameter name="aEvent"/>
+        <body><![CDATA[
+          if (aEvent.type == "resize") {
+            this.resize();
+          }
         ]]></body>
       </method>
 
     </implementation>
     <handlers>
       <handler event="popupshown"><![CDATA[
         this.nub.removeAttribute("activity");
       ]]></handler>
-      <handler event="overflow"><![CDATA[
-        // make sure we're not getting an overflow from content
-        if (event.originalTarget != this.innerbox)
-          return;
-
-        let hasHidden = this.firstCollapsedChild;
-        let child = this.firstRemovableChild;
-        if (child)
-          this.collapseChat(child);
-        if (!hasHidden) {
-          window.addEventListener("resize", this);
-        }
+      <handler event="load"><![CDATA[
+        window.addEventListener("resize", this);
+      ]]></handler>
+      <handler event="unload"><![CDATA[
+        window.removeEventListener("resize", this);
       ]]></handler>
     </handlers>
   </binding>
 
 </bindings>
--- a/browser/base/content/test/browser_social_chatwindow.js
+++ b/browser/base/content/test/browser_social_chatwindow.js
@@ -7,20 +7,22 @@ function test() {
 
   let manifest = { // normal provider
     name: "provider 1",
     origin: "https://example.com",
     sidebarURL: "https://example.com/browser/browser/base/content/test/social_sidebar.html",
     workerURL: "https://example.com/browser/browser/base/content/test/social_worker.js",
     iconURL: "https://example.com/browser/browser/base/content/test/moz.png"
   };
+  let oldwidth = window.outerWidth; // we futz with this, so we restore it
   runSocialTestWithProvider(manifest, function (finishcb) {
     runSocialTests(tests, undefined, undefined, function () {
       let chats = document.getElementById("pinnedchats");
       ok(chats.children.length == 0, "no chatty children left behind");
+      window.resizeTo(oldwidth, window.outerHeight);
       finishcb();
     });
   });
 }
 
 var tests = {
   testOpenCloseChat: function(next) {
     let chats = document.getElementById("pinnedchats");
@@ -104,18 +106,18 @@ var tests = {
         case "got-chatbox-message":
           ok(true, "got a chat window opened");
           let chats = document.getElementById("pinnedchats");
           ok(chats.selectedChat.minimized, "chatbox from worker opened as minimized");
           while (chats.selectedChat) {
             chats.selectedChat.close();
           }
           ok(!chats.selectedChat, "chats are all closed");
+          ensureSocialUrlNotRemembered(chatUrl);
           port.close();
-          ensureSocialUrlNotRemembered(chatUrl);
           next();
           break;
       }
     }
     port.postMessage({topic: "test-worker-chat", data: chatUrl});
   },
   testCloseSelf: function(next) {
     let chats = document.getElementById("pinnedchats");
@@ -133,16 +135,17 @@ var tests = {
           let chat = chats.selectedChat;
           ok(chat.parentNode, "chat has a parent node before it is closed");
           // ask it to close itself.
           let doc = chat.iframe.contentDocument;
           let evt = doc.createEvent("CustomEvent");
           evt.initCustomEvent("socialTest-CloseSelf", true, true, {});
           doc.documentElement.dispatchEvent(evt);
           ok(!chat.parentNode, "chat is now closed");
+          port.close();
           next();
           break;
       }
     }
     port.postMessage({topic: "test-init", data: { id: 1 }});
   },
   testSameChatCallbacks: function(next) {
     let chats = document.getElementById("pinnedchats");
@@ -157,39 +160,315 @@ var tests = {
         case "chatbox-opened":
           is(e.data.result, "ok", "the sidebar says it got a chatbox");
           if (seen_opened) {
             // This is the second time we've seen this message - there should
             // be exactly 1 chat open.
             let chats = document.getElementById("pinnedchats");
             chats.selectedChat.close();
             is(chats.selectedChat, null, "should only have been one chat open");
+            port.close();
             next();
           } else {
             // first time we got the opened message, so re-request the same
             // chat to be opened - we should get the message again.
             seen_opened = true;
             port.postMessage({topic: "test-chatbox-open"});
           }
       }
     }
     port.postMessage({topic: "test-init", data: { id: 1 }});
   },
+
+  // check removeAll does the right thing
+  testRemoveAll: function(next, mode) {
+    let port = Social.provider.getWorkerPort();
+    port.postMessage({topic: "test-init"});
+    get3ChatsForCollapsing(mode || "normal", function() {
+      let chatbar = window.SocialChatBar.chatbar;
+      chatbar.removeAll();
+      // should be no evidence of any chats left.
+      is(chatbar.childNodes.length, 0, "should be no chats left");
+      checkPopup();
+      is(chatbar.selectedChat, null, "nothing should be selected");
+      is(chatbar.chatboxForURL.size, 0, "chatboxForURL map should be empty");
+      port.close();
+      next();
+    });
+  },
+
+  testRemoveAllMinimized: function(next) {
+    this.testRemoveAll(next, "minimized");
+  },
+
+  // resize and collapse testing.
+  testBrowserResize: function(next, mode) {
+    let chats = document.getElementById("pinnedchats");
+    let port = Social.provider.getWorkerPort();
+    port.postMessage({topic: "test-init"});
+    get3ChatsForCollapsing(mode || "normal", function(first, second, third) {
+      let chatWidth = chats.getTotalChildWidth(first);
+      ok(chatWidth, "have a chatwidth");
+      let popupWidth = getPopupWidth();
+      ok(popupWidth, "have a popupwidth");
+      info("starting resize tests - each chat's width is " + chatWidth +
+           " and the popup width is " + popupWidth);
+      resizeAndCheckWidths(first, second, third, [
+        [chatWidth-1, false, false, true, "to < 1 chat width - only last should be visible."],
+        [chatWidth+1, false, false, true, "one pixel more then one fully exposed (not counting popup) - still only 1."],
+        [chatWidth+popupWidth+1, false, false, true, "one pixel more than one fully exposed (including popup) - still only 1."],
+        [chatWidth*2-1, false, false, true, "second not showing by 1 pixel (not counting popup) - only 1 exposed."],
+        [chatWidth*2+popupWidth-1, false, false, true, "second not showing by 1 pixel (including popup) - only 1 exposed."],
+        [chatWidth*2+popupWidth+1, false, true, true, "big enough to fit 2 - nub remains visible as first is still hidden"],
+        [chatWidth*3+popupWidth-1, false, true, true, "one smaller than the size necessary to display all three - first still hidden"],
+        [chatWidth*3+popupWidth+1, true, true, true, "big enough to fit all - all exposed (which removes the nub)"],
+        [chatWidth*3, true, true, true, "now the nub is hidden we can resize back down to chatWidth*3 before overflow."],
+        [chatWidth*3-1, false, true, true, "one pixel less and the first is again collapsed (and the nub re-appears)"],
+        [chatWidth*2+popupWidth+1, false, true, true, "back down to just big enough to fit 2"],
+        [chatWidth*2+popupWidth-1, false, false, true, "back down to just not enough to fit 2"],
+        [chatWidth*3+popupWidth+1, true, true, true, "now a large jump to make all 3 visible (ie, affects 2)"],
+        [chatWidth*1.5, false, false, true, "and a large jump back down to 1 visible (ie, affects 2)"],
+      ], function() {
+        closeAllChats();
+        port.close();
+        next();
+      });
+    });
+  },
+
+  testBrowserResizeMinimized: function(next) {
+    this.testBrowserResize(next, "minimized");
+  },
+
+  testShowWhenCollapsed: function(next) {
+    let port = Social.provider.getWorkerPort();
+    port.postMessage({topic: "test-init"});
+    get3ChatsForCollapsing("normal", function(first, second, third) {
+      let chatbar = window.SocialChatBar.chatbar;
+      chatbar.showChat(first);
+      ok(!first.collapsed, "first should no longer be collapsed");
+      ok(second.collapsed ||  third.collapsed, false, "one of the others should be collapsed");
+      closeAllChats();
+      port.close();
+      next();
+    });
+  },
+
+  testActivity: function(next) {
+    let port = Social.provider.getWorkerPort();
+    port.postMessage({topic: "test-init"});
+    get3ChatsForCollapsing("normal", function(first, second, third) {
+      let chatbar = window.SocialChatBar.chatbar;
+      is(chatbar.selectedChat, third, "third chat should be selected");
+      ok(!chatbar.selectedChat.hasAttribute("activity"), "third chat should have no activity");
+      // send an activity message to the second.
+      ok(!second.hasAttribute("activity"), "second chat should have no activity");
+      let iframe2 = second.iframe;
+      let evt = iframe2.contentDocument.createEvent("CustomEvent");
+      evt.initCustomEvent("socialChatActivity", true, true, {});
+      iframe2.contentDocument.documentElement.dispatchEvent(evt);
+      // second should have activity.
+      ok(second.hasAttribute("activity"), "second chat should now have activity");
+      // select the second - it should lose "activity"
+      chatbar.selectedChat = second;
+      ok(!second.hasAttribute("activity"), "second chat should no longer have activity");
+      // Now try the first - it is collapsed, so the 'nub' also gets activity attr.
+      ok(!first.hasAttribute("activity"), "first chat should have no activity");
+      let iframe1 = first.iframe;
+      let evt = iframe1.contentDocument.createEvent("CustomEvent");
+      evt.initCustomEvent("socialChatActivity", true, true, {});
+      iframe1.contentDocument.documentElement.dispatchEvent(evt);
+      ok(first.hasAttribute("activity"), "first chat should now have activity");
+      ok(chatbar.nub.hasAttribute("activity"), "nub should also have activity");
+      // first is collapsed, so use openChat to get it.
+      chatbar.openChat(Social.provider, first.getAttribute("src"));
+      ok(!first.hasAttribute("activity"), "first chat should no longer have activity");
+      // The nub should lose the activity flag here too
+      todo(!chatbar.nub.hasAttribute("activity"), "Bug 806266 - nub should no longer have activity");
+      // TODO: tests for bug 806266 should arrange to have 2 chats collapsed
+      // then open them checking the nub is updated correctly.
+      closeAllChats();
+      port.close();
+      next();
+    });
+  },
+
+  // XXX - note this must be the last test until we restore the login state
+  // between tests...
   testCloseOnLogout: function(next) {
     const chatUrl = "https://example.com/browser/browser/base/content/test/social_chat.html";
     let port = Social.provider.getWorkerPort();
     ok(port, "provider has a port");
     port.postMessage({topic: "test-init"});
     port.onmessage = function (e) {
       let topic = e.data.topic;
       switch (topic) {
         case "got-chatbox-message":
           ok(true, "got a chat window opened");
           port.postMessage({topic: "test-logout"});
+          port.close();
           waitForCondition(function() document.getElementById("pinnedchats").firstChild == null,
                            next,
                            "chat windows didn't close");
           break;
       }
     }
     port.postMessage({topic: "test-worker-chat", data: chatUrl});
+  },
+}
+
+// And lots of helpers for the resize tests.
+function get3ChatsForCollapsing(mode, cb) {
+  // We make one chat, then measure its size.  We then resize the browser to
+  // ensure a second can be created fully visible but a third can not - then
+  // create the other 2.  first will will be collapsed, second fully visible
+  // and the third also visible and the "selected" one.
+  // To make our life easier we don't go via the worker and ports so we get
+  // more control over creation *and* to make the code much simpler.  We
+  // assume the worker/port stuff is individually tested above.
+  let chatbar = window.SocialChatBar.chatbar;
+  let chatWidth = undefined;
+  let num = 0;
+  is(chatbar.childNodes.length, 0, "chatbar starting empty");
+  is(chatbar.menupopup.childNodes.length, 0, "popup starting empty");
+
+  makeChat(mode, "first chat", function() {
+    // got the first one.
+    checkPopup();
+    ok(chatbar.menupopup.parentNode.collapsed, "menu selection isn't visible");
+    // we kinda cheat here and get the width of the first chat, assuming
+    // that all future chats will have the same width when open.
+    chatWidth = chatbar.calcTotalWidthOf(chatbar.selectedChat);
+    let desired = chatWidth * 2.5;
+    resizeWindowToChatAreaWidth(desired, function(sizedOk) {
+      ok(sizedOk, "can't do any tests without this width");
+      checkPopup();
+      makeChat(mode, "second chat", function() {
+        is(chatbar.childNodes.length, 2, "now have 2 chats");
+        checkPopup();
+        // and create the third.
+        makeChat(mode, "third chat", function() {
+          is(chatbar.childNodes.length, 3, "now have 3 chats");
+          checkPopup();
+          // XXX - this is a hacky implementation detail around the order of
+          // the chats.  Ideally things would be a little more sane wrt the
+          // other in which the children were created.
+          let second = chatbar.childNodes[2];
+          let first = chatbar.childNodes[1];
+          let third = chatbar.childNodes[0];
+          ok(first.collapsed && !second.collapsed && !third.collapsed, "collapsed state as promised");
+          is(chatbar.selectedChat, third, "third is selected as promised")
+          info("have 3 chats for collapse testing - starting actual test...");
+          cb(first, second, third);
+        }, mode);
+      }, mode);
+    });
+  }, mode);
+}
+
+function makeChat(mode, uniqueid, cb) {
+  const chatUrl = "https://example.com/browser/browser/base/content/test/social_chat.html";
+  let provider = Social.provider;
+  window.SocialChatBar.openChat(provider, chatUrl + "?id=" + uniqueid, function(chat) {
+    // we can't callback immediately or we might close the chat during
+    // this event which upsets the implementation - it is only 1/2 way through
+    // handling the load event.
+    chat.document.title = uniqueid;
+    executeSoon(cb);
+  }, mode);
+}
+
+function checkPopup() {
+  // popup only showing if any collapsed popup children.
+  let chatbar = window.SocialChatBar.chatbar;
+  let numCollapsed = 0;
+  for (let chat of chatbar.childNodes) {
+    if (chat.collapsed) {
+      numCollapsed += 1;
+      // and it have a menuitem weakmap
+      is(chatbar.menuitemMap.get(chat).nodeName, "menuitem", "collapsed chat has a menu item");
+    } else {
+      ok(!chatbar.menuitemMap.has(chat), "open chat has no menu item");
+    }
+  }
+  is(chatbar.menupopup.parentNode.collapsed, numCollapsed == 0, "popup matches child collapsed state");
+  is(chatbar.menupopup.childNodes.length, numCollapsed, "popup has correct count of children");
+  // todo - check each individual elt is what we expect?
+}
+
+// Resize the main window so the chat area's boxObject is |desired| wide.
+// Does a callback passing |true| if the window is now big enough or false
+// if we couldn't resize large enough to satisfy the test requirement.
+function resizeWindowToChatAreaWidth(desired, cb) {
+  let current = window.SocialChatBar.chatbar.getBoundingClientRect().width;
+  let delta = desired - current;
+  info("resizing window so chat area is " + desired + " wide, currently it is "
+       + current + ".  Screen avail is " + window.screen.availWidth
+       + ", current outer width is " + window.outerWidth);
+
+  // WTF?  Some test boxes will resize to fractional values - eg: we
+  // request 660px but actually get 659.5!?
+  let widthDeltaCloseEnough = function(d) {
+    return Math.abs(d) <= 0.5;
+  }
+
+  // attempting to resize by (0,0), unsurprisingly, doesn't cause a resize
+  // event - so just callback saying all is well.
+  if (widthDeltaCloseEnough(delta)) {
+    cb(true);
+    return;
+  }
+  // On lo-res screens we may already be maxed out but still smaller than the
+  // requested size, so asking to resize up also will not cause a resize event.
+  // So just callback now saying the test must be skipped.
+  if (window.screen.availWidth - window.outerWidth < delta) {
+    info("skipping this as screen available width is less than necessary");
+    cb(false);
+    return;
+  }
+  // Otherwise we request resize and expect a resize event
+  window.addEventListener("resize", function resize_handler() {
+    window.removeEventListener("resize", resize_handler);
+    // we did resize - but did we get far enough to be able to continue?
+    let newSize = window.SocialChatBar.chatbar.getBoundingClientRect().width;
+    let sizedOk = widthDeltaCloseEnough(newSize - desired);
+    if (!sizedOk) {
+      // not an error...
+      info("skipping this as we can't resize chat area to " + desired + " - got " + newSize);
+    }
+    cb(sizedOk);
+  });
+  window.resizeBy(delta, 0);
+}
+
+function resizeAndCheckWidths(first, second, third, checks, cb) {
+  if (checks.length == 0) {
+    cb(); // nothing more to check!
+    return;
+  }
+  let [width, firstVisible, secondVisible, thirdVisible, why] = checks.shift();
+  info("Check: " + why);
+  info("resizing window to " + width + ", expect visibility of " + firstVisible + "/" + secondVisible + "/" + thirdVisible);  
+  resizeWindowToChatAreaWidth(width, function(sizedOk) {
+    checkPopup();
+    if (sizedOk) {
+      is(!first.collapsed, firstVisible, "first should be " + (firstVisible ? "visible" : "hidden"));
+      is(!second.collapsed, secondVisible, "second should be " + (secondVisible ? "visible" : "hidden"));
+      is(!third.collapsed, thirdVisible, "third should be " + (thirdVisible ? "visible" : "hidden"));
+    }
+    resizeAndCheckWidths(first, second, third, checks, cb);
+  });
+}
+
+function getPopupWidth() {
+  let popup = window.SocialChatBar.chatbar.menupopup;
+  ok(!popup.parentNode.collapsed, "asking for popup width when it is visible");
+  let cs = document.defaultView.getComputedStyle(popup.parentNode);
+  let margins = parseInt(cs.marginLeft) + parseInt(cs.marginRight);
+  return popup.parentNode.getBoundingClientRect().width + margins;
+}
+
+function closeAllChats() {
+  let chatbar = window.SocialChatBar.chatbar;
+  while (chatbar.selectedChat) {
+    chatbar.selectedChat.close();
   }
 }