From 141c517755d3419b9d2b142adee46a165aae0842 Mon Sep 17 00:00:00 2001 From: jgraham Date: Fri, 25 Sep 2020 11:00:22 +0100 Subject: [PATCH] Make testdriver click/send_keys/actions work in more browsing contexts (#25550) This is a slightly hacky change to allow testdriver actions to work in more browsing contexts. The basic idea is that when we want to run an action in a different borsing context, we pass wptrunner a context list like [window_id, frame_id...], and use that to select the correct browsing context just for the duration of the action, before switching back. There are a number of limitations with the current patch, some more serious than others: * So far this is only implmented for testdriver commands that take an explicit element. Other commands could be modified to also allow passing in an explicit context. * It must be possible to get a window reference and set the name property (see below). This means this approach only works for windows that are same origin with the test window. * WebDriver implementations don't generally support returning a window object from script, so we can't return a window handle directly. Instead we either use the existing window.name property or add a window.name when we try to start the action. Then in wptrunner we do a linear search of all windows to find the one with the appropriate name. We have to use window.name rather than writing a custom property into the window global because the way marionette sandboxes js we aren't able to read the global property. However despite these limitations this makes the feature considerably more versatile than it was previously. Co-authored-by: Robert Ma --- docs/writing-tests/testdriver.md | 12 ++ infrastructure/testdriver/click_child.html | 7 ++ infrastructure/testdriver/click_iframe.html | 24 ++++ infrastructure/testdriver/click_nested.html | 31 +++++ .../testdriver/click_outer_child.html | 4 + infrastructure/testdriver/click_window.html | 24 ++++ resources/testdriver.js | 29 ++--- tools/webdriver/webdriver/client.py | 8 +- .../wptrunner/wptrunner/executors/actions.py | 8 +- tools/wptrunner/wptrunner/executors/base.py | 36 +++++- .../wptrunner/executors/executormarionette.py | 21 +++- .../wptrunner/executors/executorwebdriver.py | 21 +++- .../wptrunner/wptrunner/executors/protocol.py | 30 ++--- tools/wptrunner/wptrunner/testdriver-extra.js | 119 +++++++++++++----- 14 files changed, 288 insertions(+), 86 deletions(-) create mode 100644 infrastructure/testdriver/click_child.html create mode 100644 infrastructure/testdriver/click_iframe.html create mode 100644 infrastructure/testdriver/click_nested.html create mode 100644 infrastructure/testdriver/click_outer_child.html create mode 100644 infrastructure/testdriver/click_window.html diff --git a/docs/writing-tests/testdriver.md b/docs/writing-tests/testdriver.md index 63608a71c27831..8eec38729f41b4 100644 --- a/docs/writing-tests/testdriver.md +++ b/docs/writing-tests/testdriver.md @@ -65,6 +65,10 @@ setKeyboard: Set the current default key source addKeyboard: Add a new key input source with the given name ``` +This works with elements in other frames/windows as long as they are +same-origin with the test, and the test does not depend on the +window.name property remaining unset on the target window. + ### bless Usage: `test_driver.bless(intent, action)` @@ -104,6 +108,10 @@ possible to click it. It returns a promise that resolves after the click has occurred or rejects if the element cannot be clicked (for example, it is obscured by an element on top of it). +This works with elements in other frames/windows as long as they are +same-origin with the test, and the test does not depend on the +window.name property remaining unset on the target window. + Note that if the element to be clicked does not have a unique ID, the document must not have any DOM mutations made between the function being called and the promise settling. @@ -120,6 +128,10 @@ make it possible to send keys. It returns a promise that resolves after the keys have been sent, or rejects if the keys cannot be sent to the element. +This works with elements in other frames/windows as long as they are +same-origin with the test, and the test does not depend on the +window.name property remaining unset on the target window. + Note that if the element that the keys need to be sent to does not have a unique ID, the document must not have any DOM mutations made between the function being called and the promise settling. diff --git a/infrastructure/testdriver/click_child.html b/infrastructure/testdriver/click_child.html new file mode 100644 index 00000000000000..5899841c4c1bcc --- /dev/null +++ b/infrastructure/testdriver/click_child.html @@ -0,0 +1,7 @@ + + +
FAIL
+ diff --git a/infrastructure/testdriver/click_iframe.html b/infrastructure/testdriver/click_iframe.html new file mode 100644 index 00000000000000..167a91afcfaa3d --- /dev/null +++ b/infrastructure/testdriver/click_iframe.html @@ -0,0 +1,24 @@ + + +TestDriver click on a document in an iframe + + + + + + + + diff --git a/infrastructure/testdriver/click_nested.html b/infrastructure/testdriver/click_nested.html new file mode 100644 index 00000000000000..378b9e8c0f1411 --- /dev/null +++ b/infrastructure/testdriver/click_nested.html @@ -0,0 +1,31 @@ + + +TestDriver click method with multiple windows and nested iframe + + + + + + + + diff --git a/infrastructure/testdriver/click_outer_child.html b/infrastructure/testdriver/click_outer_child.html new file mode 100644 index 00000000000000..ae4944635f00fb --- /dev/null +++ b/infrastructure/testdriver/click_outer_child.html @@ -0,0 +1,4 @@ + + + + diff --git a/infrastructure/testdriver/click_window.html b/infrastructure/testdriver/click_window.html new file mode 100644 index 00000000000000..614a92478e07d5 --- /dev/null +++ b/infrastructure/testdriver/click_window.html @@ -0,0 +1,24 @@ + + +TestDriver click method in window + + + + + + diff --git a/resources/testdriver.js b/resources/testdriver.js index 4d373c9281d85e..165147d1430bfe 100644 --- a/resources/testdriver.js +++ b/resources/testdriver.js @@ -15,7 +15,8 @@ } function getPointerInteractablePaintTree(element) { - if (!window.document.contains(element)) { + let elementDocument = element.ownerDocument; + if (!elementDocument.contains(element)) { return []; } @@ -27,10 +28,10 @@ var centerPoint = getInViewCenterPoint(rectangles[0]); - if ("elementsFromPoint" in document) { - return document.elementsFromPoint(centerPoint[0], centerPoint[1]); - } else if ("msElementsFromPoint" in document) { - var rv = document.msElementsFromPoint(centerPoint[0], centerPoint[1]); + if ("elementsFromPoint" in elementDocument) { + return elementDocument.elementsFromPoint(centerPoint[0], centerPoint[1]); + } else if ("msElementsFromPoint" in elementDocument) { + var rv = elementDocument.msElementsFromPoint(centerPoint[0], centerPoint[1]); return Array.prototype.slice.call(rv ? rv : []); } else { throw new Error("document.elementsFromPoint unsupported"); @@ -95,14 +96,6 @@ * the cases the WebDriver command errors */ click: function(element) { - if (window.top !== window) { - return Promise.reject(new Error("can only click in top-level window")); - } - - if (!window.document.contains(element)) { - return Promise.reject(new Error("element in different document or shadow tree")); - } - if (!inView(element)) { element.scrollIntoView({behavior: "instant", block: "end", @@ -135,14 +128,6 @@ * the cases the WebDriver command errors */ send_keys: function(element, keys) { - if (window.top !== window) { - return Promise.reject(new Error("can only send keys in top-level window")); - } - - if (!window.document.contains(element)) { - return Promise.reject(new Error("element in different document or shadow tree")); - } - if (!inView(element)) { element.scrollIntoView({behavior: "instant", block: "end", @@ -176,7 +161,7 @@ /** * Send a sequence of actions * - * This function sends a sequence of actions to the top level window + * This function sends a sequence of actions * to perform. It is modeled after the behaviour of {@link * https://w3c.github.io/webdriver/#actions|WebDriver Actions Command} * diff --git a/tools/webdriver/webdriver/client.py b/tools/webdriver/webdriver/client.py index 1a4b6498312d65..adc9b3a3f705e4 100644 --- a/tools/webdriver/webdriver/client.py +++ b/tools/webdriver/webdriver/client.py @@ -244,10 +244,6 @@ def perform(self, actions=None): """ body = {"actions": [] if actions is None else actions} actions = self.session.send_session_command("POST", "actions", body) - """WebDriver window should be set to the top level window when wptrunner - processes the next event. - """ - self.session.switch_frame(None) return actions @command @@ -347,9 +343,7 @@ def __init__(self, session): self.session = session @command - def css(self, element_selector, all=True, frame="window"): - if (frame != "window"): - self.session.switch_frame(frame) + def css(self, element_selector, all=True): elements = self._find_element("css selector", element_selector, all) return elements diff --git a/tools/wptrunner/wptrunner/executors/actions.py b/tools/wptrunner/wptrunner/executors/actions.py index fc43dd665a2f3b..b11d30bb94aa7c 100644 --- a/tools/wptrunner/wptrunner/executors/actions.py +++ b/tools/wptrunner/wptrunner/executors/actions.py @@ -43,12 +43,12 @@ def __call__(self, payload): for action in actionSequence["actions"]: if (action["type"] == "pointerMove" and isinstance(action["origin"], dict)): - action["origin"] = self.get_element(action["origin"]["selector"], action["frame"]["frame"]) + action["origin"] = self.get_element(action["origin"]["selector"]) self.protocol.action_sequence.send_actions({"actions": actions}) - def get_element(self, element_selector, frame): - element = self.protocol.select.element_by_selector(element_selector, frame) - return element + def get_element(self, element_selector): + return self.protocol.select.element_by_selector(element_selector) + class GenerateTestReportAction(object): name = "generate_test_report" diff --git a/tools/wptrunner/wptrunner/executors/base.py b/tools/wptrunner/wptrunner/executors/base.py index b5820a38085636..0247b3feed2b00 100644 --- a/tools/wptrunner/wptrunner/executors/base.py +++ b/tools/wptrunner/wptrunner/executors/base.py @@ -793,7 +793,8 @@ def process_action(self, url, payload): except KeyError: raise ValueError("Unknown action %s" % action) try: - result = action_handler(payload) + with ActionContext(self.logger, self.protocol, payload.get("context")): + result = action_handler(payload) except self.unimplemented_exc: self.logger.warning("Action %s not implemented" % action) self._send_message("complete", "error", "Action %s not implemented" % action) @@ -811,3 +812,36 @@ def process_action(self, url, payload): def _send_message(self, message_type, status, message=None): self.protocol.testdriver.send_message(message_type, status, message=message) + + +class ActionContext(object): + def __init__(self, logger, protocol, context): + self.logger = logger + self.protocol = protocol + self.context = context + self.initial_window = None + self.switched_frame = False + + def __enter__(self): + if self.context is None: + return + + window_id = self.context[0] + if window_id: + self.initial_window = self.protocol.base.current_window + self.logger.debug("Switching to window %s" % window_id) + self.protocol.testdriver.switch_to_window(window_id) + + for frame_id in self.context[1:]: + self.switched_frame = True + self.logger.debug("Switching to frame %s" % frame_id) + self.protocol.testdriver.switch_to_frame(frame_id) + + def __exit__(self, *args): + if self.initial_window is not None: + self.logger.debug("Switching back to initial window") + self.protocol.base.set_window(self.initial_window) + self.initial_window = None + elif self.switched_frame: + self.protocol.testdriver.switch_to_frame(None) + self.switched_frame = False diff --git a/tools/wptrunner/wptrunner/executors/executormarionette.py b/tools/wptrunner/wptrunner/executors/executormarionette.py index 9c1f4f366d5fe7..e7c9726974b311 100644 --- a/tools/wptrunner/wptrunner/executors/executormarionette.py +++ b/tools/wptrunner/wptrunner/executors/executormarionette.py @@ -429,9 +429,6 @@ def setup(self): def elements_by_selector(self, selector): return self.marionette.find_elements("css selector", selector) - def elements_by_selector_and_frame(self, element_selector, frame): - return self.marionette.find_elements("css selector", element_selector) - class MarionetteClickProtocolPart(ClickProtocolPart): def setup(self): @@ -472,6 +469,24 @@ def send_message(self, message_type, status, message=None): obj["message"] = str(message) self.parent.base.execute_script("window.postMessage(%s, '*')" % json.dumps(obj)) + def switch_to_window(self, window_id): + if window_id is None: + return + + for window_handle in self.marionette.window_handles: + _switch_to_window(self.marionette, window_handle) + try: + handle_window_id = self.marionette.execute_script("return window.name") + except errors.JavascriptException: + continue + if str(handle_window_id) == window_id: + return + + raise Exception("Window with id %s not found" % window_id) + + def switch_to_frame(self, frame_number): + self.marionette.switch_to_frame(frame_number) + class MarionetteCoverageProtocolPart(CoverageProtocolPart): def setup(self): diff --git a/tools/wptrunner/wptrunner/executors/executorwebdriver.py b/tools/wptrunner/wptrunner/executors/executorwebdriver.py index ad79600aeb8f94..139c475ce44ed2 100644 --- a/tools/wptrunner/wptrunner/executors/executorwebdriver.py +++ b/tools/wptrunner/wptrunner/executors/executorwebdriver.py @@ -177,9 +177,6 @@ def setup(self): def elements_by_selector(self, selector): return self.webdriver.find.css(selector) - def elements_by_selector_and_frame(self, element_selector, frame): - return self.webdriver.find.css(element_selector, frame=frame) - class WebDriverClickProtocolPart(ClickProtocolPart): def setup(self): @@ -226,6 +223,24 @@ def send_message(self, message_type, status, message=None): obj["message"] = str(message) self.webdriver.execute_script("window.postMessage(%s, '*')" % json.dumps(obj)) + def switch_to_window(self, window_id): + if window_id is None: + return + + for window_handle in self.webdriver.handles: + self.webdriver.window_handle = window_handle + try: + handle_window_id = self.webdriver.execute_script("return window.name") + except client.JavascriptErrorException: + continue + if str(handle_window_id) == window_id: + return + + raise Exception("Window with id %s not found" % window_id) + + def switch_to_frame(self, frame_number): + self.webdriver.switch_frame(frame_number) + class WebDriverGenerateTestReportProtocolPart(GenerateTestReportProtocolPart): def setup(self): diff --git a/tools/wptrunner/wptrunner/executors/protocol.py b/tools/wptrunner/wptrunner/executors/protocol.py index 3f0e44339f9b54..1c85c0f8b96348 100644 --- a/tools/wptrunner/wptrunner/executors/protocol.py +++ b/tools/wptrunner/wptrunner/executors/protocol.py @@ -245,15 +245,12 @@ class SelectorProtocolPart(ProtocolPart): name = "select" - def element_by_selector(self, element_selector, frame="window"): - elements = self.elements_by_selector_and_frame(element_selector, frame) - frame_name = "window" - if (frame != "window"): - frame_name = frame.id + def element_by_selector(self, element_selector): + elements = self.elements_by_selector(element_selector) if len(elements) == 0: - raise ValueError("Selector '%s' in frame '%s' matches no elements" % (element_selector, frame_name)) + raise ValueError("Selector '%s' matches no elements" % (element_selector,)) elif len(elements) > 1: - raise ValueError("Selector '%s' in frame '%s' matches multiple elements" % (element_selector, frame_name)) + raise ValueError("Selector '%s' matches multiple elements" % (element_selector,)) return elements[0] @abstractmethod @@ -264,13 +261,6 @@ def elements_by_selector(self, selector): :returns: A list of protocol-specific handles to elements""" pass - @abstractmethod - def elements_by_selector_and_frame(self, element_selector, frame): - """Select elements matching a CSS selector - :param str selector: The CSS selector - :returns: A list of protocol-specific handles to elements""" - pass - class ClickProtocolPart(ProtocolPart): """Protocol part for performing trusted clicks""" @@ -362,6 +352,18 @@ def send_message(self, message_type, status, message=None): :param str message: Additional data to add to the message.""" pass + def switch_to_window(self, wptrunner_id): + """Switch to a window given a wptrunner window id + + :param str wptrunner_id: window id""" + pass + + def switch_to_frame(self, index): + """Switch to a frame in the current window + + :param int index: Frame id""" + pass + class AssertsProtocolPart(ProtocolPart): """ProtocolPart that implements the functionality required to get a count of non-fatal diff --git a/tools/wptrunner/wptrunner/testdriver-extra.js b/tools/wptrunner/wptrunner/testdriver-extra.js index 5001b004f2e199..241fc83395152f 100644 --- a/tools/wptrunner/wptrunner/testdriver-extra.js +++ b/tools/wptrunner/wptrunner/testdriver-extra.js @@ -16,27 +16,51 @@ } if (data.status === "success") { - result = JSON.parse(data.message).result + result = JSON.parse(data.message).result; pending_resolve(result); } else { pending_reject(`${data.status}: ${data.message}`); } }); - const get_frame = function(element, frame) { - let foundFrame = frame; - let frameDocument = frame == window ? window.document : frame.contentDocument; - if (!frameDocument.contains(element)) { - foundFrame = null; - let frames = document.getElementsByTagName("iframe"); - for (let i = 0; i < frames.length; i++) { - if (get_frame(element, frames[i])) { - foundFrame = frames[i]; - break; + let last_window_id = 0; + function get_window_id(win) { + if (win === window) { + return null; + } + // This is a hack until some implementations support proper window ids + // It won't work cross-frame + if (!win.name) { + win.name = "__wptrunner_window " + last_window_id++; + } + return win.name; + } + + const get_context = function(element) { + if (!element) { + return null; + } + let elementWindow = element.ownerDocument.defaultView; + if (!elementWindow) { + throw new Error("Browsing context for element was detached"); + } + let top = elementWindow.top; + if (elementWindow === window) { + if (top !== window) { + throw new Error("Can't load testdriver in a frame"); } - } + // For the current window just return null + return null; + } + let rv = []; + let currentWindow = elementWindow; + while (currentWindow !== top) { + rv.push(Array.prototype.indexOf.call(currentWindow.parent.frames, currentWindow)); + currentWindow = currentWindow.parent; } - return foundFrame; + rv.push(top !== window ? get_window_id(top) : null); + rv.reverse(); + return rv; }; const get_selector = function(element) { @@ -72,22 +96,31 @@ window.test_driver_internal.in_automation = true; window.test_driver_internal.click = function(element) { + const context = get_context(element); const selector = get_selector(element); const pending_promise = new Promise(function(resolve, reject) { pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "click", "selector": selector}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "click", + selector, + context}); return pending_promise; }; window.test_driver_internal.send_keys = function(element, keys) { const selector = get_selector(element); + const context = get_context(element); const pending_promise = new Promise(function(resolve, reject) { pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "send_keys", "selector": selector, "keys": keys}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "send_keys", + selector, + keys, + context}); return pending_promise; }; @@ -96,24 +129,26 @@ pending_resolve = resolve; pending_reject = reject; }); + let context = null; for (let actionSequence of actions) { if (actionSequence.type == "pointer") { for (let action of actionSequence.actions) { // The origin of each action can only be an element or a string of a value "viewport" or "pointer". if (action.type == "pointerMove" && typeof(action.origin) != 'string') { - let frame = get_frame(action.origin, window); - if (frame != null) { - if (frame == window) - action.frame = {frame: "window"}; - else - action.frame = {frame: frame}; - action.origin = {selector: get_selector(action.origin)}; + let action_context = get_context(action.origin); + action.origin = {selector: get_selector(action.origin)}; + if (context !== null && action_context !== context) { + throw new Error("Actions must be in a single context"); } + context = action_context; } } } } - window.__wptrunner_message_queue.push({"type": "action", "action": "action_sequence", "actions": actions}); + window.__wptrunner_message_queue.push({type: "action", + action: "action_sequence", + actions, + context}); return pending_promise; }; @@ -122,7 +157,9 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "generate_test_report", "message": message}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "generate_test_report", + message}); return pending_promise; }; @@ -131,7 +168,9 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "set_permission", permission_params}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "set_permission", + permission_params}); return pending_promise; }; @@ -140,7 +179,9 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "add_virtual_authenticator", config}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "add_virtual_authenticator", + config}); return pending_promise; }; @@ -149,7 +190,9 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "remove_virtual_authenticator", authenticator_id}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "remove_virtual_authenticator", + authenticator_id}); return pending_promise; }; @@ -158,7 +201,9 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "add_credential", authenticator_id, credential}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "add_credential", + authenticator_id, credential}); return pending_promise; }; @@ -167,7 +212,9 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "get_credentials", authenticator_id}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "get_credentials", + authenticator_id}); return pending_promise; }; @@ -176,7 +223,10 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "remove_credential", authenticator_id, credential_id}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "remove_credential", + authenticator_id, + credential_id}); return pending_promise; }; @@ -185,7 +235,9 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "remove_all_credentials", authenticator_id}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "remove_all_credentials", + authenticator_id}); return pending_promise; }; @@ -194,7 +246,10 @@ pending_resolve = resolve; pending_reject = reject; }); - window.__wptrunner_message_queue.push({"type": "action", "action": "set_user_verified", authenticator_id, uv}); + window.__wptrunner_message_queue.push({"type": "action", + "action": "set_user_verified", + authenticator_id, + uv}); return pending_promise; }; })();