Bug 1337743 - Stop appending eAttemptQuit in quitApplication; r?jgraham,whimboo draft
authorAndreas Tolfsen <ato@mozilla.com>
Thu, 09 Feb 2017 18:35:00 +0000
changeset 551797 7da5498a7bc846b51d24fa729b583dd5a611b2dc
parent 551796 c955a1221f7ffe6bcec52928b38dec5031fc1f6c
child 551798 183b28a910da31b81b7989b648c3345e1ddb1a90
push id51152
push userbmo:ato@mozilla.com
push dateMon, 27 Mar 2017 12:36:48 +0000
reviewersjgraham, whimboo
bugs1337743
milestone55.0a1
Bug 1337743 - Stop appending eAttemptQuit in quitApplication; r?jgraham,whimboo Change Marionette's quitApplication command to accept an optional array of masks for Services.startup.quit. If no masks are provided or the flags field is not provided, we assume nsIAppInfo.eAttemptQuit as the default. This deviates from the current behaviour whereby eAttemptQuit is unconditionally included when passed an array of flags. This is problematic because Services.startup.quit does not allow combinations of *Quit flags, e.g. eAttemptQuit and eForceQuit cannot be combined. MozReview-Commit-ID: FVqdaXFA4aC
testing/marionette/client/marionette_driver/marionette.py
testing/marionette/driver.js
testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py
--- a/testing/marionette/client/marionette_driver/marionette.py
+++ b/testing/marionette/client/marionette_driver/marionette.py
@@ -1120,41 +1120,67 @@ class Marionette(object):
             self.delete_session()
             self.instance.restart(prefs)
             self.raise_for_port()
             self.start_session()
 
             # Restore the context as used before the restart
             self.set_context(context)
 
-    def _request_in_app_shutdown(self, shutdown_flags=None):
-        """Terminate the currently running instance from inside the application.
+    def _request_in_app_shutdown(self, *shutdown_flags):
+        """Attempt to quit the currently running instance from inside the
+        application.
+
+        Duplicate entries in `shutdown_flags` are removed, and
+        `"eAttemptQuit"` is added if no other `*Quit` flags are given.
+        This provides backwards compatible behaviour with earlier
+        Firefoxen.
 
-        :param shutdown_flags: If specified use additional flags for the shutdown
-                               of the application. Possible values here correspond
-                               to constants in nsIAppStartup: http://mzl.la/1X0JZsC.
+        This method effectively calls `Services.startup.quit` in Gecko.
+        Possible flag values are listed at http://mzl.la/1X0JZsC.
+
+        :param shutdown_flags: Optional additional quit masks to include.
+            Duplicates are removed, and `"eAttemptQuit"` is added if no
+            flags ending with `"Quit"` are present.
+
+        :throws InvalidArgumentException: If there are multiple
+            `shutdown_flags` ending with `"Quit"`.
+
         """
-        flags = set([])
-        if shutdown_flags:
-            flags.add(shutdown_flags)
+
+        # The vast majority of this function was implemented inside
+        # the quitApplication command as part of bug 1337743, and can be
+        # removed from here in Firefox 55 at the earliest.
+
+        # remove duplicates
+        flags = set(shutdown_flags)
 
-        # Trigger a 'quit-application-requested' observer notification so that
-        # components can safely shutdown before quitting the application.
+        # add eAttemptQuit if there are no *Quits
+        if not any(flag.endswith("Quit") for flag in flags):
+            flags = flags | set(("eAttemptQuit",))
+
+        # Trigger a quit-application-requested observer notification
+        # so that components can safely shutdown before quitting the
+        # application.
         with self.using_context("chrome"):
             canceled = self.execute_script("""
                 Components.utils.import("resource://gre/modules/Services.jsm");
-                let cancelQuit = Components.classes["@mozilla.org/supports-PRBool;1"].
-                                 createInstance(Components.interfaces.nsISupportsPRBool);
+                let cancelQuit = Components.classes["@mozilla.org/supports-PRBool;1"]
+                    .createInstance(Components.interfaces.nsISupportsPRBool);
                 Services.obs.notifyObservers(cancelQuit, "quit-application-requested", null);
                 return cancelQuit.data;
                 """)
             if canceled:
-                raise errors.MarionetteException("Something canceled the quit application request")
+                raise errors.MarionetteException(
+                    "Something cancelled the quit application request")
 
-        self._send_message("quitApplication", {"flags": list(flags)})
+        body = None
+        if len(flags) > 0:
+            body = {"flags": list(flags)}
+        self._send_message("quitApplication", body)
 
     @do_process_check
     def quit(self, in_app=False, callback=None):
         """Terminate the currently running instance.
 
         This command will delete the active marionette session. It also allows
         manipulation of eg. the profile data while the application is not running.
         To start the application again, start_session() has to be called.
--- a/testing/marionette/driver.js
+++ b/testing/marionette/driver.js
@@ -2705,32 +2705,78 @@ GeckoDriver.prototype._checkIfAlertIsPre
  *     True if the server should accept new socket connections.
  */
 GeckoDriver.prototype.acceptConnections = function (cmd, resp) {
   assert.boolean(cmd.parameters.value);
   this._server.acceptConnections = cmd.parameters.value;
 }
 
 /**
- * Quits Firefox with the provided flags and tears down the current
- * session.
+ * Quits the application with the provided flags.
+ *
+ * Marionette will stop accepting new connections before ending the
+ * current session, and finally attempting to quit the application.
+ *
+ * Optional {@code nsIAppStartup} flags may be provided as
+ * an array of masks, and these will be combined by ORing
+ * them with a bitmask.  The available masks are defined in
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIAppStartup.
+ *
+ * Crucially, only one of the *Quit flags can be specified. The |eRestart|
+ * flag may be bit-wise combined with one of the *Quit flags to cause
+ * the application to restart after it quits.
+ *
+ * @param {Array.<string>=} flags
+ *     Constant name of masks to pass to |Services.startup.quit|.
+ *     If empty or undefined, |nsIAppStartup.eAttemptQuit| is used.
+ *
+ * @throws {InvalidArgumentError}
+ *     If |flags| contains unknown or incompatible flags, for example
+ *     multiple Quit flags.
  */
-GeckoDriver.prototype.quitApplication = function (cmd, resp) {
-  assert.firefox("Bug 1298921 - In app initiated quit not yet available beside Firefox")
-
-  let flags = Ci.nsIAppStartup.eAttemptQuit;
-  for (let k of cmd.parameters.flags || []) {
-    flags |= Ci.nsIAppStartup[k];
+GeckoDriver.prototype.quitApplication = function* (cmd, resp) {
+  const quits = ["eConsiderQuit", "eAttemptQuit", "eForceQuit"];
+
+  let flags = [];
+  if (typeof cmd.parameters.flags != "undefined") {
+    flags = assert.array(cmd.parameters.flags);
+  }
+
+  // bug 1298921
+  assert.firefox()
+
+  let quitSeen;
+  let mode = 0;
+  if (flags.length > 0) {
+    for (let k of flags) {
+      assert.in(k, Ci.nsIAppStartup);
+
+      if (quits.includes(k)) {
+        if (quitSeen) {
+          throw new InvalidArgumentError(
+              `${k} cannot be combined with ${quitSeen}`);
+        }
+        quitSeen = k;
+      }
+
+      mode |= Ci.nsIAppStartup[k];
+    }
+  } else {
+    mode = Ci.nsIAppStartup.eAttemptQuit;
   }
 
   this._server.acceptConnections = false;
-  resp.send();
-
   this.deleteSession();
-  Services.startup.quit(flags);
+
+  // delay response until the application is about to quit
+  let quitApplication = new Promise(resolve =>
+      Services.obs.addObserver(resolve, "quit-application", false));
+
+  Services.startup.quit(mode);
+  yield quitApplication.then(() => resp.send());
 };
 
 GeckoDriver.prototype.installAddon = function (cmd, resp) {
   assert.firefox()
 
   let path = cmd.parameters.path;
   let temp = cmd.parameters.temporary || false;
   if (typeof path == "undefined" || typeof path != "string" ||
--- a/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py
@@ -1,157 +1,217 @@
 # 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/.
 
-from marionette_driver.errors import MarionetteException
+from marionette_driver import errors
 
 from marionette_harness import MarionetteTestCase
 
 
+class TestServerQuitApplication(MarionetteTestCase):
+
+    def tearDown(self):
+        if self.marionette.session is None:
+            self.marionette.start_session()
+
+    def quit(self, flags=None):
+        body = None
+        if flags is not None:
+            body = {"flags": list(flags)}
+
+        try:
+            self.marionette._send_message("quitApplication", body)
+        finally:
+            self.marionette.session_id = None
+            self.marionette.session = None
+            self.marionette.process_id = None
+            self.marionette.profile = None
+            self.marionette.window = None
+
+        self.marionette.client.close()
+        self.marionette.instance.runner.wait()
+
+    def test_types(self):
+        for typ in [42, True, "foo", []]:
+            print("testing type {}".format(type(typ)))
+            with self.assertRaises(errors.InvalidArgumentException):
+                self.marionette._send_message("quitApplication", typ)
+
+        with self.assertRaises(errors.InvalidArgumentException):
+            self.quit("foo")
+
+    def test_undefined_default(self):
+        self.quit()
+
+    def test_empty_default(self):
+        self.quit(())
+
+    def test_incompatible_flags(self):
+        with self.assertRaises(errors.InvalidArgumentException):
+            self.quit(("eAttemptQuit", "eForceQuit"))
+
+    def test_attempt_quit(self):
+        self.quit(("eAttemptQuit",))
+
+    def test_force_quit(self):
+        self.quit(("eForceQuit",))
+
+
 class TestQuitRestart(MarionetteTestCase):
 
     def setUp(self):
         MarionetteTestCase.setUp(self)
 
         self.pid = self.marionette.process_id
         self.session_id = self.marionette.session_id
 
-        self.assertNotEqual(self.marionette.get_pref("browser.startup.page"), 3)
+        self.assertNotEqual(
+            self.marionette.get_pref("browser.startup.page"), 3)
         self.marionette.set_pref("browser.startup.page", 3)
 
     def tearDown(self):
         # Ensure to restart a session if none exist for clean-up
-        if not self.marionette.session:
+        if self.marionette.session is None:
             self.marionette.start_session()
 
         self.marionette.clear_pref("browser.startup.page")
 
         MarionetteTestCase.tearDown(self)
 
     def test_force_restart(self):
         self.marionette.restart()
         self.assertEqual(self.marionette.session_id, self.session_id)
 
         # A forced restart will cause a new process id
         self.assertNotEqual(self.marionette.process_id, self.pid)
 
         # If a preference value is not forced, a restart will cause a reset
-        self.assertNotEqual(self.marionette.get_pref("browser.startup.page"), 3)
+        self.assertNotEqual(
+            self.marionette.get_pref("browser.startup.page"), 3)
 
     def test_force_quit(self):
         self.marionette.quit()
 
         self.assertEqual(self.marionette.session, None)
-        with self.assertRaisesRegexp(MarionetteException, "Please start a session"):
+        with self.assertRaisesRegexp(errors.MarionetteException, "Please start a session"):
             self.marionette.get_url()
 
         self.marionette.start_session()
         self.assertNotEqual(self.marionette.session_id, self.session_id)
-        self.assertNotEqual(self.marionette.get_pref("browser.startup.page"), 3)
+        self.assertNotEqual(
+            self.marionette.get_pref("browser.startup.page"), 3)
 
     def test_in_app_clean_restart(self):
         with self.assertRaises(ValueError):
             self.marionette.restart(in_app=True, clean=True)
 
     def test_in_app_restart(self):
         self.marionette.restart(in_app=True)
         self.assertEqual(self.marionette.session_id, self.session_id)
 
         # An in-app restart will keep the same process id only on Linux
         if self.marionette.session_capabilities["platformName"] == "linux":
             self.assertEqual(self.marionette.process_id, self.pid)
         else:
             self.assertNotEqual(self.marionette.process_id, self.pid)
 
         # If a preference value is not forced, a restart will cause a reset
-        self.assertNotEqual(self.marionette.get_pref("browser.startup.page"), 3)
+        self.assertNotEqual(
+            self.marionette.get_pref("browser.startup.page"), 3)
 
     def test_in_app_restart_with_callback(self):
         self.marionette.restart(in_app=True,
                                 callback=lambda: self.shutdown(restart=True))
 
         self.assertEqual(self.marionette.session_id, self.session_id)
 
         # An in-app restart will keep the same process id only on Linux
         if self.marionette.session_capabilities["platformName"] == "linux":
             self.assertEqual(self.marionette.process_id, self.pid)
         else:
             self.assertNotEqual(self.marionette.process_id, self.pid)
 
         # If a preference value is not forced, a restart will cause a reset
-        self.assertNotEqual(self.marionette.get_pref("browser.startup.page"), 3)
+        self.assertNotEqual(
+            self.marionette.get_pref("browser.startup.page"), 3)
 
     def test_in_app_quit(self):
         self.marionette.quit(in_app=True)
 
         self.assertEqual(self.marionette.session, None)
-        with self.assertRaisesRegexp(MarionetteException, "Please start a session"):
+        with self.assertRaisesRegexp(errors.MarionetteException, "Please start a session"):
             self.marionette.get_url()
 
         self.marionette.start_session()
         self.assertNotEqual(self.marionette.session_id, self.session_id)
-        self.assertNotEqual(self.marionette.get_pref("browser.startup.page"), 3)
+        self.assertNotEqual(
+            self.marionette.get_pref("browser.startup.page"), 3)
 
     def test_in_app_quit_with_callback(self):
         self.marionette.quit(in_app=True, callback=self.shutdown)
         self.assertEqual(self.marionette.session, None)
-        with self.assertRaisesRegexp(MarionetteException, "Please start a session"):
+        with self.assertRaisesRegexp(errors.MarionetteException, "Please start a session"):
             self.marionette.get_url()
 
         self.marionette.start_session()
         self.assertNotEqual(self.marionette.session_id, self.session_id)
-        self.assertNotEqual(self.marionette.get_pref("browser.startup.page"), 3)
+        self.assertNotEqual(
+            self.marionette.get_pref("browser.startup.page"), 3)
 
     def test_reset_context_after_quit_by_set_context(self):
-        # Check that we are in content context which is used by default in Marionette
-        self.assertNotIn('chrome://', self.marionette.get_url(),
-                         "Context doesn't default to content")
+        # Check that we are in content context which is used by default in
+        # Marionette
+        self.assertNotIn("chrome://", self.marionette.get_url(),
+                         "Context does not default to content")
 
-        self.marionette.set_context('chrome')
+        self.marionette.set_context("chrome")
         self.marionette.quit(in_app=True)
         self.assertEqual(self.marionette.session, None)
         self.marionette.start_session()
-        self.assertNotIn('chrome://', self.marionette.get_url(),
+        self.assertNotIn("chrome://", self.marionette.get_url(),
                          "Not in content context after quit with using_context")
 
     def test_reset_context_after_quit_by_using_context(self):
-        # Check that we are in content context which is used by default in Marionette
-        self.assertNotIn('chrome://', self.marionette.get_url(),
-                         "Context doesn't default to content")
+        # Check that we are in content context which is used by default in
+        # Marionette
+        self.assertNotIn("chrome://", self.marionette.get_url(),
+                         "Context does not default to content")
 
-        with self.marionette.using_context('chrome'):
+        with self.marionette.using_context("chrome"):
             self.marionette.quit(in_app=True)
             self.assertEqual(self.marionette.session, None)
             self.marionette.start_session()
-            self.assertNotIn('chrome://', self.marionette.get_url(),
+            self.assertNotIn("chrome://", self.marionette.get_url(),
                              "Not in content context after quit with using_context")
 
     def test_keep_context_after_restart_by_set_context(self):
-        # Check that we are in content context which is used by default in Marionette
-        self.assertNotIn('chrome://', self.marionette.get_url(),
+        # Check that we are in content context which is used by default in
+        # Marionette
+        self.assertNotIn("chrome://", self.marionette.get_url(),
                          "Context doesn't default to content")
 
         # restart while we are in chrome context
-        self.marionette.set_context('chrome')
+        self.marionette.set_context("chrome")
         self.marionette.restart(in_app=True)
 
         # An in-app restart will keep the same process id only on Linux
         if self.marionette.session_capabilities["platformName"] == "linux":
             self.assertEqual(self.marionette.process_id, self.pid)
         else:
             self.assertNotEqual(self.marionette.process_id, self.pid)
 
-        self.assertIn('chrome://', self.marionette.get_url(),
+        self.assertIn("chrome://", self.marionette.get_url(),
                       "Not in chrome context after a restart with set_context")
 
     def test_keep_context_after_restart_by_using_context(self):
-        # Check that we are in content context which is used by default in Marionette
-        self.assertNotIn('chrome://', self.marionette.get_url(),
-                         "Context doesn't default to content")
+        # Check that we are in content context which is used by default in
+        # Marionette
+        self.assertNotIn("chrome://", self.marionette.get_url(),
+                         "Context does not default to content")
 
         # restart while we are in chrome context
         with self.marionette.using_context('chrome'):
             self.marionette.restart(in_app=True)
 
             # An in-app restart will keep the same process id only on Linux
             if self.marionette.session_capabilities["platformName"] == "linux":
                 self.assertEqual(self.marionette.process_id, self.pid)
@@ -160,14 +220,14 @@ class TestQuitRestart(MarionetteTestCase
 
             self.assertIn("chrome://", self.marionette.get_url(),
                           "Not in chrome context after a restart with using_context")
 
     def shutdown(self, restart=False):
         self.marionette.set_context("chrome")
         self.marionette.execute_script("""
             Components.utils.import("resource://gre/modules/Services.jsm");
-            let flags = Ci.nsIAppStartup.eAttemptQuit
-            if(arguments[0]) {
+            let flags = Ci.nsIAppStartup.eAttemptQuit;
+            if (arguments[0]) {
               flags |= Ci.nsIAppStartup.eRestart;
             }
             Services.startup.quit(flags);
-        """, script_args=[restart])
+        """, script_args=(restart,))