diff --git a/Browser/entry/__main__.py b/Browser/entry/__main__.py index d4d749dea..8a353ed9f 100644 --- a/Browser/entry/__main__.py +++ b/Browser/entry/__main__.py @@ -24,6 +24,8 @@ import click +from Browser.utils.data_types import InstallableBrowser, InstallationOptions + from .constant import ( INSTALLATION_DIR, NODE_MODULES, @@ -366,6 +368,36 @@ def convert_options_types(options: list[str], browser_lib: "Browser"): return params +@cli.command() +@click.argument("browser", type=click.Choice([b.value for b in InstallableBrowser]), required=False, default=None) +def install_browser(browser: Optional[str] = None, **flags): + """Install Playwright Browsers. + + It installs the specified browser by executing 'npx playwright install' command. + All installation options are passed to the command. + """ + browser_enum = browser if browser is None else InstallableBrowser(browser) + selected = [] + for name, enabled in flags.items(): + if enabled: + key = name.replace("_", "-") # e.g. with_deps -> with-deps + selected.append(InstallationOptions[key]) + from ..browser import Browser, PlaywrightLogTypes # noqa: PLC0415 + from ..playwright import Playwright # noqa: PLC0415 + os.environ["PINO_LOG_LEVEL"] = "error" + browser_lib = Browser() + browser_lib._playwright = Playwright( + library=browser_lib, + enable_playwright_debug=PlaywrightLogTypes.library, + playwright_log=sys.stdout, + ) + with contextlib.suppress(Exception): + browser_lib.install_browser(browser_enum, *selected) +for opt in InstallationOptions: + param_name = opt.name.replace("-", "_") + install_browser = click.option(opt.value, param_name, is_flag=True, help=opt.name)(install_browser) + + @cli.command() @click.argument( "path", diff --git a/Browser/keywords/browser_control.py b/Browser/keywords/browser_control.py index 5451794ba..a7c68becb 100644 --- a/Browser/keywords/browser_control.py +++ b/Browser/keywords/browser_control.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import base64 +from enum import Enum import json import uuid from collections.abc import Iterable @@ -23,6 +24,8 @@ 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 ( @@ -633,3 +636,21 @@ def clear_permissions(self): with self.playwright.grpc_channel() as stub: response = stub.ClearPermissions(Request().Empty()) logger.info(response.log) + + @keyword + def install_browser(self, browser: Optional[InstallableBrowser] = None, *options: InstallationOptions): + """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 []) + ) + ) + ) + logger.info(response.log) + except Exception: + logger.error("Error executing Playwright command") + diff --git a/Browser/playwright.py b/Browser/playwright.py index 41301cf0a..c70f5e19b 100644 --- a/Browser/playwright.py +++ b/Browser/playwright.py @@ -20,7 +20,7 @@ from functools import cached_property from pathlib import Path from subprocess import DEVNULL, STDOUT, CalledProcessError, Popen, run -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Optional, TextIO, Union import grpc # type: ignore @@ -47,7 +47,7 @@ def __init__( enable_playwright_debug: Union[PlaywrightLogTypes, bool], host: Optional[str] = None, port: Optional[int] = None, - playwright_log: Union[Path, None] = Path(Path.cwd()), + playwright_log: Optional[Union[Path, TextIO]] = Path(Path.cwd()), ): LibraryComponent.__init__(self, library) self.enable_playwright_debug = enable_playwright_debug @@ -98,6 +98,13 @@ def ensure_node_dependencies(self): f"\nInstallation path: {installation_dir}" ) + def _get_logfile(self) -> TextIO: + if isinstance(self.playwright_log, Path): + return self.playwright_log.open("w", encoding="utf-8") + if self.playwright_log is None: + return Path(os.devnull).open("w", encoding="utf-8") + return self.playwright_log + def start_playwright(self) -> Optional[Popen]: env_node_port = os.environ.get("ROBOT_FRAMEWORK_BROWSER_NODE_PORT") existing_port = self.port or env_node_port @@ -117,10 +124,6 @@ def start_playwright(self) -> Optional[Popen]: current_dir = Path(__file__).parent workdir = current_dir / "wrapper" playwright_script = workdir / "index.js" - if self.playwright_log: - logfile = self.playwright_log.open("w", encoding="utf-8") - else: - logfile = Path(os.devnull).open("w", encoding="utf-8") # noqa: SIM115 host = str(self.host) if self.host is not None else "127.0.0.1" port = str(find_free_port()) if self.enable_playwright_debug == PlaywrightLogTypes.playwright: @@ -147,7 +150,7 @@ def start_playwright(self) -> Optional[Popen]: shell=False, cwd=workdir, env=os.environ, - stdout=logfile, + stdout=self._get_logfile(), stderr=STDOUT, ) diff --git a/Browser/utils/__init__.py b/Browser/utils/__init__.py index 88bd33486..152ff5bdc 100644 --- a/Browser/utils/__init__.py +++ b/Browser/utils/__init__.py @@ -37,6 +37,8 @@ HighLightElement, HighlightMode, HttpCredentials, + InstallableBrowser, + InstallationOptions, LambdaFunction, NewPageDetails, Media, diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index 81bc1b9ab..e4b5e1d99 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -1394,3 +1394,36 @@ class TracingGroupMode(Enum): Full = auto() Browser = auto() Playwright = auto() + + +InstallableBrowser = Enum("InstallableBrowser", { + "chromium": "chromium", + "firefox": "firefox", + "webkit": "webkit", + "chromium-headless-shell": "chromium-headless-shell", + "chromium-tip-of-tree-headless-shell": "chromium-tip-of-tree-headless-shell", + "chrome": "chrome", + "chrome-beta": "chrome-beta", + "msedge": "msedge", + "msedge-beta": "msedge-beta", + "msedge-dev": "msedge-dev", +}) +InstallableBrowser.__doc__ = """Enum of browsers that can be installed with `Install Browser` keyword.""" + + +InstallationOptions = Enum("InstallationOptions", { + "with-deps": "--with-deps", + "dry-run": "--dry-run", + "list": "--list", + "force": "--force", + "only-shell": "--only-shell", + "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""" diff --git a/node/build.wrapper.js b/node/build.wrapper.js index 96eeaa4df..9c1d3173a 100644 --- a/node/build.wrapper.js +++ b/node/build.wrapper.js @@ -10,5 +10,8 @@ esbuild.build( platform: "node", outfile: "./Browser/wrapper/index.js", plugins: [nodeExternalsPlugin()], + external: [ + 'playwright-core/*', + ], } ).catch(() => process.exit(1)); diff --git a/node/playwright-wrapper/browser-control.ts b/node/playwright-wrapper/browser-control.ts index 177dcb073..d7f26f5ae 100644 --- a/node/playwright-wrapper/browser-control.ts +++ b/node/playwright-wrapper/browser-control.ts @@ -22,6 +22,16 @@ import { pino } from 'pino'; const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime }); +const { program: pwProgram } = + require('playwright-core/lib/cli/program') as { program: import('commander').Command }; + +export async function executePlaywright(request: Request.Json): Promise { + const args = JSON.parse(request.getBody()); + pwProgram.exitOverride(); + await pwProgram.parseAsync(args, { from: 'user' }); + return emptyWithLog('Installed browsers'); +} + export async function grantPermissions(request: Request.Permissions, state: PlaywrightState): Promise { const browserContext = state.getActiveContext(); if (!browserContext) { diff --git a/node/playwright-wrapper/grpc-service.ts b/node/playwright-wrapper/grpc-service.ts index e004f893c..1c7487cae 100644 --- a/node/playwright-wrapper/grpc-service.ts +++ b/node/playwright-wrapper/grpc-service.ts @@ -32,7 +32,7 @@ import { ServerSurfaceCall } from '@grpc/grpc-js/build/src/server-call'; import { class_async_logger } from './keyword-decorators'; import { emptyWithLog, errorResponse, stringResponse } from './response-util'; import { pino } from 'pino'; -const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime }); +const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime, level: process.env.PINO_LOG_LEVEL || 'info'}); @class_async_logger export class PlaywrightServer implements IPlaywrightServer { @@ -360,6 +360,7 @@ export class PlaywrightServer implements IPlaywrightServer { } selectOption = this.wrapping(interaction.selectOption); + executePlaywright = this.wrapping(browserControl.executePlaywright); grantPermissions = this.wrapping(browserControl.grantPermissions); clearPermissions = this.wrapping(browserControl.clearPermissions); deselectOption = this.wrapping(interaction.deSelectOption); diff --git a/node/playwright-wrapper/index.ts b/node/playwright-wrapper/index.ts index c4a634722..e48816eb9 100644 --- a/node/playwright-wrapper/index.ts +++ b/node/playwright-wrapper/index.ts @@ -17,7 +17,7 @@ import { Server, ServerCredentials, ServiceDefinition, UntypedServiceImplementat import { PlaywrightService } from './generated/playwright_grpc_pb'; import { pino } from 'pino'; -const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime }); +const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime, level: process.env.PINO_LOG_LEVEL || 'info' }); const args = process.argv.slice(2); @@ -40,5 +40,4 @@ server.addService( server.bindAsync(`${host}:${port}`, ServerCredentials.createInsecure(), () => { logger.info(`Listening on ${host}:${port}`); - server.start(); }); diff --git a/protobuf/playwright.proto b/protobuf/playwright.proto index 2107c7693..7c9f78fbf 100644 --- a/protobuf/playwright.proto +++ b/protobuf/playwright.proto @@ -590,6 +590,7 @@ service Playwright { rpc SaveStorageState(Request.FilePath) returns (Response.Empty); rpc GrantPermissions(Request.Permissions) returns (Response.Empty); + rpc ExecutePlaywright(Request.Json) returns (Response.Empty); rpc ClearPermissions(Request.Empty) returns (Response.Empty); /* Extension handling */