Skip to content

Highlight on failure #4197

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Browser/base/librarycomponent.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ def run_on_failure_keyword_stack(self) -> SettingsStack:
def run_on_failure_keyword_stack(self, stack: SettingsStack):
self.library.scope_stack["run_on_failure"] = stack

@property
def highlight_on_failure(self) -> bool:
return self.library.scope_stack["highlight_on_failure"].get()

@property
def highlight_on_failure_stack(self) -> SettingsStack:
return self.library.scope_stack["highlight_on_failure"]

@highlight_on_failure_stack.setter
def highlight_on_failure_stack(self, stack: SettingsStack):
self.library.scope_stack["highlight_on_failure"] = stack

@property
def assertion_formatter_stack(self) -> SettingsStack:
return self.library.scope_stack["assertion_formatter"]
Expand Down
47 changes: 45 additions & 2 deletions Browser/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
is_falsy,
keyword,
logger,
suppress_logging,
)

# Importing this directly from .utils break the stub type checks
Expand Down Expand Up @@ -802,6 +803,7 @@ def __init__( # noqa: PLR0915
] = PlaywrightLogTypes.library,
enable_presenter_mode: Union[HighLightElement, bool] = False,
external_browser_executable: Optional[dict[SupportedBrowsers, str]] = None,
highlight_on_failure: bool = False,
jsextension: Union[list[str], str, None] = None,
language: Optional[str] = None,
playwright_process_host: Optional[str] = None,
Expand All @@ -823,6 +825,7 @@ def __init__( # noqa: PLR0915
| ``enable_playwright_debug`` | Enable low level debug information from the playwright to playwright-log.txt file. For more details, see `PlaywrightLogTypes`. |
| ``enable_presenter_mode`` | Automatic highlights the interacted components, slowMo and a small pause at the end. Can be enabled by giving True or can be customized by giving a dictionary: `{"duration": "2 seconds", "width": "2px", "style": "dotted", "color": "blue"}` Where `duration` is time format in Robot Framework format, defaults to 2 seconds. `width` is width of the marker in pixels, defaults the `2px`. `style` is the style of border, defaults to `dotted`. `color` is the color of the marker, defaults to `blue`. By default, the call banner keyword is also enabled unless explicitly disabled. |
| ``external_browser_executable`` | Dict mapping name of browser to path of executable of a browser. Will make opening new browsers of the given type use the set executablePath. Currently only configuring of `chromium` to a separate executable (chrome, chromium and Edge executables all work with recent versions) works. |
| ``highlight_on_failure`` | If set to ``True``, will highlight the element in the screenshot when a keyword fails, by highlighting the selector used in the failed keyword. If set to ``False``, will not highlight the element. |
| ``jsextension`` | Path to Javascript modules exposed as extra keywords. The modules must be in CommonJS. It can either be a single path, a comma-separated lists of path or a real list of strings |
| ``language`` | Defines language which is used to translate keyword names and documentation. |
| ``playwright_process_host`` | Hostname / Host address which should be used when spawning the Playwright process. Defaults to 127.0.0.1. |
Expand Down Expand Up @@ -928,6 +931,9 @@ def __init__( # noqa: PLR0915
self.scope_stack["run_on_failure"] = SettingsStack(
self._parse_run_on_failure_keyword(run_on_failure), self
)
self.scope_stack["highlight_on_failure"] = SettingsStack(
highlight_on_failure, self
)
self.scope_stack["show_keyword_call_banner"] = SettingsStack(
show_keyword_call_banner, self
)
Expand Down Expand Up @@ -958,6 +964,10 @@ def show_keyword_call_banner(self):
def run_on_failure_keyword(self) -> DelayedKeyword:
return self.scope_stack["run_on_failure"].get()

@property
def highlight_on_failure(self) -> bool:
return self.scope_stack["highlight_on_failure"].get()

@property
def timeout(self):
return self.scope_stack["timeout"].get()
Expand Down Expand Up @@ -1257,7 +1267,8 @@ def run_keyword(self, name, args, kwargs=None):
self._playwright_state.open_trace_group(**(self.keyword_call_stack[-1]))
return DynamicCore.run_keyword(self, name, args, kwargs)
except AssertionError as e:
self.keyword_error()
selector = self._get_selector_value_from_keyword_call(name, args, kwargs)
self.keyword_error(selector)
e.args = self._alter_keyword_error(name, e.args)
if self.pause_on_failure and sys.__stdout__ is not None:
sys.__stdout__.write(f"\n[ FAIL ] {e}")
Expand All @@ -1274,6 +1285,24 @@ def run_keyword(self, name, args, kwargs=None):
):
self._playwright_state.close_trace_group()

def _get_selector_value_from_keyword_call(self, name, args, kwargs):
selector = kwargs.get("selector")
if not selector and args:
arguments = self.get_keyword_arguments(name)
for i, arg in enumerate(args):
if isinstance(arg, str) and arg.startswith("*"):
break
if (
isinstance(arguments[i], str)
and arguments[i].startswith("selector")
) or (
isinstance(arguments[i], tuple)
and arguments[i][0].startswith("selector")
):
selector = arg
break
return selector

def get_keyword_tags(self, name: str) -> list:
tags = list(DynamicCore.get_keyword_tags(self, name))
if name in self._plugin_keywords:
Expand Down Expand Up @@ -1452,14 +1481,21 @@ def set_keyword_call_banner(self, keyword_call=None):
)
)

def keyword_error(self):
def keyword_error(self, selector):
"""Runs keyword on failure."""
if self._running_on_failure_keyword or not self.run_on_failure_keyword.name:
return
self._running_on_failure_keyword = True
varargs = self.run_on_failure_keyword.args
kwargs = self.run_on_failure_keyword.kwargs
try:
if selector and self.highlight_on_failure:
with suppress_logging():
BuiltIn()._variables.set_suite(
"${ROBOT_FRAMEWORK_BROWSER_FAILING_SELECTOR}",
selector,
children=False,
)
if self.run_on_failure_keyword.name in self.keywords:
if (
self.run_on_failure_keyword.name == "take_screenshot"
Expand Down Expand Up @@ -1492,6 +1528,13 @@ def keyword_error(self):
"playwright-log.txt is not created, consider enabling it for debug reasons."
)
self._running_on_failure_keyword = False
if selector and self.highlight_on_failure:
with suppress_logging():
BuiltIn()._variables.set_suite(
"${ROBOT_FRAMEWORK_BROWSER_FAILING_SELECTOR}",
None,
children=False,
)

def _failure_screenshot_path(self):
valid_chars = f"-_.() {string.ascii_letters}{string.digits}"
Expand Down
109 changes: 80 additions & 29 deletions Browser/keywords/browser_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,28 @@
import json
import uuid
from collections.abc import Iterable
from contextlib import contextmanager
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Optional, Union

from robot.api.logger import LOGLEVEL
from robot.libraries.BuiltIn import BuiltIn
from robot.utils import get_link_path

from ..base import LibraryComponent
from ..generated.playwright_pb2 import Request
from ..utils import Scope, keyword, logger
from ..utils.data_types import (
from ..utils import (
BoundingBox,
HighlightMode,
PageLoadStates,
Permission,
Scale,
Scope,
ScreenshotFileTypes,
ScreenshotReturnType,
keyword,
logger,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -133,6 +138,7 @@ def take_screenshot(
disableAnimations: bool = False,
fileType: ScreenshotFileTypes = ScreenshotFileTypes.png,
fullPage: bool = False,
highlight_selector: Optional[str] = None,
log_screenshot: bool = True,
mask: Union[list[str], str] = "",
maskColor: Optional[str] = None,
Expand All @@ -151,6 +157,7 @@ def take_screenshot(
| ``disableAnimations`` | When set to ``True``, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on their duration: - finite animations are fast-forwarded to completion, so they'll fire transitionend event. - infinite animations are canceled to initial state, and then played over after the screenshot. |
| ``fileType`` | ``png`` or ``jpeg`` Specify screenshot type, defaults to ``png`` . |
| ``fullPage`` | When True, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to False. |
| ``highlight_selector`` | Highlights elements while taking the screenshot. Highlight method is ``playwright``. This highlighting also automatically happens if the Robot Framework variable ``${ROBOT_FRAMEWORK_BROWSER_FAILING_SELECTOR}`` is set to a selector string and is available on page. This is the case if ``highlight_on_failure`` has been set to ``True`` when importing Browser library. |
| ``log_screenshot`` | When set to ``False`` the screenshot is taken but not logged into log.html. |
| ``mask`` | Specify selectors that should be masked when the screenshot is taken. Masked elements will be overlayed with a pink box ``#FF00FF`` that completely covers its bounding box. Argument can take a single selector string or a list of selector strings if multiple different elements should be masked. |
| ``maskColor`` | Specify the color of the overlay box for masked elements, in CSS color format. Default color is pink #FF00FF. |
Expand All @@ -172,35 +179,36 @@ def take_screenshot(

[https://forum.robotframework.org/t//4337|Comment >>]
"""
file_name = (
uuid.uuid4().hex
if filename is None or self._is_embed(filename)
else filename
)
string_path_no_extension = str(
self._get_screenshot_path(file_name, fileType.name)
)
with self.playwright.grpc_channel() as stub:
options = self._create_screenshot_options(
crop,
disableAnimations,
fileType,
fullPage,
omitBackground,
quality,
string_path_no_extension,
timeout,
maskColor,
scale,
with self._highlighting(highlight_selector):
file_name = (
uuid.uuid4().hex
if filename is None or self._is_embed(filename)
else filename
)
response = stub.TakeScreenshot(
Request().ScreenshotOptions(
selector=self.resolve_selector(selector) or "",
mask=json.dumps(self._get_mask_selectors(mask)),
options=json.dumps(options),
strict=self.strict_mode,
)
string_path_no_extension = str(
self._get_screenshot_path(file_name, fileType.name)
)
with self.playwright.grpc_channel() as stub:
options = self._create_screenshot_options(
crop,
disableAnimations,
fileType,
fullPage,
omitBackground,
quality,
string_path_no_extension,
timeout,
maskColor,
scale,
)
response = stub.TakeScreenshot(
Request().ScreenshotOptions(
selector=self.resolve_selector(selector) or "",
mask=json.dumps(self._get_mask_selectors(mask)),
options=json.dumps(options),
strict=self.strict_mode,
)
)
logger.debug(response.log)
screenshot_path_str = response.body
screenshot_path = Path(screenshot_path_str)
Expand Down Expand Up @@ -230,6 +238,30 @@ def take_screenshot(
return base64_screenshot.decode()
return None

@contextmanager
def _highlighting(self, highlight_selector: Optional[str]):
"""Context manager to temporarily set the log level."""
failing_selector = BuiltIn().get_variable_value(
"${ROBOT_FRAMEWORK_BROWSER_FAILING_SELECTOR}", None
)
if highlight_selector or failing_selector:
if failing_selector:
logger.info(f"Highlighting failing selector: {failing_selector}")
self.library.highlight_elements(
highlight_selector or failing_selector,
duration=timedelta(seconds=0),
mode=HighlightMode.playwright,
)
try:
yield
finally:
if highlight_selector or failing_selector:
self.library.highlight_elements(
"",
duration=timedelta(seconds=0),
mode=HighlightMode.playwright,
)

def _create_screenshot_options(
self,
crop,
Expand Down Expand Up @@ -412,6 +444,25 @@ def set_selector_prefix(
self.selector_prefix_stack.set(prefix or "", scope)
return old_prefix

@keyword(tags=("Setter", "Config"))
def set_highlight_on_failure(
self, highlight: bool = True, scope: Scope = Scope.Suite
) -> bool:
"""Controls if the element is highlighted on failure.

| =Arguments= | =Description= |
| ``highlight`` | If `True` element is highlighted on failure during a screenshot is taken. If `False` element is not highlighted in the screenshot. |
| ``scope`` | Scope defines the live time of that setting. Available values are ``Global``, ``Suite`` or ``Test`` / ``Task``. See `Scope` for more details. |

Example:
| `Set Highlight On Failure` True

[https://forum.robotframework.org/t//4740|Comment >>] #TODO add real link
"""
old_highlight_on_failure = self.highlight_on_failure
self.highlight_on_failure_stack.set(highlight, scope)
return old_highlight_on_failure

@keyword(tags=("Setter", "Config"))
def show_keyword_banner(
self, show: bool = True, style: str = "", scope: Scope = Scope.Suite
Expand Down
13 changes: 8 additions & 5 deletions Browser/keywords/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ def highlight_elements(
with self.playwright.grpc_channel() as stub:
response = stub.HighlightElements(
Request().ElementSelectorWithDuration(
selector=self.resolve_selector(selector),
selector=self.resolve_selector(selector)
if selector
else "ROBOT_FRAMEWORK_BROWSER_NO_ELEMENT",
duration=int(self.convert_timeout(duration)),
width=width,
style=style,
Expand All @@ -125,10 +127,11 @@ def highlight_elements(
)
)
count: int = response.body
if count == 0:
logger.info("Could not find elements to highlight.")
else:
logger.info(response.log)
if selector:
if count == 0:
logger.info("Could not find elements to highlight.")
else:
logger.info(response.log)
return count

@keyword(tags=("Setter", "PageContent"))
Expand Down
18 changes: 11 additions & 7 deletions Browser/keywords/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def save_page_as_pdf(
scale: float = 1,
tagged: bool = False,
width: str = "0px",
):
) -> str:
"""Saves page as PDF.

Saving a pdf is currently only supported in Chromium headless.
Expand Down Expand Up @@ -96,6 +96,8 @@ def save_page_as_pdf(
headerTemplate and footerTemplate markup have the following limitations: > 1.
Script tags inside templates are not evaluated. > 2. Page styles are not visible inside templates.

Returns the path to the saved PDF file.

More details can be found from [https://playwright.dev/docs/api/class-page#page-pdf|Playwright pdf documentation]

Example:
Expand Down Expand Up @@ -154,15 +156,17 @@ def emulate_media(
forcedColors: Union[ForcedColors, NotSet] = NotSet.not_set,
media: Optional[Media] = None,
reducedMotion: Optional[ReducedMotion] = None,
):
) -> None:
"""Changes the CSS media type.

``CSS media type`` is changed through the media argument,
and/or the 'prefers-colors-scheme' media feature, using
the colorScheme argument.
It changes the CSS media type through the media argument, and/or the 'prefers-colors-scheme' media feature, using the colorScheme argument.
This is useful to render the page in the correct format before using `Save Page As Pdf` keyword.

Useful to render the page in correct format before using
`Save Page As Pdf` keyword.
| =Arguments= | =Description= |
| ``colorScheme`` | Emulates prefers-colors-scheme media feature, supported values are 'light' and 'dark'. Passing null disables color scheme emulation. 'no-preference' is deprecated. |
| ``forcedColors`` | Emulates 'forced-colors' media feature, supported values are 'active' and 'none'. Passing null disables forced colors emulation. |
| ``media`` | Changes the CSS media type of the page. The only allowed values are 'screen', 'print' and null. Passing null disables CSS media emulation. |
| ``reducedMotion`` | Emulates 'prefers-reduced-motion' media feature, supported values are 'reduce', 'no-preference'. Passing null disables reduced motion emulation. |
"""
with self.playwright.grpc_channel() as stub:
response = stub.EmulateMedia(
Expand Down
5 changes: 5 additions & 0 deletions Browser/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .data_types import (
AutoClosingLevel,
BrowserInfo,
BoundingBox,
ColorScheme,
ConditionInputs,
CookieSameSite,
Expand Down Expand Up @@ -50,7 +51,10 @@
ReduceMotion,
ReducedMotion,
RequestMethod,
Scale,
Scope,
ScreenshotFileTypes,
ScreenshotReturnType,
ScrollPosition,
SelectAttribute,
SelectOptions,
Expand All @@ -69,6 +73,7 @@
is_same_keyword,
keyword,
spawn_node_process,
suppress_logging,
)
from .robot_booleans import is_falsy, is_truthy
from .settings_stack import ScopedSetting, SettingsStack
Expand Down
Loading