#
# Copyright (C) 2013 Red Hat, Inc.
# SPDX-License-Identifier: LGPL-2.1-or-later
"""Tools for writing Cockpit test cases."""
import argparse
import asyncio
import base64
import contextlib
import errno
import fnmatch
import glob
import io
import json
import logging
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import traceback
import unittest
from collections.abc import Collection, Container, Coroutine, Iterator, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, ClassVar, Literal, TypedDict, TypeVar
import webdriver_bidi
from lcov import write_lcov
from lib.constants import OSTREE_IMAGES
from machine import testvm
from PIL import Image
from pixelmatch.contrib.PIL import pixelmatch
_T = TypeVar('_T')
_FT = TypeVar("_FT", bound=Callable[..., Any])
JsonObject = dict[str, Any]
BASE_DIR = os.path.realpath(f'{__file__}/../../..')
TEST_DIR = f'{BASE_DIR}/test'
BOTS_DIR = f'{BASE_DIR}/bots'
os.environ["PATH"] = f"{os.environ.get('PATH')}:{BOTS_DIR}:{TEST_DIR}"
# Be careful when changing this string, check in cockpit-project/bots where it is being used
UNEXPECTED_MESSAGE = "FAIL: Test completed, but found unexpected "
PIXEL_TEST_MESSAGE = "Some pixel tests have failed"
__all__ = (
'PIXEL_TEST_MESSAGE',
'TEST_DIR',
'UNEXPECTED_MESSAGE',
'Browser',
'Error',
'MachineCase',
'arg_parser',
'destructive',
'no_retry_when_changed',
'nondestructive',
'onlyImage',
'opts',
'sit',
'skipBrowser',
'skipImage',
'skipOstree',
'test_main',
'timeout',
'todo',
'wait',
)
# Command line options
opts = argparse.Namespace()
opts.sit = False
opts.trace = False
opts.attachments = None
opts.revision = None
opts.address = None
opts.fetch = True
opts.coverage = False
# https://w3c.github.io/webdriver/#keyboard-actions for encoding key names
WEBDRIVER_KEYS = {
"Backspace": "\uE003",
"Tab": "\uE004",
"Return": "\uE006",
"Enter": "\uE007",
"Shift": "\uE008",
"Control": "\uE009",
"Alt": "\uE00A",
"Escape": "\uE00C",
"Space": "\uE00D",
"PageUp": "\uE00E",
"PageDown": "\uE00F",
"End": "\uE010",
"Home": "\uE011",
"ArrowLeft": "\uE012",
"ArrowUp": "\uE013",
"ArrowRight": "\uE014",
"ArrowDown": "\uE015",
"Insert": "\uE016",
"Delete": "\uE017",
"Meta": "\uE03D",
"F2": "\uE032",
}
# Browser layouts
#
# A browser can be switched into a number of different layouts, such
# as "desktop" and "mobile". A default set of layouts is defined
# here, but projects can override this with a file called
# "test/browser-layouts.json".
#
# Each layout defines the size of the shell (where the main navigation
# is) and also the size of the content iframe (where the actual page
# like "Networking" or "Overview" is displayed).
#
# When the browser layout is switched (by calling Browset.set_layout),
# this will either set the shell size or the content size, depending
# on which frame is current (as set by Browser.enter_page or
# Browser.leave_page).
#
# This makes sure that pixel tests for the whole content iframe are
# always the exact size as specified in the layout definition, and
# don't change size when the navigation stuff in the shell changes.
#
# The browser starts out in the first layout of this list, which is
# "desktop" by default.
class BrowserLayout(TypedDict):
name: str
theme: Literal["light"] | Literal["dark"]
shell_size: tuple[int, int]
content_size: tuple[int, int]
default_layouts: Sequence[BrowserLayout] = (
{
"name": "desktop",
"theme": "light",
"shell_size": (1920, 1200),
"content_size": (1680, 1130)
},
{
"name": "medium",
"theme": "light",
"shell_size": (1280, 768),
"content_size": (1040, 698)
},
{
"name": "mobile",
"theme": "light",
"shell_size": (414, 1920),
"content_size": (414, 1856)
},
{
"name": "dark",
"theme": "dark",
"shell_size": (1920, 1200),
"content_size": (1680, 1130)
},
{
"name": "rtl",
"theme": "light",
"shell_size": (1920, 1200),
"content_size": (1680, 1130)
},
)
def attach(filename: str, move: bool = False) -> None:
"""Put a file into the attachments directory.
:param filename: file to put in attachments directory
:param move: set this to true to move dynamically generated files which
are not touched by destructive tests. (default False)
"""
if not opts.attachments:
return
dest = os.path.join(opts.attachments, os.path.basename(filename))
if os.path.exists(filename) and not os.path.exists(dest):
if move:
shutil.move(filename, dest)
else:
shutil.copy(filename, dest)
def unique_filename(base: str, ext: str) -> str:
for i in range(20):
if i == 0:
f = f"{base}.{ext}"
else:
f = f"{base}-{i}.{ext}"
if not os.path.exists(f):
return f
return f"{base}.{ext}"
class Browser:
driver: webdriver_bidi.WebdriverBidi
browser: str
layouts: Sequence[BrowserLayout]
current_layout: BrowserLayout | None
port: str | int
def __init__(
self,
address: str,
label: str,
machine: 'MachineCase',
pixels_label: str | None = None,
coverage_label: str | None = None,
port: int | str | None = None
) -> None:
if ":" in address:
self.address, _, self.port = address.rpartition(":")
else:
self.address = address
self.port = 9090
if port is not None:
self.port = port
self.default_user = "admin"
self.label = label
self.pixels_label = pixels_label
self.used_pixel_references = set[str]()
self.coverage_label = coverage_label
self.machine = machine
# HACK: Tests which don't yet get along with real mouse clicks in Chromium
# can opt into falling back to the old MouseEvent emulation; this is cheating, but fixing
# all the tests at once is too much work. Remove this once all tests in all our projects
# got fixed.
self.chromium_fake_mouse = False
headless = os.environ.get("TEST_SHOW_BROWSER", '0') == '0'
self.browser = os.environ.get("TEST_BROWSER", "chromium")
if self.browser == "chromium":
self.driver = webdriver_bidi.ChromiumBidi(headless=headless)
elif self.browser == "firefox":
self.driver = webdriver_bidi.FirefoxBidi(headless=headless)
else:
raise ValueError(f"unknown browser {self.browser}")
self.loop = asyncio.new_event_loop()
self.bidi_thread = threading.Thread(target=self.asyncio_loop_thread, args=(self.loop,))
self.bidi_thread.start()
self.run_async(self.driver.start_session())
if opts.trace:
logging.basicConfig(level=logging.INFO)
webdriver_bidi.log_command.setLevel(logging.INFO if opts.trace else logging.WARNING)
# not appropriate for --trace, just enable for debugging low-level protocol with browser
# webdriver_bidi.log_proto.setLevel(logging.DEBUG)
test_functions = (Path(__file__).parent / "test-functions.js").read_text()
# Don't redefine globals, this confuses Firefox
test_functions = "if (window.ph_select) return; " + test_functions
self.bidi("script.addPreloadScript", quiet=True, functionDeclaration=f"() => {{ {test_functions} }}")
try:
sizzle_js = (Path(__file__).parent.parent.parent / "node_modules/sizzle/dist/sizzle.js").read_text()
# HACK: injecting sizzle fails on missing `document` in assert()
sizzle_js = sizzle_js.replace('function assert( fn ) {',
'function assert( fn ) { if (true) return true; else ')
# HACK: sizzle tracks document and when we switch frames, it sees the old document
# although we execute it in different context.
sizzle_js = sizzle_js.replace('context = context || document;', 'context = context || window.document;')
self.bidi("script.addPreloadScript", quiet=True, functionDeclaration=f"() => {{ {sizzle_js} }}")
except FileNotFoundError:
pass
if coverage_label:
self.cdp_command("Profiler.enable")
self.cdp_command("Profiler.startPreciseCoverage", callCount=False, detailed=True)
self.password = "foobar"
self.timeout_factor = float(os.getenv("TEST_TIMEOUT_FACTOR", "1"))
self.timeout = 15
self.failed_pixel_tests = 0
self.allow_oops = False
try:
with open(f'{TEST_DIR}/browser-layouts.json') as fp:
self.layouts = json.load(fp)
except FileNotFoundError:
self.layouts = default_layouts
self.current_layout = None
def _is_running(self) -> bool:
"""True initially, false after calling .kill()"""
return self.driver.bidi_session is not None
def have_test_api(self) -> bool:
"""Check if the browser is running and has a Cockpit page
I.e. are our test-functions.js available? This is only true after
opening cockpit, not for the initial blank page (before login_and_go)
or other URLs like Grafana.
"""
if not self._is_running():
return False
return self.eval_js("!!window.ph_find")
def run_async(self, coro: Coroutine[Any, Any, Any]) -> JsonObject:
"""Run coro in main loop in our BiDi thread
Wait for the result and return it.
"""
return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
@staticmethod
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
asyncio.set_event_loop(loop)
loop.run_forever()
def kill(self) -> None:
if not self._is_running():
return
self.run_async(self.driver.close())
self.loop.call_soon_threadsafe(self.loop.stop)
self.bidi_thread.join()
def bidi(self, method: str, **params: Any) -> webdriver_bidi.JsonObject:
"""Send a Webdriver BiDi command and return the JSON response"""
try:
return self.run_async(self.driver.bidi(method, **params))
except webdriver_bidi.WebdriverError as e:
raise Error(str(e)) from None
def cdp_command(self, method: str, **params: Any) -> webdriver_bidi.JsonObject:
"""Send a Chrome DevTools Protocol command and return the JSON response"""
if self.browser == "chromium":
assert isinstance(self.driver, webdriver_bidi.ChromiumBidi)
reply = self.run_async(self.driver.cdp(method, **params))
if 'error' in reply:
raise Error(str(reply['error'])) from None
return reply['result']
else:
raise webdriver_bidi.WebdriverError("CDP is only supported in Chromium")
def open(self, href: str, cookie: Mapping[str, str] | None = None, tls: bool = False) -> None:
"""Load a page into the browser.
:param href: the path of the Cockpit page to load, such as "/users". Either PAGE or URL needs to be given.
:param cookie: a dictionary object representing a cookie.
:param tls: load the page using https (default False)
Raises:
Error: When a timeout occurs waiting for the page to load.
"""
if href.startswith("/"):
schema = "https" if tls else "http"
href = f"{schema}://{self.address}:{self.port}{href}"
if not self.current_layout and os.environ.get("TEST_SHOW_BROWSER") in [None, "pixels"]:
self.current_layout = self.layouts[0]
size = self.current_layout["shell_size"]
self._set_window_size(size[0], size[1])
if cookie:
c = {**cookie, "value": {"type": "string", "value": cookie["value"]}}
self.bidi("storage.setCookie", cookie=c)
self.switch_to_top()
# Some browsers optimize this away if the current URL is already href
# (e.g. in TestKeys.testAuthorizedKeys). Load the blank page first to always
# force a load.
self.bidi("browsingContext.navigate", context=self.driver.context, url="about:blank", wait="complete")
self.bidi("browsingContext.navigate", context=self.driver.context, url=href, wait="complete", timeout=30)
def set_user_agent(self, ua: str) -> None:
"""Set the user agent of the browser
:param ua: user agent string
:type ua: str
"""
if self.browser == "chromium":
self.cdp_command("Emulation.setUserAgentOverride", userAgent=ua)
else:
raise NotImplementedError
def reload(self, ignore_cache: bool = False) -> None:
"""Reload the current page
:param ignore_cache: if true browser cache is ignored (default False)
:type ignore_cache: bool
"""
self.switch_to_top()
self.wait_js_cond("ph_select('iframe.container-frame').every(e => e.getAttribute('data-loaded'))")
if self.browser == "firefox":
if ignore_cache:
webdriver_bidi.log_command.warning(
"Browser.reload(): ignore_cache==True not yet supported with Firefox, ignoring")
self.bidi("browsingContext.reload", context=self.driver.context, wait="complete")
else:
self.bidi("browsingContext.reload", context=self.driver.context, ignoreCache=ignore_cache,
wait="complete")
self.machine.allow_restart_journal_messages()
def switch_to_frame(self, name: str | None) -> None:
"""Switch to frame in browser tab
Each page has a main frame and can have multiple subframes, usually
iframes.
:param name: frame name
"""
if name is None:
self.switch_to_top()
else:
self.run_async(self.driver.switch_to_frame(name))
def switch_to_top(self) -> None:
"""Switch to the main frame
Switch to the main frame from for example an iframe.
"""
self.driver.switch_to_top()
def allow_download(self) -> None:
"""Allow browser downloads"""
# this is only necessary for headless chromium
if self.browser == "chromium":
self.cdp_command("Browser.setDownloadBehavior", behavior="allow",
downloadPath=str(self.driver.download_dir))
def upload_files(self, selector: str, files: Sequence[str]) -> None:
"""Upload a local file to the browser
The selector should select the element.
Files is a list of absolute paths to files which should be uploaded.
"""
element = self.eval_js(f"ph_find({jsquote(selector)})")
self.bidi("input.setFiles", context=self.driver.context, element=element, files=files)
def eval_js(self, code: str, no_trace: bool = False) -> Any:
"""Execute JS code that returns something
:param code: a string containing JavaScript code
:param no_trace: do not print information about unknown return values (default False)
"""
return self.bidi("script.evaluate", expression=code, quiet=no_trace,
awaitPromise=True, target={"context": self.driver.context})["result"]
def call_js_func(self, func: str, *args: object) -> Any:
"""Call a JavaScript function
:param func: JavaScript function to call
:param args: arguments for the JavaScript function
"""
return self.eval_js("{}({})".format(func, ','.join(map(jsquote, args))))
def set_mock(self, mock: Mapping[str, str], base: str = "") -> None:
"""Replace some DOM elements with mock text
The 'mock' parameter is a dictionary from CSS selectors to the
text that the elements matching the selector should be
replaced with.
XXX - There is no way to easily undo the effects of this
function. There is no coordination with React. This
will improve as necessary.
:param mock: the mock data, see above
:param base: if given, all selectors are relative to this one
"""
self.call_js_func('ph_set_texts', {base + " " + k: v for k, v in mock.items()})
def cookie(self, name: str) -> Mapping[str, object] | None:
"""Retrieve a browser cookie by name
:param name: the name of the cookie
:type name: str
"""
cookies = self.bidi("storage.getCookies", filter={"name": name})["cookies"]
if len(cookies) > 0:
c = cookies[0]
# if we ever need to handle "base64", add that
assert c["value"]["type"] == "string"
c["value"] = c["value"]["value"]
return c
return None
def go(self, path_hash: str) -> None:
"""Change URL to a different path or hash
This must be called with either a path (starting with `/`) or hash (starting with `#`).
"""
if path_hash.startswith("#"):
self.call_js_func('cockpit.location.go', path_hash[1:])
elif path_hash.startswith("/"):
# this behaves like clicking in the shell menu, i.e. cockpit.jump()
self.call_js_func('ph_jump', path_hash)
else:
raise ValueError(f"go({path_hash}): must be called with a path or hash value")
def mouse(
self,
selector: str,
event: str,
x: int | None = None,
y: int | None = None,
btn: int = 0,
*,
ctrlKey: bool = False,
shiftKey: bool = False,
altKey: bool = False,
metaKey: bool = False,
scrollVisible: bool = True,
synthetic: bool = False,
) -> None:
"""Do a mouse event in the browser.
:param selector: the element to interact with
:param type: click, dblclick, mousemove; you can also use "mouseenter" (alias for mousemove) or "mouseleave"
(but this is just a heuristic by moving the mouse 500x500 pixels away; prefer moving to an explicit
target)
:param x, y: coordinates; when not given, default to center of element
:param btn: mouse button to click https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
:param crtlKey: press the ctrl key
:param shiftKey: press the shift key
:param altKey: press the alt key
:param metaKey: press the meta key
:param scrollVisible: set to False in rare cases where scrolling an element into view triggers side effects
"""
self.wait_visible(selector)
# TODO: x and y are not currently implemented: webdriver (0, 0) is the element's center, not top left corner
# in these cases, use the old MouseEvent emulation
if x is not None or y is not None:
synthetic = True
if self.browser == "chromium" and self.chromium_fake_mouse:
synthetic = True
if synthetic:
self.call_js_func('ph_mouse', selector, event, x or 0, y or 0, btn, ctrlKey, shiftKey, altKey, metaKey)
return
# in the general case, use the BiDi API, which is more realistic -- it doesn't sidestep the browser
ev_id = f"pointer-{self.driver.last_id}"
def key(type_: str, name: str) -> JsonObject:
return {"type": "key", "id": ev_id + type_, "actions": [{"type": type_, "value": WEBDRIVER_KEYS[name]}]}
for _retry in range(3):
element = self.call_js_func('ph_find_scroll_into_view' if scrollVisible else 'ph_find', selector)
actions: list[JsonObject] = [
{"type": "pointerMove", "x": 0, "y": 0, "origin": {"type": "element", "element": element}}
]
down = {"type": "pointerDown", "button": btn}
up = {"type": "pointerUp", "button": btn}
if event == "click":
actions.extend([down, up])
elif event == "dblclick":
actions.extend([down, up, down, up])
elif event in ["mousemove", "mouseenter"]:
pass
elif event == "mouseleave":
# move the mouse someplace else
actions = [{"type": "pointerMove", "x": 500, "y": 500, "origin": "pointer"}]
else:
raise NotImplementedError(f"unknown event {event}")
# modifier keys
keys_pre: list[JsonObject] = []
keys_post: list[JsonObject] = []
if altKey:
keys_pre.append(key("keyDown", "Alt"))
keys_post.append(key("keyUp", "Alt"))
if ctrlKey:
keys_pre.append(key("keyDown", "Control"))
keys_post.append(key("keyUp", "Control"))
if shiftKey:
keys_pre.append(key("keyDown", "Shift"))
keys_post.append(key("keyUp", "Shift"))
if metaKey:
keys_pre.append(key("keyDown", "Meta"))
keys_post.append(key("keyUp", "Meta"))
# the actual mouse event
actions = [{
"id": ev_id,
"type": "pointer",
"parameters": {"pointerType": "mouse"},
"actions": actions,
}]
try:
self.bidi("input.performActions", context=self.driver.context, actions=keys_pre + actions + keys_post)
break
except Error as e:
# race condition: if the page re-renders after the wait_visible() but before the mouse event,
# the element might not be present anymore; retry then
if "no such element" in str(e):
webdriver_bidi.log_command.warning(
"Browser.mouse(%r, %r): retrying after %s", selector, event, e.msg)
# retry, but don't scroll again
scrollVisible = False
else:
raise
def click(self, selector: str) -> None:
"""Click on a ui element
:param selector: the selector to click on
"""
self.mouse(selector + ":not([disabled]):not([aria-disabled=true])", "click")
def val(self, selector: str) -> Any:
"""Get the value attribute of a selector.
:param selector: the selector to get the value of
"""
self.wait_visible(selector)
return self.call_js_func('ph_val', selector)
def set_val(self, selector: str, val: object) -> None:
"""Set the value attribute of a non disabled DOM element.
This also emits a change DOM change event.
:param selector: the selector to set the value of
:param val: the value to set
"""
self.wait_visible(selector + ':not([disabled]):not([aria-disabled=true])')
self.call_js_func('ph_set_val', selector, val)
def text(self, selector: str) -> str:
"""Get an element's textContent value.
:param selector: the selector to get the value of
"""
self.wait_visible(selector)
return self.call_js_func('ph_text', selector) or ''
def attr(self, selector: str, attr: str) -> Any:
"""Get the value of a given attribute of an element.
:param selector: the selector to get the attribute of
:param attr: the DOM element attribute
"""
self._wait_present(selector)
return self.call_js_func('ph_attr', selector, attr)
def set_attr(self, selector: str, attr: str, val: object) -> None:
"""Set an attribute value of an element.
:param selector: the selector
:param attr: the element attribute
:param val: the value of the attribute
"""
self._wait_present(selector + ':not([disabled]):not([aria-disabled=true])')
self.call_js_func('ph_set_attr', selector, attr, val)
def get_checked(self, selector: str) -> bool:
"""Get checked state of a given selector.
:param selector: the selector
:return: the checked state
"""
self.wait_visible(selector + ':not([disabled]):not([aria-disabled=true])')
return self.call_js_func('ph_get_checked', selector)
def set_checked(self, selector: str, val: bool) -> None:
"""Set checked state of a given selector.
:param selector: the selector
:param val: boolean value to enable or disable checkbox
"""
# avoid ph_set_checked, that doesn't use proper mouse emulation
checked = self.get_checked(selector)
if checked != val:
self.click(selector)
def focus(self, selector: str) -> None:
"""Set focus on selected element.
:param selector: the selector
"""
self.wait_visible(selector + ':not([disabled]):not([aria-disabled=true])')
self.call_js_func('ph_focus', selector)
def blur(self, selector: str) -> None:
"""Remove keyboard focus from selected element.
:param selector: the selector
"""
self.wait_visible(selector + ':not([disabled]):not([aria-disabled=true])')
self.call_js_func('ph_blur', selector)
def input_text(self, text: str) -> None:
actions: list[JsonObject] = []
for c in text:
# quality-of-life special case
if c == '\n':
c = WEBDRIVER_KEYS["Enter"]
actions.append({"type": "keyDown", "value": c})
actions.append({"type": "keyUp", "value": c})
self.bidi("input.performActions", context=self.driver.context, actions=[
{"type": "key", "id": "key-0", "actions": actions}])
def key(self, name: str, repeat: int = 1, modifiers: list[str] | None = None) -> None:
"""Press and release a named keyboard key.
Use this function to input special characters or modifiers.
:param name: ASCII value or key name like "Enter", "Delete", or "ArrowLeft" (entry in WEBDRIVER_KEYS)
:param repeat: number of times to repeat this key (default 1)
:param modifiers: "Shift", "Control", "Alt"
"""
actions: list[JsonObject] = []
actions_pre: list[JsonObject] = []
actions_post: list[JsonObject] = []
keycode = WEBDRIVER_KEYS.get(name, name)
for m in (modifiers or []):
actions_pre.append({"type": "keyDown", "value": WEBDRIVER_KEYS[m]})
actions_post.append({"type": "keyUp", "value": WEBDRIVER_KEYS[m]})
for _ in range(repeat):
actions.append({"type": "keyDown", "value": keycode})
actions.append({"type": "keyUp", "value": keycode})
self.bidi("input.performActions", context=self.driver.context, actions=[
{"type": "key", "id": "key-0", "actions": actions_pre + actions + actions_post}])
def paste(self, val: str) -> None:
"""Simulate pasting a string into the currently focused element.
:param val: The text to paste. It will be left in the clipboard.
"""
self.call_js_func("((val) => navigator.clipboard.writeText(val))", val)
self.key("v", modifiers=["Control"])
def select_from_dropdown(self, selector: str, value: object) -> None:
"""For an actual