diff --git a/tests/addon_helper.py b/tests/addon_helper.py new file mode 100644 index 00000000..82f0e1c0 --- /dev/null +++ b/tests/addon_helper.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass +from functools import cached_property +import json +from pathlib import Path +import subprocess +import re + + +import psutil +from selenium import webdriver + + +@dataclass +class AddonHelper: + driver: webdriver.Remote + + @cached_property + def addon_id(self) -> str: + return get_addon_id(driver=self.driver) + + @property + def extension_prefix(self) -> str: + protocol = { + 'chrome': 'chrome-extension', + 'firefox': 'moz-extension', + }[self.driver.name] + return f'{protocol}://{self.addon_id}' + + def open_page(self, path: str) -> None: + self.driver.get(self.extension_prefix + '/' + path) + + +# 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 +# but here https://github.com/SeleniumHQ/selenium/blob/master/cpp/webdriver-server/command_types.h there are no Extension commands +# also see driver.command_executor._commands +def _get_chrome_addon_id(driver: webdriver.Remote) -> str: + """ + For a temporary addon extension id is autogenerated, so we need to extract it every time + """ + user_data_dir = Path(driver.capabilities['chrome']['userDataDir']) + prefs_file = user_data_dir / 'Default/Preferences' + assert prefs_file.exists(), prefs_file + + # for some idiotic reason, after chrome launches, extension settings aren't immediately available + # this can take up to 30 secons in this loop until they are populated + while True: + prefs = json.loads(prefs_file.read_text()) + extension_settings = prefs.get('extensions', {}).get('settings', None) + if extension_settings is not None: + # there are some other weird builtin extension as well + # this seems like the easiest way to filter them out extracing by extension name or path + [addon_id] = [k for k, v in extension_settings.items() if v['creation_flags'] != 1] + return addon_id + + +def _get_firefox_addon_id(driver: webdriver.Remote) -> str: + moz_profile = Path(driver.capabilities['moz:profile']) + prefs_file = moz_profile / 'prefs.js' + assert prefs_file.exists(), prefs_file + + while True: + for line in prefs_file.read_text().splitlines(): + m = re.fullmatch(r'user_pref\("extensions.webextensions.uuids", "(.*)"\);', line) + if m is None: + continue + # this contains a json with escaped quotes + user_prefs_s = m.group(1).replace('\\', '') + user_prefs = json.loads(user_prefs_s) + addon_ids = [v for k, v in user_prefs.items() if 'mozilla.' not in k] + if len(addon_ids) == 0: + # for some stupid reason it doesn't appear immediately in the file + continue + [addon_id] = addon_ids + return addon_id + + +def get_addon_id(driver: webdriver.Remote) -> str: + extractor = { + 'firefox': _get_firefox_addon_id, + 'chrome': _get_chrome_addon_id, + }[driver.name] + return extractor(driver) + + +def get_window_id(driver: webdriver.Remote) -> str: + if driver.name == 'firefox': + pid = str(driver.capabilities['moz:processID']) + elif driver.name == 'chrome': + # ugh no pid in capabilities... + driver_pid = driver.service.process.pid # type: ignore[attr-defined] + process = psutil.Process(driver_pid) + [chrome_process] = process.children() + cmdline = chrome_process.cmdline() + assert '--enable-automation' in cmdline, cmdline + pid = str(chrome_process.pid) + else: + raise RuntimeError(driver.name) + return get_wid_by_pid(pid) + + +def get_wid_by_pid(pid: str) -> str: + # https://askubuntu.com/a/385037/427470 + wids = subprocess.check_output(['xdotool', 'search', '--pid', pid]).decode('utf8').splitlines() + wids = [w.strip() for w in wids if len(w.strip()) > 0] + + def has_wm_desktop(wid: str) -> bool: + # TODO no idea why is that important. found out experimentally + out = subprocess.check_output(['xprop', '-id', wid, '_NET_WM_DESKTOP']).decode('utf8') + return 'not found' not in out + + [wid] = filter(has_wm_desktop, wids) + return wid + + +def focus_browser_window(driver: webdriver.Remote) -> None: + # FIXME assert not is_headless(driver) # just in case + wid = get_window_id(driver) + subprocess.check_call(['xdotool', 'windowactivate', '--sync', wid]) diff --git a/tests/end2end_test.py b/tests/end2end_test.py index d5bd5cc9..d7217a41 100755 --- a/tests/end2end_test.py +++ b/tests/end2end_test.py @@ -4,6 +4,7 @@ from contextlib import contextmanager, ExitStack import json from pathlib import Path +from dataclasses import dataclass from datetime import datetime from tempfile import TemporaryDirectory import os @@ -38,6 +39,7 @@ from common import under_ci, has_x, local_http_server, notnone from browser_helper import open_extension_page, get_cmd_hotkey from webdriver_utils import frame_context, window_context, is_visible +from addon_helper import AddonHelper logger = LazyLogger('promnesia-tests', level='debug') @@ -158,6 +160,7 @@ def _get_webdriver(tdir: Path, browser: Browser, extension: bool=True) -> Driver from selenium.webdriver.chrome.service import Service service = Service(**mexepath) driver = webdriver.Chrome(service=service, options=cr_options) + # TODO ad this to common helper logger.info(f"using webdriver: {driver.capabilities['browserVersion']} {driver.capabilities['chrome']['chromedriverVersion']}") else: raise RuntimeError(f'Unexpected browser {browser}') @@ -231,8 +234,8 @@ def set_checkbox(cid: str, value: bool) -> None: # TODO log properly print(f"Setting: port {port}, show_dots {show_dots}") - helper = TestHelper(driver) - page = helper.options_page + addon = Addon(helper=AddonHelper(driver=driver)) + page = addon.options_page page.open() set_host(driver=driver, host=host, port=port) @@ -497,6 +500,38 @@ def save(self) -> None: _switch_to_alert(self.driver).accept() +@dataclass +class OptionsPage2: + 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_page.html' + self.helper.open_page(page_name) + + # make sure settings are loaded first -- otherwise we might get race conditions when we try to set them in tests + Wait(self.helper.driver, timeout=5).until( + EC.presence_of_element_located((By.ID, 'promnesia-settings-loaded')) + ) + + def save(self) -> None: + OptionsPage(driver=self.helper.driver, helper=None).save() # type: ignore[arg-type] + + def set_position(self, settings: str): + OptionsPage(driver=self.helper.driver, helper=None).set_position(settings=settings) # type: ignore[arg-type] + + +# TODO gradually replace TestHelper and other older stuff +@dataclass +class Addon: + helper: AddonHelper + + @property + def options_page(self) -> OptionsPage2: + return OptionsPage2(helper=self.helper) + + class TestHelper(NamedTuple): driver: Driver @@ -678,28 +713,35 @@ def driver(browser: Browser) -> Iterator[Driver]: yield d +@pytest.fixture +def addon(browser: Browser) -> Iterator[Addon]: + with get_webdriver(browser=browser) as driver: + helper = AddonHelper(driver) + yield Addon(helper=helper) + + @browsers() -def test_installs(tmp_path: Path, driver: Driver) -> None: +def test_installs(addon: Addon) -> None: """ Even loading the extension into webdriver is pretty elaborate, so the test just checks it works """ - pass + assert addon.helper.addon_id is not None @browsers() -def test_settings(tmp_path: Path, driver: Driver) -> None: +def test_settings(addon: Addon) -> None: """ Just a basic test for opening options page and making sure it loads options """ - helper = TestHelper(driver) - helper.open_options_page() + driver = addon.helper.driver + addon.options_page.open() hh = driver.find_element(By.ID, 'host_id') assert hh.get_attribute('value') == 'http://localhost:13131' # default configure_extension(driver, port='12345', show_dots=False) driver.get('about:blank') - helper.open_options_page() + addon.options_page.open() hh = driver.find_element(By.ID, 'host_id') assert hh.get_attribute('value') == 'http://localhost:12345'