# Copyright 2020- Robot Framework Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json import os from contextlib import suppress from copy import copy from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any from uuid import uuid4 from assertionengine import AssertionOperator, list_verify_assertion, verify_assertion from robot.libraries.BuiltIn import EXECUTION_CONTEXTS, BuiltIn from robot.utils import get_link_path from robot.utils.robottypes import is_truthy from Browser.utils.data_types import ( AdoptedContextReport, BrowserInfo, ContextVideo, TracingGroupMode, ) from Browser.utils.misc import get_download_id from ..assertion_engine import assertion_formatter_used, with_assertion_polling from ..base import LibraryComponent from ..generated.playwright_pb2 import Request from ..utils import ( AdoptedContextOptions, ClientCertificate, ColorScheme, DownloadInfo, ForcedColors, GeoLocation, HttpCredentials, NewPageDetails, PageLoadStates, Permission, Proxy, RecordHar, RecordVideo, ReduceMotion, ReloadPages, SelectionType, ServiceWorkersPermissions, SupportedBrowsers, ViewportDimensions, convert_typed_dict, find_by_id, keyword, locals_to_params, logger, ) from ..utils.logger import LOGLEVEL class PlaywrightState(LibraryComponent): """Keywords to manage Playwright side Browsers, Contexts and Pages.""" # Helpers for Switch_ and Close_ keywords def _correct_browser(self, browser: SelectionType | str): if browser == SelectionType.ALL: raise ValueError if browser == SelectionType.CURRENT: return self.switch_browser(browser) @keyword(tags=("Setter", "BrowserControl")) def open_browser( self, url: str | None = None, browser: SupportedBrowsers = SupportedBrowsers.chromium, headless: bool = False, pause_on_failure: bool = True, bypassCSP=True, ): """*DEPRECATED* Opens a new browser instance. Use this keyword for quick experiments or debugging sessions. Use `New Page` directly instead of `Open Browser` for production and automated execution. See `Browser, Context and Page` for more information about Browser and related concepts. Creates a new browser, context and page with specified settings. *Arguments:* - ``url``: Navigates to URL if provided. Defaults to None. - ``browser``: Specifies which browser to use. The supported browsers are listed in the table below. - ``headless``: If set to False, a GUI is provided otherwise it is hidden. Defaults to False. - ``pause_on_failure``: Stop execution when failure detected and leave browser open. Defaults to True. - ``bypassCSP``: Defaults to bypassing CSP and enabling custom script attach to the page. Browsers: | = Value = | = Name(s) = | | ``firefox`` | [https://www.mozilla.org/en-US/firefox/new|Firefox] | | ``chromium`` | [https://www.chromium.org/Home|Chromium] | | ``webkit`` | [https://webkit.org/|webkit] | [https://forum.robotframework.org/t//4310|Comment >>] """ logger.warn( "Open Browser is for quick experimentation and debugging only. Use New Page for production." ) browser_id = self.new_browser(browser, headless=headless) self.new_context(bypassCSP=bypassCSP) self.new_page(url) if pause_on_failure: self.library.pause_on_failure.add(browser_id) @keyword(tags=("Setter", "BrowserControl")) def close_browser(self, browser: SelectionType | str = SelectionType.CURRENT): """Closes the current browser. Active browser is set to the browser that was active before this one. Closes all context and pages belonging to this browser. See `Browser, Context and Page` for more information about Browser and related concepts. *Arguments:* - ``browser``: Browser to close. ``CURRENT`` selects the active browser. ``ALL`` closes all browsers. When a browser id is provided, that browser is closed. Example: | `Close Browser` ALL # Closes all browsers | `Close Browser` CURRENT # Close current browser | `Close Browser` # Close current browser | `Close Browser` ${id} # Close browser matching id Closing is given at least 20 seconds to finish, and twice the current `Set Browser Timeout` value when that is longer. A browser that has stopped responding therefore fails this keyword instead of hanging the run, and a short Browser timeout does not cut the closing short. [https://forum.robotframework.org/t//4239|Comment >>] """ browser = SelectionType.create(browser) with self.playwright.grpc_channel() as stub: if browser == SelectionType.ALL: response = stub.CloseAllBrowsers( Request().Empty(), timeout=self.close_deadline ) self.library.pause_on_failure.clear() logger.info(response.log) self.browser_arg_mapping.clear() return if browser != SelectionType.CURRENT: self.switch_browser(browser) response = stub.CloseBrowser(Request.Empty(), timeout=self.close_deadline) closed_browser_id = response.body self.delete_browser_id_from_arg_mapping(closed_browser_id) self._update_tracing_contexts() self.library.pause_on_failure.discard(closed_browser_id) logger.info(response.log) def delete_browser_id_from_arg_mapping(self, closed_browser_id: str) -> None: for option_hash, browser_id in list(self.browser_arg_mapping.items()): if browser_id == closed_browser_id: del self.browser_arg_mapping[option_hash] @keyword(tags=("Setter", "BrowserControl")) def close_context( self, context: SelectionType | str = SelectionType.CURRENT, browser: SelectionType | str = SelectionType.CURRENT, *, save_trace: bool = True, ): """Closes a Context. Active context is set to the context that was active before this one. Closes pages belonging to this context. See `Browser, Context and Page` for more information about Context and related concepts. *Arguments:* - ``context``: Context to close. ``CURRENT`` selects the active context. ``ALL`` selects all contexts. When a context id is provided, that context is closed. - ``browser``: Browser in which contexts are closed. ``CURRENT`` selects the active browser. ``ALL`` selects all browsers. When a browser id is provided, contexts of that browser are closed. The browsers themselves are not closed. - ``save_trace``: If set to ``False``, the trace of this context is not saved, even if it was enabled by `New Context`. Defaults to ``True``. Example: | `Close Context` # Closes the current context of the current browser | `Close Context` CURRENT CURRENT # Closes the current context of the current browser | `Close Context` ALL CURRENT # Closes all contexts of the current browser | `Close Context` ALL ALL # Closes all contexts of all browsers Closing is given at least 20 seconds to finish, and twice the current `Set Browser Timeout` value when that is longer. A browser that has stopped responding therefore fails this keyword instead of hanging the run, and a short Browser timeout does not cut the closing short. [https://forum.robotframework.org/t//4240|Comment >>] """ context = SelectionType.create(context) browser = SelectionType.create(browser) for browser_instance in self._get_browser_ids(browser): # TODO: this looks weird. this ^ function should maybe not switch browsers if browser_instance["id"] == "NO BROWSER OPEN": logger.info("No browsers open. can not closing context.") return active_browser = self._get_active_browser_item( self._get_browser_catalog(include_page_details=False) ) if active_browser["id"] != browser_instance["id"]: self._switch_browser(browser_instance["id"], "TRACE") with suppress(Exception): contexts = self._get_context(context, browser_instance["contexts"]) self._close_pw_context(contexts, save_trace) self._update_tracing_contexts() def _update_tracing_contexts(self): if self.library.tracing_contexts: all_context_ids = self.get_context_ids() self.library.tracing_contexts = [ ctx_id for ctx_id in self.library.tracing_contexts if ctx_id in all_context_ids ] def _close_pw_context(self, contexts, save_trace=True): with self.playwright.grpc_channel() as stub: for context in contexts: self.context_cache.remove(context["id"]) self.switch_context(context["id"]) response = stub.CloseContext( Request().Bool(value=save_trace), timeout=self.close_deadline ) logger.info(response.log) def _get_context(self, context, contexts): if context == SelectionType.ALL: return contexts if context == SelectionType.CURRENT: current_ctx = self.switch_context("CURRENT") try: return [find_by_id(current_ctx, contexts, log_error=False)] except StopIteration: logger.info("No open context found.") return [] return [find_by_id(context, contexts, log_error=False)] def _get_browser_ids(self, browser) -> list: catalog = self._get_browser_catalog(include_page_details=False) if browser == SelectionType.ALL: browser_ids = [browser_instance["id"] for browser_instance in catalog] elif browser == SelectionType.CURRENT: current_browser = self.switch_browser("CURRENT") browser_ids = [current_browser] if current_browser == "NO BROWSER OPEN": browser_ids = [] else: browser_ids = [browser] return [find_by_id(browser_id, catalog) for browser_id in browser_ids] @staticmethod def _get_active_triplet( catalog: list[dict], ) -> tuple[str | None, str | None, str | None]: browser = next((b for b in catalog if b.get("activeBrowser")), None) if not browser: return None, None, None context_id = browser.get("activeContext") if not context_id: return browser["id"], None, None context = next( (c for c in browser.get("contexts", []) if c["id"] == context_id), None ) if not context: return browser["id"], context_id, None return browser["id"], context_id, context.get("activePage") @keyword(tags=("Setter", "BrowserControl")) def close_page( self, page: SelectionType | NewPageDetails | str = SelectionType.CURRENT, context: SelectionType | str = SelectionType.CURRENT, browser: SelectionType | str = SelectionType.CURRENT, runBeforeUnload: bool = False, ) -> list[dict]: """Closes the ``page`` in ``context`` in ``browser``. Defaults to current for all three. Active page is set to the page that was active before this one. See `Browser, Context and Page` for more information about Page and related concepts. *Arguments:* - ``page``: Page to close. ``CURRENT`` selects the active page. ``ALL`` selects all pages. When a page id is provided, that page is closed. - ``context``: Context in which pages are closed. ``CURRENT`` selects the active context. ``ALL`` selects all contexts. When a context id is provided, pages of that context are closed. The contexts themselves are not closed. - ``browser``: Browser in which pages are closed. ``CURRENT`` selects the active browser. ``ALL`` selects all browsers. When a browser id is provided, pages of that browser are closed. The browsers themselves are not closed. - ``runBeforeUnload``: defines whether to run the [https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event|before unload] page handlers. Defaults to false. If a page id is given, the ``context`` and ``browser`` arguments are ignored and the page is searched from all open browsers. Likewise, if a context id is given, the ``browser`` argument is ignored. Returns a list of dictionaries containing id, errors and console messages from the page. Example: | `Close Page` # Closes current page, within the current context and browser | `Close Page` CURRENT CURRENT CURRENT # Closes current page, within the current context and browser | `Close Page` ALL ALL ALL # Closes all pages, within all contexts and browsers [https://forum.robotframework.org/t//4241|Comment >>] """ page_id = ( self._get_page_uid(page) if isinstance(page, dict) else SelectionType.create(page) ) context = SelectionType.create(context) browser = SelectionType.create(browser) catalog = self._get_browser_catalog(False) _active_browser, _active_context, active_page = self._get_active_triplet( catalog ) if ( page_id == SelectionType.CURRENT and active_page and context == SelectionType.CURRENT and browser == SelectionType.CURRENT ): logger.info("Closing current active page.") return [self._close_current_page(runBeforeUnload)] if isinstance(page_id, str): logger.debug( f"Specific page id requested: {page_id}. Ignoring context and browser arguments." ) if page_id == active_page: logger.info("Closing current active page.") return [self._close_current_page(runBeforeUnload)] context = SelectionType.ALL browser = SelectionType.ALL if isinstance(context, str): logger.debug( f"Specific context id requested: {context}. Ignoring browser argument." ) browser = SelectionType.ALL catalog = self._get_filtered_browser_catalog(browser=browser, catalog=catalog) context_list = self._get_filtered_context_items_from_catalog( context=context, catalog=catalog ) page_ids_to_close = [ p["id"] for p in self._get_filtered_page_items_from_context_list( page=page_id, context_list=context_list ) ] results = [] for page_to_close in page_ids_to_close: self._switch_page(page_to_close) results.append(self._close_current_page(runBeforeUnload)) if active_page and active_page not in page_ids_to_close: self._switch_page(active_page) return results def _close_current_page(self, runBeforeUnload): with self.playwright.grpc_channel() as stub: response = stub.ClosePage( Request().ClosePage(runBeforeUnload=runBeforeUnload) ) if response.log: logger.info(response.log) return { "errors": json.loads(response.errors), "console": json.loads(response.console), "id": response.pageId, } @keyword(tags=("Setter", "BrowserControl")) def set_default_run_before_unload(self, runBeforeUnload: bool) -> bool: """Set default runBeforeUnload value when `Close Page` is called indirectly. Close Page is called indirectly when [https://marketsquare.github.io/robotframework-browser/Browser.html#Automatic%20page%20and%20context%20closing|automatic page closing] is done. The default value is false and this keyword can be used to change value. Returns the old runBeforeUnload value. [https://forum.robotframework.org/t/6203|Comment >>] """ old_value = self.library.auto_closing_default_run_before_unload self.library.auto_closing_default_run_before_unload = runBeforeUnload return old_value @keyword(tags=("Setter", "BrowserControl")) def connect_to_browser( self, wsEndpoint: str, browser: SupportedBrowsers = SupportedBrowsers.chromium, use_cdp: bool = False, *, timeout: timedelta = timedelta(seconds=30), ): """Connect to a Playwright browser server via playwright websocket or Chrome DevTools Protocol. See `Launch Browser Server` for more information about how to launch a playwright browser server. See `Browser, Context and Page` for more information about Browser and related concepts. Returns a stable identifier for the connected browser. *Arguments:* - ``wsEndpoint``: Address to connect to. Either ``ws://`` or ``http://`` if cdp is used. - ``browser``: Opens the specified browser. Defaults to ``chromium``. - ``use_cdp``: Connect to browser via Chrome DevTools Protocol. Defaults to False. Works only with Chromium based browsers. - ``timeout``: Maximum time in Robot Framework time format to wait for the connection to be established. Defaults to 30 seconds. The timeout can not be disabled; ``0`` also means 30 seconds. To connect to a browser via Chrome DevTools Protocol, the browser must be started with this protocol enabled. This is typically done by starting a Chrome browser with the argument ``--remote-debugging-port=9222`` or similar. When the browser is running with activated CDP, it is possible to connect to it either with websockets (``ws://``) or via HTTP (``http://``). The HTTP connection can be used when ``use_cdp`` is set to True. A typical address for a CDP connection is ``http://127.0.0.1:9222``. [https://forum.robotframework.org/t//4242|Comment >>] """ timeout_ms = int(self.convert_timeout(timeout)) with self.playwright.grpc_channel() as stub: response = stub.ConnectToBrowser( Request().ConnectBrowser( url=wsEndpoint, browser=browser.name, connectCDP=use_cdp, timeout=timeout_ms, ) ) logger.info(response.log) return response.body @keyword(tags=("Setter", "BrowserControl")) def new_browser( self, browser: SupportedBrowsers = SupportedBrowsers.chromium, headless: bool = True, *, args: list[str] | None = None, channel: str | None = None, chromiumSandbox: bool = False, devtools: bool = False, downloadsPath: str | None = None, env: dict | None = None, executablePath: str | None = None, firefoxUserPrefs: dict[str, str | int | float | bool] | None = None, handleSIGHUP: bool = True, handleSIGINT: bool = True, handleSIGTERM: bool = True, ignoreDefaultArgs: list[str] | bool | None = None, proxy: Proxy | None = None, reuse_existing: bool = True, slowMo: timedelta = timedelta(seconds=0), timeout: timedelta = timedelta(seconds=30), ) -> str: """Create a new playwright Browser with specified options. See `Browser, Context and Page` for more information about Browser and related concepts. Returns a stable identifier for the created browser. *Arguments:* - ``browser``: Opens the specified [#type-SupportedBrowsers | browser]. Defaults to chromium. - ``headless``: Set to False if you want a GUI. Defaults to True. - ``args``: Additional arguments to pass to the browser instance. The list of Chromium flags can be found [http://peter.sh/experiments/chromium-command-line-switches/ | here]. Defaults to None. - ``channel``: Allows operating against the stock Google Chrome and Microsoft Edge browsers. Can only be used together with the ``chromium`` browser, otherwise the keyword fails. For more details see: [https://playwright.dev/docs/browsers#google-chrome--microsoft-edge | Playwright documentation]. - ``chromiumSandbox``: Enable Chromium sandboxing. Defaults to False. - ``devtools``: Chromium-only. Whether to auto-open a Developer Tools panel for each tab. Defaults to False. - ``downloadsPath``: If specified, accepted downloads are downloaded into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. Regarding file deletion, see the docs of `Download` and `Promise To Wait For Download`. - ``env``: Specifies environment variables that will be visible to the browser. Dictionary keys are variable names, values are the content. Defaults to None. - ``executablePath``: Path to a browser executable to run instead of the bundled one. If executablePath is a relative path, then it is resolved relative to current working directory. Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. Defaults to None. - ``firefoxUserPrefs``: Firefox user preferences. Learn more about the Firefox user preferences at [https://support.mozilla.org/en-US/kb/about-config-editor-firefox | about:config]. - ``handleSIGHUP``: Close the browser process on SIGHUP. Defaults to True. - ``handleSIGINT``: Close the browser process on Ctrl-C. Defaults to True. - ``handleSIGTERM``: Close the browser process on SIGTERM. Defaults to True. - ``ignoreDefaultArgs``: If True, Playwright does not pass its own configuration args and only uses the ones from ``args``. If a list is given, then the given default arguments are filtered out. Dangerous option; use with care. Defaults to None, which means Playwright's own default arguments are used. - ``proxy``: Network [#type-Proxy | Proxy] settings. Structure: ``{'server': , 'bypass': , 'username': , 'password': }``. Robot Framework 7.4 Secret type is supported. - ``reuse_existing``: If set to True, an existing browser instance that was created with the same arguments is reused. If no such browser exists, a new one is started. Defaults to True. - ``slowMo``: Slows down Playwright operations by the given time, in Robot Framework time format. Useful so that you can see what is going on. Defaults to no delay. - ``timeout``: Maximum time in Robot Framework time format to wait for the browser instance to start. Defaults to 30 seconds. Pass 0 to disable timeout. [https://forum.robotframework.org/t//4306|Comment >>] """ params = locals_to_params(locals()) parameter_hash = self._get_parameter_hash(params) existing_browser_id = self._switch_to_existing_browser( reuse_existing, parameter_hash ) if existing_browser_id: return existing_browser_id params = self._set_browser_options(params, browser, channel, slowMo, timeout) options = json.dumps(params, default=str) logger.info(options) with self.playwright.grpc_channel() as stub: response = stub.NewBrowser( Request().Browser(browser=browser.name, rawOptions=options) ) logger.info(response.log) self.browser_arg_mapping[parameter_hash] = response.body return response.body @keyword(tags=("Setter", "BrowserControl")) def launch_browser_server( self, browser: SupportedBrowsers = SupportedBrowsers.chromium, headless: bool = True, *, args: list[str] | None = None, channel: str | None = None, chromiumSandbox: bool = False, devtools: bool = False, downloadsPath: str | None = None, env: dict | None = None, executablePath: str | None = None, firefoxUserPrefs: dict[str, str | int | float | bool] | None = None, handleSIGHUP: bool = True, handleSIGINT: bool = True, handleSIGTERM: bool = True, ignoreDefaultArgs: list[str] | bool | None = None, port: int | None = None, proxy: Proxy | None = None, reuse_existing: bool = True, slowMo: timedelta = timedelta(seconds=0), timeout: timedelta = timedelta(seconds=30), wsPath: str | None = None, ) -> str: """Launches a new playwright Browser server with specified options. Returns a websocket endpoint (wsEndpoint) string that can be used to connect to the server. *Arguments:* - ``port``: Port to use for the browser server. Defaults to 0, which results in a random free port being assigned. - ``wsPath``: Path at which to serve the browser server. For security, this defaults to an unguessable string. Check `New Browser` for the other argument docs. The launched browser server can be used to connect to it with `Connect To Browser` keyword. This keyword can also be used from command line with ``rfbrowser launch-browser-server`` command. See [https://playwright.dev/docs/api/class-browserserver#browser-server|Playwright documentation] for more information. [https://forum.robotframework.org/t//4306|Comment >>] """ params = locals_to_params(locals()) params = self._set_browser_options(params, browser, channel, slowMo, timeout) options = json.dumps(params, default=str) logger.info(options) with self.playwright.grpc_channel() as stub: response = stub.LaunchBrowserServer( Request().Browser(browser=browser.name, rawOptions=options) ) logger.info(response.log) return response.body @keyword(tags=("Setter", "BrowserControl")) def close_browser_server(self, wsEndpoint: str) -> None: """Close a playwright Browser Server identified by its websocket endpoint (wsEndpoint). The wsEndpoint string is returned by `Launch Browser Server` and is also used by `Connect To Browser`. *Arguments:* - ``wsEndpoint``: Address of the browser server. Example: ``ws://127.0.0.1:63784/ca69bf0e9471391e8183d9ac1e90e1ba`` """ with self.playwright.grpc_channel() as stub: response = stub.CloseBrowserServer(Request().ConnectBrowser(url=wsEndpoint)) logger.info(response.log) self._update_tracing_contexts() def _switch_to_existing_browser( self, reuse_existing: bool, parameter_hash: int ) -> str | None: if not reuse_existing: return None existing_browser_id = self.browser_arg_mapping.get(parameter_hash) if existing_browser_id is None: return None try: self.switch_browser(existing_browser_id) logger.info(f"Reusing existing browser with id: {existing_browser_id}") return existing_browser_id except AssertionError: self.browser_arg_mapping.pop(parameter_hash, None) return None def _get_parameter_hash(self, params: dict[str, Any]) -> int: params.pop("reuse_existing", None) return hash(repr(params)) @keyword(tags=("Setter", "BrowserControl")) def new_context( self, *, acceptDownloads: bool = True, baseURL: str | None = None, bypassCSP: bool = False, clientCertificates: list[ClientCertificate] | None = None, colorScheme: ColorScheme | None = None, defaultBrowserType: SupportedBrowsers | None = None, deviceScaleFactor: float | None = None, extraHTTPHeaders: dict[str, str] | None = None, forcedColors: ForcedColors = ForcedColors.none, geolocation: GeoLocation | None = None, hasTouch: bool | None = None, httpCredentials: HttpCredentials | None = None, ignoreHTTPSErrors: bool = False, isMobile: bool | None = None, javaScriptEnabled: bool = True, locale: str | None = None, offline: bool = False, permissions: list[Permission] | None = None, proxy: Proxy | None = None, recordHar: RecordHar | None = None, recordVideo: RecordVideo | None = None, reducedMotion: ReduceMotion = ReduceMotion.no_preference, screen: dict[str, int] | None = None, serviceWorkers: ServiceWorkersPermissions | None = ServiceWorkersPermissions.allow, storageState: Path | None = None, timezoneId: str | None = None, tracing: bool | Path | None = None, userAgent: str | None = None, viewport: ViewportDimensions | None = ViewportDimensions( width=1280, height=720 ), ) -> str: """Create a new BrowserContext with specified options. See `Browser, Context and Page` for more information about BrowserContext. Returns a stable identifier for the created context that can be used in `Switch Context`. *Arguments:* - ``acceptDownloads``: Whether to automatically download all the attachments. Defaults to True where all the downloads are accepted. - ``baseURL``: When using `Go To`, `Wait For Request`, `Wait For Response` or `Wait For Navigation` it takes the base URL in consideration by using the URL() constructor for building the corresponding URL. Unset by default. Examples: ``baseURL=http://localhost:3000`` and navigating to ``/bar.html`` results in ``http://localhost:3000/bar.html``. ``baseURL=http://localhost:3000/foo/`` and navigating to ``./bar.html`` results in ``http://localhost:3000/foo/bar.html``. ``baseURL=http://localhost:3000/foo`` (without trailing slash) and navigating to ``./bar.html`` results in ``http://localhost:3000/bar.html``. - ``bypassCSP``: Toggles bypassing page's Content-Security-Policy. Defaults to False. - ``clientCertificates``: Specifies a client certificate for mTLS authentication, for example ``clientCertificates=[{'origin': 'https://playwright.dev', 'pfxPath': 'certificate.p12', 'passphrase': 'password'}]``. *NOTE:* The origin needs to be exact without any path. - ``colorScheme``: Emulates the ``prefers-color-scheme`` media feature, supported values are ``light``, ``dark``, ``no-preference`` and ``null``. ``null`` disables the emulation. - ``defaultBrowserType``: If no browser is open and `New Context` opens a new browser with defaults, this setting defines which browser is opened. Very useful together with the `Get Device` keyword. - ``deviceScaleFactor``: Specify device scale factor (can be thought of as dpr). Defaults to ``1``. - ``extraHTTPHeaders``: A dictionary containing additional HTTP headers to be sent with every request. All header values must be strings. - ``forcedColors``: Emulates the ``forced-colors`` media feature, supported values are ``active``, ``none`` and ``null``. Defaults to ``none``. - ``geolocation``: A dictionary containing ``latitude`` and ``longitude`` and optionally ``accuracy`` to emulate. If ``latitude`` or ``longitude`` is not specified, the device geolocation won't be overridden. - ``hasTouch``: Specifies if viewport supports touch events. Defaults to False. - ``httpCredentials``: Credentials for [https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication | HTTP authentication]. - ``ignoreHTTPSErrors``: Whether to ignore HTTPS errors during navigation. Defaults to False. - ``isMobile``: Whether the meta viewport tag is taken into account and touch events are enabled. Defaults to False. - ``javaScriptEnabled``: Whether or not to enable JavaScript in the context. Defaults to True. - ``locale``: Specify user locale, for example ``en-GB``, ``de-DE``, etc. - ``offline``: Toggles browser's offline mode. Defaults to False. - ``permissions``: A list containing permissions to grant to all pages in this context. All permissions that are not listed here will be automatically denied. - ``proxy``: Network proxy settings to use with this context. Defaults to None. *NOTE:* For Chromium on Windows the browser needs to be launched with the global proxy for this option to work. If all contexts override the proxy, global proxy will be never used and can be any string, for example ``proxy={ server: 'http://per-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'}``. 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 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``. - ``screen``: Emulates consistent window screen size available inside web page via window.screen. Is only used when the viewport is set. Example {'width': 414, 'height': 896} - ``serviceWorkers``: Whether to allow sites to register Service workers. Defaults to ``allow``. - ``storageState``: Restores the storage state created by the `Save Storage State` keyword. Must be a path to an existing file, otherwise the keyword fails. Relative paths are resolved against the current working directory. - ``timezoneId``: Changes the timezone of the context. See [https://source.chromium.org/chromium/chromium/src/+/master:third_party/icu/source/data/misc/metaZones.txt | ICU metaZones.txt] for a list of supported timezone IDs. - ``tracing``: Boolean ``True`` (recommendation) or file path or directory where the [https://playwright.dev/docs/api/class-tracing/ | tracing] file is saved. The string ``{contextid}`` will be replaced with the context id. Path to *.zip files can be absolute or relative to ${OUTPUT_DIR}. Path to folders can be absolute or relative to ${OUTPUT_DIR}/browser/traces. If boolean ``True`` or a directory is given, the trace file will automatically be named ``trace_{contextid}.zip``. Temporary trace files will be saved to ${OUTPUT_DIR}/browser/traces/temp. Tracing is automatically closed when context is closed. Temporary trace files will be automatically deleted at start of each test execution. Trace file can be opened after the test execution by running command from shell: ``rfbrowser show-trace /path/to/trace.zip``. Tracing can also be enabled by setting a Robot Framework variable or environment variable ``ROBOT_FRAMEWORK_BROWSER_TRACING`` to ``True``. - ``userAgent``: Specific user agent to use in this context. - ``viewport``: A dictionary containing ``width`` and ``height``. Emulates consistent viewport for each page. Defaults to 1280x720. ``None`` disables the default viewport. If ``width`` and ``height`` are ``0``, the viewport will scale with the window. Example: | Test an iPhone | ${device}= `Get Device` iPhone X | `New Context` &{device} # unpacking here with & | `New Page` http://example.com A BrowserContext is the Playwright object that controls a single browser profile. Within a context caches and cookies are shared. See [https://playwright.dev/docs/api/class-browser#browsernewcontextoptions|Playwright browser.newContext] for a list of supported options. If there's no open Browser this keyword will open one. Does not create pages. It is not possible to create a new context in a browser that was opened with `New Persistent Context`. The httpCredentials and proxy arguments do support Robot Framework 7.4 Secret type. If Secret is used, the dictionary structure must be created before hand, example with Robot Framework's VAR syntax: | ${user} ${password} = Get Secrets # Returns username and password as Robot Framework Secret type | VAR &{httpCredentials} = username=${user} password=${password} | New Context httpCredentials=${httpCredentials} Using dictionary literals with Robot Framework 7.4 Secret type is not supported, for example this will fail on variable conversion on Robot Framework side: | ${user} ${password} = Get Secrets # Returns username and password as Robot Framework Secret type | New Context httpCredentials={'username': ${secret}, 'password': ${secret}} # This will fail [https://forum.robotframework.org/t//4307|Comment >>] """ params = locals_to_params(locals()) if permissions: params["permissions"] = [ ( permission.value if not isinstance(permission, str) else Permission[permission].value ) for permission in permissions ] params["viewport"] = copy(viewport) trace_file = self._resolve_trace_file(tracing) params = self._set_context_options(params, httpCredentials, storageState) options = json.dumps(params, default=str) response = self._new_context(options, trace_file) context_options = self._mask_credentials(json.loads(response.contextOptions)) logger.info(response.log) logger.info(context_options) if response.newBrowser: logger.info( "No browser was open. New browser was automatically opened " "when this context is created." ) self._register_context(response.id, trace_file, params.get("recordVideo")) return response.id @keyword() def new_persistent_context( self, userDataDir: str = "", # TODO: change to PurePath browser: SupportedBrowsers = SupportedBrowsers.chromium, headless: bool = True, *, acceptDownloads: bool = True, args: list[str] | None = None, baseURL: str | None = None, bypassCSP: bool = False, channel: str | None = None, chromiumSandbox: bool = False, colorScheme: ColorScheme | None = None, defaultBrowserType: SupportedBrowsers | None = None, deviceScaleFactor: float | None = None, devtools: bool = False, downloadsPath: str | None = None, env: dict | None = None, executablePath: str | None = None, extraHTTPHeaders: dict[str, str] | None = None, forcedColors: ForcedColors = ForcedColors.none, geolocation: GeoLocation | None = None, handleSIGHUP: bool = True, handleSIGINT: bool = True, handleSIGTERM: bool = True, hasTouch: bool | None = None, httpCredentials: HttpCredentials | None = None, ignoreDefaultArgs: list[str] | bool | None = None, ignoreHTTPSErrors: bool = False, isMobile: bool | None = None, javaScriptEnabled: bool = True, locale: str | None = None, offline: bool = False, permissions: list[Permission] | None = None, proxy: Proxy | None = None, recordHar: RecordHar | None = None, recordVideo: RecordVideo | None = None, reducedMotion: ReduceMotion = ReduceMotion.no_preference, screen: dict[str, int] | None = None, serviceWorkers: ServiceWorkersPermissions | None = ServiceWorkersPermissions.allow, slowMo: timedelta = timedelta(seconds=0), timeout: timedelta = timedelta(seconds=30), timezoneId: str | None = None, tracing: bool | Path | None = None, url: str | None = None, userAgent: str | None = None, viewport: ViewportDimensions | None = ViewportDimensions( width=1280, height=720 ), ): """Opens a new persistent context. `New Persistent Context` basically executes `New Browser`, `New Context` and `New Page` in one step and sets a profile at the same time. See [https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context | persistent context] in Playwright docs. This keyword returns a tuple of browser id, context id and page details. (New in Browser 15.0.0) *Arguments:* - ``userDataDir``: Path to a User Data Directory, which stores browser session data like cookies and local storage. Note that Chromium's user data directory is the parent directory of the "Profile Path" seen at chrome://version. Pass an empty string to use a temporary directory instead. - ``browser``: Browser type to use. Default is Chromium. - ``headless``: Whether to run browser in headless mode. Defaults to ``True``. ``other Arguments``: Please see `New Browser`, `New Context` and `New Page` for more information about the other arguments. If you want to use extensions you need to download the extension as a .zip, enable loading the extension, and load the extensions using chromium arguments like below. Extensions only work with chromium and with a headful browser. | ${launch_args}= Set Variable ["--disable-extensions-except=./ublock/uBlock0.chromium", "--load-extension=./ublock/uBlock0.chromium"] | ${browserId} ${contextId} ${pageDetails}= `New Persistent Context` browser=chromium headless=False url=https://robocon.io args=${launch_args} Check `New Browser`, `New Context` and `New Page` for the specific argument docs. [https://forum.robotframework.org/t//4309|Comment >>] """ params = locals_to_params(locals()) if permissions: params["permissions"] = [ ( permission.value if not isinstance(permission, str) else Permission[permission].value ) for permission in permissions ] params["viewport"] = copy(viewport) trace_file = self._resolve_trace_file(tracing) params = self._set_browser_options(params, browser, channel, slowMo, timeout) params = self._set_context_options(params, httpCredentials, None) options = json.dumps(params, default=str) with self.playwright.grpc_channel() as stub: catalog = self._get_browser_catalog(include_page_details=False) previously_active_browser_id = None existing_browser_ids = [] for browser_info in catalog: if browser_info["activeBrowser"]: previously_active_browser_id = browser_info["id"] existing_browser_ids.append(browser_info["id"]) response = stub.NewPersistentContext( Request().PersistentContext( browser=browser.name, rawOptions=options, defaultTimeout=int(self.timeout), traceFile=str(trace_file), ) ) self.add_context_and_keyword_call_stack_to_trace(trace_file, response.id) context_options = self._mask_credentials( json.loads(response.contextOptions) ) logger.info(response.log) logger.info(context_options) browser_id = response.browserId if url: try: stub.GoTo( Request().UrlOptions( url=Request().Url( url=url, defaultTimeout=int(self.get_timeout(timeout)) ) ) ) except Exception as e: if browser_id in existing_browser_ids: self.close_context() self.switch_browser(previously_active_browser_id) logger.debug( "Teardown: Closed new Context and switched back " f"to previously active browser: {previously_active_browser_id}" ) else: self.close_browser() logger.debug("Teardown: Closed new persistent context.") raise e video_path = self._cache_context( response.id, params.get("recordVideo"), 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, report: AdoptedContextReport) -> None: options = report["contextOptions"] video = report["video"] record_video: RecordVideo | None = None if options.get("recordVideo") is not None or video["video_path"]: requested: RecordVideo = options.get("recordVideo") or RecordVideo() record_video = { **requested, "size": self._video_size(requested, options.get("viewport")), } self._register_context(report["id"], report["traceFile"], record_video, video) def _resolve_trace_file(self, tracing: bool | Path | None) -> str: if isinstance(tracing, str): tracing = Path(tracing) tracing_by_var = ( is_truthy( BuiltIn().get_variable_value("${ROBOT_FRAMEWORK_BROWSER_TRACING}", "") ) if EXECUTION_CONTEXTS.current else False ) tracing_by_env = is_truthy(os.getenv("ROBOT_FRAMEWORK_BROWSER_TRACING") or "") if not tracing and not tracing_by_var and not tracing_by_env: return "" if tracing is True or tracing_by_var or tracing_by_env: return str( self.traces_output ) # will be extended by '`trace_${context_id}.zip`' on js side. if isinstance(tracing, Path) and tracing.suffix == ".zip": return str(Path(self.outputdir, tracing).resolve()) return str(Path(self.traces_output, str(tracing)).resolve()) def _set_context_options(self, params, httpCredentials, storageState): params = convert_typed_dict(self.new_context.__annotations__, 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 if storageState: if not storageState.is_file(): raise ValueError( f"storageState argument value '{storageState}' is not file, but it should be." ) params["storageState"] = storageState.resolve() if "httpCredentials" in params and params["httpCredentials"] is not None: secret = self.resolve_secret(httpCredentials, "httpCredentials") params["httpCredentials"] = secret masked_params = self._mask_credentials(params.copy()) logger.info(json.dumps(masked_params, default=str, indent=2)) return params def _set_browser_options(self, params, browser, channel, slowMo, timeout): params = convert_typed_dict(self.new_context.__annotations__, params) params["timeout"] = self.convert_timeout(timeout) params["slowMo"] = self.convert_timeout(slowMo) browser_path = self.library.external_browser_executable.get(browser) if browser_path: params["executablePath"] = browser_path if channel and browser != SupportedBrowsers.chromium: raise ValueError( f"Must use {SupportedBrowsers.chromium.name} browser with channel definition" ) params["tracesDir"] = str(self.traces_temp / str(uuid4())) return params def _new_context(self, options: str, trace_file: Path | str): with self.playwright.grpc_channel() as stub: return stub.NewContext( Request().Context( rawOptions=options, defaultTimeout=int(self.timeout), traceFile=str(trace_file), ) ) def add_context_and_keyword_call_stack_to_trace(self, trace_file, ctx_id): if ( not trace_file or self.library.tracing_group_mode == TracingGroupMode.Playwright ): return None self.library.tracing_contexts.append(ctx_id) if self.library.tracing_group_mode == TracingGroupMode.Browser: if not self.library.keyword_call_stack: return None return self.open_trace_group( **self.library._trace_group_arguments( self.library.keyword_call_stack[-1] ), context_id=ctx_id, ) for keyword_call in self.library.keyword_call_stack: self.open_trace_group( **self.library._trace_group_arguments(keyword_call), context_id=ctx_id, ) return None def _mask_credentials(self, data: dict): if "httpCredentials" in data: data["httpCredentials"] = "XXX" return data def _register_context( self, context_id: str, trace_file: str, record_video: RecordVideo | None, video: ContextVideo | None = None, ) -> str: self.add_context_and_keyword_call_stack_to_trace(trace_file, context_id) return self._cache_context(context_id, record_video, video) def _cache_context( self, context_id: str, record_video: RecordVideo | None, video: ContextVideo | None = None, ) -> str: if record_video is not None: self.context_cache.add(context_id, record_video["size"]) return self._embed_video(video) if video else "" def _record_video( self, record_video: RecordVideo | None, viewport: ViewportDimensions | None, ) -> RecordVideo | 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: RecordVideo, viewport: ViewportDimensions | None, ) -> ViewportDimensions: size = record_video.get("size") if size and size.get("width") and size.get("height"): return {"width": int(size["width"]), "height": int(size["height"])} if viewport: return {"width": viewport["width"], "height": viewport["height"]} return {"width": 1280, "height": 720} def _record_har(self, record_har: RecordHar | None) -> RecordHar | 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( self, url: str | None = None, wait_until: PageLoadStates = PageLoadStates.load, ) -> NewPageDetails: """Open a new Page. A Page is the Playwright equivalent to a tab. See `Browser, Context and Page` for more information about Page concept. *Arguments:* - ``url``: Optional URL to navigate the page to. The url should include the protocol, for example ``https://``. - ``wait_until``: When to consider operation succeeded, defaults to load. Events can be either: ``domcontentloaded`` - consider operation to be finished when the DOMContentLoaded event is fired. ``load`` - consider operation to be finished when the load event is fired. ``networkidle`` - consider operation to be finished when there are no network connections for at least 500 ms. ``commit`` - consider operation to be finished when network response is received and the document started loading. Returns `NewPageDetails` as dictionary for created page. `NewPageDetails` (dict) contains the keys ``page_id`` and ``video_path``. ``page_id`` is a stable identifier for the created page. ``video_path`` is path to the created video or empty if video is not created. When a `New Page` is called without an open browser, `New Browser` and `New Context` are executed with default values first. If navigating to ``url`` fails, the keyword fails and the newly created page is closed again. The page is closed only after the keyword set with `Register Keyword To Run On Failure` has run, so the failure screenshot shows the page that failed to load. If the wait times out, the page's load report is logged: whether its navigation committed, how far the document got and which requests were still open. [https://forum.robotframework.org/t//4308|Comment >>] """ failed_page_token = str(uuid4()) try: with self.playwright.grpc_channel() as stub: response = stub.NewPage( # '' will be treated as falsy on .ts side. # TODO: Use optional url field instead once it stabilizes at upstream # https://stackoverflow.com/a/62566052 Request().NewPage( url=Request().Url( url=(url or ""), defaultTimeout=int(self.timeout) ), waitUntil=wait_until.name, failedPageToken=failed_page_token, ) ) except Exception: self.library._remove_failed_page_after_failure_handling(failed_page_token) raise logger.info(response.log) if response.newBrowser: logger.info( "No browser and context was open. New browser and context was " "automatically opened when page is created." ) if response.newContext and not response.newBrowser: logger.info( "No context was open. New context was automatically opened when " "this page is created." ) video_path = self._embed_video(json.loads(response.video)) return NewPageDetails(page_id=response.body, video_path=video_path) def _remove_failed_page(self, token: str): with self.playwright.grpc_channel() as stub: response = stub.RemoveFailedPage(Request().FailedPage(token=token)) logger.info(response.log) def _embed_video(self, video: ContextVideo) -> str: if not video.get("video_path"): logger.debug("Video is not enabled.") return "" video_path = video.get("video_path") relative_path = get_link_path(video_path, self.outputdir) video_size = self.context_cache.get(video["contextUuid"]) video_width = video_size["width"] video_height = video_size["height"] video_type = relative_path.split(".")[1] logger.info( '' f'', html=True, ) return str(video_path) @keyword(tags=("Getter", "BrowserControl", "Assertion")) @with_assertion_polling @assertion_formatter_used def get_browser_catalog( self, assertion_operator: AssertionOperator | None = None, assertion_expected: Any | None = None, message: str | None = None, ) -> list[BrowserInfo]: """Returns all browsers, open contexts in them and open pages in these contexts. See `Browser, Context and Page` for more information about these concepts. *Arguments:* - ``assertion_operator``: Optional assertion operator. See `Assertions` for more information. - ``assertion_expected``: Optional expected value. See `Assertions` for more information. - ``message``: Optional custom message to use on failure. See `Assertions` for more information. The data is parsed into a python list containing data representing the open Objects. On the root level the data contains a list of open browsers. The ``type`` of a browser is ``chromium``, ``firefox`` or ``webkit``. For a context that a JavaScript extension adopted, it is the name the extension passed, ``adopted`` by default; see ``jsextension`` in `Importing`. Data can be manipulated also with ``assertion_operator`` for example to find a specific id based on index or page title with ``then`` operator. Return value can also be asserted against expected value. Sample: | [ | { | "type": "chromium", | "id": "browser=96207191-8147-44e7-b9ac-5e04f2709c1d", | "contexts": [ | { | "type": "context", | "id": "context=525d8e5b-3c4e-4baa-bfd4-dfdbc6e86089", | "activePage": "page=f90c97b8-eaaf-47f2-98b2-ccefd3450f12", | "pages": [ | { | "type": "page", | "title": "Robocorp", | "url": "https://robocorp.com/", | "id": "page=7ac15782-22d2-48b4-8591-ff17663fa737", | "timestamp": 1598607713.858 | }, | { | "type": "page", | "title": "Home - Reaktor", | "url": "https://www.reaktor.com/", | "id": "page=f90c97b8-eaaf-47f2-98b2-ccefd3450f12", | "timestamp": 1598607714.702 | } | ] | } | ], | "activeContext": "context=525d8e5b-3c4e-4baa-bfd4-dfdbc6e86089", | "activeBrowser": false | }, | { | "type": "firefox", | "id": "browser=ad99abac-17a9-472b-ac7f-d6352630834e", | "contexts": [ | { | "type": "context", | "id": "context=bc64f1ba-5e76-46dd-9735-4bd344afb9c0", | "activePage": "page=8baf2991-5eaf-444d-a318-8045f914e96a", | "pages": [ | { | "type": "page", | "title": "Software-Qualit\u00e4tssicherung und Softwaretest", | "url": "https://www.imbus.de/", | "id": "page=8baf2991-5eaf-444d-a318-8045f914e96a", | "timestamp": 1598607716.828 | } | ] | } | ], | "activeContext": "context=bc64f1ba-5e76-46dd-9735-4bd344afb9c0", | "activeBrowser": true | } | ] [https://forum.robotframework.org/t//4259|Comment >>] """ catalog = self._get_browser_catalog() logger.debug(json.dumps(catalog, indent=2)) formatter = self.get_assertion_formatter("Get Browser Catalog") return verify_assertion( catalog, assertion_operator, assertion_expected, "Browser Catalog", message, formatter, ) def _get_browser_catalog(self, include_page_details: bool = True) -> list: with self.playwright.grpc_channel() as stub: response = stub.GetBrowserCatalog( Request().Bool(value=include_page_details) ) return json.loads(response.json) @keyword(tags=("Getter", "BrowserControl", "Assertion")) def get_console_log( self, assertion_operator: AssertionOperator | None = None, assertion_expected: Any | None = None, message: str | None = None, *, full: bool = False, last: int | timedelta | None = None, ) -> list[dict]: """Returns the console log of the active page. If assertions are used and fail, this keyword will fail immediately without retrying. *Arguments:* - ``assertion_operator``: Optional assertion operator. See `Assertions` for more information. - ``assertion_expected``: Optional expected value. See `Assertions` for more information. - ``message``: Optional custom message to use on failure. See `Assertions` for more information. - ``full``: If true, returns the full console log. If false, returns only new entries that were added since last time. - ``last``: If set, returns only the last n entries. Can be an integer for the number of entries or a time period in Robot Framework time format. The returned data is a list of log messages. A log message is a dictionary with the following structure: | [{ | "type": str, | "text": str, | "location": { | "url": str, | "lineNumber": int, | "columnNumber": int | }, | "time": str | }] Example: | [{ | 'type': 'log', | 'text': 'Stuff loaded...', | 'location': { | 'url': 'https://example.com/js/chunk-769742de.6a462276.js', | 'lineNumber': 60, | 'columnNumber': 63771 | }, | 'time': '2023-02-05T17:42:52.064Z' | }] Keys: | =Key= | =Description= | | ``type`` | One of the following values: ``log``, ``debug``, ``info``, ``error``, ``warning``, ``dir``, ``dirxml``, ``table``, ``trace``, ``clear``, ``startGroup``, ``startGroupCollapsed``, ``endGroup``, ``assert``, ``profile``, ``profileEnd``, ``count``, ``timeEnd`` | | ``text`` | The text of the console message. | | ``location.url`` | The URL of the resource that generated this message. | | ``location.lineNumber`` | The line number in the resource that generated this message (0-based). | | ``location.columnNumber`` | The column number in the resource that generated this message (0-based). | | ``time`` | The timestamp of the log message as ISO 8601 string. | [https://forum.robotframework.org/t//5267|Comment >>] """ with self.playwright.grpc_channel() as stub: response = stub.GetConsoleLog(Request().Bool(value=full)) if response.log: logger.info(response.log) log_messages = json.loads(response.json) returned_messages = self._slice_messages(log_messages, last) return verify_assertion( returned_messages, assertion_operator, assertion_expected, "Console Log", message, ) @keyword(tags=("Getter", "BrowserControl", "Assertion")) def get_page_errors( self, assertion_operator: AssertionOperator | None = None, assertion_expected: Any | None = None, message: str | None = None, *, full: bool = False, last: int | timedelta | None = None, ) -> list[dict]: """Returns the page errors of the active page. If assertions are used and fail, this keyword will fail immediately without retrying. *Arguments:* - ``assertion_operator``: Optional assertion operator. See `Assertions` for more information. - ``assertion_expected``: Optional expected value. See `Assertions` for more information. - ``message``: Optional custom message to use on failure. See `Assertions` for more information. - ``full``: If true, returns all page errors. If false, returns only new errors that were added since last time. - ``last``: If set, returns only the last n entries. Can be an integer for the number of entries or a time period in Robot Framework time format. The returned data is a list of error messages. An error message is a dictionary with the following structure: | { | "name": str, | "message": str, | "stack": str, | "time": str | } Example: | [{ | 'name': 'ReferenceError', | 'message': 'YT is not defined', | 'stack': 'ReferenceError: YT is not defined\\n at HTMLIFrameElement.onload (https://example.com/:20:2245)', | 'time': '2023-02-05T20:08:48.912Z' | }] Keys: | =Key= | =Description= | | ``name`` | The name/type of the error. | | ``message`` | The human readable error message. | | ``stack`` | The stack trace of the error, if given. | | ``time`` | The timestamp of the error as ISO 8601 string. | [https://forum.robotframework.org/t//5268|Comment >>] """ with self.playwright.grpc_channel() as stub: response = stub.GetErrorMessages(Request().Bool(value=full)) if response.log: logger.info(response.log) errors = json.loads(response.json) returned_errors = self._slice_messages(errors, last) return verify_assertion( returned_errors, assertion_operator, assertion_expected, "Page Errors", message, ) def _slice_messages(self, message, last): if isinstance(last, int): return message[-last:] if not isinstance(last, timedelta): return message returned_messages = [] now = datetime.now(UTC) for msg in reversed(message): if datetime.fromisoformat(msg["time"]) < now - last: break returned_messages.append(msg) return returned_messages @keyword(tags=("Setter", "BrowserControl")) def switch_browser(self, id: str) -> str: # noqa: A002 """Switches the currently active Browser to another open Browser. Returns a stable identifier for the previous browser. See `Browser, Context and Page` for more information about Browser and related concepts. *Arguments:* - ``id``: The id of the browser to switch to. Example: ``browser=96207191-8147-44e7-b9ac-5e04f2709c1d``. A browser id is returned by `New Browser` when it is started or can be fetched from the browser catalog when returned by `Get Browser Catalog`. [https://forum.robotframework.org/t//4334|Comment >>] """ logger.info(f"Switching browser to {id}") return self._switch_browser(id) def _switch_browser(self, browser_id: str, loglevel: LOGLEVEL = "INFO") -> str: with self.playwright.grpc_channel() as stub: response = stub.SwitchBrowser(Request().Index(index=browser_id)) logger.write( response.log, loglevel=loglevel, ) return response.body def _switch_context(self, context_id, loglevel: LOGLEVEL = "INFO") -> str: with self.playwright.grpc_channel() as stub: response = stub.SwitchContext(Request().Index(index=str(context_id))) logger.write( response.log, loglevel=loglevel, ) return response.body @keyword(tags=("Setter", "BrowserControl")) def switch_context( self, id: str, # noqa: A002 browser: SelectionType | str = SelectionType.CURRENT, ) -> str: """Switches the active BrowserContext to another open context. Returns a stable identifier for the previous context. See `Browser, Context and Page` for more information about Context and related concepts. *Arguments:* - ``id``: The id of the context to switch to. Example: ``context=525d8e5b-3c4e-4baa-bfd4-dfdbc6e86089``. A context id is returned by `New Context` when it is started or can be fetched from the browser catalog when returned by `Get Browser Catalog`. - ``browser``: The browser in which to search for that context. ``CURRENT`` for the currently active browser, ``ALL`` to search in all open browsers or the id of the browser where to switch context. Example: | ${first_context} = `New Context` | `New Page` ${URL1} | ${second_context} = `New Context` | `New Page` ${URL2} | `Switch Context` ${first_context} # Switches back to first context and page. [https://forum.robotframework.org/t//4335|Comment >>] """ logger.info(f"Switching context to {id} in {browser}") browser = SelectionType.create(browser) id = SelectionType.create(id) # noqa: A001 browser_catalog = self._get_browser_catalog(include_page_details=False) if isinstance(id, str): parent_browser_id = self._get_context_parent_id(browser_catalog, id) if browser == SelectionType.ALL: self._correct_browser(parent_browser_id) else: if isinstance(browser, str) and browser != parent_browser_id: raise ValueError(f"Context {id} is not in browser {browser}") self._correct_browser(browser) return self._switch_context(id) def _get_page_uid(self, id) -> str: # noqa: A002 if isinstance(id, dict): uid = id.get("page_id") if not uid: raise ValueError( f"Invalid page id format: {id} . Expected format: {NewPageDetails.__annotations__}" ) else: uid = SelectionType.create(id) if isinstance(uid, str) and not ( uid.lower().startswith("page=") or uid.upper() == "NEW" ): raise ValueError(f"Malformed page `id`: {uid}") return uid @keyword(tags=("Setter", "BrowserControl")) def switch_page( self, id: NewPageDetails | SelectionType | str, # noqa: A002 context: SelectionType | str = SelectionType.CURRENT, browser: SelectionType | str = SelectionType.CURRENT, ) -> str: """Switches the active browser page to another open page by ``id`` or ``NEW``. Returns a stable identifier ``id`` for the previous page. See `Browser, Context and Page` for more information about Page and related concepts. *Arguments:* - ``id``: The id or alias of the page to switch to. Example: ``page=8baf2991-5eaf-444d-a318-8045f914e96a`` or ``NEW``. Can be a string or a dictionary returned by `New Page` Keyword. A page id can be fetched from the browser catalog when returned by `Get Browser Catalog`. ``NEW`` can be used to switch to a pop-up that just has been opened by the webpage, ``CURRENT`` can be used to switch to the active page of a different context or browser, identified by their id. - ``context``: The context in which to search for that page. ``CURRENT`` for the currently active context, ``ALL`` to search in all open contexts or the id of the context where to switch page. - ``browser``: The browser in which to search for that page. ``CURRENT`` for the currently active browser, ``ALL`` to search in all open browsers or the id of the browser where to switch page. If a page id is given, the ``context`` and ``browser`` arguments are ignored and the page is searched from all open browsers. ``NEW`` may time out if no new page is opened before the library timeout expires. Example: | `Click` button#pops_up # Open new page | ${previous} = `Switch Page` NEW [https://forum.robotframework.org/t//4336|Comment >>] """ if id == SelectionType.ALL: raise ValueError( "Page id cannot be ALL. Use NEW, CURRENT or a page id instead." ) logger.info(f"Switching to page {id},context {context}, browser {browser}") return self._switch_page(id, context, browser) def _switch_page( self, id: NewPageDetails | SelectionType | str, # noqa: A002 context: SelectionType | str = SelectionType.CURRENT, browser: SelectionType | str = SelectionType.CURRENT, ) -> str: context = SelectionType.create(context) browser = SelectionType.create(browser) uid = self._get_page_uid(id) browser_catalog = self._get_browser_catalog(include_page_details=False) active_browser, active_context, active_page = self._get_active_triplet( browser_catalog ) if isinstance(uid, str) and uid.upper() != "NEW": logger.debug( "Specific page id requested. Ignoring context and browser arguments." ) if uid == active_page: logger.debug("Page to switch is already the active page.") return self._call_switch_page(SelectionType.CURRENT) parent_browser_id, parent_context_id = self._get_page_parent_ids( browser_catalog, str(uid) ) if parent_browser_id != active_browser: self.switch_browser(parent_browser_id) if parent_context_id != active_context: self.switch_context(parent_context_id) return self._call_switch_page(str(uid)) if isinstance(context, str) and context != active_context: logger.debug("Specific context id requested. Ignoring browser argument.") parent_browser_id = self._get_context_parent_id(browser_catalog, context) self._switch_browser(parent_browser_id, loglevel="DEBUG") self._switch_context(context, loglevel="DEBUG") elif isinstance(browser, str) and browser != active_browser: self._switch_browser(browser, loglevel="DEBUG") if uid == SelectionType.CURRENT: return self._call_switch_page(SelectionType.CURRENT) return self._call_switch_page(uid) def _call_switch_page(self, uid): with self.playwright.grpc_channel() as stub: response = stub.SwitchPage( Request().IdWithTimeout(id=str(uid), timeout=self.timeout) ) logger.info(response.log) return response.body @staticmethod def _get_page_parent_ids( browser_catalog: list[dict], page_id: str ) -> tuple[str, str]: for browser in browser_catalog: for context in browser.get("contexts", []): for page in context.get("pages", []): if page.get("id") == page_id: return browser["id"], context["id"] raise ValueError(f"No page with requested id '{page_id}' found.") @staticmethod def _get_context_parent_id(browser_catalog: list[dict], context_id: str) -> str: for browser in browser_catalog: for context in browser.get("contexts", []): if context.get("id") == context_id: return browser["id"] raise ValueError(f"No context with requested id '{context_id}' found.") @keyword(tags=("Getter", "BrowserControl", "Assertion")) @with_assertion_polling def get_browser_ids( self, browser: SelectionType = SelectionType.ALL, assertion_operator: AssertionOperator | None = None, *assertion_expected: Any | None, message: str | None = None, ) -> list[str]: """Returns a list of ids from open browsers. See `Browser, Context and Page` for more information about Browser and related concepts. ``browser`` Defaults to ``ALL`` - ``ALL`` / ``ANY`` Returns all ids as a list. - ``ACTIVE`` / ``CURRENT`` Returns the id of the currently active browser as list. *Arguments:* - ``browser``: The browser to get the ids from. ``ALL`` for all open browsers or ``ACTIVE`` for the currently active browser. The ACTIVE browser is a synonym for the CURRENT Browser. [https://forum.robotframework.org/t//4260|Comment >>] """ return list_verify_assertion( [b["id"] for b in self._get_filtered_browser_catalog(browser)], assertion_operator, list(assertion_expected), "Browser IDs", message, ) @keyword(tags=("Getter", "BrowserControl", "Assertion")) @with_assertion_polling def get_context_ids( self, context: SelectionType = SelectionType.ALL, browser: SelectionType | str = SelectionType.ALL, assertion_operator: AssertionOperator | None = None, *assertion_expected: Any | None, message: str | None = None, ) -> list: """Returns a list of context ids based on the browser selection. See `Browser, Context and Page` for more information about Context and related concepts. ``ALL`` and ``ANY`` are synonyms. ``ACTIVE`` and ``CURRENT`` are also synonyms. *Arguments:* - ``context``: The context to get the ids from. ``ALL`` will return all ids from selected browser(s), ``ACTIVE`` for the one active context of each selected browser. - ``browser``: The browser id or selection to get the context ids from. ``ALL`` Context ids from all open browsers shall be fetched. ``ACTIVE`` Only context ids from the active browser shall be fetched. If a browser id is given and no browser with that id is open, the keyword fails. The ACTIVE context of the ACTIVE Browser is the ``Current`` Context. [https://forum.robotframework.org/t//4264|Comment >>] """ browser = SelectionType.create(browser) catalog = self._get_filtered_browser_catalog(browser) if not catalog and isinstance(browser, str): raise ValueError(f"No browser with id '{browser}' found.") return list_verify_assertion( [ c["id"] for c in self._get_filtered_context_items_from_catalog(context, catalog) ], assertion_operator, list(assertion_expected), "Context IDs", message, ) @staticmethod def _get_filtered_page_items_from_context_list( page: SelectionType | str, context_list ) -> list[dict]: return [ page_item for context_item in context_list for page_item in context_item.get("pages", []) if page == SelectionType.ALL or ( page == SelectionType.CURRENT and context_item.get("activePage") == page_item["id"] ) or (isinstance(page, str) and page_item["id"] == page) ] @staticmethod def _get_filtered_context_items_from_catalog( context: SelectionType | str, catalog: list[dict] ) -> list[dict]: return [ context_item for browser_item in catalog for context_item in browser_item.get("contexts", []) if context == SelectionType.ALL or ( context == SelectionType.CURRENT and browser_item.get("activeContext") == context_item["id"] ) or (isinstance(context, str) and context_item["id"] == context) ] def _get_filtered_browser_catalog( self, browser: SelectionType | str, catalog: list[dict] | None = None ) -> list[dict]: if catalog is None: catalog = self._get_browser_catalog(include_page_details=False) return [ b for b in catalog if (isinstance(browser, str) and b.get("id") == browser) or (browser == SelectionType.ALL) or (browser == SelectionType.CURRENT and b.get("activeBrowser", False)) ] @keyword(tags=("Getter", "BrowserControl", "Assertion")) @with_assertion_polling def get_page_ids( self, page: SelectionType = SelectionType.ALL, context: SelectionType | str = SelectionType.ALL, browser: SelectionType | str = SelectionType.ALL, assertion_operator: AssertionOperator | None = None, *assertion_expected: Any | None, message: str | None = None, ) -> list: """Returns a list of page ids based on the context and browser selection. See `Browser, Context and Page` for more information about Page and related concepts. ``ALL`` and ``ANY`` are synonyms. ``ACTIVE`` and ``CURRENT`` are also synonyms. *Arguments:* - ``page``: The page to get the ids from. ``ALL`` Returns all page ids as a list. ``ACTIVE`` Returns the id of the active page as a list. - ``context``: The context id or selection to get the page ids from. ``ALL`` Page ids from all contexts shall be fetched. ``ACTIVE`` Only page ids from the active context shall be fetched. - ``browser``: The browser id or selection to get the page ids from. ``ALL`` Page ids from all open browsers shall be fetched. ``ACTIVE`` Only page ids from the active browser shall be fetched. Example: | Test Case | `New Page` https://www.imbus.de | `New Page` https://www.reaktor.com | ${current_page}= `Get Page IDs` ACTIVE ACTIVE ACTIVE | Log Current page ID is: ${current_page}[0] | ${all_pages}= `Get Page IDs` CURRENT CURRENT ALL | Log Many These are all Page IDs @{all_pages} Example to count open pages of a specific context: | Test Case | `New Browser` firefox | ${context}= `New Context` | `New Page` https://www.imbus.de | `New Page` https://www.op.fi | `New Context` | `New Page` https://www.robocon.io | ${page_count}= `Get Page IDs` ALL ${context} ALL then len(value) | Should Be Equal As Integers ${page_count} 2 The ACTIVE page of the ACTIVE context of the ACTIVE Browser is the ``Current`` Page. [https://forum.robotframework.org/t//4274|Comment >>] """ context = SelectionType.create(context) browser = SelectionType.create(browser) if isinstance(context, str): logger.debug("Specific context id requested. Ignoring browser argument.") browser = SelectionType.ALL catalog = self._get_filtered_browser_catalog(browser) context_list = self._get_filtered_context_items_from_catalog(context, catalog) page_items = self._get_filtered_page_items_from_context_list(page, context_list) return list_verify_assertion( [p["id"] for p in page_items], assertion_operator, list(assertion_expected), "Page IDs", message, ) @staticmethod def _get_active_browser_item(browser_catalog): for browser in browser_catalog: if browser["activeBrowser"]: return browser return {} @keyword(tags=("Getter", "BrowserControl")) def save_storage_state( self, path: Path | None = None, *, indexedDB: bool = False, credentials: bool = False, ) -> str: """Saves the current active context storage state to a file. Web apps use cookie-based or token-based authentication, where authenticated state is stored as [https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies|cookies] or in [https://developer.mozilla.org/en-US/docs/Web/API/Storage|local storage]. This keyword retrieves the storage state from authenticated contexts and saves it to disk. Then `New Context` can be created with prepopulated state. Please note that the state file may contain secrets and should not be shared with people outside of your organisation. *Arguments:* - ``path``: Where the state file is written. Relative paths are resolved against the current working directory and missing parent directories are created. If the file already exists, it is overwritten. If not given, a file with a generated name is created in ${OUTPUTDIR}/browser/state. The absolute path of the written file is returned. - ``indexedDB``: Also save IndexedDB. Needed by applications, like Firebase, which store authentication tokens in IndexedDB. - ``credentials``: Also save the context's virtual WebAuthn credentials, as created by `Create Credential`. This is not related to the ``httpCredentials`` argument of `New Context`, which is about HTTP authentication. Files in ${OUTPUTDIR}/browser/state are automatically deleted when new test execution starts. To keep a state file over several executions, save it with ``path`` to a location outside of that folder. File path is returned by the keyword. Example: | Test Case | `New context` | `New Page` https://login.page.html | # Perform login | VAR ${username: Secret} %{USERNAME} # Convert environment variable to secret | VAR ${password: Secret} %{PASSWORD} # Convert environment variable to secret | `Fill Secret` id=username ${username} | `Fill Secret` id=password ${password} | `Click` id=button | `Get Text` id=header == Something | # Save storage to disk | ${state_file} = `Save Storage State` | # Create new context with saved state | `New context` storageState=${state_file} | `New Page` https://login.page.html | # Login is not needed because authentication is read from state file | `Get Text` id=header == Something [https://forum.robotframework.org/t//4318|Comment >>] """ state_file = ( path.resolve() if path is not None else (self.state_file / f"{uuid4()!s}.json").resolve() ) state_file.parent.mkdir(parents=True, exist_ok=True) log = self._save_storage_state(str(state_file), indexedDB, credentials) logger.info(log) return str(state_file) def _save_storage_state( self, path: str, indexedDB: bool = False, credentials: bool = False ) -> str: with self.playwright.grpc_channel() as stub: response = stub.SaveStorageState( Request().StorageState( path=path, indexedDB=indexedDB, credentials=credentials ) ) return response.log @keyword(tags=("Setter", "BrowserControl")) def set_storage_state( self, path: Path, timeout: timedelta | None = None, reload_pages: ReloadPages = ReloadPages.affected, ) -> None: """Restores a storage state file into the current active context. Clears the cookies, local storage, IndexedDB and virtual WebAuthn credentials of the currently active context and replaces them with the ones from the ``path`` file, which must have been created by `Save Storage State`. Unlike creating a `New Context` with the ``storageState`` argument, the context and all of its pages stay open. Pages that are already open keep the state they have loaded into memory. Reload them, with the `Reload` keyword, to make them see the restored state. Cookies and local storage are readable right away, without a reload, but the application has read them long ago. Note that ``sessionStorage`` is not part of a storage state, neither when saving nor when restoring. It survives this keyword unchanged, so an application which keeps data of the previous user there still has it after the state was replaced. Restoring a state which was saved with ``credentials=True`` installs the virtual WebAuthn authenticator into the context, the same way `Install Credential` does. Real authenticators do not work in that context afterwards. *Arguments:* - ``path``: Path to a state file created by `Save Storage State`. Relative paths are resolved against the current working directory. The keyword fails if the file does not exist. - ``timeout``: Time to wait for the state to be restored. If not defined, the library default timeout is used. Pass 0 to disable the timeout. - ``reload_pages``: Which pages are reloaded while the state is restored, see `ReloadPages`. Only relevant when the state file contains IndexedDB. == Restoring IndexedDB == Restoring IndexedDB deletes the databases of the origin first, and that does not finish while any client of that origin holds an open connection to them. An application which keeps its connection open, which is the normal pattern when authentication tokens are stored in IndexedDB, therefore blocks the restore indefinitely. Playwright does not time out on its own, see [https://github.com/microsoft/playwright/issues/42258|playwright#42258]. This keyword works around that by navigating the pages of the affected origins to ``about:blank``, restoring the state, and navigating them back to the url they had before. Use ``reload_pages`` to control which pages that applies to. A reload is needed in any case, because deleting the databases closes the connection of the application as well. A service worker of the origin can hold a connection open too, and no value of ``reload_pages`` helps against that, because a service worker outlives the pages. The keyword then fails when ``timeout`` expires. ``reload_pages=none`` detects a blocking connection up front and fails immediately instead of waiting for the timeout. That detection needs ``indexedDB.databases()``, which older browsers, Firefox before 126 among them, do not have. There the keyword cannot tell whether a connection blocks and falls back to waiting for ``timeout``. Example: | `New Context` | `New Page` https://login.page.html | # Perform login as first user | ${user_a} = `Save Storage State` indexedDB=True | # Perform login as second user | ${user_b} = `Save Storage State` indexedDB=True | # Switch back to the first user without creating a new context | `Set Storage State` ${user_a} | `Reload` | `Get Text` id=current-user == userA """ if not path.is_file(): raise ValueError( f"path argument value '{path}' is not file, but it should be." ) with self.playwright.grpc_channel() as stub: response = stub.SetStorageState( Request().SetStorageState( path=str(path.resolve()), timeout=int(self.get_timeout(timeout)), reloadPages=reload_pages.name, navigationTimeout=int(self.get_timeout(None)), ) ) logger.info(response.log) def set_peer_id(self, new_id) -> str: """Sets the peer_id for the current GRPC connection to browser's backend. Useful for sharing the same browsers or even pages among multiple separate python processes. Meaningful usage requires the port of both Browser library instances to be configured the same. """ with self.playwright.grpc_channel() as stub: response = stub.SetPeerId(Request().Index(index=new_id)) return response.body @keyword(tags=("Setter", "BrowserControl")) def cancel_download(self, download: DownloadInfo | str): """Cancels an active download. *Arguments:* - ``download``: A `DownloadInfo` object or id of the download to be canceled. [https://forum.robotframework.org/t//6478|Comment >>] """ with self.playwright.grpc_channel() as stub: response = stub.CancelDownload( Request().DownloadID(id=get_download_id(download)) ) logger.info(response.log) # Mute this from automatic group logging before this is exposed as keyword def open_trace_group( self, name: str, file: Path | str | None = None, line: int = 0, column: int = 0, context_id: str = "", ): """Opens the trace group in the trace viewer""" if not self.library.tracing_contexts: return with suppress(Exception), self.playwright.grpc_channel() as stub: stub.OpenTraceGroup( Request().TraceGroup( name=name, file=str(file or ""), line=line, column=column, contextId=context_id, ) ) # Mute this from automatic group logging before this is exposed as keyword def close_trace_group(self): """Closes the trace group in the trace viewer""" if not self.library.tracing_contexts: return with suppress(Exception), self.playwright.grpc_channel() as stub: stub.CloseTraceGroup(Request().Empty()) def set_rf_context( self, test_id: str = "", test_name: str = "", suite_id: str = "", suite_name: str = "", ): if self.library._playwright is None: return with suppress(Exception), self.playwright.grpc_channel() as stub: stub.SetRFContext( Request().RFContext( test_id=test_id, test_name=test_name, suite_id=suite_id, suite_name=suite_name, ) )