# 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 asyncio import contextlib import math import time import zipfile from base64 import b64decode, b64encode from io import BytesIO from urllib.parse import quote
import pytest import webdriver from PIL import Image, ImageChops from webdriver.bidi.error import InvalidArgumentException, NoSuchFrameException from webdriver.bidi.modules.script import ContextTarget
async def maybe_enable_font_inflation(self): # GVE does not enable font inflation by default. We want to match Fenix. if self.session.capabilities["platformName"] != "android": return with self.using_context("chrome"):
self.execute_script(
r"""
const minTwips = "font.size.inflation.minTwips"; if (!Services.prefs.getIntPref(minTwips)) {
Services.prefs.setIntPref(minTwips, 120);
} """
)
async def maybe_override_platform(self): if hasattr(self, "_platform_override_checked"): return
self._platform_override_checked = True
// We must override the userAgent as a header, like our addon does.
const defaultUA = navigator.userAgent;
Services.obs.addObserver(function (subject) {
const channel = subject.QueryInterface(Ci.nsIHttpChannel);
// If we raced with the webcompat addon, and it changed the UA already, leave it alone. if (defaultUA === channel.getRequestHeader("user-agent")) {
channel.setRequestHeader("user-agent", userAgent, true);
}
}, "http-on-modify-request");
} """,
target,
)
async def send_apz_scroll_gesture(
self, units, element=None, offset=None, coords=None
): if coords isNone: if element isNone: raise ValueError("require coords and/or element")
coords = self.get_element_screen_position(element) if offset isnotNone:
coords[0] += offset[0]
coords[1] += offset[1] with self.using_context("chrome"): return self.execute_async_script( """
const [units, coords, done] = arguments;
const { devicePixelRatio, windowUtils } = window;
const resolution = windowUtils.getResolution();
const toScreenCoords = x => x * devicePixelRatio * resolution;
// based on nativeVerticalWheelEventMsg()
let msg = 4; // linux default
switch (Services.appinfo.OS) {
case "WINNT":
msg = 0x0115; // WM_VSCROLL break;
case "Darwin":
msg = 1; // use a gesture; don't synthesize a wheel scroll break;
}
async def on_event(event, data):
val = data if checkFn:
val = await checkFn(event, data) if val isNone: return for remover in remove_listeners:
remover()
await self.unsubscribe(events)
future.set_result(val)
for event in events:
remove_listeners.append(
self.session.bidi_session.add_event_listener(event, on_event)
)
await self.subscribe(events) return await asyncio.wait_for(future, timeout=timeout)
async def get_iframe_by_url(self, url): def check_children(children): for child in children: if"url"in child and url in child["url"]: return child for child in children: if"children"in child:
frame = check_children(child["children"]) if frame: return frame returnNone
tree = await self.session.bidi_session.browsing_context.get_tree() for top in tree:
frame = check_children(top["children"]) if frame isnotNone: return frame
returnNone
async def is_iframe(self, context): def check_children(children): for child in children: if"context"in child and child["context"] == context: returnTrue if"children"in child: return check_children(child["children"]) returnFalse
for top in await self.session.bidi_session.browsing_context.get_tree(): if check_children(top["children"]): returnTrue returnFalse
async def wait_for_iframe_loaded(self, url, timeout=None):
async def wait_for_url(_, data): if url in data["url"] and await self.is_iframe(data["context"]): return data["context"] returnNone
async def navigate(self, url, timeout=90, no_skip=False, **kwargs):
await self.await_interventions_started()
await self.maybe_override_platform()
await self.maybe_enable_font_inflation() try: return await asyncio.wait_for(
asyncio.ensure_future(self._navigate(url, **kwargs)), timeout=timeout
) except asyncio.exceptions.TimeoutError as t: if no_skip: raise t return
pytest.skip(
f"{self.request.fspath.basename}: Timed out navigating to site after {timeout} seconds. Please try again later."
) except webdriver.bidi.error.UnknownErrorException as e: if no_skip: raise e return
s = str(e) if"Address rejected"in s or"NS_ERROR_NET_TIMEOUT"in s:
pytest.skip(
f"{self.request.fspath.basename}: Site not responding. Please try again later."
) return elif"NS_ERROR_UNKNOWN_HOST"in s:
pytest.skip(
f"{self.request.fspath.basename}: Site appears to be down. Please try again later."
) return elif"NS_ERROR_REDIRECT_LOOP"in s:
pytest.skip(
f"{self.request.fspath.basename}: Site is stuck in a redirect loop. Please try again later."
) return elif"NS_ERROR_CONNECTION_REFUSED"in s: raise ConnectionRefusedError("Connection refused") raise e
async def _navigate(self, url, wait="complete", await_console_message=None): if self.session.test_config.get("use_pbm") or self.session.test_config.get( "use_strict_etp"
):
print("waiting for content blocker...")
self.wait_for_content_blocker() if await_console_message isnotNone:
console_message = await self.promise_console_message_listener(
await_console_message
) if wait == "load":
page_load = await self.promise_readystate_listener("load", url=url) try:
await self.session.bidi_session.browsing_context.navigate(
context=(await self.top_context())["context"],
url=url,
wait=wait if wait != "load"elseNone,
) except webdriver.bidi.error.UnknownErrorException as u:
m = str(u) if ( "NS_BINDING_ABORTED"notin m and"NS_ERROR_ABORT"notin m and"NS_ERROR_WONT_HANDLE_CONTENT"notin m
): raise u if wait == "load":
await page_load if await_console_message isnotNone:
await console_message
def remove_listeners(): for listener_remover in listener_removers: try:
listener_remover() except Exception: pass
async def on_event(method, data):
print("on_event", method, data)
val = None if check_fn isnotNone:
val = check_fn(method, data) if val isNone: return
future.set_result(val)
for event in events:
r = self.session.bidi_session.add_event_listener(event, on_event)
listener_removers.append(r)
async def promise_navigation_begins(self, url=None, **kwargs): def check(method, data): if url isNone: return data if"url"in data and url in data["url"]: return data
async def promise_console_message_listener(self, msg, **kwargs): def check(method, data): if"text"in data: if msg in data["text"]: return data if"args"in data and len(data["args"]): for arg in data["args"]: if"value"in arg and msg in arg["value"]: return data
async def find_frame_context_by_url(self, url): def find_in(arr, url): for context in arr: if url in context["url"]: return context for context in arr:
found = find_in(context["children"], url) if found: return found
async def await_xpath(
self, xpath, all=False, timeout=10, poll=0.25, is_displayed=False
):
all = "true"if all else"false" return await self.client.session.bidi_session.script.evaluate(
expression=self.timed_js(
timeout,
poll, """
var ret = [];
var r, res = document.evaluate(`{xpath}`, document, null, 4); while (r = res.iterateNext()) {
ret.push(r);
}
resolve({all} ? ret : ret[0]); """,
),
target=self.target,
await_promise=True,
)
def wrap_script_args(self, args): if args isNone: return args
out = [] for arg in args: if arg isNone:
out.append({"type": "undefined"}) continue elif isinstance(arg, webdriver.client.WebElement):
out.append({"sharedId": arg.id}) continue
t = type(arg) if t is int or t is float:
out.append({"type": "number", "value": arg}) elif t is bool:
out.append({"type": "boolean", "value": arg}) elif t is str:
out.append({"type": "string", "value": arg}) else: if"type"in arg:
out.append(arg) continue raise ValueError(f"Unhandled argument type: {t}") return out
class PreloadScript: def __init__(self, client, script, target):
self.client = client
self.script = script if type(target) is list:
self.target = target[0] else:
self.target = target
def _start_collecting_alerts(self): # WebDriver doesn't make it easy to just wait for an alert, because while you can # listen for the events, there is no guarantee that UnexpectedAlertExceptions won't # be thrown while you're doing other things. So we just tell Gecko to collect the # prompts as they come in, and immediately dismiss them to prevent the exceptions. with self.using_context("chrome"):
self.execute_script( """
const lazy = {};
def _get_prompts(self): with self.using_context("chrome"): return self.execute_script( "return Services.cpmm.sharedData.get('WebCompatTests:Prompts')"
)
def _check_prompts(self, specific_messages, prompts): ifnot prompts: return for prompt in prompts:
message = prompt["message"] ifnot specific_messages: return message else: for specific_message in specific_messages: if specific_message in prompt["message"]: return prompt["message"]
async def find_alert(self, specific_messages=None, delay=None): if delay:
await asyncio.sleep(delay)
found = self._check_prompts(specific_messages, self._get_prompts()) if found isnotNone: return found
async def await_alert(
self, specific_messages=None, timeout=20, polling_interval=0.2
): with self.using_context("chrome"):
print(math.ceil(timeout / polling_interval)) for _ in range(math.ceil(timeout / polling_interval)):
found = self._check_prompts(specific_messages, self._get_prompts()) if found isnotNone: return found
await asyncio.sleep(polling_interval)
def _do_is_displayed_check(self, ele, is_displayed): if ele isNone: returnNone
if type(ele) in [list, tuple]: return [x for x in ele if self._do_is_displayed_check(x, is_displayed)]
if is_displayed isFalseand ele and self.is_displayed(ele): returnNone if is_displayed isTrueand ele andnot self.is_displayed(ele): returnNone return ele
exc = None while time.time() < t0 + timeout: for i, finder in enumerate(finders): try:
result = finder.find(self, all=True, **kwargs) if len(result): if condition:
result = self.session.execute_script(condition, [result]) ifnot len(result): continue
found[i] = result[0] ifnot all else result return found except webdriver.error.NoSuchElementException as e:
exc = e
time.sleep(delay) raise exc if exc isnotNoneelse webdriver.error.NoSuchElementException return found
async def dom_ready(self, timeout=None): if timeout isNone:
timeout = 20
def is_float_cleared(self, elem1, elem2): return self.session.execute_script( """return (function(a, b) {
// Ensure that a is placed under b (andnot to its right) return a?.offsetTop >= b?.offsetTop + b?.offsetHeight &&
a?.offsetLeft < b?.offsetLeft + b?.offsetWidth;
}(arguments[0], arguments[1]));""",
elem1,
elem2,
)
def try_closing_popups(self, popup_close_button_finders, timeout=None):
left_to_try = list(popup_close_button_finders)
closed_one = False
num_intercepted = 0 while left_to_try:
finder = left_to_try.pop(0) try: if self.try_closing_popup(finder, timeout=timeout):
closed_one = True
num_intercepted = 0 except webdriver.error.ElementClickInterceptedException as e: # If more than one popup is visible at the same time, we will # get this exception for all but the topmost one. So we re-try # removing the others again after the topmost one is dismissed, # until we've removed them all.
num_intercepted += 1 if num_intercepted == len(left_to_try): raise e
left_to_try.append(finder) return closed_one
def click(
self, element, force=False, popups=None, popups_timeout=None, button=None
):
tries = 0 whileTrue:
self.scroll_into_view(element) try: if button:
self.mouse.pointer_move(0, 0, origin=element).pointer_down(
button
).pointer_up(button).perform() else:
element.click() return except webdriver.error.ElementClickInterceptedException as c: if force:
self.clear_covering_elements(element) elifnot popups ornot self.try_closing_popups(
popups, timeout=popups_timeout
): raise c except webdriver.error.WebDriverException as e: ifnot"could not be scrolled into view"in str(e): raise e
tries += 1 if tries == 5: raise e
time.sleep(0.5)
@contextlib.asynccontextmanager
async def monitor_for_fastclick_attachment(self):
fastclick_preload_script = await self.make_preload_script( """
// FastClick can check for document.documentElement.scrollWidth <= window.outerWidth
// in notNeeded, so let's force it to enable (as there may be devices where it's false).
window.wrappedJSObject.outerWidth--;
window.detected = false;
const { prototype } = window.wrappedJSObject.EventTarget;
const { addEventListener } = prototype;
prototype.addEventListener = function (type, fn, c, d) { if (type == "touchstart" && new Error().stack?.includes("attach@")) {
window.detected = true;
} try { return addEventListener.call(this, type, fn, c, d);
} catch(_) { // throws if attaching to window, since it's a sandbox, not EventTarget return addEventListener.call(window, type, fn, c, d);
}
}; """, "fastclick_detector",
) yield
fastclick_preload_script.stop()
def test_future_plc_trending_scrollbar(self, shouldFail=False):
trending_list = self.await_css(".trending__list") ifnot trending_list: raise ValueError("trending list is still where expected")
# First confirm that the scrollbar is the color the site specifies.
css_var_colors = self.execute_script( """
// first, force a scrollbar, as the content on each site might
// not always be wide enough to force a scrollbar to appear.
const list = arguments[0];
list.style.overflow = "scroll hidden !important";
const computedStyle = getComputedStyle(list); return [
computedStyle.getPropertyValue('--trending-scrollbar-color'),
computedStyle.getPropertyValue('--trending-scrollbar-background-color'),
]; """,
trending_list,
) ifnot css_var_colors[0] ornot css_var_colors[1]: raise ValueError("expected CSS vars are still used for scrollbar-color")
[expected, actual] = self.execute_script( """
const [list, cssVarColors] = arguments;
const sbColor = getComputedStyle(list).scrollbarColor;
// scrollbar-color is a two-color value wth no easy way to separate
// them and no way to be sure the value will remain consistent in
// the format "rgb(x, y, z) rgb(x, y, z)". Likewise, the colors the
// site specified in the CSS might be in hex format or any CSS color
// value. So rather than trying to normalize the values ourselves, we
// set the border-color of an element, which is also a two-color CSS
// value, and then also read it back through the computed style, so
// Firefox normalizes both colors the same way for us and lets us
// compare their equivalence as simple strings.
list.style.borderColor = sbColor;
const actual = getComputedStyle(list).borderColor;
list.style.borderColor = cssVarColors.join(" ");
const expected = getComputedStyle(list).borderColor; return [expected, actual]; """,
trending_list,
css_var_colors,
) if shouldFail: assert expected != actual, "scrollbar is not the correct color" else: assert expected == actual, "scrollbar is the correct color"
# Also check that the scrollbar does not cover any text (it may not # actually cover any text even without the intervention, so we skip # checking that case). To find out, we color the scrollbar the same as # the trending list's background, and compare screenshots of the # list with and without the scrollbar. This way if no text is covered, # the screenshots will not differ. ifnot shouldFail:
self.execute_script( """
const list = arguments[0];
const bgc = getComputedStyle(list).backgroundColor;
list.style.scrollbarColor = `${bgc} ${bgc}`; """,
trending_list,
)
time.sleep(0.5)
with_scrollbar = trending_list.screenshot()
self.execute_script( """
arguments[0].style.scrollbarWidth = "none"; """,
trending_list,
)
time.sleep(0.5)
without_scrollbar = trending_list.screenshot() assert with_scrollbar == without_scrollbar, ( "scrollbar does not cover any text"
)
await self.stall(0.5)
coords = self.get_element_screen_position(img)
coords = [coords[0] + 50, coords[1] + 100]
await self.apz_move(coords=coords) for _ in range(20): try:
old_x = float(get_zoom_x()) break except TypeError as e: if _ == 20: raise e
await self.stall(0.5)
for i in range(20):
coords = [coords[0] + 10, coords[1]]
await self.apz_move(coords=coords)
await self.stall(0.01)
x = float(get_zoom_x()) if x < old_x: returnFalse
old_x = x
returnTrue
def is_displayed(self, element): if element isNone: returnFalse
try: return self.session.execute_script( """
const e = arguments[0],
s = window.getComputedStyle(e),
v = s.visibility === "visible",
o = Math.abs(parseFloat(s.opacity)),
d = s.display === "contents" || e.getClientRects().length > 0; return d && v && (isNaN(o) || o === 1.0); """,
args=[element],
) except webdriver.error.StaleElementReferenceException: returnFalse
def is_one_solid_color(self, image, max_fuzz=8): # max_fuzz is needed as screenshots can have slight color bleeding/fringing if isinstance(image, webdriver.client.WebElement):
shotb64 = image.screenshot()
image = Image.open(BytesIO(b64decode(shotb64))).convert("RGB") for min, max in image.getextrema(): if max - min > max_fuzz: returnFalse returnTrue
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.