Skip to content
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
52 changes: 52 additions & 0 deletions Browser/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,19 @@

# 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,
SupportedBrowsers,
TracingGroupMode,
ViewportDimensions,
)
from .version import __version__ as VERSION

Expand Down Expand Up @@ -896,6 +900,54 @@ def call_js_keyword(self, keyword_name: str, **args) -> Any:
return None
return json.loads(last_json)

def adopted_context_options(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Browser launches a browser, it sets tracesDir to a unique folder under ${OUTPUT_DIR}/browser/traces/temp (_set_browser_options), and Browser removes these temporary trace files. The options here do not include tracesDir, so when an extension launches its own browser or Electron app with tracing, the temporary trace files end up outside that folder. Could adopted_context_options return tracesDir too, so that the extension can pass it to the launch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The options include tracesDir, a new folder under browser/traces/temp, as New Browser uses for the browsers it launches, and the docstring says to pass it to the launch. The Electron library passes it to _electron.launch, and the new acceptance test passes it to launchPersistentContext.

self,
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, 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, and
``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(
tracing, recordVideo, recordHar, viewport
)

def register_adopted_context(
self,
context_id: str,
options: AdoptedContextOptions,
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.
"""
return self._playwright_state.register_adopted_context(
context_id, options, video_path
)

@property
def outputdir(self) -> str:
if EXECUTION_CONTEXTS.current:
Expand Down
149 changes: 105 additions & 44 deletions Browser/keywords/playwright_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +34,7 @@
from ..base import LibraryComponent
from ..generated.playwright_pb2 import Request
from ..utils import (
AdoptedContextOptions,
ClientCertificate,
ColorScheme,
DownloadInfo,
Expand Down Expand Up @@ -751,7 +753,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
Expand Down Expand Up @@ -845,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()
Expand Down Expand Up @@ -960,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)
Expand Down Expand Up @@ -991,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)
Expand All @@ -1022,8 +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)
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
Expand Down Expand Up @@ -1056,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 (
Expand Down Expand Up @@ -1094,38 +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 _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(
Expand Down
1 change: 1 addition & 0 deletions Browser/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# flake8: noqa

from .data_types import (
AdoptedContextOptions,
AutoClosingLevel,
BrowserInfo,
BoundingBox,
Expand Down
18 changes: 17 additions & 1 deletion Browser/utils/data_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -718,6 +719,21 @@ 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 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why dict[str, Any] here, when RecordVideo and RecordHar already exist and are used for the arguments of adopted_context_options? Let's discuss this one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of showActions: the Electron library passes Playwright's recordVideo.showActions through adopted_context_options, and RecordVideo does not declare it, so typing the returned dictionary as RecordVideo would hide a key that is there. If RecordVideo gets showActions, as I suggested in the thread about the case-insensitive keys, AdoptedContextOptions can use RecordVideo | None and RecordHar | None, and I will change it then. Until we agree on that, I left dict[str, Any].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with your suggestion. I will merge this PR and send time on fixing few other minor things in the same go.

recordHar: dict[str, Any] | None
tracesDir: str


class _HttpCredentials(TypedDict):
username: str | Secret
password: str | Secret
Expand Down
34 changes: 34 additions & 0 deletions atest/library/AdoptingLibrary.py
Original file line number Diff line number Diff line change
@@ -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"]
)
4 changes: 0 additions & 4 deletions atest/library/os_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
6 changes: 6 additions & 0 deletions atest/test/01_Browser_Management/record_har.robot
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading