diff --git a/Browser/browser.py b/Browser/browser.py index 713a18bd6..0f0c90e59 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -26,7 +26,7 @@ from copy import copy from datetime import timedelta from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, get_type_hints from assertionengine import AssertionOperator from overrides import overrides @@ -64,7 +64,7 @@ ) from .keywords.crawling import Crawling from .playwright import Playwright -from .python_arguments import add_argument_conversion +from .python_arguments import add_argument_conversion, converting_proxy from .utils import ( AutoClosingLevel, PlaywrightLogTypes, @@ -912,13 +912,15 @@ def adopted_context_options( They follow the rules of `New Context`: ``tracing`` gives the trace path or folder, also when ``ROBOT_FRAMEWORK_BROWSER_TRACING`` is set. - ``recordVideo`` gets the video folder, Browser's video folder by default, and - the size, and keeps its other keys, so an empty dictionary records with the - defaults. Without a ``size``, the video has the size of ``viewport``, the - viewport that the extension creates the context with, or 1280x720. A - relative ``path`` of ``recordHar`` becomes relative to the output directory. - ``None`` means off. ``tracesDir`` is a new folder for Playwright's temporary - trace files, as Browser gives one to each browser that it launches. + ``recordVideo``, ``recordHar`` and ``viewport`` are converted like the + arguments of `New Context`, so an unknown key fails. ``recordVideo`` gets + the video folder, Browser's video folder by default, and the size, so an + empty dictionary records with the defaults. Without a ``size``, the video + has the size of ``viewport``, the viewport that the extension creates the + context with, or 1280x720. A relative ``path`` of ``recordHar`` becomes + relative to the output directory. ``None`` means off. ``tracesDir`` is a + new folder for Playwright's temporary trace files, as Browser gives one to + each browser that it launches. The keys can be passed to the extension function as keyword arguments: ``tracing`` for the ``tracing`` option of ``adoptContext``, ``recordVideo`` @@ -926,7 +928,8 @@ def adopted_context_options( ``tracesDir`` when it launches a browser or an application. After the extension returns, call ``register_adopted_context`` with these options. """ - return self._playwright_state.adopted_context_options( + options = self._playwright_state.adopted_context_options + return converting_proxy(options, get_type_hints(options))( tracing, recordVideo, recordHar, viewport ) diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 1b44406b6..9dd23ee17 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -758,7 +758,8 @@ def new_context( Make sure to close the context for the HAR to be saved. - ``recordVideo``: Enables video recording for all pages into a folder. If not specified videos are not recorded. Make sure to close the context for - videos to be saved. Video is not supported in remote browsers. + videos to be saved. Video is not supported in remote browsers. See + `RecordVideo` for the folder, size, frame rate and action annotations. - ``reducedMotion``: Emulates the ``prefers-reduced-motion`` media feature, supported values are ``reduce`` and ``no-preference``. Defaults to ``no-preference``. @@ -1139,7 +1140,7 @@ def _register_context( self, context_id: str, trace_file: str, - video_size: dict, + video_size: ViewportDimensions, video: dict | None = None, ) -> str: self.add_context_and_keyword_call_stack_to_trace(trace_file, context_id) @@ -1148,9 +1149,9 @@ def _register_context( def _record_video( self, - record_video: Mapping[str, Any] | None, + record_video: RecordVideo | None, viewport: Mapping[str, Any] | None, - ) -> dict[str, Any] | None: + ) -> RecordVideo | None: if record_video is None: return None video_dir = record_video.get("dir") @@ -1170,15 +1171,15 @@ def _video_size( self, record_video: Mapping[str, Any] | None, viewport: Mapping[str, Any] | None, - ) -> dict: + ) -> ViewportDimensions: size = (record_video or {}).get("size") or {} if size.get("width") and size.get("height"): return {"width": int(size["width"]), "height": int(size["height"])} - return dict(viewport) if viewport else {"width": 1280, "height": 720} + if viewport: + return {"width": viewport["width"], "height": viewport["height"]} + return {"width": 1280, "height": 720} - def _record_har( - self, record_har: Mapping[str, Any] | None - ) -> dict[str, Any] | None: + def _record_har(self, record_har: RecordHar | None) -> RecordHar | None: if record_har is None: return None if not record_har.get("path"): diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index 63aa6cb6f..d3afa38c7 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -686,16 +686,59 @@ class Dimensions(ViewportDimensions): """Dimensions of an object in pixels.""" +class ShowActionsPosition(str, Enum): + top_left = "top-left" + top = "top" + top_right = "top-right" + bottom_left = "bottom-left" + bottom = "bottom" + bottom_right = "bottom-right" + + +class ShowActionsCursor(str, Enum): + pointer = "pointer" + none = "none" + + +class ShowActionsStyle(TypedDict, total=False): + point: str + highlight: str + title: str + + +class ShowActions(TypedDict, total=False): + duration: int + position: ShowActionsPosition + cursor: ShowActionsCursor + style: ShowActionsStyle + + class RecordVideo(TypedDict, total=False): """Enables Video recording + | =Key= | =Description= | + | ``dir`` | Folder of the videos. | + | ``size`` | `ViewportDimensions` of the videos. | + | ``fps`` | Frame rate of the videos. Defaults to 25. Firefox and WebKit record at most 25 frames per second. | + | ``showActions`` | Annotates the interacted elements in the videos, with the keys below. | + + Keys of ``showActions``: + | =Key= | =Description= | + | ``duration`` | How long each annotation is shown, in milliseconds. Defaults to 500. | + | ``position`` | Position of the action title: ``top-left``, ``top``, ``top-right`` (default), ``bottom-left``, ``bottom`` or ``bottom-right``. | + | ``cursor`` | ``pointer`` (default) shows a mouse pointer that moves from one action point to the next, ``none`` shows no cursor. | + | ``style`` | CSS declarations of the annotations: ``point`` for the marker on the action point and ``highlight`` for the box over the element, both shown only when given, and ``title`` for the action title. | + Examples: | New Context recordVideo={'dir':'videos', 'size':{'width':400, 'height':200}} | New Context recordVideo={'dir': 'd:/automation/video'} + | New Context recordVideo={'showActions': {'position': 'bottom', 'style': {'title': 'font-size: 32px'}}} """ dir: str size: ViewportDimensions + fps: int + showActions: ShowActions class RecordHar(TypedDict, total=False): @@ -729,8 +772,8 @@ class AdoptedContextOptions(TypedDict): """ tracing: str | None - recordVideo: dict[str, Any] | None - recordHar: dict[str, Any] | None + recordVideo: RecordVideo | None + recordHar: RecordHar | None tracesDir: str diff --git a/atest/test/01_Browser_Management/video.robot b/atest/test/01_Browser_Management/video.robot index f8f656517..f821927c7 100644 --- a/atest/test/01_Browser_Management/video.robot +++ b/atest/test/01_Browser_Management/video.robot @@ -75,6 +75,15 @@ Create Video With VideoSize New Page file://${details}[video_path] Get BoundingBox video ALL validate value['width'] == 300 and value['height'] == 200 +Create Video With Action Annotations And Frame Rate + VAR &{show_actions} = position=bottom-left cursor=none style={'title': 'font-size: 32px'} + VAR &{record_video} = dir=annotated_video fps=${10} showActions=${show_actions} + New Context recordVideo=${record_video} + ${details} = New Page ${LOGIN_URL} + Click input#login_button + Close Context + Wait Until File Exists ${details}[video_path] + Create Video With Viewport [Documentation] ... LOG 4:3 INFO GLOB: *width="400" height="250"*.webm"* diff --git a/utest/test_adopted_context.py b/utest/test_adopted_context.py index 22849382d..daae34cad 100644 --- a/utest/test_adopted_context.py +++ b/utest/test_adopted_context.py @@ -4,6 +4,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import pytest + from Browser import Browser from Browser.base.cache import ContextCache from Browser.utils.data_types import KeywordCallStackEntry, TracingGroupMode @@ -124,6 +126,24 @@ def test_video_keeps_its_other_keys(tmpdir, monkeypatch): assert options["recordVideo"]["showActions"] == {"duration": 500} +def test_video_options_are_converted_like_new_context(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + options = browser.adopted_context_options( + recordVideo={"fps": "10", "showActions": {"duration": "300"}} + ) + + assert options["recordVideo"]["fps"] == 10 + assert options["recordVideo"]["showActions"] == {"duration": 300} + + +def test_deprecated_font_size_fails_like_new_context(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + with pytest.raises(ValueError, match="RecordVideo"): + browser.adopted_context_options(recordVideo={"showActions": {"fontSize": 20}}) + + def test_video_folder_and_size_follow_new_context(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) @@ -253,3 +273,28 @@ def test_new_persistent_context_resolves_a_relative_har_path(tmpdir, monkeypatch assert options["recordHar"] == { "path": str(Path(tmpdir, "persistent.har").resolve()) } + + +def test_new_context_sends_frame_rate_and_action_annotations(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + show_actions = { + "duration": 300, + "position": "bottom-left", + "cursor": "none", + "style": {"title": "font-size: 32px"}, + } + + browser.new_context(recordVideo={"fps": 10, "showActions": show_actions}) + + options = sent_options(browser._playwright.stub.NewContext) + assert options["recordVideo"]["fps"] == 10 + assert options["recordVideo"]["showActions"] == show_actions + + +def test_new_persistent_context_sends_action_annotations(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + browser.new_persistent_context(recordVideo={"showActions": {"position": "top"}}) + + options = sent_options(browser._playwright.stub.NewPersistentContext) + assert options["recordVideo"]["showActions"] == {"position": "top"}