From 95804447fc4c21fdeaed97eaf3aae0d354368690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Mon, 29 Sep 2025 10:04:56 +0200 Subject: [PATCH 1/5] switched show-trace to node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René --- Browser/browser.py | 10 +- Browser/entry/__main__.py | 164 +++++++++++++++------ Browser/entry/constant.py | 10 +- Browser/keywords/browser_control.py | 25 +--- Browser/playwright.py | 12 +- Browser/utils/data_types.py | 16 +- node/playwright-wrapper/browser-control.ts | 2 +- 7 files changed, 159 insertions(+), 80 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index 25c94fa10..9a3c3e231 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -74,8 +74,6 @@ from .utils.data_types import ( DelayedKeyword, HighLightElement, - InstallableBrowser, - InstallationOptions, KeywordCallStackEntry, LambdaFunction, RegExp, @@ -1624,9 +1622,9 @@ def _get_translation(language: Union[str, None]) -> Union[Path, None]: return Path(item.get("path")).absolute() return None - def install_browser( + def execute_npx_playwright( self, - browser: Optional[InstallableBrowser] = None, - *options: InstallationOptions, + command: str, + *args: str, ): - self._browser_control.install_browser(browser, *options) + self._browser_control.execute_npx_playwright(command, *args) diff --git a/Browser/entry/__main__.py b/Browser/entry/__main__.py index ba05d3152..a01438d0d 100644 --- a/Browser/entry/__main__.py +++ b/Browser/entry/__main__.py @@ -14,26 +14,29 @@ import contextlib import json -import os import shutil import subprocess import sys -import textwrap import traceback from pathlib import Path from typing import TYPE_CHECKING, Optional +from urllib.parse import urlparse import click -from Browser.utils.data_types import InstallableBrowser, InstallationOptions +from Browser.utils.data_types import ( + AutoClosingLevel, + InstallableBrowser, + InstallationOptions, + InstallationOptionsHelp, + SupportedBrowsers, +) from .constant import ( - INSTALLATION_DIR, NODE_MODULES, - PLAYWRIGHT_BROWSERS_PATH, SHELL, + ensure_playwright_browsers_path, get_browser_lib, - get_playwright_browser_path, log, write_marker, ) @@ -256,31 +259,90 @@ def clean_node(): shutil.rmtree(NODE_MODULES) +def _is_url(value: str) -> bool: + p = urlparse(value) + return p.scheme in {"http", "https"} and bool(p.netloc) + + +def _normalize_traces(item: str) -> str: + """Return a list of absolute file paths (for locals) and URLs (unchanged).""" + if _is_url(item): + return item + p = Path(item) + if not p.exists() or not p.is_file(): + raise click.BadParameter(f"not a file or does not exist: {item!r}") + return str(p.resolve(strict=True)) + + @cli.command(epilog="") @click.argument( - "file", type=click.Path(exists=True, dir_okay=False, path_type=Path), required=True + "file", + type=str, # allow URLs and paths; we validate ourselves + required=False, ) -def show_trace(file: Path): - """Start the Playwright trace viewer tool. +@click.option( + "--browser", + "-b", + type=click.Choice(tuple(SupportedBrowsers._member_names_), case_sensitive=False), + show_default=True, + help="Browser to use, one of chromium, firefox, webkit.", +) +# @click.option( +# "--host", +# "-h", +# type=str, +# default=None, +# help="Host to serve trace on; specifying this opens the trace in a browser tab.", +# ) +# @click.option( +# "--port", +# "-p", +# type=click.IntRange(0, 65535), +# default=None, +# help="Port to serve trace on (0 for any free port); specifying this opens a browser tab.", +# ) +# @click.option( +# "--stdin", +# is_flag=True, +# default=False, +# help="Accept trace URLs over stdin to update the viewer.", +# ) +def show_trace( + file: str, + browser: str, # host: Optional[str], port: Optional[int], stdin: bool +): + """Start the Playwright trace viewer. + + Accepts paths to trace .zip files and/or HTTP(S) URLs. Example: + + show-trace trace1.zip trace2.zip https://example.com/trace.zip - Provide path to trace zip FILE. + To stream more traces dynamically, use --stdin and write newline-separated URLs to stdin. - See New Context keyword documentation for more details how to create trace file: - https://marketsquare.github.io/robotframework-browser/Browser.html#New%20Contex + See the "New Context" keyword docs for creating traces: + https://marketsquare.github.io/robotframework-browser/Browser.html#New%20Context """ - absolute_file = file.resolve(strict=True) - log(f"Opening file: {absolute_file}") - env = os.environ.copy() - env[PLAYWRIGHT_BROWSERS_PATH] = str(get_playwright_browser_path()) - trace_arguments = [ - "npx", - "playwright", - "show-trace", - str(absolute_file), - ] - subprocess.run( # noqa: PLW1510 - trace_arguments, env=env, shell=SHELL, cwd=INSTALLATION_DIR - ) + try: + normalized_trace = _normalize_traces(file) if file else "" + except click.BadParameter as e: + raise click.UsageError(str(e)) from e + if not _is_url(normalized_trace): + log(f"Opening file: {normalized_trace}") + ensure_playwright_browsers_path() + args: list[str] = [] + if browser: + args += ["--browser", browser] + # if host: + # args += ["--host", host] + # if port is not None: + # args += ["--port", str(port)] + # if stdin: + # args += ["--stdin"] + if normalized_trace: + args += [normalized_trace] + browser_lib = get_browser_lib() + browser_lib._auto_closing_level = AutoClosingLevel.KEEP + browser_lib.execute_npx_playwright("show-trace", *args) @cli.command() @@ -403,36 +465,50 @@ def install(browser: Optional[str] = None, **flags): Also not run this command if you have only installed Browser library. When Browser library is installed, run only the `rfbrowser init` command. """ - try: - import BrowserBatteries # noqa: PLC0415 F401 - except ImportError: - heading = "\nBrowserBatteries library is not installed." - body = ( - "You should only run `rfbrowser install` command if you have both " - "Browser and BrowserBatteries libraries installed. If you have only " - "installed Browser library, run only the `rfbrowser init` command." - ) - - text_list = textwrap.wrap(body, width=50) - text_list.insert(0, "") - text_list.insert(0, heading) - raise RuntimeError("\n".join(text_list)) - browser_enum = browser if browser is None else InstallableBrowser(browser) + browser_enum = InstallableBrowser(browser) if browser else None selected = [] for name, enabled in flags.items(): if enabled: key = name.replace("_", "-") # e.g. with_deps -> with-deps selected.append(InstallationOptions[key]) - if not os.environ.get(PLAYWRIGHT_BROWSERS_PATH): - os.environ[PLAYWRIGHT_BROWSERS_PATH] = str(get_playwright_browser_path()) + ensure_playwright_browsers_path() browser_lib = get_browser_lib() + options = [opt.value for opt in selected] + if browser_enum: + options.append(browser_enum.value) with contextlib.suppress(Exception): - browser_lib.install_browser(browser_enum, *selected) + browser_lib.execute_npx_playwright("install", *options) for opt in InstallationOptions: param_name = opt.name.replace("-", "_") - install = click.option(opt.value, param_name, is_flag=True, help=opt.name)(install) + install = click.option( + opt.value, param_name, is_flag=True, help=InstallationOptionsHelp[opt.name] + )(install) + + +@cli.command() +@click.option( + "--all", + is_flag=True, + help="Removes all browsers used by Robot Framework Browser installation.", +) +def uninstall(all: bool): # noqa: A002 + """Uninstall Playwright Browsers binaries. + + It uninstalls browsers by executing 'npx playwright uninstall' command. + + This command removes browsers used by this installation of Robot Framework Browser from the system + (chromium, firefox, webkit, ffmpeg). This does not include branded channels. + + If --all option is provided, it removes all browsers used by any Robot Framework Browser installation + from the system. + """ + ensure_playwright_browsers_path() + browser_lib = get_browser_lib() + with contextlib.suppress(Exception): + args = ["--all"] if all else [] + browser_lib.execute_npx_playwright("uninstall", *args) @cli.command() diff --git a/Browser/entry/constant.py b/Browser/entry/constant.py index 8b6f491ff..d55ccb1f9 100644 --- a/Browser/entry/constant.py +++ b/Browser/entry/constant.py @@ -85,6 +85,14 @@ def get_browser_lib(): def get_playwright_browser_path() -> Path: - if pw_env := os.getenv(PLAYWRIGHT_BROWSERS_PATH): + pw_env = os.environ.get(PLAYWRIGHT_BROWSERS_PATH) + if pw_env and pw_env.strip(): return Path(pw_env) return NODE_MODULES / "playwright-core" / ".local-browsers" + + +def ensure_playwright_browsers_path(): + if not os.environ.get(PLAYWRIGHT_BROWSERS_PATH): + os.environ[PLAYWRIGHT_BROWSERS_PATH] = str( + get_playwright_browser_path().resolve() + ) diff --git a/Browser/keywords/browser_control.py b/Browser/keywords/browser_control.py index 1a273cab7..ce1152124 100644 --- a/Browser/keywords/browser_control.py +++ b/Browser/keywords/browser_control.py @@ -23,8 +23,6 @@ from robot.libraries.BuiltIn import BuiltIn, RobotNotRunningError from robot.utils import get_link_path -from Browser.utils.data_types import InstallableBrowser, InstallationOptions - from ..base import LibraryComponent from ..generated.playwright_pb2 import Request from ..utils import ( @@ -636,23 +634,16 @@ def clear_permissions(self): response = stub.ClearPermissions(Request().Empty()) logger.info(response.log) - def install_browser( + def execute_npx_playwright( self, - browser: Optional[InstallableBrowser] = None, - *options: InstallationOptions, + command: str, + *args: str, ): - """Executes a Playwright install command with the given arguments.""" + """Executes a Playwright command with the given arguments.""" with self.playwright.grpc_channel() as stub: try: - response = stub.ExecutePlaywright( - Request().Json( - body=json.dumps( - ["install"] - + [opt.value for opt in options] - + ([browser.value] if browser else []) - ) - ) - ) + body = json.dumps([command, *args]) + response = stub.ExecutePlaywright(Request().Json(body=body)) logger.info(response.log) - except Exception: - logger.error("Error executing Playwright command") + except Exception as error: + logger.error(f"Error executing Playwright command: {error}") diff --git a/Browser/playwright.py b/Browser/playwright.py index 1085ae7b7..ebd211ced 100644 --- a/Browser/playwright.py +++ b/Browser/playwright.py @@ -29,7 +29,10 @@ except ImportError: start_grpc_server = None # type: ignore[assignment] -from Browser.entry.constant import PLAYWRIGHT_BROWSERS_PATH, get_playwright_browser_path +from Browser.entry.constant import ( + PLAYWRIGHT_BROWSERS_PATH, + ensure_playwright_browsers_path, +) from Browser.generated import playwright_pb2_grpc from Browser.generated.playwright_pb2 import Request @@ -151,8 +154,8 @@ def start_playwright(self) -> Optional[Popen]: self.port = port if start_grpc_server is None: return self._start_playwright_from_node(self._get_logfile(), host, port) - if not os.environ.get(PLAYWRIGHT_BROWSERS_PATH): - os.environ[PLAYWRIGHT_BROWSERS_PATH] = str(get_playwright_browser_path()) + ensure_playwright_browsers_path() + return start_grpc_server( self._get_logfile(), host, port, self.enable_playwright_debug ) @@ -190,6 +193,9 @@ def _start_playwright_from_node( def wait_until_server_up(self): for _ in range(150): # About 15 seconds + logger.debug( + f"Waiting for Playwright server at {self.host}:{self.port} to start..." + ) with grpc.insecure_channel(f"{self.host}:{self.port}") as channel: try: stub = playwright_pb2_grpc.PlaywrightStub(channel) diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index 950bcf8fd..60212f9f9 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -1427,11 +1427,11 @@ class TracingGroupMode(Enum): "no-shell": "--no-shell", }, ) -InstallationOptions.__doc__ = """Enum of installation options for `Install Browser` keyword. - -- with-deps install system dependencies for browsers -- dry-run do not execute installation, only print information -- list prints list of browsers from all playwright installations -- force force reinstall of stable browser channels -- only-shell only install headless shell when installing chromium -- no-shell do not install chromium headless shell""" +InstallationOptionsHelp = { + "with-deps": "install system dependencies for browsers", + "dry-run": "do not execute installation, only print information", + "list": "prints list of browsers from all playwright installations", + "force": "force reinstall of stable browser channels", + "only-shell": "only install headless shell when installing chromium", + "no-shell": "do not install chromium headless shell", +} diff --git a/node/playwright-wrapper/browser-control.ts b/node/playwright-wrapper/browser-control.ts index 70f72daba..fd38eae6e 100644 --- a/node/playwright-wrapper/browser-control.ts +++ b/node/playwright-wrapper/browser-control.ts @@ -26,7 +26,7 @@ export async function executePlaywright(request: Request.Json): Promise { From e78140579eb7869362e77bafdbfb45d02611285a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Mon, 29 Sep 2025 10:07:48 +0200 Subject: [PATCH 2/5] test ssh-signed From 638d85de7e8e6522ec8c942385af3b19ce149b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sun, 5 Oct 2025 20:32:49 +0200 Subject: [PATCH 3/5] fixed #4450 #4451 and extended show-trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René --- Browser/entry/__main__.py | 80 +-- Browser/keywords/playwright_state.py | 454 ++++++++++-------- .../playwright_state.robot | 27 +- .../switching_browsers.robot | 46 +- node/playwright-wrapper/playwright-state.ts | 3 +- 5 files changed, 351 insertions(+), 259 deletions(-) diff --git a/Browser/entry/__main__.py b/Browser/entry/__main__.py index a01438d0d..bb8ede38b 100644 --- a/Browser/entry/__main__.py +++ b/Browser/entry/__main__.py @@ -33,6 +33,7 @@ ) from .constant import ( + INSTALLATION_DIR, NODE_MODULES, SHELL, ensure_playwright_browsers_path, @@ -284,32 +285,31 @@ def _normalize_traces(item: str) -> str: "--browser", "-b", type=click.Choice(tuple(SupportedBrowsers._member_names_), case_sensitive=False), - show_default=True, + default=None, help="Browser to use, one of chromium, firefox, webkit.", ) -# @click.option( -# "--host", -# "-h", -# type=str, -# default=None, -# help="Host to serve trace on; specifying this opens the trace in a browser tab.", -# ) -# @click.option( -# "--port", -# "-p", -# type=click.IntRange(0, 65535), -# default=None, -# help="Port to serve trace on (0 for any free port); specifying this opens a browser tab.", -# ) -# @click.option( -# "--stdin", -# is_flag=True, -# default=False, -# help="Accept trace URLs over stdin to update the viewer.", -# ) +@click.option( + "--host", + "-h", + type=str, + default=None, + help="Host to serve trace on; specifying this opens the trace in a browser tab.", +) +@click.option( + "--port", + "-p", + type=click.IntRange(0, 65535), + default=None, + help="Port to serve trace on (0 for any free port); specifying this opens a browser tab.", +) +@click.option( + "--stdin", + is_flag=True, + default=False, + help="Accept trace URLs over stdin to update the viewer.", +) def show_trace( - file: str, - browser: str, # host: Optional[str], port: Optional[int], stdin: bool + file: str, browser: str, host: Optional[str], port: Optional[int], stdin: bool ): """Start the Playwright trace viewer. @@ -317,6 +317,8 @@ def show_trace( show-trace trace1.zip trace2.zip https://example.com/trace.zip + If using Browser Batteries, only --browser argument is supported. + To stream more traces dynamically, use --stdin and write newline-separated URLs to stdin. See the "New Context" keyword docs for creating traces: @@ -329,15 +331,33 @@ def show_trace( if not _is_url(normalized_trace): log(f"Opening file: {normalized_trace}") ensure_playwright_browsers_path() - args: list[str] = [] + try: + _show_trace_via_npx(browser, host, port, stdin, normalized_trace) + except Exception: + _show_trace_via_grpc(browser, normalized_trace) + + +def _show_trace_via_npx(browser, host, port, stdin, normalized_trace): + trace_arguments = ["npx", "playwright", "show-trace"] if browser: + trace_arguments.extend(["--browser", browser]) + if host is not None: + trace_arguments.extend(["--host", host]) + if port is not None: + trace_arguments.extend(["--port", str(port)]) + if stdin: + trace_arguments.append("--stdin") + if normalized_trace: + trace_arguments.append(str(normalized_trace)) + subprocess.run( # noqa: PLW1510 + trace_arguments, shell=SHELL, cwd=INSTALLATION_DIR + ) + + +def _show_trace_via_grpc(browser, normalized_trace): + args: list[str] = [] + if browser is not None: args += ["--browser", browser] - # if host: - # args += ["--host", host] - # if port is not None: - # args += ["--port", str(port)] - # if stdin: - # args += ["--stdin"] if normalized_trace: args += [normalized_trace] browser_lib = get_browser_lib() diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 0ad08232d..21f137a38 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -21,7 +21,7 @@ from typing import Any, Optional, Union from uuid import uuid4 -from assertionengine import AssertionOperator, verify_assertion +from assertionengine import AssertionOperator, list_verify_assertion, verify_assertion from robot.libraries.BuiltIn import EXECUTION_CONTEXTS, BuiltIn from robot.utils import get_link_path @@ -71,13 +71,6 @@ def _correct_browser(self, browser: Union[SelectionType, str]): return self.switch_browser(browser) - def _correct_context(self, context: Union[SelectionType, str]): - if context == SelectionType.ALL: - raise ValueError - if context == SelectionType.CURRENT: - return - self.switch_context(context) - @keyword(tags=("Setter", "BrowserControl")) def open_browser( self, @@ -194,6 +187,7 @@ def close_context( 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 @@ -249,26 +243,31 @@ def _get_browser_ids(self, browser) -> list: browser_ids = [browser] return [find_by_id(browser_id, catalog) for browser_id in browser_ids] - def _get_context_id( - self, context_selection: Union[str, SelectionType], contexts: list - ) -> list: - if context_selection == SelectionType.CURRENT: - current_ctx = self.switch_context("CURRENT") - if current_ctx == "NO CONTEXT OPEN": - return [] - contexts = [find_by_id(current_ctx, contexts, log_error=False)] - elif context_selection != SelectionType.ALL: - contexts = [find_by_id(str(context_selection), contexts, log_error=False)] - return contexts + @staticmethod + def _get_active_triplet( + catalog: list[dict], + ) -> tuple[Optional[str], Optional[str], Optional[str]]: + 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: Union[SelectionType, str] = SelectionType.CURRENT, + page: Union[SelectionType, NewPageDetails, str] = SelectionType.CURRENT, context: Union[SelectionType, str] = SelectionType.CURRENT, browser: Union[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. @@ -293,44 +292,69 @@ def close_page( [https://forum.robotframework.org/t//4241|Comment >>] """ - page = SelectionType.create(page) + page_id = ( + self._get_page_uid(page) + if isinstance(page, dict) + else SelectionType.create(page) + ) context = SelectionType.create(context) browser = SelectionType.create(browser) - result = [] - browser_ids = self._get_browser_ids(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: - for browser_id in browser_ids: - self.switch_browser(browser_id["id"]) - contexts_ids = browser_id["contexts"] - try: - contexts_ids = self._get_context_id(context, contexts_ids) - except StopIteration: - continue - for context_id in contexts_ids: - self.switch_context(context_id["id"]) - if page == SelectionType.ALL: - pages_ids = [p["id"] for p in context_id["pages"]] - else: - pages_ids = [page] - - for page_id in pages_ids: - if page_id == "NO PAGE OPEN": - return None - if page != SelectionType.CURRENT: - self.switch_page(page_id) - response = stub.ClosePage( - Request().ClosePage(runBeforeUnload=runBeforeUnload) - ) - if response.log: - logger.info(response.log) - result.append( - { - "errors": json.loads(response.errors), - "console": json.loads(response.console), - "id": response.pageId, - } - ) - return result + 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: @@ -1271,9 +1295,18 @@ def switch_browser(self, id: str) -> str: # noqa: A002 logger.info(f"Switching browser to {id}") return self._switch_browser(id) - def _switch_browser(self, id: str, loglevel: LOGLEVEL = "INFO") -> str: # noqa: A002 + 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.SwitchBrowser(Request().Index(index=id)) + response = stub.SwitchContext(Request().Index(index=str(context_id))) logger.write( response.log, loglevel=loglevel, @@ -1307,19 +1340,16 @@ def switch_context( 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(id) + 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) - with self.playwright.grpc_channel() as stub: - response = stub.SwitchContext(Request().Index(index=str(id))) - logger.info(response.log) - return response.body + return self._switch_context(id) def _get_page_uid(self, id) -> str: # noqa: A002 if isinstance(id, dict): @@ -1339,7 +1369,7 @@ def _get_page_uid(self, id) -> str: # noqa: A002 @keyword(tags=("Setter", "BrowserControl")) def switch_page( self, - id: Union[NewPageDetails, str], # noqa: A002 + id: Union[NewPageDetails, SelectionType, str], # noqa: A002 context: Union[SelectionType, str] = SelectionType.CURRENT, browser: Union[SelectionType, str] = SelectionType.CURRENT, ) -> str: @@ -1361,46 +1391,53 @@ def switch_page( [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: Union[NewPageDetails, SelectionType, str], # noqa: A002 + context: Union[SelectionType, str] = SelectionType.CURRENT, + browser: Union[SelectionType, str] = SelectionType.CURRENT, + ) -> str: context = SelectionType.create(context) browser = SelectionType.create(browser) - correct_context_selected = True - correct_browser_selected = True uid = self._get_page_uid(id) - - if ( - not (isinstance(uid, str) and uid.upper() == "NEW") - and uid != SelectionType.CURRENT - ): - parent_browser_id, parent_context_id = self._get_page_parent_ids(str(uid)) - if isinstance(browser, str) and browser != parent_browser_id: - logger.error( - f"Page {uid} is not in browser {browser}. Switching to browser {parent_browser_id}" - ) - correct_browser_selected = False - if isinstance(context, str) and context != parent_context_id: - logger.error( - f"Page {uid} is not in context {context}. Switching to context {parent_context_id}" - ) - correct_context_selected = False - if not correct_context_selected or not correct_browser_selected: - raise ValueError(f"Page Switch to {uid} failed") - - if context == SelectionType.ALL: - self.switch_context(parent_context_id, browser) - elif context == SelectionType.CURRENT and browser == SelectionType.ALL: + 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) - elif context == SelectionType.ALL and browser == SelectionType.ALL: - self.switch_context(parent_context_id, parent_browser_id) - elif isinstance(context, str): - self.switch_context(context, browser) - elif context == SelectionType.CURRENT and isinstance(browser, str): - self.switch_browser(browser) - - logger.debug(f"Page: {uid}") - logger.debug(f"Context: {context}") - logger.debug(f"Browser: {browser}") - + 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) @@ -1408,25 +1445,33 @@ def switch_page( logger.info(response.log) return response.body - def _get_page_parent_ids(self, page_id: str) -> tuple[str, str]: - browser_catalog = self._get_browser_catalog(include_page_details=False) + @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["contexts"]: - for page in context["pages"]: - if page["id"] == page_id: + 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.") - def _get_context_parent_id(self, context_id: str) -> str: - browser_catalog = self._get_browser_catalog(include_page_details=False) + @staticmethod + def _get_context_parent_id(browser_catalog: list[dict], context_id: str) -> str: for browser in browser_catalog: - for context in browser["contexts"]: - if context["id"] == context_id: + 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")) - def get_browser_ids(self, browser: SelectionType = SelectionType.ALL) -> list[str]: + def get_browser_ids( + self, + browser: SelectionType = SelectionType.ALL, + assertion_operator: Optional[AssertionOperator] = None, + *assertion_expected: Optional[Any], + message: Optional[str] = None, + ) -> list[str]: """Returns a list of ids from open browsers. See `Browser, Context and Page` for more information about Browser and related concepts. @@ -1442,24 +1487,22 @@ def get_browser_ids(self, browser: SelectionType = SelectionType.ALL) -> list[st [https://forum.robotframework.org/t//4260|Comment >>] """ - if browser == SelectionType.CURRENT: - browser_item = self._get_active_browser_item( - self._get_browser_catalog(include_page_details=False) - ) - if "id" in browser_item: - return [browser_item["id"]] - else: - return [ - browser["id"] - for browser in self._get_browser_catalog(include_page_details=False) - ] - return [] + 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")) def get_context_ids( self, context: SelectionType = SelectionType.ALL, - browser: SelectionType = SelectionType.ALL, + browser: Union[SelectionType, str] = SelectionType.ALL, + assertion_operator: Optional[AssertionOperator] = None, + *assertion_expected: Optional[Any], + message: Optional[str] = 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. @@ -1475,35 +1518,75 @@ def get_context_ids( [https://forum.robotframework.org/t//4264|Comment >>] """ - if browser == SelectionType.CURRENT: - browser_item = self._get_active_browser_item( - self._get_browser_catalog(include_page_details=False) + 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: Union[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"] ) - if context == SelectionType.CURRENT: - if "activeContext" in browser_item: - return [browser_item["activeContext"]] - elif "contexts" in browser_item: - return [context["id"] for context in browser_item["contexts"]] - elif context == SelectionType.CURRENT: - context_ids = [] - for browser_item in self._get_browser_catalog(include_page_details=False): - if "activeContext" in browser_item: - context_ids.append(browser_item["activeContext"]) - return context_ids - else: - context_ids = [] - for browser_item in self._get_browser_catalog(include_page_details=False): - for context_item in browser_item["contexts"]: - context_ids.append(context_item["id"]) - return context_ids - return [] + or (isinstance(page, str) and page_item["id"] == page) + ] + + @staticmethod + def _get_filtered_context_items_from_catalog( + context: Union[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: Union[SelectionType, str], catalog: Optional[list[dict]] = 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")) def get_page_ids( self, page: SelectionType = SelectionType.ALL, - context: SelectionType = SelectionType.ALL, - browser: SelectionType = SelectionType.ALL, + context: Union[SelectionType, str] = SelectionType.ALL, + browser: Union[SelectionType, str] = SelectionType.ALL, + assertion_operator: Optional[AssertionOperator] = None, + *assertion_expected: Optional[Any], + message: Optional[str] = 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. @@ -1513,58 +1596,49 @@ def get_page_ids( | =Arguments= | =Description= | | ``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 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 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. | + | ``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` http://www.imbus.de - | `New Page` http://www.reaktor.com + | `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 >>] """ - if browser == SelectionType.CURRENT: - browser_item = self._get_active_browser_item( - self._get_browser_catalog(include_page_details=False) - ) - if "contexts" in browser_item: - if context == SelectionType.CURRENT: - return self._get_page_ids_from_context_list( - page, self._get_active_context_item(browser_item) - ) - return self._get_page_ids_from_context_list( - page, browser_item["contexts"] - ) - else: - context_list = [] - for browser_item in self._get_browser_catalog(include_page_details=False): - if "contexts" in browser_item: - if context == SelectionType.CURRENT: - context_list.extend(self._get_active_context_item(browser_item)) - else: - context_list.extend(browser_item["contexts"]) - return self._get_page_ids_from_context_list(page, context_list) - return [] - - @staticmethod - def _get_page_ids_from_context_list( - page_selection_type: SelectionType, context_list - ): - page_ids = [] - for context_item in context_list: - if page_selection_type == SelectionType.CURRENT: - if "activePage" in context_item: - page_ids.append(context_item["activePage"]) - else: - page_ids.extend([page["id"] for page in context_item["pages"]]) - return page_ids + 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): @@ -1573,16 +1647,6 @@ def _get_active_browser_item(browser_catalog): return browser return {} - @staticmethod - def _get_active_context_item(browser_item): - for context in browser_item["contexts"]: - if ( - "activeContext" in browser_item - and browser_item["activeContext"] == context["id"] - ): - return [context] - return [] - @keyword(tags=("Getter", "BrowserControl")) def save_storage_state(self) -> str: """Saves the current active context storage state to a file. diff --git a/atest/test/01_Browser_Management/playwright_state.robot b/atest/test/01_Browser_Management/playwright_state.robot index 7e19591f5..d4869f3c5 100644 --- a/atest/test/01_Browser_Management/playwright_state.robot +++ b/atest/test/01_Browser_Management/playwright_state.robot @@ -239,6 +239,19 @@ Closing Page/Contex/Browser Multiple Times With All Should Not Cause Errors Close Context ALL ALL Close Browser ALL Close Browser ALL + Get Browser Catalog validate value == list() + +Close Page With Page Id + ${c1} = New Context + &{p1_1} = New Page + &{p1_2} = New Page + ${c2} = New Context + ${p2_1} = New Page + ${page_count} = Get Page IDs ALL ${c1} ALL then len(value) + Should Be Equal As Integers ${page_count} 2 + Get Page Ids ALL ALL CURRENT equals ${p1_1.page_id} ${p1_2.page_id} ${p2_1}[page_id] + Close Page ${p1_1}[page_id] + Get Page Ids ACTIVE ACTIVE ACTIVE validate value[0] == '${p2_1}[page_id]' and len(value) == 1 New Context With DefaultBrowserType Ff [Tags] slow @@ -379,6 +392,12 @@ Switch Page With ALL Browsers Failing ${page221} = New Page ${page222} = New Page + Get Browser Ids ALL then len(value) == 2 + Get Context Ids ACTIVE ALL equals ${context12} ${context22} + Get Context Ids ALL ${browser1} equals ${context11} ${context12} + Get Page Ids ACTIVE ACTIVE ACTIVE equals ${page222}[page_id] + # setting specific context ignores browser + Get Page Ids ALL ${context12} ${browser2} equals ${page121}[page_id] ${page122}[page_id] ${cat} = Get Browser Catalog Log ${cat} ${cur_page} = Get Page Ids ACTIVE ACTIVE ACTIVE @@ -388,14 +407,6 @@ Switch Page With ALL Browsers Failing ... Run Keyword And Continue On Failure ... Switch Page page=123 ALL ALL - Run Keyword And Expect Error - ... EQUALS:Error: No page for id ${page211}[page_id]. Open pages: { id: ${page221}[page_id], url: about:blank },{ id: ${page222}[page_id], url: about:blank } - ... Run Keyword And Continue On Failure - ... Switch Page - ... ${page211} - ... CURRENT - ... CURRENT - Run Keyword And Expect Error EQUALS:ValueError: Malformed page `id`: 1 ... Run Keyword And Continue On Failure ... Switch Page 1 ALL ALL diff --git a/atest/test/01_Browser_Management/switching_browsers.robot b/atest/test/01_Browser_Management/switching_browsers.robot index 56458818b..a31456c76 100644 --- a/atest/test/01_Browser_Management/switching_browsers.robot +++ b/atest/test/01_Browser_Management/switching_browsers.robot @@ -51,40 +51,36 @@ Switch Context ... ${br2} Switch Page - ${br} = New Browser headless=True - ${ctx} = New Context - ${pg} = New Page ${FORM_URL} + ${br1} = New Browser headless=True + ${ctx1_1} = New Context + ${pg1_1_1} = New Page ${FORM_URL} Get Title == prefilled_email_form.html ${br2} = New Browser headless=True reuse_existing=False - ${ctx2} = New Context - ${pg2} = New Page ${LOGIN_URL} + ${ctx2_1} = New Context + ${pg2_1_1} = New Page ${LOGIN_URL} Get Title matches (?i)login - ${pg3} = New Page ${ERROR_URL} + ${pg2_1_2} = New Page ${ERROR_URL} Get Title == Error Page - Switch Page ${pg} ${ctx} ${br} + Switch Page ${pg1_1_1} ${ctx1_1} ${br1} Get Title == prefilled_email_form.html - Switch Page ${pg2} ${ctx2} ALL + Switch Page ${pg2_1_1} ${ctx2_1} ALL Get Title matches (?i)login - Switch Page ${pg} ALL ALL + Switch Page ${pg1_1_1} ALL ALL Get Title == prefilled_email_form.html - Switch Page ${pg3} CURRENT ${br2} + Switch Page ${pg2_1_2} CURRENT ${br2} Get Title == Error Page - Switch Page ${pg2} CURRENT CURRENT + Switch Page ${pg2_1_1} CURRENT CURRENT Get Title matches (?i)login - Switch Page ${pg} CURRENT ALL - Get Title == prefilled_email_form.html - Run Keyword And Expect Error - ... ValueError: Page Switch to ${pg2}[page_id] failed - ... Switch Page - ... ${pg2} - ... ${ctx} - ... ${br} + Switch Page ${pg1_1_1} CURRENT ALL Get Title == prefilled_email_form.html - Run Keyword And Expect Error - ... ValueError: Page Switch to ${pg2}[page_id] failed - ... Switch Page - ... ${pg2} - ... ${ctx} + Switch Page # This passses because the browser and context are ignored + ... ${pg2_1_2} + ... ${ctx1_1} + ... ${br1} + Get Title == Error Page + Switch Page + ... ${pg2_1_1} + ... ${ctx1_1} ... ANY - Get Title == prefilled_email_form.html + Get Title matches (?i)login diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index 5565fec9b..a4a09bf34 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -980,7 +980,8 @@ export async function switchPage( const previous = browserState.page?.id || 'NO PAGE OPEN'; browserState.page?.p.bringToFront(); return stringResponse(previous, 'Returned active page id'); - } else if (id === 'NEW') { + } + if (id === 'NEW') { const previous = browserState.page?.id || 'NO PAGE OPEN'; const previousTime = browserState.page?.timestamp || 0; const latest = await findLatestPageAfter(previousTime, request.getTimeout(), context); From 3ac34a2d8125c570fffc755c290e4f70d721f10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sun, 5 Oct 2025 20:42:26 +0200 Subject: [PATCH 4/5] added assertion tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René --- Browser/keywords/playwright_state.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 21f137a38..d6686b303 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -1464,7 +1464,8 @@ def _get_context_parent_id(browser_catalog: list[dict], context_id: str) -> str: return browser["id"] raise ValueError(f"No context with requested id '{context_id}' found.") - @keyword(tags=("Getter", "BrowserControl")) + @keyword(tags=("Getter", "BrowserControl", "Assertion")) + @with_assertion_polling def get_browser_ids( self, browser: SelectionType = SelectionType.ALL, @@ -1495,7 +1496,8 @@ def get_browser_ids( message, ) - @keyword(tags=("Getter", "BrowserControl")) + @keyword(tags=("Getter", "BrowserControl", "Assertion")) + @with_assertion_polling def get_context_ids( self, context: SelectionType = SelectionType.ALL, @@ -1578,7 +1580,8 @@ def _get_filtered_browser_catalog( or (browser == SelectionType.CURRENT and b.get("activeBrowser", False)) ] - @keyword(tags=("Getter", "BrowserControl")) + @keyword(tags=("Getter", "BrowserControl", "Assertion")) + @with_assertion_polling def get_page_ids( self, page: SelectionType = SelectionType.ALL, From 1bfbf1e17700deaf43d6dcc8665f09dee5b34633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sun, 5 Oct 2025 21:37:21 +0200 Subject: [PATCH 5/5] fixed linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René --- atest/test/01_Browser_Management/switching_browsers.robot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atest/test/01_Browser_Management/switching_browsers.robot b/atest/test/01_Browser_Management/switching_browsers.robot index a31456c76..4816af95c 100644 --- a/atest/test/01_Browser_Management/switching_browsers.robot +++ b/atest/test/01_Browser_Management/switching_browsers.robot @@ -74,7 +74,7 @@ Switch Page Get Title matches (?i)login Switch Page ${pg1_1_1} CURRENT ALL Get Title == prefilled_email_form.html - Switch Page # This passses because the browser and context are ignored + Switch Page # This passses because the browser and context are ignored ... ${pg2_1_2} ... ${ctx1_1} ... ${br1}