Skip to content

Commit

Permalink
Make testdriver click/send_keys/actions work in more browsing contexts (
Browse files Browse the repository at this point in the history
#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 <[email protected]>
  • Loading branch information
jgraham and Hexcles authored Sep 25, 2020
1 parent 5ef2319 commit 141c517
Show file tree
Hide file tree
Showing 14 changed files with 288 additions and 86 deletions.
12 changes: 12 additions & 0 deletions docs/writing-tests/testdriver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions infrastructure/testdriver/click_child.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<!doctype html>
<button id="button">Button</button>
<div id="log">FAIL</div>
<script>
document.getElementById("button").addEventListener("click", () =>
document.getElementById("log").textContent = "PASS");
</script>
24 changes: 24 additions & 0 deletions infrastructure/testdriver/click_iframe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>TestDriver click on a document in an iframe</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>

<iframe src="click_child.html"></iframe>

<script>
setup({single_test: true});
addEventListener("load", () => {
let child = frames[0];
let button = child.document.getElementById("button");
test_driver
.click(button)
.then(() => {
assert_equals(child.document.getElementById("log").textContent, "PASS");
done();
})
.catch(() => assert_unreached("click failed"));
});
</script>
31 changes: 31 additions & 0 deletions infrastructure/testdriver/click_nested.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>TestDriver click method with multiple windows and nested iframe</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>

<iframe src="about:blank"></iframe>

<script>
setup({single_test: true});

window.open("about:blank")
var child = window.open("click_outer_child.html")
window.open("about:blank")

addEventListener("load",() => {
child.addEventListener("load", () => {
let doc = child.frames[2].document;
let button = doc.getElementById("button");
test_driver
.click(button)
.then(() => {
assert_equals(doc.getElementById("log").textContent, "PASS");
done();
})
.catch(() => assert_unreached("click failed"));
});
});
</script>
4 changes: 4 additions & 0 deletions infrastructure/testdriver/click_outer_child.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!doctype html>
<iframe src="about:blank"></iframe>
<iframe src="about:blank"></iframe>
<iframe src="click_child.html"></iframe>
24 changes: 24 additions & 0 deletions infrastructure/testdriver/click_window.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>TestDriver click method in window</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>

<script>
setup({single_test: true});
addEventListener("load", () => {
let child = window.open("click_child.html");
child.addEventListener("load", () => {
let button = child.document.getElementById("button");
test_driver
.click(button)
.then(() => {
assert_equals(child.document.getElementById("log").textContent, "PASS");
done();
})
.catch(() => assert_unreached("click failed"));
});
})
</script>
29 changes: 7 additions & 22 deletions resources/testdriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
}

function getPointerInteractablePaintTree(element) {
if (!window.document.contains(element)) {
let elementDocument = element.ownerDocument;
if (!elementDocument.contains(element)) {
return [];
}

Expand All @@ -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");
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}
*
Expand Down
8 changes: 1 addition & 7 deletions tools/webdriver/webdriver/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions tools/wptrunner/wptrunner/executors/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 35 additions & 1 deletion tools/wptrunner/wptrunner/executors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
21 changes: 18 additions & 3 deletions tools/wptrunner/wptrunner/executors/executormarionette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
21 changes: 18 additions & 3 deletions tools/wptrunner/wptrunner/executors/executorwebdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading

0 comments on commit 141c517

Please sign in to comment.