Bug 736055 - Mozmill tests for Filelink URL insertion behaviour. r=bwinton,a=Standard8.
authorMike Conley <mconley@mozilla.com>
Mon, 02 Apr 2012 17:09:26 -0400
changeset 11156 659db89dd3e30cf0460a6fda57f96fa9d074b81c
parent 11155 815cc3b21b2df83f647a43f05fb9b5634be70ec2
child 11157 a183704a54497317d10c3e720f4d6b32d7102567
push id463
push userbugzilla@standard8.plus.com
push dateTue, 24 Apr 2012 17:34:51 +0000
treeherdercomm-beta@e53588e8f7b0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbwinton, Standard8
bugs736055
Bug 736055 - Mozmill tests for Filelink URL insertion behaviour. r=bwinton,a=Standard8.
mail/test/mozmill/attachment/test-attachment-events.js
mail/test/mozmill/cloudfile/test-cloudfile-attachment-urls.js
mail/test/mozmill/shared-modules/test-attachment-helpers.js
mail/test/mozmill/shared-modules/test-compose-helpers.js
mail/test/mozmill/shared-modules/test-dom-helpers.js
--- a/mail/test/mozmill/attachment/test-attachment-events.js
+++ b/mail/test/mozmill/attachment/test-attachment-events.js
@@ -47,40 +47,16 @@ function setupModule(module) {
 
   let oh = collector.getModule('observer-helpers');
   oh.installInto(module);
 
   gPath = os.getFileForPath(__file__);
 };
 
 /**
- * A helper function that selects either one, or a continuous range
- * of items in the attachment list.
- *
- * @param aController a composer window controller
- * @param aIndexStart the index of the first item to select
- * @param aIndexEnd (optional) the index of the last item to select
- */
-function select_attachments(aController, aIndexStart, aIndexEnd) {
-  let bucket = aController.e("attachmentBucket");
-  bucket.clearSelection();
-
-  if (aIndexEnd !== undefined) {
-    let startItem = bucket.getItemAtIndex(aIndexStart);
-    let endItem = bucket.getItemAtIndex(aIndexEnd);
-    bucket.selectItemRange(startItem, endItem);
-  } else {
-    bucket.selectedIndex = aIndexStart;
-  }
-
-  bucket.focus();
-  return bucket.selectedItems;
-}
-
-/**
  * Test that the attachments-added event is fired when we add a single
  * attachment.
  */
 function test_attachments_added_on_single() {
   // Prepare to listen for attachments-added
   let eventCount = 0;
   let lastEvent;
   let listener = function(event) {
new file mode 100644
--- /dev/null
+++ b/mail/test/mozmill/cloudfile/test-cloudfile-attachment-urls.js
@@ -0,0 +1,680 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests Filelink URL insertion behaviours in compose windows.
+ */
+
+let MODULE_NAME = 'test-cloudfile-attachment-urls';
+
+let RELATIVE_ROOT = '../shared-modules';
+let MODULE_REQUIRES = ['folder-display-helpers',
+                       'compose-helpers',
+                       'cloudfile-helpers',
+                       'attachment-helpers',
+                       'dom-helpers',
+                       'window-helpers'];
+
+Cu.import('resource://gre/modules/Services.jsm');
+Cu.import('resource:///modules/mailServices.js');
+
+const kUploadedFile = "attachment-uploaded";
+const kHtmlPrefKey = "mail.identity.default.compose_html";
+const kReplyOnTopKey = "mail.identity.default.reply_on_top";
+const kReplyOnTop = 1;
+const kReplyOnBottom = 0;
+const kTextNodeType = 3;
+const kSigPrefKey = "mail.identity.id1.htmlSigText";
+const kSigOnReplyKey = "mail.identity.default.sig_on_reply";
+const kSigOnForwardKey = "mail.identity.default.sig_on_fwd";
+const kDefaultSigKey = "mail.identity.id1.htmlSigText";
+const kDefaultSig = "This is my signature.\n\nCheck out my website sometime!";
+const kFiles = ['./data/testFile1', './data/testFile2'];
+const kLines = ["This is a line of text", "and here's another!"];
+
+var ah, cfh, gFolder, gOldHtmlPref, gOldSigPref;
+
+function setupModule(module) {
+  let fdh = collector.getModule('folder-display-helpers');
+  fdh.installInto(module);
+
+  // For replies and forwards, we'll work off a message in the Inbox folder
+  // of the fake "tinderbox" account.
+  let server = MailServices.accounts.FindServer("tinderbox", "tinderbox",
+                                                "pop3");
+  gFolder = server.rootFolder.getChildNamed("Inbox");
+  fdh.add_message_to_folder(gFolder, create_message());
+
+  collector.getModule('compose-helpers').installInto(module);
+
+  ah = collector.getModule('attachment-helpers');
+  ah.installInto(module);
+  ah.gMockFilePickReg.register();
+
+  cfh = collector.getModule('cloudfile-helpers');
+  cfh.installInto(module);
+  cfh.gMockCloudfileManager.register();
+
+  collector.getModule('dom-helpers').installInto(module);
+  collector.getModule('window-helpers').installInto(module);
+
+  // These tests assume that we default to writing mail in HTML.  We'll
+  // save the current preference, force defaulting to HTML, and restore the
+  // pref in teardownModule.
+  gOldHtmlPref = Services.prefs.getBoolPref(kHtmlPrefKey);
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+  // Same goes for the default signature.
+  gOldSigPref = Services.prefs.getCharPref(kDefaultSigKey);
+}
+
+function teardownModule(module) {
+  cfh.gMockCloudfileManager.unregister();
+  ah.gMockFilePickReg.unregister();
+  Services.prefs.setCharPref(kDefaultSigKey, gOldSigPref);
+  Services.prefs.setBoolPref(kHtmlPrefKey, gOldHtmlPref);
+}
+
+function setupTest() {
+  // If our signature got accidentally wiped out, let's just put it back.
+  Services.prefs.setCharPref(kDefaultSigKey, kDefaultSig);
+}
+
+/**
+ * Helper function for getting the nsILocalFile's for some files located
+ * in a subdirectory of the test directory.
+ *
+ * @param aFiles an array of filename strings for files underneath the test
+ *               file directory.
+ *
+ * Example: let files = collectFiles(['./data/testFile1', './data/testFile2']);
+ */
+function collectFiles(aFiles) {
+  return [cfh.getFile(filename, __file__)
+          for each (filename in aFiles)]
+}
+
+/**
+ * Given some compose window controller, wait for some Filelink URLs to be
+ * inserted.
+ *
+ * @param aController the controller for a compose window.
+ * @param aNumUrls the number of Filelink URLs that are expected.
+ * @returns an array containing the root containment node, the list node, and
+ *          an array of the link URL nodes.
+ */
+function wait_for_attachment_urls(aController, aNumUrls) {
+  let mailBody = get_compose_body(aController);
+
+  // Wait until we can find the root attachment URL node...
+  let root = wait_for_element(mailBody.parentNode,
+                              "body > #cloudAttachmentListRoot");
+
+  let list = wait_for_element(mailBody,
+                              "#cloudAttachmentListRoot > #cloudAttachmentList");
+
+  let urls = null;
+  aController.waitFor(function() {
+    urls = mailBody.querySelectorAll("#cloudAttachmentList > .cloudAttachmentItem");
+    return (urls != null);
+  });
+
+  assert_equals(aNumUrls, urls.length);
+
+  return [root, list, urls];
+}
+
+/**
+ * Helper function that sets up the mock file picker for a series of files,
+ * spawns a reply window for the first message in the gFolder, optionally
+ * types some strings into the compose window, and then attaches some
+ * Filelinks.
+ *
+ * @param aText an array of strings to type into the compose window. Each
+ *              string is followed by pressing the RETURN key, except for
+ *              the final string.  Pass an empty array if you don't want
+ *              anything typed.
+ * @param aFiles an array of filename strings for files located beneath
+ *               the test directory.
+ */
+function prepare_some_attachments_and_reply(aText, aFiles) {
+  gMockFilePicker.returnFiles = collectFiles(aFiles);
+
+  let provider = new MockCloudfileAccount();
+  provider.init("someKey");
+
+  be_in_folder(gFolder);
+  let msg = select_click_row(0);
+  assert_selected_and_displayed(mc, msg);
+
+  let cw = open_compose_with_reply();
+
+  // If we have any typing to do, let's do it.
+  type_in_composer(cw, aText);
+  cw.window.attachToCloud(provider);
+  return cw;
+}
+
+/**
+ * Helper function that sets up the mock file picker for a series of files,
+ * spawns an inline forward compose window for the first message in the gFolder,
+ * optionally types some strings into the compose window, and then attaches
+ * some Filelinks.
+ *
+ * @param aText an array of strings to type into the compose window. Each
+ *              string is followed by pressing the RETURN key, except for
+ *              the final string.  Pass an empty array if you don't want
+ *              anything typed.
+ * @param aFiles an array of filename strings for files located beneath
+ *               the test directory.
+ */
+function prepare_some_attachments_and_forward(aText, aFiles) {
+  gMockFilePicker.returnFiles = collectFiles(aFiles);
+
+  let provider = new MockCloudfileAccount();
+  provider.init("someKey");
+
+  be_in_folder(gFolder);
+  let msg = select_click_row(0);
+  assert_selected_and_displayed(mc, msg);
+
+  let cw = open_compose_with_forward();
+
+  // Put the selection at the beginning of the document...
+  let editor = cw.window.GetCurrentEditor();
+  editor.beginningOfDocument();
+
+  // Do any necessary typing...
+  type_in_composer(cw, aText);
+  cw.window.attachToCloud(provider);
+  return cw;
+}
+
+/**
+ * Helper function that runs a test function with signature-in-reply and
+ * signature-in-forward enabled, and then runs the test again with those
+ * prefs disabled.
+ *
+ * @param aSpecialTest a test that takes two arguments - the first argument
+ *                     is the aText array of any text that should be typed,
+ *                     and the second is a boolean for whether or not the
+ *                     special test should expect a signature or not.
+ * @param aText any text to be typed into the compose window, passed to
+ *              aSpecialTest.
+ */
+function try_with_and_without_signature_in_reply_or_fwd(aSpecialTest, aText) {
+  // By default, we have a signature included in replies, so we'll start
+  // with that.
+  Services.prefs.setBoolPref(kSigOnReplyKey, true);
+  Services.prefs.setBoolPref(kSigOnForwardKey, true);
+  aSpecialTest(aText, true);
+
+  Services.prefs.setBoolPref(kSigOnReplyKey, false);
+  Services.prefs.setBoolPref(kSigOnForwardKey, false);
+  aSpecialTest(aText, false);
+}
+
+/**
+ * Helper function that runs a test function without a signature, once
+ * in HTML mode, and again in plaintext mode.
+ *
+ * @param aTest a test that takes no arguments.
+ */
+function try_without_signature(aTest) {
+  let oldSig = Services.prefs.getCharPref(kSigPrefKey);
+  Services.prefs.setCharPref(kSigPrefKey, "");
+
+  aTest();
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+  aTest();
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+  Services.prefs.setCharPref(kSigPrefKey, oldSig);
+}
+
+/**
+ * Test that if we open up a composer and immediately attach a Filelink,
+ * a linebreak is inserted before the containment node in order to allow
+ * the user to write before the attachment URLs.  This assumes the user
+ * does not have a signature already inserted into the message body.
+ */
+function test_inserts_linebreak_on_empty_compose() {
+  try_without_signature(subtest_inserts_linebreak_on_empty_compose);
+}
+
+/**
+ * Subtest for test_inserts_linebreak_on_empty_compose - can be executed
+ * on both plaintext and HTML compose windows.
+ */
+function subtest_inserts_linebreak_on_empty_compose() {
+  gMockFilePicker.returnFiles = collectFiles(kFiles);
+  let provider = new MockCloudfileAccount();
+  provider.init("someKey");
+  let cw = open_compose_new_mail();
+  cw.window.attachToCloud(provider);
+
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  let br = root.previousSibling;
+  assert_equals(br.localName, "br",
+                "The attachment URL containment node should be preceded by " +
+                "a linebreak");
+
+  let mailBody = get_compose_body(cw);
+
+  assert_equals(mailBody.firstChild, br,
+                "The linebreak should be the first child of the compose body");
+
+  close_window(cw);
+}
+
+/**
+ * Test that if we open up a composer and immediately attach a Filelink,
+ * a linebreak is inserted before the containment node. This test also
+ * ensures that, with a signature already in the compose window, we don't
+ * accidentally insert the attachment URL containment within the signature
+ * node.
+ */
+function test_inserts_linebreak_on_empty_compose_with_signature() {
+  gMockFilePicker.returnFiles = collectFiles(kFiles);
+  let provider = new MockCloudfileAccount();
+  provider.init("someKey");
+  let cw = open_compose_new_mail();
+  cw.window.attachToCloud(provider);
+  // wait_for_attachment_urls ensures that the attachment URL containment
+  // node is an immediate child of the body of the message, so if this
+  // succeeds, then we were not in the signature node.
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  let br = assert_previous_nodes("br", root, 1);
+
+  let mailBody = get_compose_body(cw);
+  assert_equals(mailBody.firstChild, br,
+                "The linebreak should be the first child of the compose body");
+
+  // Now ensure that the node after the attachments is a br, and following
+  // that is the signature.
+  br = assert_next_nodes("br", root, 1);
+
+  let pre = br.nextSibling;
+  assert_equals(pre.localName, "pre",
+                "The linebreak should be followed by the signature pre");
+  assert_true(pre.classList.contains("moz-signature"),
+              "The pre should have the moz-signature class");
+
+  close_window(cw);
+
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+
+  // Now let's try with plaintext mail.
+  let cw = open_compose_new_mail();
+  cw.window.attachToCloud(provider);
+  [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  br = assert_previous_nodes("br", root, 1);
+
+  mailBody = get_compose_body(cw);
+  assert_equals(mailBody.firstChild, br,
+                "The linebreak should be the first child of the compose body");
+
+  // Now ensure that the node after the attachments is a br, and following
+  // that is the signature.
+  br = assert_next_nodes("br", root, 1);
+
+  let div = br.nextSibling;
+  assert_equals(div.localName, "div",
+                "The linebreak should be followed by the signature div");
+  assert_true(div.classList.contains("moz-signature"),
+              "The div should have the moz-signature class");
+
+  close_window(cw);
+
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+}
+
+/**
+ * Tests that removing all Filelinks causes the root node to be removed.
+ */
+function test_removing_filelinks_removes_root_node() {
+  subtest_removing_filelinks_removes_root_node();
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+  subtest_removing_filelinks_removes_root_node();
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+}
+
+/**
+ * Test for test_removing_filelinks_removes_root_node - can be executed
+ * on both plaintext and HTML compose windows.
+ */
+function subtest_removing_filelinks_removes_root_node() {
+  let cw = prepare_some_attachments_and_reply([], kFiles);
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  // Now select the attachments in the attachment bucket, and remove them.
+  select_attachments(cw, 0, 1);
+  cw.window.goDoCommand("cmd_delete");
+
+  // Wait for the root to be removed.
+  let mailBody = get_compose_body(cw);
+  cw.waitFor(function() {
+    let result = mailBody.querySelector(root.id);
+    return (result == null);
+  }, "Timed out waiting for attachment container to be removed");
+}
+
+/**
+ * Test that if we write some text in an empty message (no signature),
+ * and the selection is at the end of a line of text, attaching some Filelinks
+ * causes the attachment URL container to be separated from the text by
+ * two br tags.
+ */
+function test_adding_filelinks_to_written_message() {
+  try_without_signature(subtest_adding_filelinks_to_written_message);
+}
+
+/**
+ * Subtest for test_adding_filelinks_to_written_message - generalized for both
+ * HTML and plaintext mail.
+ */
+function subtest_adding_filelinks_to_written_message() {
+  gMockFilePicker.returnFiles = collectFiles(kFiles);
+  let provider = new MockCloudfileAccount();
+  provider.init("someKey");
+  let cw = open_compose_new_mail();
+
+  type_in_composer(cw, kLines);
+  cw.window.attachToCloud(provider);
+
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  let br = root.previousSibling;
+  assert_equals(br.localName, "br",
+                "The attachment URL containment node should be preceded by " +
+                "a linebreak");
+  br = br.previousSibling;
+  assert_equals(br.localName, "br",
+                "The attachment URL containment node should be preceded by " +
+                "two linebreaks");
+  close_window(cw);
+}
+
+/**
+ * Tests for inserting Filelinks into a reply, when we're configured to
+ * reply above the quote.
+ */
+function test_adding_filelinks_to_empty_reply_above() {
+  let oldReplyOnTop = Services.prefs.getIntPref(kReplyOnTopKey);
+  Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnTop);
+
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_reply_above, []);
+  // Now with HTML mail...
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_reply_above_plaintext, []);
+
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+  Services.prefs.setIntPref(kReplyOnTopKey, oldReplyOnTop);
+}
+
+/**
+ * Tests for inserting Filelinks into a reply, when we're configured to
+ * reply above the quote, after entering some text.
+ */
+function test_adding_filelinks_to_nonempty_reply_above() {
+  let oldReplyOnTop = Services.prefs.getIntPref(kReplyOnTopKey);
+  Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnTop);
+
+  subtest_adding_filelinks_to_reply_above(kLines);
+
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+  subtest_adding_filelinks_to_reply_above_plaintext(kLines);
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+  Services.prefs.setIntPref(kReplyOnTopKey, oldReplyOnTop);
+}
+
+/**
+ * Subtest for test_adding_filelinks_to_reply_above for the plaintext composer.
+ * Does some special casing for the weird br insertions that happens in
+ * various cases.
+ */
+function subtest_adding_filelinks_to_reply_above_plaintext(aText, aWithSig) {
+  let cw = prepare_some_attachments_and_reply(aText, kFiles);
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  let br;
+  if (aText.length)
+    br = assert_next_nodes("br", root, 2);
+  else
+    br = assert_next_nodes("br", root, 1);
+
+  let div = br.nextSibling;
+  assert_equals(div.localName, "div",
+                "The linebreak should be followed by a div");
+
+  assert_true(div.classList.contains("moz-cite-prefix"));
+
+  if (aText.length)
+    br = assert_previous_nodes("br", root, 2);
+  else
+    br = assert_previous_nodes("br", root, 1);
+
+  if (aText.length == 0) {
+    // If we didn't type anything, that br should be the first element of the
+    // message body.
+    let msgBody = get_compose_body(cw);
+    assert_equals(msgBody.firstChild, br,
+                  "The linebreak should have been the first element in the " +
+                  "message body");
+  } else {
+    let targetText = aText[aText.length - 1];
+    let textNode = br.previousSibling;
+    assert_equals(textNode.nodeType, kTextNodeType);
+    assert_equals(textNode.nodeValue, targetText);
+  }
+
+  close_window(cw);
+}
+
+/**
+ * Subtest for test_adding_filelinks_to_reply_above for the HTML composer.
+ */
+function subtest_adding_filelinks_to_reply_above(aText) {
+  let cw = prepare_some_attachments_and_reply(aText, kFiles);
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  // So, we should have the root, followed by a br
+  let br = root.nextSibling;
+  assert_equals(br.localName, "br",
+                "The attachment URL containment node should be followed by " +
+                " a br");
+
+  // ... which is followed by a div with a class of "moz-cite-prefix".
+  let div = br.nextSibling;
+  assert_equals(div.localName, "div",
+                "The linebreak should be followed by a div");
+
+  assert_true(div.classList.contains("moz-cite-prefix"));
+
+  close_window(cw);
+}
+
+/**
+ * Tests for inserting Filelinks into a reply, when we're configured to
+ * reply below the quote.
+ */
+function test_adding_filelinks_to_empty_reply_below() {
+  let oldReplyOnTop = Services.prefs.getIntPref(kReplyOnTopKey);
+  Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnBottom);
+
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_reply_below, []);
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_plaintext_reply_below, []);
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+  Services.prefs.setIntPref(kReplyOnTopKey, oldReplyOnTop);
+}
+
+/**
+ * Tests for inserting Filelinks into a reply, when we're configured to
+ * reply below the quote, after entering some text.
+ */
+function test_adding_filelinks_to_nonempty_reply_below() {
+  let oldReplyOnTop = Services.prefs.getIntPref(kReplyOnTopKey);
+  Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnBottom);
+
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_reply_below, kLines);
+
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_plaintext_reply_below, kLines);
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+
+  Services.prefs.setIntPref(kReplyOnTopKey, oldReplyOnTop);
+}
+
+/**
+ * Subtest for test_adding_filelinks_to_reply_below for the HTML composer.
+ */
+function subtest_adding_filelinks_to_reply_below(aText, aWithSig) {
+  let cw = prepare_some_attachments_and_reply(aText, kFiles);
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+  // So, we should have the root, followed by a br
+  let br = root.nextSibling;
+  assert_equals(br.localName, "br",
+                "The attachment URL containment node should be followed by " +
+                " a br");
+
+  let blockquote;
+  if (aText.length) {
+    // If there was any text inserted, check for 2 previous br nodes, and then
+    // the inserted text, and then the blockquote.
+    br = assert_previous_nodes("br", root, 2);
+    let textNode = assert_previous_text(br.previousSibling, aText);
+    blockquote = textNode.previousSibling;
+  }
+  else {
+    // If no text was inserted, check for 1 previous br node, and then the
+    // blockquote.
+    br = assert_previous_nodes("br", root, 1);
+    blockquote = br.previousSibling;
+  }
+
+  assert_equals(blockquote.localName, "blockquote",
+                "The linebreak should be preceded by a blockquote.");
+
+  let prefix = blockquote.previousSibling;
+  assert_equals(prefix.localName, "div",
+                "The blockquote should be preceded by the prefix div");
+  assert_true(prefix.classList.contains("moz-cite-prefix"),
+              "The prefix should have the moz-cite-prefix class");
+
+  close_window(cw);
+}
+
+/**
+ * Subtest for test_adding_filelinks_to_reply_below for the plaintext composer.
+ */
+function subtest_adding_filelinks_to_plaintext_reply_below(aText, aWithSig) {
+  let cw = prepare_some_attachments_and_reply(aText, kFiles);
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  // So, we should have the root, followed by a br
+  let br = root.nextSibling;
+  assert_equals(br.localName, "br",
+                "The attachment URL containment node should be followed by " +
+                " a br");
+
+  // If a signature was inserted AND no text was entered, then there
+  // should only be a single br preceding the root.
+  if (aWithSig && !aText.length)
+    br = assert_previous_nodes("br", root, 1);
+  else {
+    // Otherwise, there should be two br's preceding the root.
+    br = assert_previous_nodes("br", root, 2);
+  }
+
+  let span;
+
+  if (aText.length) {
+    // If text was entered, make sure it matches what we expect...
+    let textNode = assert_previous_text(br.previousSibling, aText);
+    // And then grab the span, which should be before the final text node.
+    span = textNode.previousSibling;
+  }
+  else {
+    // If no text was entered, just grab the last br's previous sibling - that
+    // will be the span.
+    span = br.previousSibling;
+  }
+
+  assert_equals(span.localName, "span",
+                "The linebreak should be preceded by a span.");
+
+  let prefix = span.previousSibling;
+  assert_equals(prefix.localName, "div",
+                "The blockquote should be preceded by the prefix div");
+  assert_true(prefix.classList.contains("moz-cite-prefix"),
+              "The prefix should have the moz-cite-prefix class");
+
+  close_window(cw);
+}
+
+/**
+ * Tests Filelink insertion on an inline-forward compose window with nothing
+ * typed into it.
+ */
+function test_adding_filelinks_to_empty_forward() {
+  Services.prefs.setIntPref(kReplyOnTopKey, kReplyOnTop);
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_forward, []);
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_forward, []);
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+}
+
+/**
+ * Tests Filelink insertion on an inline-forward compose window with some
+ * text typed into it.
+ */
+function test_adding_filelinks_to_forward() {
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_forward, kLines);
+  Services.prefs.setBoolPref(kHtmlPrefKey, false);
+  try_with_and_without_signature_in_reply_or_fwd(
+    subtest_adding_filelinks_to_forward, kLines);
+  Services.prefs.setBoolPref(kHtmlPrefKey, true);
+}
+
+/**
+ * Subtest for both test_adding_filelinks_to_empty_forward and
+ * test_adding_filelinks_to_forward - ensures that the inserted Filelinks
+ * are positioned correctly.
+ */
+function subtest_adding_filelinks_to_forward(aText, aWithSig) {
+  let cw = prepare_some_attachments_and_forward(aText, kFiles);
+  let [root, list, urls] = wait_for_attachment_urls(cw, kFiles.length);
+
+  let br = assert_next_nodes("br", root, 1);
+  let forwardDiv = br.nextSibling;
+  assert_equals(forwardDiv.localName, "div");
+  assert_true(forwardDiv.classList.contains("moz-forward-container"));
+
+  if (aText.length) {
+    // If there was text typed in, it should be separated from the root by two
+    // br's
+    let br = assert_previous_nodes("br", root, 2);
+    let textNode = assert_previous_text(br.previousSibling, aText);
+  } else {
+    // Otherwise, there's only 1 br, and that br should be the first element
+    // of the message body.
+    let br = assert_previous_nodes("br", root, 1);
+    let mailBody = get_compose_body(cw);
+    assert_equals(br, mailBody.firstChild);
+  }
+}
--- a/mail/test/mozmill/shared-modules/test-attachment-helpers.js
+++ b/mail/test/mozmill/shared-modules/test-attachment-helpers.js
@@ -58,16 +58,17 @@ function installInto(module) {
   setupModule(module);
 
   // Now copy helper functions
   module.create_body_part = create_body_part;
   module.create_detached_attachment = create_detached_attachment;
   module.create_deleted_attachment = create_deleted_attachment;
   module.gMockFilePickReg = gMockFilePickReg;
   module.gMockFilePicker = gMockFilePicker;
+  module.select_attachments = select_attachments;
 }
 
 function MockFilePickerConstructor() {
   return gMockFilePicker;
 };
 
 let gMockFilePicker = {
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIFilePicker]),
@@ -201,8 +202,34 @@ function create_deleted_attachment(filen
             "Content-Transfer-Encoding: 8bit\r\n" +
             "Content-Disposition: inline; filename=\"Deleted: " + filename +
               "\"\r\n" +
             "X-Mozilla-Altered: AttachmentDeleted; date=\""
               "Wed Oct 06 17:28:24 2010\"\r\n\r\n";
   str += help_create_detached_deleted_attachment(filename, type);
   return str;
 }
+
+/**
+ * A helper function that selects either one, or a continuous range
+ * of items in the attachment list.
+ *
+ * @param aController a composer window controller
+ * @param aIndexStart the index of the first item to select
+ * @param aIndexEnd (optional) the index of the last item to select
+ */
+function select_attachments(aController, aIndexStart, aIndexEnd) {
+  let bucket = aController.e("attachmentBucket");
+  bucket.clearSelection();
+
+  if (aIndexEnd !== undefined) {
+    let startItem = bucket.getItemAtIndex(aIndexStart);
+    let endItem = bucket.getItemAtIndex(aIndexEnd);
+    bucket.selectItemRange(startItem, endItem);
+  } else {
+    bucket.selectedIndex = aIndexStart;
+  }
+
+  bucket.focus();
+  return bucket.selectedItems;
+}
+
+
--- a/mail/test/mozmill/shared-modules/test-compose-helpers.js
+++ b/mail/test/mozmill/shared-modules/test-compose-helpers.js
@@ -46,26 +46,30 @@ Cu.import('resource://mozmill/modules/mo
 var utils = {};
 Cu.import('resource://mozmill/modules/utils.js', utils);
 
 const MODULE_NAME = 'compose-helpers';
 
 const RELATIVE_ROOT = '../shared-modules';
 
 // we need this for the main controller
-const MODULE_REQUIRES = ['folder-display-helpers', 'window-helpers'];
+const MODULE_REQUIRES = ['folder-display-helpers',
+                         'window-helpers',
+                         'dom-helpers'];
+const kTextNodeType = 3;
 
 var folderDisplayHelper;
 var mc;
-var windowHelper;
+var windowHelper, domHelper;
 
 function setupModule() {
   folderDisplayHelper = collector.getModule('folder-display-helpers');
   mc = folderDisplayHelper.mc;
   windowHelper = collector.getModule('window-helpers');
+  domHelper = collector.getModule('dom-helpers');
 }
 
 function installInto(module) {
   setupModule();
 
   // Now copy helper functions
   module.open_compose_new_mail = open_compose_new_mail;
   module.open_compose_with_reply = open_compose_with_reply;
@@ -74,23 +78,30 @@ function installInto(module) {
   module.open_compose_with_forward_as_attachments = open_compose_with_forward_as_attachments;
   module.open_compose_with_element_click = open_compose_with_element_click;
   module.close_compose_window = close_compose_window;
   module.wait_for_compose_window = wait_for_compose_window;
   module.create_msg_attachment = create_msg_attachment;
   module.add_attachments = add_attachments;
   module.add_attachment = add_attachments;
   module.delete_attachment = delete_attachment;
+  module.get_compose_body = get_compose_body;
+  module.type_in_composer = type_in_composer;
+  module.assert_previous_text = assert_previous_text;
 }
 
 /**
  * Opens the compose window by starting a new message
  *
+ * @param aController the controller for the mail:3pane from which to spawn
+ *                    the compose window.  If left blank, defaults to mc.
+ *
  * @return The loaded window of type "msgcompose" wrapped in a MozmillController
  *         that is augmented using augment_controller.
+ *
  */
 function open_compose_new_mail(aController) {
   if (aController === undefined)
     aController = mc;
 
   windowHelper.plan_for_new_window("msgcompose");
   aController.keypress(null, "n", {shiftKey: false, accelKey: true});
 
@@ -294,8 +305,62 @@ function add_attachments(aComposeWindow,
  */
 function delete_attachment(aComposeWindow, aIndex) {
   let bucket = aComposeWindow.e('attachmentBucket');
   let node = bucket.getElementsByTagName('attachmentitem')[aIndex];
 
   aComposeWindow.click(new elib.Elem(node));
   aComposeWindow.window.RemoveSelectedAttachment();
 }
+
+/**
+ * Helper function returns the message body element of a composer window.
+ *
+ * @param aController the controller for a compose window.
+ */
+function get_compose_body(aController) {
+  let mailDoc = aController.e("content-frame").contentDocument;
+  return mailDoc.querySelector("body");
+}
+
+/**
+ * Given some compose window controller, type some text into that composer,
+ * pressing enter after each line except for the last.
+ *
+ * @param aController a compose window controller.
+ * @param aText an array of strings to type.
+ */
+function type_in_composer(aController, aText) {
+  // If we have any typing to do, let's do it.
+  let frame = aController.eid("content-frame");
+  for each (let [i, aLine] in Iterator(aText)) {
+    aController.type(frame, aLine);
+    if (i < aText.length - 1)
+      aController.keypress(frame, "VK_RETURN", {});
+  }
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart is a text node which
+ * has a value matching the last value of the aText string array, and has
+ * a br node immediately preceding it. Repeated for each subsequent string
+ * of the aText array (working from end to start).
+ *
+ * @param aStart the first node to check
+ * @param aText an array of strings that should be checked for in reverse
+ *              order (so the last element of the array should be the first
+ *              text node encountered, the second last element of the array
+ *              should be the next text node encountered, etc).
+ */
+function assert_previous_text(aStart, aText) {
+  let textNode = aStart;
+  for (let i = aText.length - 1; i > 0; --i) {
+    if (textNode.nodeType != kTextNodeType)
+      throw new Error("Expected a text node");
+
+    if (textNode.nodeValue != aText[i])
+      throw new Error("Unexpected inequality - " + textNode.nodeValue + " != " +
+                      + aText[i]);
+    let br = domHelper.assert_previous_nodes("br", textNode, 1);
+    textNode = br.previousSibling;
+  }
+  return textNode;
+}
--- a/mail/test/mozmill/shared-modules/test-dom-helpers.js
+++ b/mail/test/mozmill/shared-modules/test-dom-helpers.js
@@ -71,20 +71,21 @@ function setupModule() {
 }
 
 function installInto(module) {
   setupModule();
 
   // Now copy helper functions
   module.assert_element_visible = assert_element_visible;
   module.assert_element_not_visible = assert_element_not_visible;
+  module.wait_for_element = wait_for_element;
+  module.assert_next_nodes = assert_next_nodes;
+  module.assert_previous_nodes = assert_previous_nodes;
 }
 
-
-
 /**
  * This function takes either a string or an elementlibs.Elem, and returns
  * whether it is hidden or not (simply by poking at its hidden property). It
  * doesn't try to do anything smart, like is it not into view, or whatever.
  *
  * @param aElt The element to query.
  * @return Whether the element is visible or not.
  */
@@ -110,8 +111,62 @@ function assert_element_visible(aElt, aW
 /**
  * Assert that en element's not visible.
  * @param aElt The element, an ID or an elementlibs.Elem
  * @param aWhy The error message in case of failure
  */
 function assert_element_not_visible(aElt, aWhy) {
   folderDisplayHelper.assert_true(!element_visible(aElt), aWhy);
 }
+
+/**
+ * Wait for and return an element matching a particular CSS selector.
+ *
+ * @param aParent the node to begin searching from
+ * @param aSelector the CSS selector to search with
+ */
+function wait_for_element(aParent, aSelector) {
+  let target = null;
+  mc.waitFor(function() {
+    target = aParent.querySelector(aSelector);
+    return (target != null);
+  }, "Timed out waiting for a target for selector: " + aSelector);
+
+  return target;
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart and the aNum next
+ * siblings of aStart are nodes of type aNodeType.
+ *
+ * @param aNodeType the type of node to look for, example: "br".
+ * @param aStart the first node to check.
+ * @param aNum the number of sibling br nodes to check for.
+ */
+function assert_next_nodes(aNodeType, aStart, aNum) {
+  let node = aStart;
+  for (let i = 0; i < aNum; ++i) {
+    node = node.nextSibling;
+    if (node.localName != aNodeType)
+      throw new Error("The node should be followed by " + aNum + " nodes of " +
+                      "type " + aNodeType);
+  }
+  return node;
+}
+
+/**
+ * Given some starting node aStart, ensure that aStart and the aNum previous
+ * siblings of aStart are nodes of type aNodeType.
+ *
+ * @param aNodeType the type of node to look for, example: "br".
+ * @param aStart the first node to check.
+ * @param aNum the number of sibling br nodes to check for.
+ */
+function assert_previous_nodes(aNodeType, aStart, aNum) {
+  let node = aStart;
+  for (let i = 0; i < aNum; ++i) {
+    node = node.previousSibling;
+    if (node.localName != aNodeType)
+      throw new Error("The node should be preceded by " + aNum + " nodes of " +
+                      "type " + aNodeType);
+  }
+  return node;
+}