Bug 1149500 - Delay autofill until the document is visible. r=MattN
authorSam Foster <sfoster@mozilla.com>
Tue, 26 Feb 2019 20:51:56 +0000
changeset 519148 d16bf53b358583baa538e24697f85a597e4cf41c
parent 519147 9eab7d33fb582d01e005b4125894d35b6d8e7690
child 519149 e715e094485f8a0f7e9ee926494819a3d20f1980
push id10862
push userffxbld-merge
push dateMon, 11 Mar 2019 13:01:11 +0000
treeherdermozilla-beta@a2e7f5c935da [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1149500
milestone67.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 1149500 - Delay autofill until the document is visible. r=MattN Differential Revision: https://phabricator.services.mozilla.com/D20046
toolkit/components/passwordmgr/LoginManagerContent.jsm
toolkit/components/passwordmgr/test/browser/browser.ini
toolkit/components/passwordmgr/test/browser/browser_hidden_document_autofill.js
--- a/toolkit/components/passwordmgr/LoginManagerContent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerContent.jsm
@@ -159,26 +159,37 @@ var LoginManagerContent = {
    * fills for the same set of elements when a field gets added between arming and running the
    * DeferredTask.
    *
    * @type {WeakMap}
    */
   _formLikeByRootElement: new WeakMap(),
 
   /**
-   * WeakMap of the root element of a WeakMap to the DeferredTask to fill its fields.
+   * WeakMap of the root element of a FormLike to the DeferredTask to fill its fields.
    *
    * This is used to be able to throttle fills for a FormLike since onDOMInputPasswordAdded gets
    * dispatched for each password field added to a document but we only want to fill once per
    * FormLike when multiple fields are added at once.
    *
    * @type {WeakMap}
    */
   _deferredPasswordAddedTasksByRootElement: new WeakMap(),
 
+  /**
+   * WeakMap of a document to the array of callbacks to execute when it becomes visible
+   *
+   * This is used to defer handling DOMFormHasPassword and onDOMInputPasswordAdded events when the
+   * containing document is hidden.
+   * When the document first becomes visible, any queued events will be handled as normal.
+   *
+   * @type {WeakMap}
+   */
+  _onVisibleTasksByDocument: new WeakMap(),
+
   // Map from form login requests to information about that request.
   _requests: new Map(),
 
   // Number of outstanding requests to each manager.
   _managers: new Map(),
 
   _takeRequest(msg) {
     let data = msg.data;
@@ -348,44 +359,98 @@ var LoginManagerContent = {
 
     // We're invoked before the content's |submit| event handlers, so we
     // can grab form data before it might be modified (see bug 257781).
     log("notified before form submission");
     let formLike = LoginFormFactory.createFromForm(event.target);
     LoginManagerContent._onFormSubmit(formLike);
   },
 
+  onDocumentVisibilityChange(event) {
+    if (!event.isTrusted) {
+      return;
+    }
+    let document = event.target;
+    let onVisibleTasks = this._onVisibleTasksByDocument.get(document);
+    if (!onVisibleTasks) {
+      return;
+    }
+    for (let task of onVisibleTasks) {
+      log("onDocumentVisibilityChange, executing queued task");
+      task();
+    }
+    this._onVisibleTasksByDocument.delete(document);
+  },
+
+  _deferHandlingEventUntilDocumentVisible(event, document, fn) {
+    log(`document.visibilityState: ${document.visibilityState}, defer handling ${event.type}`);
+    let onVisibleTasks = this._onVisibleTasksByDocument.get(document);
+    if (!onVisibleTasks) {
+      log(`deferHandling, first queued event, register the visibilitychange handler`);
+      onVisibleTasks = [];
+      this._onVisibleTasksByDocument.set(document, onVisibleTasks);
+      document.addEventListener("visibilitychange", event => {
+        this.onDocumentVisibilityChange(event);
+      }, { once: true });
+    }
+    onVisibleTasks.push(fn);
+  },
+
   onDOMFormHasPassword(event) {
     if (!event.isTrusted) {
       return;
     }
+    let document = event.target.ownerDocument;
+    if (document.visibilityState == "visible") {
+      this._processDOMFormHasPasswordEvent(event);
+    } else {
+      // wait until the document becomes visible before handling this event
+      this._deferHandlingEventUntilDocumentVisible(event, document, () => {
+        this._processDOMFormHasPasswordEvent(event);
+      });
+    }
+  },
 
+  _processDOMFormHasPasswordEvent(event) {
     let form = event.target;
     let formLike = LoginFormFactory.createFromForm(form);
-    log("onDOMFormHasPassword:", form, formLike);
+    log("_processDOMFormHasPasswordEvent:", form, formLike);
     this._fetchLoginsFromParentAndFillForm(formLike);
   },
 
   onDOMInputPasswordAdded(event, topWindow) {
     if (!event.isTrusted) {
       return;
     }
 
     let pwField = event.originalTarget;
     if (pwField.form) {
       // Fill is handled by onDOMFormHasPassword which is already throttled.
       return;
     }
 
+    let document = pwField.ownerDocument;
+    if (document.visibilityState == "visible") {
+      this._processDOMInputPasswordAddedEvent(event, topWindow);
+    } else {
+      // wait until the document becomes visible before handling this event
+      this._deferHandlingEventUntilDocumentVisible(event, document, () => {
+        this._processDOMInputPasswordAddedEvent(event, topWindow);
+      });
+    }
+  },
+
+  _processDOMInputPasswordAddedEvent(event, topWindow) {
+    let pwField = event.originalTarget;
     // Only setup the listener for formless inputs.
     // Capture within a <form> but without a submit event is bug 1287202.
     this.setupProgressListener(topWindow);
 
     let formLike = LoginFormFactory.createFromField(pwField);
-    log("onDOMInputPasswordAdded:", pwField, formLike);
+    log(" _processDOMInputPasswordAddedEvent:", pwField, formLike);
 
     let deferredTask = this._deferredPasswordAddedTasksByRootElement.get(formLike.rootElement);
     if (!deferredTask) {
       log("Creating a DeferredTask to call _fetchLoginsFromParentAndFillForm soon");
       this._formLikeByRootElement.set(formLike.rootElement, formLike);
 
       deferredTask = new DeferredTask(() => {
         // Get the updated formLike instead of the one at the time of creating the DeferredTask via
--- a/toolkit/components/passwordmgr/test/browser/browser.ini
+++ b/toolkit/components/passwordmgr/test/browser/browser.ini
@@ -52,16 +52,17 @@ support-files =
 skip-if = (os == "linux") || (os == "mac") # Bug 1337606
 [browser_exceptions_dialog.js]
 [browser_formless_submit_chrome.js]
 [browser_hasInsecureLoginForms.js]
 skip-if = verify
 [browser_hasInsecureLoginForms_streamConverter.js]
 [browser_http_autofill.js]
 skip-if = verify
+[browser_hidden_document_autofill.js]
 [browser_insecurePasswordConsoleWarning.js]
 skip-if = verify
 [browser_master_password_autocomplete.js]
 [browser_notifications.js]
 [browser_notifications_username.js]
 [browser_notifications_password.js]
 [browser_notifications_2.js]
 skip-if = os == "linux" # Bug 1272849 Main action button disabled state intermittent
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_hidden_document_autofill.js
@@ -0,0 +1,184 @@
+const TEST_URL_PATH = "/browser/toolkit/components/passwordmgr/test/browser/";
+const INITIAL_URL = `about:blank`;
+const FORM_URL = `https://example.org${TEST_URL_PATH}form_basic.html`;
+const FORMLESS_URL = `https://example.org${TEST_URL_PATH}formless_basic.html`;
+const testUrls = [FORM_URL, FORMLESS_URL];
+
+async function getDocumentVisibilityState(browser) {
+  let visibility = await ContentTask.spawn(browser, null, async function() {
+    return content.document.visibilityState;
+  });
+  return visibility;
+}
+
+async function addContentObserver(browser, topic) {
+  // add an observer.
+  await ContentTask.spawn(browser, [topic], function(contentTopic) {
+    this.gObserver = {
+      wasObserved: false,
+      observe: () => {
+        content.wasObserved = true;
+      },
+    };
+    Services.obs.addObserver(this.gObserver, contentTopic);
+  });
+}
+
+async function getContentObserverResult(browser, topic) {
+  let result = await ContentTask.spawn(browser, [topic], async function(contentTopic) {
+    const {TestUtils} = ChromeUtils.import("resource://testing-common/TestUtils.jsm");
+    try {
+      await TestUtils.waitForCondition(() => {
+        return content.wasObserved;
+      }, `Wait for "passwordmgr-processed-form"`);
+    } catch (ex) {
+      content.wasObserved = false;
+    }
+    Services.obs.removeObserver(this.gObserver, "passwordmgr-processed-form");
+    return content.wasObserved;
+  });
+  return result;
+}
+
+// Waits for the master password prompt and cancels it.
+function observeMasterPasswordDialog(window, result) {
+  let closedPromise;
+  function topicObserver(subject) {
+    if (subject.Dialog.args.title == "Password Required") {
+      result.wasShown = true;
+      subject.Dialog.ui.button1.click();
+      closedPromise = BrowserTestUtils.waitForEvent(window, "DOMModalDialogClosed");
+    }
+  }
+  Services.obs.addObserver(topicObserver, "common-dialog-loaded");
+
+  let waited = TestUtils.waitForCondition(() => {
+    return result.wasShown;
+  }, "Wait for master password dialog");
+
+  return Promise.all([waited, closedPromise]).catch(ex => {
+    info(`observeMasterPasswordDialog, caught exception from topicObserved: ${ex}`);
+  }).finally(() => {
+    Services.obs.removeObserver(topicObserver, "common-dialog-loaded");
+  });
+}
+
+add_task(async function setup() {
+  Services.logins.removeAllLogins();
+  let login = LoginTestUtils.testData.formLogin({
+    hostname: "http://example.org",
+    formSubmitURL: "http://example.org",
+    username: "user1",
+    password: "pass1",
+  });
+  Services.logins.addLogin(login);
+});
+
+add_task(async function test_processed_form_fired() {
+  // Sanity check. If this doesnt work any results for the subsequent tasks are suspect
+  const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, INITIAL_URL);
+  let tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser);
+  is(tab1Visibility, "visible", "The first tab should be foreground");
+
+  await addContentObserver(tab1.linkedBrowser, "passwordmgr-processed-form");
+  await BrowserTestUtils.loadURI(tab1.linkedBrowser, FORM_URL);
+  let result = await getContentObserverResult(tab1.linkedBrowser, "passwordmgr-processed-form");
+  ok(result, "Observer should be notified when form is loaded into a visible document");
+  gBrowser.removeTab(tab1);
+});
+
+testUrls.forEach(testUrl => {
+  add_task(async function test_defer_autofill_until_visible() {
+    let result, tab1Visibility;
+    // open 2 tabs
+    const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, INITIAL_URL);
+    const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, INITIAL_URL);
+
+    // confirm document is hidden
+    tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser);
+    is(tab1Visibility, "hidden", "The first tab should be backgrounded");
+
+    // we shouldn't even try to autofill while hidden, so look for the passwordmgr-processed-form
+    // to be observed rather than any result of filling the form
+    await addContentObserver(tab1.linkedBrowser, "passwordmgr-processed-form");
+    await BrowserTestUtils.loadURI(tab1.linkedBrowser, testUrl);
+    result = await getContentObserverResult(tab1.linkedBrowser, "passwordmgr-processed-form");
+    ok(!result, "Observer should not be notified when form is loaded into a hidden document");
+
+    // Add the observer before switching tab
+    await addContentObserver(tab1.linkedBrowser, "passwordmgr-processed-form");
+    await BrowserTestUtils.switchTab(gBrowser, tab1);
+    result = await getContentObserverResult(tab1.linkedBrowser, "passwordmgr-processed-form");
+    tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser);
+    is(tab1Visibility, "visible", "The first tab should be foreground");
+    ok(result, "Observer should be notified when input's document becomes visible");
+
+    // the form should have been autofilled with the login
+    let fieldValues = await ContentTask.spawn(tab1.linkedBrowser, null, function() {
+      let doc = content.document;
+      return {
+        username: doc.getElementById("form-basic-username").value,
+        password: doc.getElementById("form-basic-password").value,
+      };
+    });
+    is(fieldValues.username, "user1", "Checking filled username");
+    is(fieldValues.password, "pass1", "Checking filled password");
+
+    gBrowser.removeTab(tab1);
+    gBrowser.removeTab(tab2);
+  });
+});
+
+add_task(async function test_defer_autofill_with_masterpassword() {
+  // Set master password prompt timeout to 3s.
+  // If this test goes intermittent, you likely have to increase this value.
+  await SpecialPowers.pushPrefEnv({set: [["signon.masterPasswordReprompt.timeout_ms", 3000]]});
+  LoginTestUtils.masterPassword.enable();
+
+  registerCleanupFunction(function() {
+    LoginTestUtils.masterPassword.disable();
+  });
+
+  let result, tab1Visibility, dialogObserved;
+  // open 2 tabs
+  const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, INITIAL_URL);
+  const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, INITIAL_URL);
+
+  info("sanity check by first loading a form into the visible tab");
+  is(await getDocumentVisibilityState(tab2.linkedBrowser),
+     "visible", "The second tab should be visible");
+  result = { wasShown: false };
+
+  dialogObserved = observeMasterPasswordDialog(tab2.ownerGlobal, result);
+  await BrowserTestUtils.loadURI(tab2.linkedBrowser, FORM_URL);
+  await dialogObserved;
+  ok(result.wasShown, "Dialog should be shown when form is loaded into a visible document");
+
+  info("load a background login form tab with a matching saved login " +
+       "and wait to see if the master password dialog is shown");
+  // confirm document is hidden
+  tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser);
+  is(tab1Visibility, "hidden", "The first tab should be backgrounded");
+  result = { wasShown: false };
+
+  dialogObserved = observeMasterPasswordDialog(tab1.ownerGlobal, result);
+  await BrowserTestUtils.loadURI(tab1.linkedBrowser, FORM_URL);
+  await dialogObserved;
+  ok(!result.wasShown, "Dialog should not be shown when form is loaded into a hidden document");
+
+  info("switch to the form tab " +
+       "and confirm the master password dialog is then shown");
+  tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser);
+  is(tab1Visibility, "hidden", "The first tab should be backgrounded");
+  result = { wasShown: false };
+
+  dialogObserved = observeMasterPasswordDialog(tab1.ownerGlobal, result);
+  await BrowserTestUtils.switchTab(gBrowser, tab1);
+  await dialogObserved;
+  tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser);
+  is(tab1Visibility, "visible", "The first tab should be foreground");
+  ok(result.wasShown, "Dialog should be shown when input's document becomes visible");
+
+  gBrowser.removeTab(tab1);
+  gBrowser.removeTab(tab2);
+});