From a36203103b946b652ff9b925453d865415b44805 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sun, 11 Oct 2026 13:29:12 +0200 Subject: [PATCH 1/5] feat: resolve relative recordHar paths against the output directory New Context and New Persistent Context passed a relative recordHar path to the Node process as given, so the HAR file ended up in Browser's own wrapper folder. A relative path is now relative to the output directory, as a .zip path of tracing is. --- Browser/keywords/playwright_state.py | 10 +++++++++- Browser/utils/data_types.py | 3 ++- atest/test/01_Browser_Management/record_har.robot | 6 ++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 960897f7a..f97ffde13 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -751,7 +751,8 @@ def new_context( - ``recordHar``: Enables [http://www.softwareishard.com/blog/har-12-spec/ | HAR] recording for all pages into a file. The ``path`` key must be a path to a file, for example ``recordHar={'path': - '${OUTPUT_DIR}/har.file'}``. If not specified, the HAR is not recorded. + '${OUTPUT_DIR}/har.file'}``. A relative path is relative to the output + directory. If not specified, the HAR is not recorded. 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 @@ -1024,6 +1025,7 @@ def _set_context_options(self, params, httpCredentials, storageState): params = convert_typed_dict(self.new_context.__annotations__, params) params = self._set_video_path(params) params = self._set_video_size_to_int(params) + params = self._set_har_path(params) reduced_motion = str(params.get("reducedMotion")) reduced_motion = reduced_motion.replace("_", "-") params["reducedMotion"] = reduced_motion @@ -1108,6 +1110,12 @@ def _set_video_path(self, params: dict) -> dict: params["recordVideo"]["dir"] = Path(vid_path).resolve().absolute() return params + def _set_har_path(self, params: dict) -> dict: + record_har = params.get("recordHar") + if record_har and record_har.get("path"): + record_har["path"] = str(Path(self.outputdir, record_har["path"]).resolve()) + return params + def _get_record_video_size(self, params) -> tuple[int | None, int | None]: width = params.get("recordVideo", {}).get("size", {}).get("width") height = params.get("recordVideo", {}).get("size", {}).get("height") diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index a1f58f0e3..0b2232411 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -707,7 +707,8 @@ class RecordHar(TypedDict, total=False): `omitContent`: Optional setting to control whether to omit request content from the HAR. Default is False - `path`: Path on the filesystem to write the HAR file to. + `path`: Path on the filesystem to write the HAR file to. A relative path is + relative to the output directory. Example: | ${har} = Create Dictionary path=/path/to/har.file omitContent=True diff --git a/atest/test/01_Browser_Management/record_har.robot b/atest/test/01_Browser_Management/record_har.robot index 64cf06c5e..afca44f8b 100644 --- a/atest/test/01_Browser_Management/record_har.robot +++ b/atest/test/01_Browser_Management/record_har.robot @@ -31,3 +31,9 @@ No Har Created Close Context ${files} = List Files In Directory ${OUTPUT_DIR} har* Length Should Be ${files} 3 + +Relative Har Path Is Relative To The Output Directory + New Context recordHar={"path": "relative-path.har"} + New Page ${LOGIN_URL} + Close Context + File Should Not Be Empty ${OUTPUT_DIR}/relative-path.har From 1c1943988e028c50111311fed4cd10f2a9a20cb0 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sun, 11 Oct 2026 13:29:18 +0200 Subject: [PATCH 2/5] feat: give libraries the recording options of adopted contexts A library that hands over its own context with adoptContext had to reach into Browser's internals to trace and record it the way New Context does. - adopted_context_options returns tracing, recordVideo and recordHar by the rules of New Context, including ROBOT_FRAMEWORK_BROWSER_TRACING and Browser's trace and video folders. - register_adopted_context registers the adopted context afterwards, so that the keyword calls become trace groups and the log embeds the video. --- Browser/browser.py | 60 +++++++++++++- utest/test_adopted_context.py | 145 ++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 utest/test_adopted_context.py diff --git a/Browser/browser.py b/Browser/browser.py index 24ff6ef57..349a90ca8 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -21,7 +21,7 @@ import threading import time import types -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from concurrent.futures._base import Future from copy import copy from datetime import timedelta @@ -896,6 +896,64 @@ def call_js_keyword(self, keyword_name: str, **args) -> Any: return None return json.loads(last_json) + def adopted_context_options( + self, + tracing: bool | Path | None = None, + recordVideo: Mapping[str, Any] | None = None, + recordHar: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + """Returns the tracing and recording options for a context that a JavaScript + extension creates and hands over with ``adoptContext``. + + They follow the rules of `New Context`: ``tracing`` gives the trace path or + folder, also when ``ROBOT_FRAMEWORK_BROWSER_TRACING`` is set, or an empty + string. ``recordVideo`` gets the video folder and size, Browser's video + folder and 1280x720 by default, and keeps its other keys. A relative + ``path`` of ``recordHar`` becomes relative to the output directory. Pass + ``tracing`` on as the ``tracing`` option of ``adoptContext``, and + ``recordVideo`` and ``recordHar`` to Playwright when the extension creates + the context. After the extension returns, call `register_adopted_context` + with these options. + """ + state = self._playwright_state + options: dict[str, Any] = {"tracing": state._resolve_trace_file(tracing)} + if recordVideo is not None: + params = {"recordVideo": {"dir": None, **recordVideo}} + params = state._set_video_size_to_int(state._set_video_path(params)) + options["recordVideo"] = { + **params["recordVideo"], + "dir": str(params["recordVideo"]["dir"]), + } + if recordHar is not None: + options["recordHar"] = state._set_har_path({"recordHar": {**recordHar}})[ + "recordHar" + ] + return options + + def register_adopted_context( + self, + context_id: str, + options: dict[str, Any], + video_path: str | None = None, + ) -> str: + """Registers a context that a JavaScript extension handed over with + ``adoptContext`` the way `New Context` registers its own. + + ``options`` are those of `adopted_context_options`. With ``tracing``, the + keyword calls become groups in the trace. With ``recordVideo``, the log + embeds the video at ``video_path``. Returns ``video_path``, or an empty + string if there is no video. + """ + state = self._playwright_state + if options.get("tracing"): + state.add_context_and_keyword_call_stack_to_trace( + options["tracing"], context_id + ) + if not options.get("recordVideo"): + return "" + state.context_cache.add(context_id, options["recordVideo"]["size"]) + return state._embed_video({"video_path": video_path, "contextUuid": context_id}) + @property def outputdir(self) -> str: if EXECUTION_CONTEXTS.current: diff --git a/utest/test_adopted_context.py b/utest/test_adopted_context.py new file mode 100644 index 000000000..b8d6651cb --- /dev/null +++ b/utest/test_adopted_context.py @@ -0,0 +1,145 @@ +from contextlib import contextmanager +from pathlib import Path + +from Browser import Browser +from Browser.base.cache import ContextCache + + +class NoPlaywright: + """Stands in for the Node process, which these tests do not need.""" + + @contextmanager + def grpc_channel(self): + yield self + + +def library(tmpdir, monkeypatch) -> Browser: + monkeypatch.setattr(Browser, "_output_dir", tmpdir) + monkeypatch.setattr(Browser, "_context_cache", ContextCache()) + monkeypatch.delenv("ROBOT_FRAMEWORK_BROWSER_TRACING", raising=False) + browser = Browser() + browser._playwright = NoPlaywright() + return browser + + +def test_no_tracing_and_no_video_by_default(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + assert browser.adopted_context_options() == {"tracing": ""} + + +def test_tracing_follows_new_context(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + assert browser.adopted_context_options(tracing=True)["tracing"] == str( + browser.traces_output + ) + assert browser.adopted_context_options(tracing=Path("trace.zip"))["tracing"] == str( + Path(tmpdir, "trace.zip").resolve() + ) + + +def test_tracing_follows_the_global_setting(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + monkeypatch.setenv("ROBOT_FRAMEWORK_BROWSER_TRACING", "True") + + assert browser.adopted_context_options()["tracing"] == str(browser.traces_output) + + +def test_video_gets_browsers_folder_and_size(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + options = browser.adopted_context_options(recordVideo={"showActions": {}}) + + assert options["recordVideo"] == { + "dir": str(browser.video_output.resolve()), + "size": {"width": 1280, "height": 720}, + "showActions": {}, + } + + +def test_video_folder_and_size_follow_new_context(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + options = browser.adopted_context_options( + recordVideo={"dir": "demo", "size": {"width": "800", "height": "600"}} + ) + + assert options["recordVideo"] == { + "dir": str((browser.video_output / "demo").resolve()), + "size": {"width": 800, "height": 600}, + } + + +def test_registering_without_tracing_and_video_changes_nothing(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + video_path = browser.register_adopted_context("context=1", {"tracing": ""}) + + assert video_path == "" + assert browser.tracing_contexts == [] + + +def test_registered_context_gets_the_trace_groups(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + options = browser.adopted_context_options(tracing=True) + + browser.register_adopted_context("context=1", options) + + assert browser.tracing_contexts == ["context=1"] + + +def test_registered_context_embeds_its_video(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + options = browser.adopted_context_options(recordVideo={}) + video = str(Path(options["recordVideo"]["dir"], "page.webm")) + + video_path = browser.register_adopted_context("context=1", options, video) + + assert video_path == video + assert browser._playwright_state.context_cache.get("context=1") == { + "width": 1280, + "height": 720, + } + + +def test_video_path_without_record_video_is_ignored(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + options = browser.adopted_context_options() + + video_path = browser.register_adopted_context("context=1", options, "page.webm") + + assert video_path == "" + assert browser._playwright_state.context_cache.get("context=1") is None + + +def test_relative_har_path_is_relative_to_the_output_directory(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + options = browser.adopted_context_options( + recordHar={"path": "har/app.har", "omitContent": True} + ) + + assert options["recordHar"] == { + "path": str(Path(tmpdir, "har", "app.har").resolve()), + "omitContent": True, + } + + +def test_absolute_har_path_is_kept(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + har = str(Path(tmpdir, "elsewhere", "app.har").resolve()) + + options = browser.adopted_context_options(recordHar={"path": har}) + + assert options["recordHar"] == {"path": har} + + +def test_new_context_resolves_a_relative_har_path(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + params = browser._playwright_state._set_har_path( + {"recordHar": {"path": "context.har"}} + ) + + assert params == {"recordHar": {"path": str(Path(tmpdir, "context.har").resolve())}} From ccb951ef4c490f2fb6c87cb571d50f531e7100dd Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Sun, 11 Oct 2026 13:47:04 +0200 Subject: [PATCH 3/5] feat: return the options of adopted contexts as AdoptedContextOptions adopted_context_options returns a typed dictionary with tracing, recordVideo and recordHar, None when off, so that a library can pass it on to the extension function as keyword arguments. Its parameters take Browser's RecordVideo and RecordHar, as New Context does. --- Browser/browser.py | 40 +++++++++++++++++++++-------------- Browser/utils/__init__.py | 1 + Browser/utils/data_types.py | 14 ++++++++++++ utest/test_adopted_context.py | 10 +++++++-- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index 349a90ca8..a0633d715 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -21,7 +21,7 @@ import threading import time import types -from collections.abc import Iterator, Mapping +from collections.abc import Iterator from concurrent.futures._base import Future from copy import copy from datetime import timedelta @@ -78,10 +78,13 @@ # Importing this directly from .utils break the stub type checks from .utils.data_types import ( + AdoptedContextOptions, DelayedKeyword, HighLightElement, KeywordCallStackEntry, LambdaFunction, + RecordHar, + RecordVideo, RegExp, RobotTypeConverter, SelectionType, @@ -899,24 +902,29 @@ def call_js_keyword(self, keyword_name: str, **args) -> Any: def adopted_context_options( self, tracing: bool | Path | None = None, - recordVideo: Mapping[str, Any] | None = None, - recordHar: Mapping[str, Any] | None = None, - ) -> dict[str, Any]: + recordVideo: RecordVideo | None = None, + recordHar: RecordHar | None = None, + ) -> AdoptedContextOptions: """Returns the tracing and recording options for a context that a JavaScript extension creates and hands over with ``adoptContext``. They follow the rules of `New Context`: ``tracing`` gives the trace path or - folder, also when ``ROBOT_FRAMEWORK_BROWSER_TRACING`` is set, or an empty - string. ``recordVideo`` gets the video folder and size, Browser's video - folder and 1280x720 by default, and keeps its other keys. A relative - ``path`` of ``recordHar`` becomes relative to the output directory. Pass - ``tracing`` on as the ``tracing`` option of ``adoptContext``, and - ``recordVideo`` and ``recordHar`` to Playwright when the extension creates - the context. After the extension returns, call `register_adopted_context` - with these options. + folder, also when ``ROBOT_FRAMEWORK_BROWSER_TRACING`` is set. + ``recordVideo`` gets the video folder and size, Browser's video folder and + 1280x720 by default, and keeps its other keys. A relative ``path`` of + ``recordHar`` becomes relative to the output directory. ``None`` means off. + The keys can be passed to the extension function as keyword arguments: + ``tracing`` for the ``tracing`` option of ``adoptContext``, ``recordVideo`` + and ``recordHar`` for Playwright when the extension creates the context. + After the extension returns, call `register_adopted_context` with these + options. """ state = self._playwright_state - options: dict[str, Any] = {"tracing": state._resolve_trace_file(tracing)} + options: AdoptedContextOptions = { + "tracing": state._resolve_trace_file(tracing) or None, + "recordVideo": None, + "recordHar": None, + } if recordVideo is not None: params = {"recordVideo": {"dir": None, **recordVideo}} params = state._set_video_size_to_int(state._set_video_path(params)) @@ -933,7 +941,7 @@ def adopted_context_options( def register_adopted_context( self, context_id: str, - options: dict[str, Any], + options: AdoptedContextOptions, video_path: str | None = None, ) -> str: """Registers a context that a JavaScript extension handed over with @@ -945,11 +953,11 @@ def register_adopted_context( string if there is no video. """ state = self._playwright_state - if options.get("tracing"): + if options["tracing"]: state.add_context_and_keyword_call_stack_to_trace( options["tracing"], context_id ) - if not options.get("recordVideo"): + if not options["recordVideo"]: return "" state.context_cache.add(context_id, options["recordVideo"]["size"]) return state._embed_video({"video_path": video_path, "contextUuid": context_id}) diff --git a/Browser/utils/__init__.py b/Browser/utils/__init__.py index 175609087..060bc755d 100644 --- a/Browser/utils/__init__.py +++ b/Browser/utils/__init__.py @@ -15,6 +15,7 @@ # flake8: noqa from .data_types import ( + AdoptedContextOptions, AutoClosingLevel, BrowserInfo, BoundingBox, diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index 0b2232411..3005800d1 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -719,6 +719,20 @@ class RecordHar(TypedDict, total=False): path: str +class AdoptedContextOptions(TypedDict): + """Tracing and recording options of a context that a JavaScript extension + creates and hands over with ``adoptContext``, as ``adopted_context_options`` + returns them. ``None`` means off. + + The keys are named like the options of `New Context`, so that they can be + passed on to the extension function as keyword arguments. + """ + + tracing: str | None + recordVideo: dict[str, Any] | None + recordHar: dict[str, Any] | None + + class _HttpCredentials(TypedDict): username: str | Secret password: str | Secret diff --git a/utest/test_adopted_context.py b/utest/test_adopted_context.py index b8d6651cb..8c6dcb7e4 100644 --- a/utest/test_adopted_context.py +++ b/utest/test_adopted_context.py @@ -25,7 +25,11 @@ def library(tmpdir, monkeypatch) -> Browser: def test_no_tracing_and_no_video_by_default(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) - assert browser.adopted_context_options() == {"tracing": ""} + assert browser.adopted_context_options() == { + "tracing": None, + "recordVideo": None, + "recordHar": None, + } def test_tracing_follows_new_context(tmpdir, monkeypatch): @@ -74,7 +78,9 @@ def test_video_folder_and_size_follow_new_context(tmpdir, monkeypatch): def test_registering_without_tracing_and_video_changes_nothing(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) - video_path = browser.register_adopted_context("context=1", {"tracing": ""}) + video_path = browser.register_adopted_context( + "context=1", browser.adopted_context_options() + ) assert video_path == "" assert browser.tracing_contexts == [] From 6da0ee2bd0798e48c59f2ce85412241cd17cbbcf Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Thu, 15 Oct 2026 23:55:09 +0200 Subject: [PATCH 4/5] refactor: resolve and register contexts in PlaywrightState adopted_context_options and register_adopted_context call PlaywrightState now, where New Context and New Persistent Context resolve their recordVideo and recordHar options and register their contexts with the same helpers. Browser keeps only the two public methods. - adopted_context_options takes the viewport of the adopted context, which gives the video its size when recordVideo has none, as in New Context. It also returns tracesDir, a new folder for Playwright's temporary trace files, as New Browser gives one to each browser it launches. - Only None means off, so an empty recordVideo records with the defaults. New Context recordVideo={} got no dir before, and Playwright recorded into its temporary artifacts folder, which it deletes when the browser closes. - New Context no longer changes the caller's recordVideo. Before Python 3.14, dir became an absolute PosixPath in it; video.robot checked that per Python version and no longer needs ${PYTHON_314}. - New Persistent Context registers the context right after creating it, where it added the trace groups before, so that the navigation to url stays inside the keyword's trace group. --- Browser/browser.py | 54 +++---- Browser/keywords/playwright_state.py | 153 +++++++++++++------ Browser/utils/data_types.py | 3 +- atest/library/os_wrapper.py | 4 - atest/test/01_Browser_Management/video.robot | 14 +- atest/test/__init__.robot | 2 - atest/test/variables.resource | 1 - utest/test_adopted_context.py | 152 +++++++++++++++--- utest/test_get_video_size.py | 15 +- 9 files changed, 261 insertions(+), 137 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index a0633d715..713a18bd6 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -90,6 +90,7 @@ SelectionType, SupportedBrowsers, TracingGroupMode, + ViewportDimensions, ) from .version import __version__ as VERSION @@ -904,39 +905,30 @@ def adopted_context_options( tracing: bool | Path | None = None, recordVideo: RecordVideo | None = None, recordHar: RecordHar | None = None, + viewport: ViewportDimensions | None = None, ) -> AdoptedContextOptions: """Returns the tracing and recording options for a context that a JavaScript extension creates and hands over with ``adoptContext``. 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 and size, Browser's video folder and - 1280x720 by default, and keeps its other keys. A relative ``path`` of - ``recordHar`` becomes relative to the output directory. ``None`` means off. + ``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. + The keys can be passed to the extension function as keyword arguments: ``tracing`` for the ``tracing`` option of ``adoptContext``, ``recordVideo`` - and ``recordHar`` for Playwright when the extension creates the context. - After the extension returns, call `register_adopted_context` with these - options. + and ``recordHar`` for Playwright when the extension creates the context, and + ``tracesDir`` when it launches a browser or an application. After the + extension returns, call ``register_adopted_context`` with these options. """ - state = self._playwright_state - options: AdoptedContextOptions = { - "tracing": state._resolve_trace_file(tracing) or None, - "recordVideo": None, - "recordHar": None, - } - if recordVideo is not None: - params = {"recordVideo": {"dir": None, **recordVideo}} - params = state._set_video_size_to_int(state._set_video_path(params)) - options["recordVideo"] = { - **params["recordVideo"], - "dir": str(params["recordVideo"]["dir"]), - } - if recordHar is not None: - options["recordHar"] = state._set_har_path({"recordHar": {**recordHar}})[ - "recordHar" - ] - return options + return self._playwright_state.adopted_context_options( + tracing, recordVideo, recordHar, viewport + ) def register_adopted_context( self, @@ -947,20 +939,14 @@ def register_adopted_context( """Registers a context that a JavaScript extension handed over with ``adoptContext`` the way `New Context` registers its own. - ``options`` are those of `adopted_context_options`. With ``tracing``, the + ``options`` are those of ``adopted_context_options``. With ``tracing``, the keyword calls become groups in the trace. With ``recordVideo``, the log embeds the video at ``video_path``. Returns ``video_path``, or an empty string if there is no video. """ - state = self._playwright_state - if options["tracing"]: - state.add_context_and_keyword_call_stack_to_trace( - options["tracing"], context_id - ) - if not options["recordVideo"]: - return "" - state.context_cache.add(context_id, options["recordVideo"]["size"]) - return state._embed_video({"video_path": video_path, "contextUuid": context_id}) + return self._playwright_state.register_adopted_context( + context_id, options, video_path + ) @property def outputdir(self) -> str: diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index f97ffde13..1b44406b6 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -14,6 +14,7 @@ import json import os +from collections.abc import Mapping from contextlib import suppress from copy import copy from datetime import datetime, timedelta, timezone @@ -33,6 +34,7 @@ from ..base import LibraryComponent from ..generated.playwright_pb2 import Request from ..utils import ( + AdoptedContextOptions, ClientCertificate, ColorScheme, DownloadInfo, @@ -846,7 +848,11 @@ def new_context( "No browser was open. New browser was automatically opened " "when this context is created." ) - self.context_cache.add(response.id, self._get_video_size(params)) + self._register_context( + response.id, + trace_file, + self._video_size(params.get("recordVideo"), params.get("viewport")), + ) return response.id @keyword() @@ -961,8 +967,11 @@ def new_persistent_context( traceFile=str(trace_file), ) ) - self.add_context_and_keyword_call_stack_to_trace( - trace_file=trace_file, ctx_id=response.id + video_path = self._register_context( + response.id, + trace_file, + self._video_size(params.get("recordVideo"), params.get("viewport")), + json.loads(response.video), ) context_options = self._mask_credentials( json.loads(response.contextOptions) @@ -992,14 +1001,45 @@ def new_persistent_context( self.close_browser() logger.debug("Teardown: Closed new persistent context.") raise e - self.context_cache.add(response.id, self._get_video_size(params)) - video_path = self._embed_video(json.loads(response.video)) return ( browser_id, response.id, NewPageDetails(page_id=response.pageId, video_path=video_path), ) + def adopted_context_options( + self, + tracing: bool | Path | None = None, + recordVideo: RecordVideo | None = None, + recordHar: RecordHar | None = None, + viewport: ViewportDimensions | None = None, + ) -> AdoptedContextOptions: + return { + "tracing": self._resolve_trace_file(tracing) or None, + "recordVideo": self._record_video(recordVideo, viewport), + "recordHar": self._record_har(recordHar), + "tracesDir": str(self.traces_temp / str(uuid4())), + } + + def register_adopted_context( + self, + context_id: str, + options: AdoptedContextOptions, + video_path: str | None = None, + ) -> str: + record_video = options["recordVideo"] + video = ( + {"video_path": video_path, "contextUuid": context_id} + if record_video is not None + else None + ) + return self._register_context( + context_id, + options["tracing"] or "", + self._video_size(record_video, None), + video, + ) + def _resolve_trace_file(self, tracing: bool | Path | None) -> str: if isinstance(tracing, str): tracing = Path(tracing) @@ -1023,9 +1063,12 @@ def _resolve_trace_file(self, tracing: bool | Path | None) -> str: def _set_context_options(self, params, httpCredentials, storageState): params = convert_typed_dict(self.new_context.__annotations__, params) - params = self._set_video_path(params) - params = self._set_video_size_to_int(params) - params = self._set_har_path(params) + if "recordVideo" in params: + params["recordVideo"] = self._record_video( + params["recordVideo"], params.get("viewport") + ) + if "recordHar" in params: + params["recordHar"] = self._record_har(params["recordHar"]) reduced_motion = str(params.get("reducedMotion")) reduced_motion = reduced_motion.replace("_", "-") params["reducedMotion"] = reduced_motion @@ -1058,17 +1101,13 @@ def _set_browser_options(self, params, browser, channel, slowMo, timeout): def _new_context(self, options: str, trace_file: Path | str): with self.playwright.grpc_channel() as stub: - context = stub.NewContext( + return stub.NewContext( Request().Context( rawOptions=options, defaultTimeout=int(self.timeout), traceFile=str(trace_file), ) ) - self.add_context_and_keyword_call_stack_to_trace( - trace_file=trace_file, ctx_id=context.id - ) - return context def add_context_and_keyword_call_stack_to_trace(self, trace_file, ctx_id): if ( @@ -1096,44 +1135,58 @@ def _mask_credentials(self, data: dict): data["httpCredentials"] = "XXX" return data - def _set_video_path(self, params: dict) -> dict: - record_video = params.get("recordVideo", {}) - video_path = record_video.get("dir") - if not record_video: - return params - if video_path is None: - vid_path = self.video_output - elif Path(video_path).is_absolute(): - vid_path = params["recordVideo"]["dir"] - else: - vid_path = self.video_output / video_path - params["recordVideo"]["dir"] = Path(vid_path).resolve().absolute() - return params - - def _set_har_path(self, params: dict) -> dict: - record_har = params.get("recordHar") - if record_har and record_har.get("path"): - record_har["path"] = str(Path(self.outputdir, record_har["path"]).resolve()) - return params - - def _get_record_video_size(self, params) -> tuple[int | None, int | None]: - width = params.get("recordVideo", {}).get("size", {}).get("width") - height = params.get("recordVideo", {}).get("size", {}).get("height") - return int(width) if width else None, int(height) if height else None - - def _set_video_size_to_int(self, params: dict) -> dict: - if "recordVideo" not in params: - return params - params["recordVideo"]["size"] = self._get_video_size(params) - return params + def _register_context( + self, + context_id: str, + trace_file: str, + video_size: dict, + video: dict | None = None, + ) -> str: + self.add_context_and_keyword_call_stack_to_trace(trace_file, context_id) + self.context_cache.add(context_id, video_size) + return self._embed_video(video) if video else "" - def _get_video_size(self, params: dict) -> dict: - width, height = self._get_record_video_size(params) - if width and height: - return {"width": width, "height": height} - if "viewport" in params: - return params["viewport"] - return {"width": 1280, "height": 720} + def _record_video( + self, + record_video: Mapping[str, Any] | None, + viewport: Mapping[str, Any] | None, + ) -> dict[str, Any] | None: + if record_video is None: + return None + video_dir = record_video.get("dir") + if video_dir is None: + resolved_dir = self.video_output + elif Path(video_dir).is_absolute(): + resolved_dir = Path(video_dir) + else: + resolved_dir = self.video_output / video_dir + return { + **record_video, + "dir": str(resolved_dir.resolve()), + "size": self._video_size(record_video, viewport), + } + + def _video_size( + self, + record_video: Mapping[str, Any] | None, + viewport: Mapping[str, Any] | None, + ) -> dict: + 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} + + def _record_har( + self, record_har: Mapping[str, Any] | None + ) -> dict[str, Any] | None: + if record_har is None: + return None + if not record_har.get("path"): + return {**record_har} + return { + **record_har, + "path": str(Path(self.outputdir, record_har["path"]).resolve()), + } @keyword(tags=("Setter", "BrowserControl")) def new_page( diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index 3005800d1..63aa6cb6f 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -724,13 +724,14 @@ class AdoptedContextOptions(TypedDict): creates and hands over with ``adoptContext``, as ``adopted_context_options`` returns them. ``None`` means off. - The keys are named like the options of `New Context`, so that they can be + The keys are named like the options they are meant for, so that they can be passed on to the extension function as keyword arguments. """ tracing: str | None recordVideo: dict[str, Any] | None recordHar: dict[str, Any] | None + tracesDir: str class _HttpCredentials(TypedDict): diff --git a/atest/library/os_wrapper.py b/atest/library/os_wrapper.py index 2a8afc802..095dc5123 100644 --- a/atest/library/os_wrapper.py +++ b/atest/library/os_wrapper.py @@ -167,10 +167,6 @@ def get_python_binary_path() -> str: return sys.executable -def is_python_314() -> bool: - return sys.version_info >= (3, 14) and sys.version_info < (3, 15) - - def get_python_version() -> str: return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" diff --git a/atest/test/01_Browser_Management/video.robot b/atest/test/01_Browser_Management/video.robot index 3fa3b23e9..f8f656517 100644 --- a/atest/test/01_Browser_Management/video.robot +++ b/atest/test/01_Browser_Management/video.robot @@ -15,12 +15,7 @@ Create Video With Full Path Go To ${FRAMES_URL} Get Browser Catalog Verify Video Files 1 - IF ${PYTHON_314} - VAR ${expected_path} = ${OUTPUT_DIR}/video - ELSE - ${expected_path} = Convert To Path ${OUTPUT_DIR}/video - END - Should Be Equal ${record_video.dir} ${expected_path} + Should Be Equal ${record_video.dir} ${OUTPUT_DIR}/video Create Video With Relative Path [Documentation] @@ -36,12 +31,7 @@ Create Video With Relative Path Wait File Count In Directory ${OUTPUT_DIR}/browser/video/my_video ${1} Should Start With ${details}[video_path] ${OUTPUT_DIR}${/}browser${/}video${/}my_video Should End With ${details}[video_path] .webm - IF ${PYTHON_314} - VAR ${expected_path} = my_video - ELSE - ${expected_path} = Convert To Path ${OUTPUT_DIR}/browser/video/my_video - END - Should Be Equal ${record_video.dir} ${expected_path} + Should Be Equal ${record_video.dir} my_video New Context viewport={'width': 2048, 'height': 1200} New Page file://${details}[video_path] Get BoundingBox video ALL validate value['width'] == 1280 and value['height'] == 720 diff --git a/atest/test/__init__.robot b/atest/test/__init__.robot index 83db9d077..21ac636ee 100644 --- a/atest/test/__init__.robot +++ b/atest/test/__init__.robot @@ -14,8 +14,6 @@ Start Test Application ${port} = Start Test Server VAR ${SERVER_PORT} = ${port} scope=GLOBAL VAR ${SERVER} = localhost:${SERVER_PORT} scope=GLOBAL - ${python_version} = Is Python 314 - VAR ${PYTHON_314} = ${python_version} scope=GLOBAL ${rf_version} = Get Robot Version Set Suite Metadata Robot Framework Version ${rf_version} Log To Console RF Version: ${rf_version} diff --git a/atest/test/variables.resource b/atest/test/variables.resource index fad5f814d..494c4d9e0 100644 --- a/atest/test/variables.resource +++ b/atest/test/variables.resource @@ -50,4 +50,3 @@ ${SYS_VAR_CLEAN} = ${True} ${NETWORK_IDLE_FILE_DATA} = ${EMPTY} ${INPUT_ELEMENT_COUNT_IN_LOGIN} = 5 ${BUTTON_ELEMENT_COUNT_IN_LOGIN} = ${18} -${PYTHON_314} = ${None} diff --git a/utest/test_adopted_context.py b/utest/test_adopted_context.py index 8c6dcb7e4..22849382d 100644 --- a/utest/test_adopted_context.py +++ b/utest/test_adopted_context.py @@ -1,16 +1,26 @@ +import json from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock from Browser import Browser from Browser.base.cache import ContextCache +from Browser.utils.data_types import KeywordCallStackEntry, TracingGroupMode -class NoPlaywright: - """Stands in for the Node process, which these tests do not need.""" +class FakePlaywright: + def __init__(self): + self.stub = MagicMock() + self.stub.NewContext.return_value = MagicMock(contextOptions="{}") + self.stub.NewPersistentContext.return_value = MagicMock( + contextOptions="{}", video="{}" + ) + self.stub.GetBrowserCatalog.return_value = MagicMock(json="[]") @contextmanager def grpc_channel(self): - yield self + yield self.stub def library(tmpdir, monkeypatch) -> Browser: @@ -18,18 +28,38 @@ def library(tmpdir, monkeypatch) -> Browser: monkeypatch.setattr(Browser, "_context_cache", ContextCache()) monkeypatch.delenv("ROBOT_FRAMEWORK_BROWSER_TRACING", raising=False) browser = Browser() - browser._playwright = NoPlaywright() + browser._playwright = FakePlaywright() return browser -def test_no_tracing_and_no_video_by_default(tmpdir, monkeypatch): +def keyword_call(name: str) -> KeywordCallStackEntry: + return {"name": name, "file": "", "line": 0, "kwname": name, "args": []} + + +def sent_options(request) -> dict: + return json.loads(request.call_args.args[0].rawOptions) + + +def test_no_tracing_and_no_recording_by_default(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) - assert browser.adopted_context_options() == { - "tracing": None, - "recordVideo": None, - "recordHar": None, - } + options = browser.adopted_context_options() + + assert options["tracing"] is None + assert options["recordVideo"] is None + assert options["recordHar"] is None + + +def test_traces_dir_is_a_new_folder_in_browsers_temporary_trace_folder( + tmpdir, monkeypatch +): + browser = library(tmpdir, monkeypatch) + + first = browser.adopted_context_options()["tracesDir"] + second = browser.adopted_context_options()["tracesDir"] + + assert Path(first).parent == browser.traces_temp + assert first != second def test_tracing_follows_new_context(tmpdir, monkeypatch): @@ -43,6 +73,14 @@ def test_tracing_follows_new_context(tmpdir, monkeypatch): ) +def test_relative_trace_folder_is_in_browsers_trace_folder(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + options = browser.adopted_context_options(tracing=Path("adopted")) + + assert options["tracing"] == str((browser.traces_output / "adopted").resolve()) + + def test_tracing_follows_the_global_setting(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) monkeypatch.setenv("ROBOT_FRAMEWORK_BROWSER_TRACING", "True") @@ -50,18 +88,42 @@ def test_tracing_follows_the_global_setting(tmpdir, monkeypatch): assert browser.adopted_context_options()["tracing"] == str(browser.traces_output) -def test_video_gets_browsers_folder_and_size(tmpdir, monkeypatch): +def test_tracing_follows_the_robot_framework_variable(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) + variables = {"${ROBOT_FRAMEWORK_BROWSER_TRACING}": "True"} + monkeypatch.setattr( + "Browser.keywords.playwright_state.EXECUTION_CONTEXTS", + SimpleNamespace(current=True), + ) + monkeypatch.setattr( + "Browser.keywords.playwright_state.BuiltIn", + lambda: SimpleNamespace(get_variable_value=variables.get), + ) + + assert browser.adopted_context_options()["tracing"] == str(browser.traces_output) + - options = browser.adopted_context_options(recordVideo={"showActions": {}}) +def test_empty_video_options_record_with_the_defaults(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + options = browser.adopted_context_options(recordVideo={}) assert options["recordVideo"] == { "dir": str(browser.video_output.resolve()), "size": {"width": 1280, "height": 720}, - "showActions": {}, } +def test_video_keeps_its_other_keys(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + options = browser.adopted_context_options( + recordVideo={"showActions": {"duration": 500}} + ) + + assert options["recordVideo"]["showActions"] == {"duration": 500} + + def test_video_folder_and_size_follow_new_context(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) @@ -75,26 +137,59 @@ def test_video_folder_and_size_follow_new_context(tmpdir, monkeypatch): } -def test_registering_without_tracing_and_video_changes_nothing(tmpdir, monkeypatch): +def test_absolute_video_folder_is_kept(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + videos = str(Path(tmpdir, "elsewhere", "videos").resolve()) + + options = browser.adopted_context_options(recordVideo={"dir": videos}) + + assert options["recordVideo"]["dir"] == videos + + +def test_video_without_size_has_the_size_of_the_viewport(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) - video_path = browser.register_adopted_context( - "context=1", browser.adopted_context_options() + options = browser.adopted_context_options( + recordVideo={}, viewport={"width": 1024, "height": 768} ) - assert video_path == "" - assert browser.tracing_contexts == [] + assert options["recordVideo"]["size"] == {"width": 1024, "height": 768} -def test_registered_context_gets_the_trace_groups(tmpdir, monkeypatch): +def test_registered_context_gets_the_keyword_calls_as_trace_groups(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) + browser.keyword_call_stack.append(keyword_call("Open Application")) options = browser.adopted_context_options(tracing=True) browser.register_adopted_context("context=1", options) + group = browser._playwright.stub.OpenTraceGroup.call_args.args[0] + assert (group.name, group.contextId) == ("Open Application", "context=1") assert browser.tracing_contexts == ["context=1"] +def test_registered_context_without_tracing_gets_no_trace_groups(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + browser.keyword_call_stack.append(keyword_call("Open Application")) + + browser.register_adopted_context("context=1", browser.adopted_context_options()) + + assert not browser._playwright.stub.OpenTraceGroup.called + assert browser.tracing_contexts == [] + + +def test_playwright_tracing_group_mode_adds_no_trace_groups(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + browser.tracing_group_mode = TracingGroupMode.Playwright + browser.keyword_call_stack.append(keyword_call("Open Application")) + options = browser.adopted_context_options(tracing=True) + + browser.register_adopted_context("context=1", options) + + assert not browser._playwright.stub.OpenTraceGroup.called + assert browser.tracing_contexts == [] + + def test_registered_context_embeds_its_video(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) options = browser.adopted_context_options(recordVideo={}) @@ -116,7 +211,6 @@ def test_video_path_without_record_video_is_ignored(tmpdir, monkeypatch): video_path = browser.register_adopted_context("context=1", options, "page.webm") assert video_path == "" - assert browser._playwright_state.context_cache.get("context=1") is None def test_relative_har_path_is_relative_to_the_output_directory(tmpdir, monkeypatch): @@ -144,8 +238,18 @@ def test_absolute_har_path_is_kept(tmpdir, monkeypatch): def test_new_context_resolves_a_relative_har_path(tmpdir, monkeypatch): browser = library(tmpdir, monkeypatch) - params = browser._playwright_state._set_har_path( - {"recordHar": {"path": "context.har"}} - ) + browser.new_context(recordHar={"path": "context.har"}) - assert params == {"recordHar": {"path": str(Path(tmpdir, "context.har").resolve())}} + options = sent_options(browser._playwright.stub.NewContext) + assert options["recordHar"] == {"path": str(Path(tmpdir, "context.har").resolve())} + + +def test_new_persistent_context_resolves_a_relative_har_path(tmpdir, monkeypatch): + browser = library(tmpdir, monkeypatch) + + browser.new_persistent_context(recordHar={"path": "persistent.har"}) + + options = sent_options(browser._playwright.stub.NewPersistentContext) + assert options["recordHar"] == { + "path": str(Path(tmpdir, "persistent.har").resolve()) + } diff --git a/utest/test_get_video_size.py b/utest/test_get_video_size.py index 712613d63..6432bd956 100644 --- a/utest/test_get_video_size.py +++ b/utest/test_get_video_size.py @@ -11,15 +11,12 @@ def state(): def test_get_video_size(state: PlaywrightState): results = [ - state._get_video_size({}), - state._get_video_size({"recordVideo": {"path": "/path/to"}}), - state._get_video_size({"recordVideo": {"size": {"width": 12, "height": 11}}}), - state._get_video_size({"viewport": {"width": 123, "height": 111}}), - state._get_video_size( - { - "recordVideo": {"size": {"width": 12, "height": 11}}, - "viewport": {"width": 123, "height": 111}, - } + state._video_size(None, None), + state._video_size({"path": "/path/to"}, None), + state._video_size({"size": {"width": 12, "height": 11}}, None), + state._video_size(None, {"width": 123, "height": 111}), + state._video_size( + {"size": {"width": 12, "height": 11}}, {"width": 123, "height": 111} ), ] verify_all("Video size", results) From 0c429078cf7091489f4aa3ee58cc8b4e32a43f24 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Thu, 15 Oct 2026 23:55:09 +0200 Subject: [PATCH 5/5] test: trace and record a context that a library adopts AdoptingLibrary subclasses Browser, adopts a persistent context through adopt.js with the options of adopted_context_options and registers it with register_adopted_context. The test checks that the trace has the groups of the keywords and that the video is written to Browser's video folder. --- atest/library/AdoptingLibrary.py | 34 +++++++++++++++++++ .../01_Browser_Management/tracing_groups.py | 8 +++++ atest/test/05_JS_Tests/adopt.js | 14 ++++++++ .../adopted_context_recording.robot | 23 +++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 atest/library/AdoptingLibrary.py create mode 100644 atest/test/05_JS_Tests/adopted_context_recording.robot diff --git a/atest/library/AdoptingLibrary.py b/atest/library/AdoptingLibrary.py new file mode 100644 index 000000000..ddbd69a17 --- /dev/null +++ b/atest/library/AdoptingLibrary.py @@ -0,0 +1,34 @@ +from pathlib import Path + +from robot.api.deco import keyword + +from Browser import Browser +from Browser.utils.data_types import RecordVideo + + +class AdoptingLibrary(Browser): + @keyword + def open_adopted_context_with_recording( + self, + user_data_dir: str, + url: str, + headless: bool, + tracing: Path, + record_video: RecordVideo, + ) -> str: + """Opens a persistent context in the JavaScript extension with the options of + ``adopted_context_options``, registers it with ``register_adopted_context`` + and returns the path of its video.""" + options = self.adopted_context_options( + tracing=tracing, recordVideo=record_video + ) + adopted = self.call_js_keyword( + "openAdoptedPersistentContextWithRecording", + userDataDir=user_data_dir, + url=url, + headless=headless, + **options, + ) + return self.register_adopted_context( + adopted["contextId"], options, adopted["videoPath"] + ) diff --git a/atest/test/01_Browser_Management/tracing_groups.py b/atest/test/01_Browser_Management/tracing_groups.py index a43284a3b..593a3896b 100644 --- a/atest/test/01_Browser_Management/tracing_groups.py +++ b/atest/test/01_Browser_Management/tracing_groups.py @@ -10,6 +10,14 @@ def get_trace_lines(path: Path) -> list[dict]: ] +def get_trace_group_titles(path: Path) -> list[str]: + return [ + line["title"] + for line in get_trace_lines(path) + if line.get("method") == "tracingGroup" + ] + + def get_file_line(file: Union[Path, None], line: int, column: int) -> str: if file is None: return None diff --git a/atest/test/05_JS_Tests/adopt.js b/atest/test/05_JS_Tests/adopt.js index 9fda4b99a..e9dd5d30d 100644 --- a/atest/test/05_JS_Tests/adopt.js +++ b/atest/test/05_JS_Tests/adopt.js @@ -42,9 +42,23 @@ async function adoptedContextOnCloseWasCalled() { return onCloseCalled; } +async function openAdoptedPersistentContextWithRecording(userDataDir, url, headless, tracing, recordVideo, tracesDir, playwright, adoptContext) { + const contextOptions = { recordVideo }; + const context = await playwright.chromium.launchPersistentContext(userDataDir, { + headless: String(headless).toLowerCase() !== 'false', + tracesDir, + ...contextOptions, + }); + const page = context.pages()[0]; + await page.goto(url); + const adopted = await adoptContext(context, { tracing, contextOptions }); + return { ...adopted, videoPath: await page.video().path() }; +} + exports.__esModule = true; exports.openAdoptedPersistentContext = openAdoptedPersistentContext; exports.openAdoptedPersistentContextWithTracingAndDownloads = openAdoptedPersistentContextWithTracingAndDownloads; exports.messageOfAFailedWait = messageOfAFailedWait; exports.adoptedContextIsClosed = adoptedContextIsClosed; exports.adoptedContextOnCloseWasCalled = adoptedContextOnCloseWasCalled; +exports.openAdoptedPersistentContextWithRecording = openAdoptedPersistentContextWithRecording; diff --git a/atest/test/05_JS_Tests/adopted_context_recording.robot b/atest/test/05_JS_Tests/adopted_context_recording.robot new file mode 100644 index 000000000..be2adb035 --- /dev/null +++ b/atest/test/05_JS_Tests/adopted_context_recording.robot @@ -0,0 +1,23 @@ +*** Settings *** +Library ../../library/AdoptingLibrary.py jsextension=${CURDIR}/adopt.js +Library ArchiveLibrary +Library Collections +Library OperatingSystem +Library ../01_Browser_Management/tracing_groups.py +Resource ../variables.resource + +Test Tags no-iframe + +*** Test Cases *** +A Library Built On Browser Traces And Records An Adopted Context + ${video} = Open Adopted Context With Recording + ... ${OUTPUT_DIR}/adopted-profile-recorded ${LOGIN_URL} ${HEADLESS} + ... ${OUTPUT_DIR}/adopted-recording.zip {} + Get Text h1 == Login Page + Close Browser + Should Contain ${video} browser${/}video + File Should Not Be Empty ${video} + Extract Zip File ${OUTPUT_DIR}/adopted-recording.zip ${OUTPUT_DIR}/adopted-recording + ${groups} = Get Trace Group Titles ${OUTPUT_DIR}/adopted-recording/trace.trace + Should Contain Match ${groups} AdoptingLibrary.Open Adopted Context With Recording* + Should Contain Match ${groups} AdoptingLibrary.Get Text*