Bug 1280300 - Support navigation by fragment; r?automatedtester
Adds support for navigating to a fragment on the currenty visible document
without waiting for a DOM event that the document has been fully loaded.
This addresses https://github.com/mozilla/geckodriver/issues/150.
MozReview-Commit-ID: 7uiPT5cjGQE
--- a/testing/marionette/harness/marionette/tests/unit/test_navigation.py
+++ b/testing/marionette/harness/marionette/tests/unit/test_navigation.py
@@ -1,17 +1,24 @@
# 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/.
+import time
+import urllib
+
from marionette import MarionetteTestCase
from marionette_driver.errors import MarionetteException, TimeoutException
from marionette_driver.by import By
+def inline(doc):
+ return "data:text/html;charset=utf-8,%s" % urllib.quote(doc)
+
+
class TestNavigate(MarionetteTestCase):
def setUp(self):
MarionetteTestCase.setUp(self)
self.marionette.execute_script("window.location.href = 'about:blank'")
self.assertEqual("about:blank", self.location_href)
self.test_doc = self.marionette.absolute_url("test.html")
self.iframe_doc = self.marionette.absolute_url("test_iframe.html")
@@ -55,30 +62,32 @@ class TestNavigate(MarionetteTestCase):
def test_go_forward(self):
self.marionette.navigate(self.test_doc)
self.assertNotEqual("about:blank", self.location_href)
self.assertEqual("Marionette Test", self.marionette.title)
self.marionette.navigate("about:blank")
self.assertEqual("about:blank", self.location_href)
self.marionette.go_back()
- self.assertNotEqual("about:blank", self.location_href)
+ self.assertEqual(self.test_doc, self.location_href)
self.assertEqual("Marionette Test", self.marionette.title)
self.marionette.go_forward()
self.assertEqual("about:blank", self.location_href)
def test_refresh(self):
self.marionette.navigate(self.test_doc)
self.assertEqual("Marionette Test", self.marionette.title)
self.assertTrue(self.marionette.execute_script(
"""var elem = window.document.createElement('div'); elem.id = 'someDiv';
window.document.body.appendChild(elem); return true;"""))
self.assertFalse(self.marionette.execute_script(
"return window.document.getElementById('someDiv') == undefined"))
self.marionette.refresh()
+ # TODO(ato): Bug 1291320
+ time.sleep(0.2)
self.assertEqual("Marionette Test", self.marionette.title)
self.assertTrue(self.marionette.execute_script(
"return window.document.getElementById('someDiv') == undefined"))
""" Disabled due to Bug 977899
def test_navigate_frame(self):
self.marionette.navigate(self.marionette.absolute_url("test_iframe.html"))
self.marionette.switch_to_frame(0)
@@ -90,17 +99,17 @@ class TestNavigate(MarionetteTestCase):
def test_should_not_error_if_nonexistent_url_used(self):
try:
self.marionette.navigate("thisprotocoldoesnotexist://")
self.fail("Should have thrown a MarionetteException")
except TimeoutException:
self.fail("The socket shouldn't have timed out when navigating to a non-existent URL")
except MarionetteException as e:
- self.assertIn("Error loading page", str(e))
+ self.assertIn("Reached error page", str(e))
except Exception as e:
import traceback
print traceback.format_exc()
self.fail("Should have thrown a MarionetteException instead of %s" % type(e))
def test_should_navigate_to_requested_about_page(self):
self.marionette.navigate("about:neterror")
self.assertEqual(self.marionette.get_url(), "about:neterror")
@@ -127,11 +136,18 @@ class TestNavigate(MarionetteTestCase):
print traceback.format_exc()
self.fail("Should have thrown a TimeoutException instead of %s" % type(e))
def test_navigate_iframe(self):
self.marionette.navigate(self.iframe_doc)
self.assertTrue('test_iframe.html' in self.marionette.get_url())
self.assertTrue(self.marionette.find_element(By.ID, "test_iframe"))
+ def test_fragment(self):
+ doc = inline("<p id=foo>")
+ self.marionette.navigate(doc)
+ self.marionette.execute_script("window.visited = true", sandbox=None)
+ self.marionette.navigate("%s#foo" % doc)
+ self.assertTrue(self.marionette.execute_script("return window.visited", sandbox=None))
+
@property
def location_href(self):
return self.marionette.execute_script("return window.location.href")
--- a/testing/marionette/jar.mn
+++ b/testing/marionette/jar.mn
@@ -20,16 +20,17 @@ marionette.jar:
content/dispatcher.js (dispatcher.js)
content/modal.js (modal.js)
content/proxy.js (proxy.js)
content/capture.js (capture.js)
content/cookies.js (cookies.js)
content/atom.js (atom.js)
content/evaluate.js (evaluate.js)
content/logging.js (logging.js)
+ content/navigate.js (navigate.js)
#ifdef ENABLE_TESTS
content/test.xul (harness/marionette/chrome/test.xul)
content/test2.xul (harness/marionette/chrome/test2.xul)
content/test_dialog.xul (harness/marionette/chrome/test_dialog.xul)
content/test_nested_iframe.xul (harness/marionette/chrome/test_nested_iframe.xul)
content/test_anonymous_content.xul (harness/marionette/chrome/test_anonymous_content.xul)
#endif
--- a/testing/marionette/listener.js
+++ b/testing/marionette/listener.js
@@ -18,16 +18,17 @@ Cu.import("chrome://marionette/content/a
Cu.import("chrome://marionette/content/capture.js");
Cu.import("chrome://marionette/content/cookies.js");
Cu.import("chrome://marionette/content/element.js");
Cu.import("chrome://marionette/content/error.js");
Cu.import("chrome://marionette/content/evaluate.js");
Cu.import("chrome://marionette/content/event.js");
Cu.import("chrome://marionette/content/interaction.js");
Cu.import("chrome://marionette/content/logging.js");
+Cu.import("chrome://marionette/content/navigate.js");
Cu.import("chrome://marionette/content/proxy.js");
Cu.import("chrome://marionette/content/simpletest.js");
Cu.import("resource://gre/modules/FileUtils.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.importGlobalProperties(["URL"]);
@@ -876,97 +877,115 @@ function multiAction(args, maxLen) {
setDispatch(concurrentEvent, pendingTouches);
}
/*
* This implements the latter part of a get request (for the case we need to resume one
* when a remoteness update happens in the middle of a navigate request). This is most of
* of the work of a navigate request, but doesn't assume DOMContentLoaded is yet to fire.
*/
-function pollForReadyState(msg, start, callback) {
+function pollForReadyState(msg, start = undefined, callback = undefined) {
let {pageTimeout, url, command_id} = msg.json;
- start = start ? start : new Date().getTime();
-
+ if (!start) {
+ start = new Date().getTime();
+ }
if (!callback) {
callback = () => {};
}
- let end = null;
- function checkLoad() {
+ let checkLoad = function() {
navTimer.cancel();
- end = new Date().getTime();
- let aboutErrorRegex = /about:.+(error)\?/;
- let elapse = end - start;
+
let doc = curContainer.frame.document;
- if (pageTimeout == null || elapse <= pageTimeout) {
+ let now = new Date().getTime();
+ if (pageTimeout == null || (now - start) <= pageTimeout) {
+ // document fully loaded
if (doc.readyState == "complete") {
callback();
sendOk(command_id);
+
+ // we have reached an error url without requesting it
} else if (doc.readyState == "interactive" &&
- aboutErrorRegex.exec(doc.baseURI) &&
- !doc.baseURI.startsWith(url)) {
- // We have reached an error url without requesting it.
+ /about:.+(error)\?/.exec(doc.baseURI) &&
+ !doc.baseURI.startsWith(url)) {
callback();
- sendError(new UnknownError("Error loading page"), command_id);
- } else if (doc.readyState == "interactive" &&
- doc.baseURI.startsWith("about:")) {
+ sendError(new UnknownError("Reached error page: " + doc.baseURI), command_id);
+
+ // return early for about: urls
+ } else if (doc.readyState == "interactive" && doc.baseURI.startsWith("about:")) {
callback();
sendOk(command_id);
+
+ // document not fully loaded
} else {
navTimer.initWithCallback(checkLoad, 100, Ci.nsITimer.TYPE_ONE_SHOT);
}
+
} else {
callback();
sendError(new TimeoutError("Error loading page, timed out (checkLoad)"), command_id);
}
- }
+ };
checkLoad();
}
/**
* Navigate to the given URL. The operation will be performed on the
* current browsing context, which means it handles the case where we
* navigate within an iframe. All other navigation is handled by the
* driver (in chrome space).
*/
function get(msg) {
let start = new Date().getTime();
- let requestedURL = new URL(msg.json.url).toString();
+ let command_id = msg.json.command_id;
+
let docShell = curContainer.frame
- .document
- .defaultView
- .QueryInterface(Ci.nsIInterfaceRequestor)
- .getInterface(Ci.nsIWebNavigation)
- .QueryInterface(Ci.nsIDocShell);
+ .document
+ .defaultView
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShell);
let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
- .getInterface(Ci.nsIWebProgress);
+ .getInterface(Ci.nsIWebProgress);
let sawLoad = false;
+ let requestedURL;
+ let loadEventExpected = false;
+ try {
+ requestedURL = new URL(msg.json.url).toString();
+ let curURL = curContainer.frame.location;
+ loadEventExpected = navigate.isLoadEventExpected(curURL, requestedURL);
+ } catch (e) {
+ sendError(new InvalidArgumentError("Malformed URL: " + e.message), command_id);
+ return;
+ }
+
// It's possible that a site we're being sent to will end up redirecting
// us before we end up on a page that fires DOMContentLoaded. We can ensure
// This loadListener ensures that we don't send a success signal back to
// the caller until we've seen the load of the requested URL attempted
// on this frame.
let loadListener = {
- QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
- Ci.nsISupportsWeakReference]),
+ QueryInterface: XPCOMUtils.generateQI(
+ [Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference]),
+
onStateChange(webProgress, request, state, status) {
if (!(request instanceof Ci.nsIChannel)) {
return;
}
let isDocument = state & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
let isStart = state & Ci.nsIWebProgressListener.STATE_START;
let loadedURL = request.URI.spec;
// We have to look at the originalURL because for about: pages,
// the loadedURL is what the about: page resolves to, and is
// not the one that was requested.
let originalURL = request.originalURI.spec;
let isRequestedURL = loadedURL == requestedURL ||
- originalURL == requestedURL;
+ originalURL == requestedURL;
if (isDocument && isStart && isRequestedURL) {
// We started loading the requested document. This document
// might not be the one that ends up firing DOMContentLoaded
// (if it, for example, redirects), but because we've started
// loading this URL, we know that any future DOMContentLoaded's
// are fair game to tell the Marionette client about.
sawLoad = true;
@@ -974,26 +993,25 @@ function get(msg) {
},
onLocationChange() {},
onProgressChange() {},
onStatusChange() {},
onSecurityChange() {},
};
- webProgress.addProgressListener(loadListener,
- Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT);
+ webProgress.addProgressListener(
+ loadListener, Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT);
// Prevent DOMContentLoaded events from frames from invoking this
// code, unless the event is coming from the frame associated with
// the current window (i.e. someone has used switch_to_frame).
onDOMContentLoaded = function onDOMContentLoaded(event) {
- let correctFrame =
- !event.originalTarget.defaultView.frameElement ||
- event.originalTarget.defaultView.frameElement == curContainer.frame.frameElement;
+ let frameEl = event.originalTarget.defaultView.frameElement;
+ let correctFrame = !frameEl || frameEl == curContainer.frame.frameElement;
// If the page we're at fired DOMContentLoaded and appears
// to be the one we asked to load, then we definitely
// saw the load occur. We need this because for error
// pages, like about:neterror for unsupported protocols,
// we don't end up opening a channel that our
// WebProgressListener can monitor.
if (curContainer.frame.location == requestedURL) {
@@ -1007,39 +1025,46 @@ function get(msg) {
if (correctFrame && sawLoad && loadedNonAboutBlank) {
webProgress.removeProgressListener(loadListener);
pollForReadyState(msg, start, () => {
removeEventListener("DOMContentLoaded", onDOMContentLoaded, false);
});
}
};
- function timerFunc() {
- removeEventListener("DOMContentLoaded", onDOMContentLoaded, false);
- webProgress.removeProgressListener(loadListener);
- sendError(new TimeoutError("Error loading page, timed out (onDOMContentLoaded)"), msg.json.command_id);
- }
- if (msg.json.pageTimeout != null) {
- navTimer.initWithCallback(timerFunc, msg.json.pageTimeout, Ci.nsITimer.TYPE_ONE_SHOT);
+ if (msg.json.pageTimeout) {
+ let onTimeout = function() {
+ if (loadEventExpected) {
+ removeEventListener("DOMContentLoaded", onDOMContentLoaded, false);
+ }
+ webProgress.removeProgressListener(loadListener);
+ sendError(new TimeoutError("Error loading page, timed out (onDOMContentLoaded)"), command_id);
+ }
+ navTimer.initWithCallback(onTimeout, msg.json.pageTimeout, Ci.nsITimer.TYPE_ONE_SHOT);
}
- addEventListener("DOMContentLoaded", onDOMContentLoaded, false);
- if (isB2G) {
- curContainer.frame.location = requestedURL;
- } else {
- // We need to move to the top frame before navigating
- sendSyncMessage("Marionette:switchedToFrame", { frameValue: null });
+
+ // in Firefox we need to move to the top frame before navigating
+ if (!isB2G) {
+ sendSyncMessage("Marionette:switchedToFrame", {frameValue: null});
curContainer.frame = content;
- curContainer.frame.location = requestedURL;
+ }
+
+ if (loadEventExpected) {
+ addEventListener("DOMContentLoaded", onDOMContentLoaded, false);
+ }
+ curContainer.frame.location = requestedURL;
+ if (!loadEventExpected) {
+ sendOk(command_id);
}
}
- /**
- * Cancel the polling and remove the event listener associated with a current
- * navigation request in case we're interupted by an onbeforeunload handler
- * and navigation doesn't complete.
+/**
+ * Cancel the polling and remove the event listener associated with a
+ * current navigation request in case we're interupted by an onbeforeunload
+ * handler and navigation doesn't complete.
*/
function cancelRequest() {
navTimer.cancel();
if (onDOMContentLoaded) {
removeEventListener("DOMContentLoaded", onDOMContentLoaded, false);
}
}
new file mode 100644
--- /dev/null
+++ b/testing/marionette/navigate.js
@@ -0,0 +1,124 @@
+/* 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/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+this.EXPORTED_SYMBOLS = ["navigate"];
+
+this.navigate = {};
+
+/**
+ * Determines if we expect to get a DOM load event (DOMContentLoaded)
+ * on navigating to the |future| URL.
+ *
+ * @param {string} current
+ * URL the browser is currently visiting.
+ * @param {string=} future
+ * Destination URL, if known.
+ *
+ * @return {boolean}
+ * Full page load would be expected if future is followed.
+ *
+ * @throws TypeError
+ * If |current| is not defined, or any of |current| or |future|
+ * are invalid URLs.
+ */
+navigate.isLoadEventExpected = function(current, future = undefined) {
+ if (typeof current == "undefined") {
+ throw TypeError("Expected at least one URL");
+ }
+
+ // assume we will go somewhere exciting
+ if (typeof future == "undefined") {
+ return true;
+ }
+
+ let cur = new navigate.IdempotentURL(current);
+ let fut = new navigate.IdempotentURL(future);
+
+ // assume javascript:<whatever> will modify current document
+ // but this is not an entirely safe assumption to make,
+ // considering it could be used to set window.location
+ if (fut.protocol == "javascript:") {
+ return false;
+ }
+
+ // navigating to same url, but with any hash
+ if (cur.origin == fut.origin &&
+ cur.pathname == fut.pathname &&
+ fut.hash != "") {
+ return false;
+ }
+
+ return true;
+};
+
+/**
+ * Sane URL implementation that normalises URL fragments (hashes) and
+ * path names for "data:" URLs, and makes them idempotent.
+ *
+ * At the time of writing this, the web is approximately 10 000 days (or
+ * ~27.39 years) old. One should think that by this point we would have
+ * solved URLs. The following code is prudent example that we have not.
+ *
+ * When a URL with a fragment identifier but no explicit name for the
+ * fragment is given, i.e. "#", the {@code hash} property a {@code URL}
+ * object computes is an empty string. This is incidentally the same as
+ * the default value of URLs without fragments, causing a lot of confusion.
+ *
+ * This means that the URL "http://a/#b" produces a hash of "#b", but that
+ * "http://a/#" produces "". This implementation rectifies this behaviour
+ * by returning the actual full fragment, which is "#".
+ *
+ * "data:" URLs that contain fragments, which if they have the same origin
+ * and path name are not meant to cause a page reload on navigation,
+ * confusingly adds the fragment to the {@code pathname} property.
+ * This implementation remedies this behaviour by trimming it off.
+ *
+ * The practical result of this is that while {@code URL} objects are
+ * not idempotent, the returned URL elements from this implementation
+ * guarantees that |url.hash == url.hash|.
+ *
+ * @param {string|URL} o
+ * Object to make an URL of.
+ *
+ * @return {navigate.IdempotentURL}
+ * Considered by some to be a somewhat saner URL.
+ *
+ * @throws TypeError
+ * If |o| is not a valid type or if is a string that cannot be parsed
+ * as a URL.
+ */
+navigate.IdempotentURL = function(o) {
+ let url = new URL(o);
+
+ let hash = url.hash;
+ if (hash == "" && url.href[url.href.length - 1] == "#") {
+ hash = "#";
+ }
+
+ let pathname = url.pathname;
+ if (url.protocol == "data:" && hash != "") {
+ pathname = pathname.substring(0, pathname.length - hash.length);
+ }
+
+ return {
+ hash: hash,
+ host: url.host,
+ hostname: url.hostname,
+ href: url.href,
+ origin: url.origin,
+ password: url.password,
+ pathname: pathname,
+ port: url.port,
+ protocol: url.protocol,
+ search: url.search,
+ searchParams: url.searchParams,
+ username: url.username,
+ };
+};
new file mode 100644
--- /dev/null
+++ b/testing/marionette/test_navigate.js
@@ -0,0 +1,67 @@
+/* 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/. */
+
+const {utils: Cu} = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("chrome://marionette/content/navigate.js");
+
+add_test(function test_isLoadEventExpected() {
+ Assert.throws(() => navigate.isLoadEventExpected(undefined),
+ /Expected at least one URL/);
+
+ equal(true, navigate.isLoadEventExpected("http://a/"));
+ equal(true, navigate.isLoadEventExpected("http://a/", "http://a/"));
+ equal(true, navigate.isLoadEventExpected("http://a/", "http://a/b"));
+ equal(true, navigate.isLoadEventExpected("http://a/", "http://b"));
+ equal(true, navigate.isLoadEventExpected("http://a/", "data:text/html;charset=utf-8,foo"));
+ equal(true, navigate.isLoadEventExpected("about:blank", "http://a/"));
+ equal(true, navigate.isLoadEventExpected("http://a/", "about:blank"));
+ equal(true, navigate.isLoadEventExpected("http://a/", "https://a/"));
+
+ equal(false, navigate.isLoadEventExpected("http://a/", "javascript:whatever"));
+ equal(false, navigate.isLoadEventExpected("http://a/", "http://a/#"));
+ equal(false, navigate.isLoadEventExpected("http://a/", "http://a/#b"));
+ equal(false, navigate.isLoadEventExpected("http://a/#b", "http://a/#b"));
+ equal(false, navigate.isLoadEventExpected("http://a/#b", "http://a/#c"));
+ equal(false, navigate.isLoadEventExpected("data:text/html;charset=utf-8,foo", "data:text/html;charset=utf-8,foo#bar"));
+ equal(false, navigate.isLoadEventExpected("data:text/html;charset=utf-8,foo", "data:text/html;charset=utf-8,foo#"));
+
+ run_next_test();
+});
+
+add_test(function test_IdempotentURL() {
+ Assert.throws(() => new navigate.IdempotentURL(undefined));
+ Assert.throws(() => new navigate.IdempotentURL(true));
+ Assert.throws(() => new navigate.IdempotentURL({}));
+ Assert.throws(() => new navigate.IdempotentURL(42));
+
+ // propagated URL properties
+ let u1 = new URL("http://a/b");
+ let u2 = new navigate.IdempotentURL(u1);
+ equal(u1.host, u2.host);
+ equal(u1.hostname, u2.hostname);
+ equal(u1.href, u2.href);
+ equal(u1.origin, u2.origin);
+ equal(u1.password, u2.password);
+ equal(u1.port, u2.port);
+ equal(u1.protocol, u2.protocol);
+ equal(u1.search, u2.search);
+ equal(u1.username, u2.username);
+
+ // specialisations
+ equal("#b", new navigate.IdempotentURL("http://a/#b").hash);
+ equal("#", new navigate.IdempotentURL("http://a/#").hash);
+ equal("", new navigate.IdempotentURL("http://a/").hash);
+ equal("#bar", new navigate.IdempotentURL("data:text/html;charset=utf-8,foo#bar").hash);
+ equal("#", new navigate.IdempotentURL("data:text/html;charset=utf-8,foo#").hash);
+ equal("", new navigate.IdempotentURL("data:text/html;charset=utf-8,foo").hash);
+
+ equal("/", new navigate.IdempotentURL("http://a/").pathname);
+ equal("/", new navigate.IdempotentURL("http://a/#b").pathname);
+ equal("text/html;charset=utf-8,foo", new navigate.IdempotentURL("data:text/html;charset=utf-8,foo#bar").pathname);
+
+ run_next_test();
+});
--- a/testing/marionette/unit.ini
+++ b/testing/marionette/unit.ini
@@ -5,8 +5,9 @@
# xpcshell unit tests for Marionette
[DEFAULT]
skip-if = appname == "thunderbird"
[test_element.js]
[test_error.js]
[test_message.js]
+[test_navigate.js]