Update configs. IGNORE BROKEN CHANGESETS CLOSED TREE NO BUG a=release ba=release
[gecko.git] / testing / webcompat / client.py
blob88208e550700471ce43811f89a6167c5458c7e9f
1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
5 import asyncio
6 import contextlib
7 import time
8 from urllib.parse import quote
10 import webdriver
11 from webdriver.bidi.modules.script import ContextTarget
14 class Client:
15 def __init__(self, session, event_loop):
16 self.session = session
17 self.event_loop = event_loop
18 self.content_blocker_loaded = False
20 @property
21 def current_url(self):
22 return self.session.url
24 @property
25 def alert(self):
26 return self.session.alert
28 @property
29 def context(self):
30 return self.session.send_session_command("GET", "moz/context")
32 @context.setter
33 def context(self, context):
34 self.session.send_session_command("POST", "moz/context", {"context": context})
36 @contextlib.contextmanager
37 def using_context(self, context):
38 orig_context = self.context
39 needs_change = context != orig_context
41 if needs_change:
42 self.context = context
44 try:
45 yield
46 finally:
47 if needs_change:
48 self.context = orig_context
50 def wait_for_content_blocker(self):
51 if not self.content_blocker_loaded:
52 with self.using_context("chrome"):
53 self.session.execute_async_script(
54 """
55 const done = arguments[0],
56 signal = "safebrowsing-update-finished";
57 function finish() {
58 Services.obs.removeObserver(finish, signal);
59 done();
61 Services.obs.addObserver(finish, signal);
62 """
64 self.content_blocker_loaded = True
66 @property
67 def keyboard(self):
68 return self.session.actions.sequence("key", "keyboard_id")
70 @property
71 def mouse(self):
72 return self.session.actions.sequence(
73 "pointer", "pointer_id", {"pointerType": "mouse"}
76 @property
77 def pen(self):
78 return self.session.actions.sequence(
79 "pointer", "pointer_id", {"pointerType": "pen"}
82 @property
83 def touch(self):
84 return self.session.actions.sequence(
85 "pointer", "pointer_id", {"pointerType": "touch"}
88 @property
89 def wheel(self):
90 return self.session.actions.sequence("wheel", "wheel_id")
92 @property
93 def modifier_key(self):
94 if self.session.capabilities["platformName"] == "mac":
95 return "\ue03d" # meta (command)
96 else:
97 return "\ue009" # control
99 def inline(self, doc):
100 return "data:text/html;charset=utf-8,{}".format(quote(doc))
102 async def top_context(self):
103 contexts = await self.session.bidi_session.browsing_context.get_tree()
104 return contexts[0]
106 async def navigate(self, url, timeout=None, **kwargs):
107 return await asyncio.wait_for(
108 asyncio.ensure_future(self._navigate(url, **kwargs)), timeout=timeout
111 async def _navigate(self, url, wait="complete", await_console_message=None):
112 if self.session.test_config.get("use_pbm") or self.session.test_config.get(
113 "use_strict_etp"
115 print("waiting for content blocker...")
116 self.wait_for_content_blocker()
117 if await_console_message is not None:
118 console_message = await self.promise_console_message_listener(
119 await_console_message
121 if wait == "load":
122 page_load = await self.promise_readystate_listener("load", url=url)
123 try:
124 await self.session.bidi_session.browsing_context.navigate(
125 context=(await self.top_context())["context"],
126 url=url,
127 wait=wait if wait != "load" else None,
129 except webdriver.bidi.error.UnknownErrorException as u:
130 m = str(u)
131 if (
132 "NS_BINDING_ABORTED" not in m
133 and "NS_ERROR_ABORT" not in m
134 and "NS_ERROR_WONT_HANDLE_CONTENT" not in m
136 raise u
137 if wait == "load":
138 await page_load
139 if await_console_message is not None:
140 await console_message
142 async def promise_event_listener(self, events, check_fn=None, timeout=20):
143 if type(events) is not list:
144 events = [events]
146 await self.session.bidi_session.session.subscribe(events=events)
148 future = self.event_loop.create_future()
150 listener_removers = []
152 def remove_listeners():
153 for listener_remover in listener_removers:
154 try:
155 listener_remover()
156 except Exception:
157 pass
159 async def on_event(method, data):
160 print("on_event", method, data)
161 val = None
162 if check_fn is not None:
163 val = check_fn(method, data)
164 if val is None:
165 return
166 future.set_result(val)
168 for event in events:
169 r = self.session.bidi_session.add_event_listener(event, on_event)
170 listener_removers.append(r)
172 async def task():
173 try:
174 return await asyncio.wait_for(future, timeout=timeout)
175 finally:
176 remove_listeners()
177 try:
178 await asyncio.wait_for(
179 self.session.bidi_session.session.unsubscribe(events=events),
180 timeout=4,
182 except asyncio.exceptions.TimeoutError:
183 print("Unexpectedly timed out unsubscribing", events)
184 pass
186 return asyncio.create_task(task())
188 async def promise_console_message_listener(self, msg, **kwargs):
189 def check(method, data):
190 if "text" in data:
191 if msg in data["text"]:
192 return data
193 if "args" in data and len(data["args"]):
194 for arg in data["args"]:
195 if "value" in arg and msg in arg["value"]:
196 return data
198 return await self.promise_event_listener("log.entryAdded", check, **kwargs)
200 async def is_console_message(self, message):
201 try:
202 await (await self.promise_console_message_listener(message, timeout=2))
203 return True
204 except asyncio.exceptions.TimeoutError:
205 return False
207 async def promise_readystate_listener(self, state, url=None, **kwargs):
208 event = f"browsingContext.{state}"
210 def check(method, data):
211 if url is None or url in data["url"]:
212 return data
214 return await self.promise_event_listener(event, check, **kwargs)
216 async def promise_frame_listener(self, url, state="domContentLoaded", **kwargs):
217 event = f"browsingContext.{state}"
219 def check(method, data):
220 if url is None or url in data["url"]:
221 return Client.Context(self, data["context"])
223 return await self.promise_event_listener(event, check, **kwargs)
225 async def find_frame_context_by_url(self, url):
226 def find_in(arr, url):
227 for context in arr:
228 if url in context["url"]:
229 return context
230 for context in arr:
231 found = find_in(context["children"], url)
232 if found:
233 return found
235 return find_in([await self.top_context()], url)
237 class Context:
238 def __init__(self, client, id):
239 self.client = client
240 self.target = ContextTarget(id)
242 async def find_css(self, selector, all=False):
243 all = "All" if all else ""
244 return await self.client.session.bidi_session.script.evaluate(
245 expression=f"document.querySelector{all}('{selector}')",
246 target=self.target,
247 await_promise=False,
250 def timed_js(self, timeout, poll, fn, is_displayed=False):
251 return f"""() => new Promise((_good, _bad) => {{
252 {self.is_displayed_js()}
253 var _poll = {poll} * 1000;
254 var _time = {timeout} * 1000;
255 var _done = false;
256 var resolve = val => {{
257 if ({is_displayed}) {{
258 if (val.length) {{
259 val = val.filter(v = is_displayed(v));
260 }} else {{
261 val = is_displayed(val) && val;
263 if (!val.length && !val.matches) {{
264 return;
267 _done = true;
268 clearInterval(_int);
269 _good(val);
271 var reject = str => {{
272 _done = true;
273 clearInterval(_int);
274 _bad(val);
276 var _int = setInterval(() => {{
277 {fn};
278 if (!_done) {{
279 _time -= _poll;
280 if (_time <= 0) {{
281 reject();
284 }}, poll);
285 }})"""
287 def is_displayed_js(self):
288 return """
289 function is_displayed(e) {
290 const s = window.getComputedStyle(e),
291 v = s.visibility === "visible",
292 o = Math.abs(parseFloat(s.opacity));
293 return e.getClientRects().length > 0 && v && (isNaN(o) || o === 1.0);
297 async def await_css(
298 self,
299 selector,
300 all=False,
301 timeout=10,
302 poll=0.25,
303 condition=False,
304 is_displayed=False,
306 all = "All" if all else ""
307 condition = (
308 f"var elem=arguments[0]; if ({condition})" if condition else False
310 return await self.client.session.bidi_session.script.evaluate(
311 expression=self.timed_js(
312 timeout,
313 poll,
314 f"""
315 var ele = document.querySelector{all}('{selector}')";
316 if (ele && (!"length" in ele || ele.length > 0)) {{
317 '{condition}'
318 resolve(ele);
320 """,
322 target=self.target,
323 await_promise=True,
326 async def await_text(self, text, **kwargs):
327 xpath = f"//*[contains(text(),'{text}')]"
328 return await self.await_xpath(self, xpath, **kwargs)
330 async def await_xpath(
331 self, xpath, all=False, timeout=10, poll=0.25, is_displayed=False
333 all = "true" if all else "false"
334 return await self.client.session.bidi_session.script.evaluate(
335 expression=self.timed_js(
336 timeout,
337 poll,
339 var ret = [];
340 var r, res = document.evaluate(`{xpath}`, document, null, 4);
341 while (r = res.iterateNext()) {
342 ret.push(r);
344 resolve({all} ? ret : ret[0]);
345 """,
347 target=self.target,
348 await_promise=True,
351 def wrap_script_args(self, args):
352 if args is None:
353 return args
354 out = []
355 for arg in args:
356 if arg is None:
357 out.append({"type": "undefined"})
358 continue
359 t = type(arg)
360 if t == int or t == float:
361 out.append({"type": "number", "value": arg})
362 elif t == bool:
363 out.append({"type": "boolean", "value": arg})
364 elif t == str:
365 out.append({"type": "string", "value": arg})
366 else:
367 if "type" in arg:
368 out.push(arg)
369 continue
370 raise ValueError(f"Unhandled argument type: {t}")
371 return out
373 class PreloadScript:
374 def __init__(self, client, script, target):
375 self.client = client
376 self.script = script
377 if type(target) == list:
378 self.target = target[0]
379 else:
380 self.target = target
382 def stop(self):
383 return self.client.session.bidi_session.script.remove_preload_script(
384 script=self.script
387 async def run(self, fn, *args, await_promise=False):
388 val = await self.client.session.bidi_session.script.call_function(
389 arguments=self.client.wrap_script_args(args),
390 await_promise=await_promise,
391 function_declaration=fn,
392 target=self.target,
394 if val and "value" in val:
395 return val["value"]
396 return val
398 async def make_preload_script(self, text, sandbox, args=None, context=None):
399 if not context:
400 context = (await self.top_context())["context"]
401 target = ContextTarget(context, sandbox)
402 if args is None:
403 text = f"() => {{ {text} }}"
404 script = await self.session.bidi_session.script.add_preload_script(
405 function_declaration=text,
406 arguments=self.wrap_script_args(args),
407 sandbox=sandbox,
409 return Client.PreloadScript(self, script, target)
411 async def await_alert(self, text):
412 if not hasattr(self, "alert_preload_script"):
413 self.alert_preload_script = await self.make_preload_script(
415 window.__alerts = [];
416 window.wrappedJSObject.alert = function(text) {
417 window.__alerts.push(text);
419 """,
420 "alert_detector",
422 return self.alert_preload_script.run(
423 """(msg) => new Promise(done => {
424 const to = setInterval(() => {
425 if (window.__alerts.includes(msg)) {
426 clearInterval(to);
427 done();
429 }, 200);
431 """,
432 text,
433 await_promise=True,
436 async def await_popup(self, url=None):
437 if not hasattr(self, "popup_preload_script"):
438 self.popup_preload_script = await self.make_preload_script(
440 window.__popups = [];
441 window.wrappedJSObject.open = function(url) {
442 window.__popups.push(url);
444 """,
445 "popup_detector",
447 return self.popup_preload_script.run(
448 """(url) => new Promise(done => {
449 const to = setInterval(() => {
450 if (url === undefined && window.__popups.length) {
451 clearInterval(to);
452 return done(window.__popups[0]);
454 const found = window.__popups.find(u => u.includes(url));
455 if (found !== undefined) {
456 clearInterval(to);
457 done(found);
459 }, 1000);
461 """,
462 url,
463 await_promise=True,
466 async def track_listener(self, type, selector):
467 if not hasattr(self, "listener_preload_script"):
468 self.listener_preload_script = await self.make_preload_script(
470 window.__listeners = {};
471 var proto = EventTarget.wrappedJSObject.prototype;
472 var def = Object.getOwnPropertyDescriptor(proto, "addEventListener");
473 var old = def.value;
474 def.value = function(type, fn, opts) {
475 if ("matches" in this) {
476 if (!window.__listeners[type]) {
477 window.__listeners[type] = new Set();
479 window.__listeners[type].add(this);
481 return old.call(this, type, fn, opts)
483 Object.defineProperty(proto, "addEventListener", def);
484 """,
485 "listener_detector",
487 return Client.ListenerTracker(self.listener_preload_script, type, selector)
489 @contextlib.asynccontextmanager
490 async def preload_script(self, text, *args):
491 script = await self.make_preload_script(text, "preload", args=args)
492 yield script
493 await script.stop()
495 def back(self):
496 self.session.back()
498 def switch_to_frame(self, frame):
499 return self.session.transport.send(
500 "POST",
501 "session/{session_id}/frame".format(**vars(self.session)),
502 {"id": frame},
503 encoder=webdriver.protocol.Encoder,
504 decoder=webdriver.protocol.Decoder,
505 session=self.session,
508 def switch_frame(self, frame):
509 self.session.switch_frame(frame)
511 async def load_page_and_wait_for_iframe(
512 self, url, finder, loads=1, timeout=None, **kwargs
514 while loads > 0:
515 await self.navigate(url, **kwargs)
516 frame = self.await_element(finder, timeout=timeout)
517 loads -= 1
518 self.switch_frame(frame)
519 return frame
521 def execute_script(self, script, *args):
522 return self.session.execute_script(script, args=args)
524 def execute_async_script(self, script, *args, **kwargs):
525 return self.session.execute_async_script(script, args, **kwargs)
527 def clear_all_cookies(self):
528 self.session.transport.send(
529 "DELETE", "session/%s/cookie" % self.session.session_id
532 def send_element_command(self, element, method, uri, body=None):
533 url = "element/%s/%s" % (element.id, uri)
534 return self.session.send_session_command(method, url, body)
536 def get_element_attribute(self, element, name):
537 return self.send_element_command(element, "GET", "attribute/%s" % name)
539 def _do_is_displayed_check(self, ele, is_displayed):
540 if ele is None:
541 return None
543 if type(ele) in [list, tuple]:
544 return [x for x in ele if self._do_is_displayed_check(x, is_displayed)]
546 if is_displayed is False and ele and self.is_displayed(ele):
547 return None
548 if is_displayed is True and ele and not self.is_displayed(ele):
549 return None
550 return ele
552 def find_css(self, *args, all=False, is_displayed=None, **kwargs):
553 try:
554 ele = self.session.find.css(*args, all=all, **kwargs)
555 return self._do_is_displayed_check(ele, is_displayed)
556 except webdriver.error.NoSuchElementException:
557 return None
559 def find_xpath(self, xpath, all=False, is_displayed=None):
560 route = "elements" if all else "element"
561 body = {"using": "xpath", "value": xpath}
562 try:
563 ele = self.session.send_session_command("POST", route, body)
564 return self._do_is_displayed_check(ele, is_displayed)
565 except webdriver.error.NoSuchElementException:
566 return None
568 def find_text(self, text, is_displayed=None, **kwargs):
569 try:
570 ele = self.find_xpath(f"//*[contains(text(),'{text}')]", **kwargs)
571 return self._do_is_displayed_check(ele, is_displayed)
572 except webdriver.error.NoSuchElementException:
573 return None
575 def find_element(self, finder, is_displayed=None, **kwargs):
576 ele = finder.find(self, **kwargs)
577 return self._do_is_displayed_check(ele, is_displayed)
579 def await_css(self, selector, **kwargs):
580 return self.await_element(self.css(selector), **kwargs)
582 def await_xpath(self, selector, **kwargs):
583 return self.await_element(self.xpath(selector), **kwargs)
585 def await_text(self, selector, *args, **kwargs):
586 return self.await_element(self.text(selector), **kwargs)
588 def await_element(self, finder, **kwargs):
589 return self.await_first_element_of([finder], **kwargs)[0]
591 class css:
592 def __init__(self, selector):
593 self.selector = selector
595 def find(self, client, **kwargs):
596 return client.find_css(self.selector, **kwargs)
598 class xpath:
599 def __init__(self, selector):
600 self.selector = selector
602 def find(self, client, **kwargs):
603 return client.find_xpath(self.selector, **kwargs)
605 class text:
606 def __init__(self, selector):
607 self.selector = selector
609 def find(self, client, **kwargs):
610 return client.find_text(self.selector, **kwargs)
612 def await_first_element_of(
613 self, finders, timeout=None, delay=0.25, condition=False, **kwargs
615 t0 = time.time()
616 condition = f"var elem=arguments[0]; return {condition}" if condition else False
618 if timeout is None:
619 timeout = 10
621 found = [None for finder in finders]
623 exc = None
624 while time.time() < t0 + timeout:
625 for i, finder in enumerate(finders):
626 try:
627 result = finder.find(self, **kwargs)
628 if result and (
629 not condition
630 or self.session.execute_script(condition, [result])
632 found[i] = result
633 return found
634 except webdriver.error.NoSuchElementException as e:
635 exc = e
636 time.sleep(delay)
637 raise exc if exc is not None else webdriver.error.NoSuchElementException
638 return found
640 async def dom_ready(self, timeout=None):
641 if timeout is None:
642 timeout = 20
644 async def wait():
645 return self.session.execute_async_script(
647 const cb = arguments[0];
648 setInterval(() => {
649 if (document.readyState === "complete") {
650 cb();
652 }, 500);
656 task = asyncio.create_task(wait())
657 return await asyncio.wait_for(task, timeout)
659 def is_float_cleared(self, elem1, elem2):
660 return self.session.execute_script(
661 """return (function(a, b) {
662 // Ensure that a is placed under b (and not to its right)
663 return a?.offsetTop >= b?.offsetTop + b?.offsetHeight &&
664 a?.offsetLeft < b?.offsetLeft + b?.offsetWidth;
665 }(arguments[0], arguments[1]));""",
666 elem1,
667 elem2,
670 @contextlib.contextmanager
671 def assert_getUserMedia_called(self):
672 self.execute_script(
674 navigator.mediaDevices.getUserMedia =
675 navigator.mozGetUserMedia =
676 navigator.getUserMedia =
677 () => { window.__gumCalled = true; };
680 yield
681 assert self.execute_script("return window.__gumCalled === true;")
683 def await_element_hidden(self, finder, timeout=None, delay=0.25):
684 t0 = time.time()
686 if timeout is None:
687 timeout = 20
689 elem = finder.find(self)
690 while time.time() < t0 + timeout:
691 try:
692 if not self.is_displayed(elem):
693 return
694 time.sleep(delay)
695 except webdriver.error.StaleElementReferenceException:
696 return
698 def soft_click(self, element):
699 self.execute_script("arguments[0].click()", element)
701 def remove_element(self, element):
702 self.execute_script("arguments[0].remove()", element)
704 def scroll_into_view(self, element):
705 self.execute_script(
706 "arguments[0].scrollIntoView({block:'center', inline:'center', behavior: 'instant'})",
707 element,
710 @contextlib.asynccontextmanager
711 async def ensure_fastclick_activates(self):
712 fastclick_preload_script = await self.make_preload_script(
714 var _ = document.createElement("webcompat_test");
715 _.style = "position:absolute;right:-1px;width:1px;height:1px";
716 document.documentElement.appendChild(_);
717 """,
718 "fastclick_forcer",
720 yield
721 fastclick_preload_script.stop()
723 def test_for_fastclick(self, element):
724 # FastClick cancels touchend, breaking default actions on Fenix.
725 # It instead fires a mousedown or click, which we can detect.
726 self.execute_script(
728 const sel = arguments[0];
729 window.fastclicked = false;
730 const evt = sel.nodeName === "SELECT" ? "mousedown" : "click";
731 document.addEventListener(evt, e => {
732 if (e.target === sel && !e.isTrusted) {
733 window.fastclicked = true;
735 }, true);
736 """,
737 element,
739 self.scroll_into_view(element)
740 # tap a few times in case the site's other code interferes
741 self.touch.click(element=element).perform()
742 self.touch.click(element=element).perform()
743 self.touch.click(element=element).perform()
744 return self.execute_script("return window.fastclicked")
746 def is_displayed(self, element):
747 if element is None:
748 return False
750 return self.session.execute_script(
752 const e = arguments[0],
753 s = window.getComputedStyle(e),
754 v = s.visibility === "visible",
755 o = Math.abs(parseFloat(s.opacity));
756 return e.getClientRects().length > 0 && v && (isNaN(o) || o === 1.0);
757 """,
758 args=[element],