diff --git a/extension/.ci/build b/extension/.ci/build index 7c8be10..3db6080 100755 --- a/extension/.ci/build +++ b/extension/.ci/build @@ -11,8 +11,7 @@ npm install FAILED=0 npm run eslint || FAILED=1 -# TODO later -#npm run test || FAILED=1 +npm run test || FAILED=1 for browser in 'firefox' 'chrome'; do ./build --target "$browser" "$@" diff --git a/extension/babel.config.js b/extension/babel.config.js index e34a262..ec598f0 100644 --- a/extension/babel.config.js +++ b/extension/babel.config.js @@ -1,5 +1,12 @@ const presets = [ - '@babel/preset-flow', + // this is necessary for jest? otherwsie it can't import modules.. + // ugh... I don't understand tbh, seems that even without preset-env, webpack respects browserlist?? + // and looks like without preset-env the code is cleaner??? + // but whatever, the difference is minor and I don't have energy to investigate now.. + '@babel/preset-env', + + // also necessary for jest? otherwise fails to import typescript + '@babel/preset-typescript', ] const plugins = [] diff --git a/extension/build b/extension/build index 0235743..0dfc2fa 100755 --- a/extension/build +++ b/extension/build @@ -3,6 +3,7 @@ import argparse import os from subprocess import check_call from pathlib import Path +from sys import platform # right, specifying id in manifest doesn't seem to work # AMO responds with: Server response: Duplicate add-on ID found. (status: 400) @@ -17,12 +18,14 @@ TARGETS = [ 'firefox', ] -def main(): +npm = "npm.cmd" if platform == "win32" else "npm" + +def main() -> None: p = argparse.ArgumentParser() p.add_argument('--release', action='store_true', help="Use release flavor of build") p.add_argument('--watch' , action='store_true') p.add_argument('--lint' , action='store_true') - p.add_argument('--publish', action='store_true', help="Publish on chrome web store/addons.mozilla.org") + p.add_argument('--publish', choices=['listed', 'unlisted'], help="Publish on chrome web store/addons.mozilla.org") p.add_argument('--v3', action='store_const', const='3', dest='manifest') p.add_argument('--v2', action='store_const', const='2', dest='manifest') @@ -41,9 +44,9 @@ def main(): ext_dir = (base_ext_dir / target).resolve() # webext can't into symlinks # sadly no way to specify zip name in the regex.. artifacts_dir = (base_ext_dir / 'artifacts' / target).resolve() - def webext(*args, **kwargs): + def webext(*args, **kwargs) -> None: check_call([ - 'npm', 'run', 'web-ext', + npm, 'run', 'web-ext', '--', '--source-dir' , ext_dir, '--artifacts-dir', artifacts_dir, @@ -53,7 +56,7 @@ def main(): env = { 'TARGET' : target, 'RELEASE': 'YES' if args.release else 'NO', - 'PUBLISH': 'YES' if args.publish else 'NO', + 'PUBLISH': 'YES' if args.publish is not None else 'NO', 'MANIFEST': manifest, 'EXT_ID' : IDS[target], **os.environ, @@ -61,13 +64,13 @@ def main(): if args.watch: check_call([ - 'npm', 'run', 'watch', + npm, 'run', 'watch', ], env=env, cwd=str(Path(__file__).absolute().parent)) # TODO exec instead? return check_call([ - 'npm', 'run', 'build', + npm, 'run', 'build', ], env=env, cwd=str(Path(__file__).absolute().parent)) if args.lint: @@ -91,20 +94,22 @@ def main(): '--id' , IDS[target], ] - if args.publish: + if args.publish is not None: + assert args.lint assert args.release if 'firefox' in target: check_call([ - 'npm', 'run', 'release:amo', + npm, 'run', 'release:amo', '--', - '--channel', 'listed', + '--channel', args.publish, '--source-dir', str(ext_dir), *firefox_release_args(), ]) elif target == 'chrome': + assert args.publish == 'listed' # no notion of unlisted on chrome store? from chrome_dev_secrets import CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN check_call([ - 'npm', 'run', 'release:cws', + npm, 'run', 'release:cws', '--', 'upload', # '--auto-publish', diff --git a/extension/eslint.config.js b/extension/eslint.config.js index b6fae42..8c32353 100644 --- a/extension/eslint.config.js +++ b/extension/eslint.config.js @@ -1,5 +1,6 @@ // @ts-check +const globals = require('globals') const eslint = require('@eslint/js') const tseslint = require('typescript-eslint') @@ -20,5 +21,12 @@ module.exports = tseslint.config( }, ], }, + languageOptions: { + globals: { + // necessary for document. window. etc variables to work + ...globals.browser, + ...globals.webextensions, + }, + }, }, ) diff --git a/extension/package.json b/extension/package.json index 31d88a2..6763788 100644 --- a/extension/package.json +++ b/extension/package.json @@ -28,23 +28,29 @@ }, "homepage": "https://github.com/karlicoss/grasp#readme", "devDependencies": { - "@babel/core": "^7.24.4", - "@babel/eslint-parser": "^7.24.1", - "@babel/preset-env": "^7.24.4", - "@eslint/js": "^9.1.1", + "@babel/core": "^7.24.5", + "@babel/eslint-parser": "^7.24.5", + "@babel/preset-env": "^7.24.5", + "@babel/preset-typescript": "^7.24.1", + "@eslint/js": "^9.3.0", "@types/webextension-polyfill": "^0.10.7", "babel-loader": "^9.1.3", "chrome-webstore-upload-cli": "^3.1.0", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^12.0.2", - "css-loader": "^7.1.1", + "css-loader": "^7.1.2", "eslint": "^8.57.0", + "globals": "^15.3.0", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "jest-fetch-mock": "^3.0.3", + "node-fetch": "^3.3.2", "style-loader": "^4.0.0", "ts-loader": "^9.5.1", "typescript": "^5.4.5", - "typescript-eslint": "^7.7.1", + "typescript-eslint": "^7.10.0", "web-ext": "^7.11.0", - "webextension-polyfill": "^0.11.0", + "webextension-polyfill": "^0.12.0", "webpack": "^5.91.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4", diff --git a/extension/tests/dummy.test.js b/extension/tests/dummy.test.js new file mode 100644 index 0000000..6fdea6a --- /dev/null +++ b/extension/tests/dummy.test.js @@ -0,0 +1,4 @@ +test('dummy', () => { + const hello = 'hello' + expect(hello).toBe('hello') +}) diff --git a/pyproject.toml b/pyproject.toml index 170eb1a..c47a44d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,10 @@ testing = [ ] +[project.scripts] +grasp_backend = 'grasp_backend.__main__:main' + + [build-system] requires = ["setuptools", "setuptools-scm"] build-backend = "setuptools.build_meta" diff --git a/tests/addon.py b/tests/addon.py new file mode 100644 index 0000000..3b29bab --- /dev/null +++ b/tests/addon.py @@ -0,0 +1,126 @@ +""" +Grasp-specific addon wrappers +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import time +from typing import Iterator + +import click +import pytest +from selenium.webdriver import Remote as Driver + +from .addon_helper import AddonHelper + + +def get_addon_source(kind: str) -> Path: + # TODO compile first? + addon_path = (Path(__file__).parent.parent / 'extension' / 'dist' / kind).absolute() + assert addon_path.exists() + assert (addon_path / 'manifest.json').exists() + return addon_path + + +class Command: + # TODO assert these against manifest? + CAPTURE_SIMPLE = 'capture-simple' + CAPTURE_EXTRA = '_execute_browser_action' + CAPTURE_EXTRA_V3 = '_execute_action' + + +@dataclass +class OptionsPage: + # I suppose it's inevitable it's at least somewhat driver aware? since we want it to locate elements etc + helper: AddonHelper + + def open(self) -> None: + self.helper.open_page(self.helper.options_page_name) + + def change_endpoint(self, endpoint: str, *, wait_for_permissions: bool = False) -> None: + driver = self.helper.driver + + current_url = driver.current_url + assert current_url.endswith(self.helper.options_page_name), current_url # just in case + + ep = driver.find_element('id', 'endpoint_id') + while ep.get_attribute('value') == '': + # data is set asynchronously, so need to wait for data to appear + # TODO is there some webdriver wait? + time.sleep(0.001) + ep.clear() + ep.send_keys(endpoint) + + se = driver.find_element('id', 'save_id') + se.click() + + if wait_for_permissions: + # we can't accept this alert via webdriver, it's a native chrome alert, not DOM + click.confirm(click.style('You should see prompt for permissions. Accept them', blink=True, fg='yellow'), abort=True) + + alert = driver.switch_to.alert + assert alert.text == 'Saved!', alert.text # just in case + alert.accept() + + +@dataclass +class Popup: + addon: 'Addon' + + def open(self) -> None: + self.addon.activate() + # time.sleep(2) # TODO not sure if can do better? + + def enter_data(self, *, comment: str, tags: str) -> None: + helper = self.addon.helper + + if helper.driver.name == 'firefox': + # for some reason in firefox under geckodriver it woudn't focus comment input field?? + # tried both regular and dev edition firefox with latest geckodriver + # works fine when extension is loaded in firefox manually or in chrome with chromedriver.. + # TODO file a bug?? + helper.gui_hotkey('tab') # give focus to the input + + helper.gui_write(comment) + + helper.gui_hotkey('tab') # switch to tags + + # erase default, without interval doesn't remove everything + for _ in range(10): + helper.gui_hotkey('backspace') + # pyautogui.hotkey(['backspace' for i in range(10)], interval=0.05) + helper.gui_write(tags) + + def submit(self) -> None: + self.addon.helper.gui_hotkey('shift+enter') + + +@dataclass +class Addon: + helper: AddonHelper + + def activate(self) -> None: + cmd = { + 2: Command.CAPTURE_EXTRA, + 3: Command.CAPTURE_EXTRA_V3, # meh + }[self.helper.manifest_version] + self.helper.trigger_command(cmd) + + def quick_capture(self) -> None: + self.helper.trigger_command(Command.CAPTURE_SIMPLE) + + @property + def options_page(self) -> OptionsPage: + return OptionsPage(helper=self.helper) + + @property + def popup(self) -> Popup: + return Popup(addon=self) + + +@pytest.fixture +def addon(driver: Driver) -> Iterator[Addon]: + addon_source = get_addon_source(kind=driver.name) + helper = AddonHelper(driver=driver, addon_source=addon_source) + yield Addon(helper=helper) diff --git a/tests/addon_helper.py b/tests/addon_helper.py index 82f0e1c..676e6ee 100644 --- a/tests/addon_helper.py +++ b/tests/addon_helper.py @@ -2,22 +2,35 @@ from functools import cached_property import json from pathlib import Path -import subprocess import re +import subprocess +from typing import Any - +from loguru import logger import psutil from selenium import webdriver +from .webdriver_utils import is_headless + @dataclass class AddonHelper: driver: webdriver.Remote + addon_source: Path @cached_property def addon_id(self) -> str: return get_addon_id(driver=self.driver) + @cached_property + def manifest(self) -> Any: + # ugh. sadly (at least in Firefox) there doesn't seem a way to read actual manifest loaded in browser? + return json.loads((self.addon_source / 'manifest.json').read_text()) + + @cached_property + def manifest_version(self) -> int: + return self.manifest['manifest_version'] + @property def extension_prefix(self) -> str: protocol = { @@ -29,6 +42,51 @@ def extension_prefix(self) -> str: def open_page(self, path: str) -> None: self.driver.get(self.extension_prefix + '/' + path) + @property + def options_page_name(self) -> str: + return self.manifest['options_ui']['page'] + + @property + def headless(self) -> bool: + return is_headless(self.driver) + + def trigger_command(self, command: str) -> None: + # note: also for chrome possible to extract from prefs['extensions']['commands'] if necessary + commands = self.manifest['commands'] + assert command in commands, (command, commands) + + if self.headless: + # see selenium_bridge.js + ccc = f'selenium-bridge-{command}' + self.driver.execute_script( + f""" + var event = document.createEvent('HTMLEvents'); + event.initEvent('{ccc}', true, true); + document.dispatchEvent(event); + """ + ) + else: + hotkey = commands[command]['suggested_key']['default'] + self.gui_hotkey(hotkey) + + def gui_hotkey(self, key: str) -> None: + assert not self.headless # just in case + lkey = key.lower().split('+') + logger.debug(f'sending hotkey {lkey}') + + import pyautogui + + focus_browser_window(self.driver) + pyautogui.hotkey(*lkey) + + def gui_write(self, *args, **kwargs) -> None: + assert not self.headless + + import pyautogui + + focus_browser_window(self.driver) + pyautogui.write(*args, **kwargs) # select first item + # NOTE looks like it used to be posssible in webdriver api? # at least as of 2011 https://github.com/gavinp/chromium/blob/681563ea0f892a051f4ef3d5e53438e0bb7d2261/chrome/test/webdriver/test/chromedriver.py#L35-L40 @@ -114,6 +172,6 @@ def has_wm_desktop(wid: str) -> bool: def focus_browser_window(driver: webdriver.Remote) -> None: - # FIXME assert not is_headless(driver) # just in case + assert not is_headless(driver) # just in case wid = get_window_id(driver) subprocess.check_call(['xdotool', 'windowactivate', '--sync', wid]) diff --git a/tests/test_end2end.py b/tests/test_end2end.py index ad99fe8..bf352f6 100644 --- a/tests/test_end2end.py +++ b/tests/test_end2end.py @@ -12,112 +12,26 @@ from loguru import logger import psutil import pytest -from selenium import webdriver +from selenium.webdriver import Remote as Driver -from .addon_helper import AddonHelper, focus_browser_window -from . import paths # type: ignore[attr-defined] +from .addon import addon, get_addon_source, Addon +from .webdriver_utils import get_webdriver -@dataclass -class OptionsPage: - # I suppose it's inevitable it's at least somewhat driver aware? since we want it to locate elements etc - helper: AddonHelper - - def open(self) -> None: - # TODO extract from manifest -> options_id -> options.html - # seems like addon just links to the actual manifest on filesystem, so will need to read from that - page_name = 'options.html' - self.helper.open_page(page_name) - - def change_endpoint(self, endpoint: str, *, wait_for_permissions: bool = False) -> None: - driver = self.helper.driver - - current_url = driver.current_url - assert current_url.endswith('options.html'), current_url # just in case - - ep = driver.find_element('id', 'endpoint_id') - while ep.get_attribute('value') == '': - # data is set asynchronously, so need to wait for data to appear - # TODO is there some webdriver wait? - time.sleep(0.001) - ep.clear() - ep.send_keys(endpoint) - - se = driver.find_element('id', 'save_id') - se.click() - - if wait_for_permissions: - # we can't accept this alert via webdriver, it's a native chrome alert, not DOM - confirm('You should see prompt for permissions. Accept them') - - alert = driver.switch_to.alert - assert alert.text == 'Saved!', alert.text # just in case - alert.accept() - - -@dataclass -class Popup: - helper: AddonHelper - - def open(self) -> None: - # I suppose triggeing via hotkey is bound to be cursed? - # maybe replace with selenium_bridge thing... or ydotool? - # although to type into in, I'll need pyautogui anyway... - import pyautogui - - modifier = {'firefox': 'alt', 'chrome': 'shift'}[self.helper.driver.name] - - focus_browser_window(self.helper.driver) - - pyautogui.hotkey('ctrl', modifier, 'y') - time.sleep(2) # TODO not sure if can do better? - - def enter_data(self, *, comment: str, tags: str) -> None: - import pyautogui - - if self.helper.driver.name == 'firefox': - # for some reason in firefox under geckodriver it woudn't focus comment input field?? - # tried both regular and dev edition firefox with latest geckodriver - # works fine when extension is loaded in firefox manually or in chrome with chromedriver.. - # TODO file a bug?? - pyautogui.hotkey('tab') # give focus to the input - - pyautogui.write(comment) - - pyautogui.hotkey('tab') # switch to tags - - # erase default, without interval doesn't remove everything - for _ in range(10): - pyautogui.hotkey('backspace') - # pyautogui.hotkey(['backspace' for i in range(10)], interval=0.05) - pyautogui.write(tags) - - def submit(self) -> None: - import pyautogui - - pyautogui.hotkey('shift', 'enter') - - -@dataclass -class Addon: - helper: AddonHelper - - def quick_capture(self) -> None: - import pyautogui - - modifier = {'firefox': 'alt', 'chrome': 'shift'}[self.helper.driver.name] - - focus_browser_window(self.helper.driver) - - pyautogui.hotkey('ctrl', modifier, 'h') - - @property - def options_page(self) -> OptionsPage: - return OptionsPage(helper=self.helper) - - @property - def popup(self) -> Popup: - return Popup(helper=self.helper) +@pytest.fixture +def driver(tmp_path: Path, browser: str) -> Iterator[Driver]: + profile_dir = tmp_path / 'browser_profile' + res = get_webdriver( + profile_dir=profile_dir, + addon_source=get_addon_source(kind=browser), + browser=browser, + headless=False, # TODO? + logger=logger, + ) + try: + yield res + finally: + res.quit() @dataclass @@ -152,64 +66,6 @@ def server(tmp_path: Path, grasp_port: str) -> Iterator[Server]: yield Server(port=grasp_port, capture_file=capture_file) -# TODO move to addon_helper?? not sure -@pytest.fixture -def addon(tmp_path: Path, browser: str) -> Iterator[Addon]: - addon_path = Path('extension/dist').absolute() / browser - assert (addon_path / 'manifest.json').exists() - - driver: webdriver.Remote - # FIXME headless (there is some code in promnesia) - if browser == 'firefox': - ff_options = webdriver.FirefoxOptions() - # NOTE: using dev edition firefox - ff_options.binary_location = paths.firefox - - # TODO not sure what's the difference with install_addon? - # seems like this one is depecated - # https://github.com/SeleniumHQ/selenium/blob/ba27d0f7675a3d8139544e5522b8f0690a2ba4ce/py/selenium/webdriver/firefox/firefox_profile.py#L82 - # ff_profile = webdriver.FirefoxProfile() - # ff_profile.add_extension(str(addon_path)) - # ff_options.profile = ff_profile - - # WTF?? it's autodownloaded by selenium??? - # .local/lib/python3.10/site-packages/selenium/webdriver/common/selenium_manager.py - # service = webdriver.FirefoxService(executable_path=paths.geckodriver) - - # todo use tmp_path for profile path? - driver = webdriver.Firefox(options=ff_options) - elif browser == 'chrome': - # todo name it chromium? - # NOTE: something like this https://storage.googleapis.com/chrome-for-testing-public/123.0.6312.122/linux64/chrome-linux64.zip - cr_options = webdriver.ChromeOptions() - # shit. ok, this seems to work... - cr_options.binary_location = paths.chrome - cr_options.add_argument('--load-extension=' + str(addon_path)) - cr_options.add_argument('--user-data-dir=' + str(tmp_path)) - # NOTE: there is also .add_extension, but it seems to require a packed extension (zip or crx?) - - service = webdriver.ChromeService(executable_path=paths.chromedriver) - driver = webdriver.Chrome(service=service, options=cr_options) - else: - raise RuntimeError(browser) - - with driver: - if browser == 'firefox': - # todo log driver.capabilities['moz:geckodriverVersion'] and 'browserVersion' - # TODO doesn't work with regular Firefox? says addon must be signed - # FIXME crap, it seems that the addon is installed as an xpi from a temporary location? - # so if we modify code we have to rerun the test - assert isinstance(driver, webdriver.Firefox), driver - driver.install_addon(str(addon_path), temporary=True) - elif browser == 'chrome': - pass - else: - raise RuntimeError(browser) - - helper = AddonHelper(driver=driver) - yield Addon(helper=helper) - - # TODO adapt for multiple params def myparametrize(param: str, values): """ @@ -332,7 +188,9 @@ def test_capture_with_extra_data(addon: Addon, server: Server) -> None: captured = server.capture_file.read_text() captured = re.sub(r'\[.*?\]', '[date]', captured) # dates are volatile, can't test against them - assert captured == '''\ + assert ( + captured + == '''\ * [date] Example Domain :tag2:tag1: https://example.com/ Selection: @@ -341,3 +199,4 @@ def test_capture_with_extra_data(addon: Addon, server: Server) -> None: some multiline note ''' + ) diff --git a/tests/webdriver_utils.py b/tests/webdriver_utils.py new file mode 100644 index 0000000..3015f3f --- /dev/null +++ b/tests/webdriver_utils.py @@ -0,0 +1,149 @@ +from contextlib import contextmanager +from pathlib import Path +from time import sleep +from typing import Dict, Iterator, Optional + +from selenium import webdriver +from selenium.common.exceptions import NoAlertPresentException +from selenium.webdriver import Remote as Driver +from selenium.webdriver.common.alert import Alert +from selenium.webdriver.remote.webelement import WebElement + + +def get_current_frame(driver: Driver) -> Optional[WebElement]: + # idk why is it so hard to get current frame in selenium, but it is what it is + # https://github.com/seleniumhq/selenium-google-code-issue-archive/issues/4305#issuecomment-192026569 + return driver.execute_script('return window.frameElement') + + +@contextmanager +def frame_context(driver: Driver, frame) -> Iterator[Optional[WebElement]]: + # todo return the frame maybe? + current = get_current_frame(driver) + driver.switch_to.frame(frame) + try: + new_frame = get_current_frame(driver) + yield new_frame + finally: + # hmm mypy says it can't be None + # but pretty sure it worked when current frame is None? + # see https://github.com/SeleniumHQ/selenium/blob/trunk/py/selenium/webdriver/remote/switch_to.py + driver.switch_to.frame(current) # type: ignore[arg-type] + + +@contextmanager +def window_context(driver: Driver, window_handle: str) -> Iterator[None]: + original = driver.current_window_handle + driver.switch_to.window(window_handle) + try: + yield + finally: + driver.switch_to.window(original) + + +def is_visible(driver: Driver, element: WebElement) -> bool: + # apparently is_display checks if element is on the page, not necessarily within viewport?? + # but I also found element.is_displayed() to be unreliable in other direction too + # (returning true for elements that aren't displayed) + # it seems to even differ between browsers + return driver.execute_script('return arguments[0].checkVisibility()', element) + + +def is_headless(driver: Driver) -> bool: + if driver.name == 'firefox': + return driver.capabilities.get('moz:headless', False) + elif driver.name == 'chrome': + # https://antoinevastel.com/bot%20detection/2018/01/17/detect-chrome-headless-v2.html + return driver.execute_script("return navigator.webdriver") is True + else: + raise RuntimeError(driver.name) + + +def wait_for_alert(driver: Driver) -> Alert: + """ + Alert is often shown as a result of async operations, so this is to prevent race conditions + """ + e: Optional[Exception] = None + for _ in range(100 * 10): # wait 10 secs max + try: + return driver.switch_to.alert + except NoAlertPresentException as ex: + e = ex + sleep(0.01) + continue + assert e is not None + raise e + + +def get_webdriver( + *, + profile_dir: Path, + addon_source: Path, + browser: str, + headless: bool, + logger, +) -> Driver: + # useful for debugging + # import logging + # from selenium.webdriver.remote.remote_connection import LOGGER + # LOGGER.setLevel(logging.DEBUG) + + # hmm. seems like if it can't find the driver, selenium automatically downloads it? + driver: Driver + version_data: Dict[str, str] + if browser == 'firefox': + ff_options = webdriver.FirefoxOptions() + ff_options.set_preference('profile', str(profile_dir)) + # ff_options.binary_location = '' # set custom path here + # e.g. use firefox from here to test https://www.mozilla.org/en-GB/firefox/developer/ + if headless: + ff_options.add_argument('--headless') + driver = webdriver.Firefox(options=ff_options) + + addon_id = driver.install_addon(str(addon_source), temporary=True) + logger.debug(f'firefox addon id: {addon_id}') + + version_data = {} + # TODO 'binary'? might not be present? + for key in ['browserName', 'browserVersion', 'moz:geckodriverVersion', 'moz:headless', 'moz:profile']: + version_data[key] = driver.capabilities[key] + version_data['driver_path'] = getattr(driver.service, '_path') + elif browser == 'chrome': + cr_options = webdriver.ChromeOptions() + chrome_bin: Optional[str] = None # default (e.g. apt version) + + if chrome_bin is not None: + cr_options.binary_location = chrome_bin + + cr_options.add_argument(f'--load-extension={addon_source}') + cr_options.add_argument(f'--user-data-dir={profile_dir}') # todo point to a subdir? + + if headless: + if Path('/.dockerenv').exists(): + # necessary, otherwise chrome fails to start under containers + cr_options.add_argument('--no-sandbox') + + # regular --headless doesn't support extensions for some reason + cr_options.add_argument('--headless=new') + + # generally 'selenium manager' download the correct driver version itself + chromedriver_bin: Optional[str] = None # default + + service = webdriver.ChromeService(executable_path=chromedriver_bin) + driver = webdriver.Chrome(service=service, options=cr_options) + + version_data = {} + # TODO 'binary'? might not be present? + for key in ['browserName', 'browserVersion']: + version_data[key] = driver.capabilities[key] + version_data['chromedriverVersion'] = driver.capabilities['chrome']['chromedriverVersion'] + version_data['userDataDir'] = driver.capabilities['chrome']['userDataDir'] + version_data['driver_path'] = getattr(driver.service, '_path') + + browser_version = tuple(map(int, version_data['browserVersion'].split('.'))) + driver_version = tuple(map(int, version_data['chromedriverVersion'].split(' ')[0].split('.'))) + else: + raise RuntimeError(f'Unexpected browser {browser}') + version_string = ' '.join(f'{k}={v}' for k, v in version_data.items()) + logger.info(f'webdriver version: {version_string}') + return driver