diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index a687e8b65ada..580c05b5b5f0 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -69,7 +69,7 @@ jobs: TEST_GROUP_STRATEGY: roundrobin PYTHONHASHSEED: '0' XDIST_PER_WORKER_SNUBA: '1' - XDIST_WORKERS: '2' + XDIST_WORKERS: '3' steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 diff --git a/src/sentry/testutils/pytest/selenium.py b/src/sentry/testutils/pytest/selenium.py index ebbde71a3e83..e0fee3f8e5e1 100644 --- a/src/sentry/testutils/pytest/selenium.py +++ b/src/sentry/testutils/pytest/selenium.py @@ -13,6 +13,7 @@ import pytest from selenium import webdriver from selenium.common.exceptions import ( + NoAlertPresentException, NoSuchElementException, SessionNotCreatedException, WebDriverException, @@ -31,6 +32,11 @@ logger = logging.getLogger("sentry.testutils") +# Selenium's default of 0.5s adds up to half a second to every wait that +# isn't already satisfied, and acceptance tests wait many times each. +WAIT_POLL_SECONDS = 0.1 + + class Browser: def __init__(self, driver, live_server): self.driver = driver @@ -214,7 +220,7 @@ def wait_until_clickable(self, selector=None, xpath=None, timeout=10): Waits until ``selector`` is visible and enabled to be clicked, or until ``timeout`` is hit, whichever happens first. """ - wait = WebDriverWait(self.driver, timeout) + wait = WebDriverWait(self.driver, timeout, poll_frequency=WAIT_POLL_SECONDS) if selector: wait.until(expected_conditions.element_to_be_clickable((By.CSS_SELECTOR, selector))) elif xpath: @@ -229,7 +235,7 @@ def wait_until(self, selector=None, xpath=None, title=None, timeout=10): Waits until ``selector`` is found in the browser, or until ``timeout`` is hit, whichever happens first. """ - wait = WebDriverWait(self.driver, timeout) + wait = WebDriverWait(self.driver, timeout, poll_frequency=WAIT_POLL_SECONDS) if selector: wait.until(expected_conditions.presence_of_element_located((By.CSS_SELECTOR, selector))) elif xpath: @@ -249,7 +255,7 @@ def wait_until_not(self, selector=None, xpath=None, title=None, timeout=10): Waits until ``selector`` is NOT found in the browser, or until ``timeout`` is hit, whichever happens first. """ - wait = WebDriverWait(self.driver, timeout) + wait = WebDriverWait(self.driver, timeout, poll_frequency=WAIT_POLL_SECONDS) if selector: wait.until_not( expected_conditions.presence_of_element_located((By.CSS_SELECTOR, selector)) @@ -268,7 +274,7 @@ def wait_until_script_execution(self, script, timeout=10): Waits until ``script`` executes and evaluates truthy, or until ``timeout`` is hit, whichever happens first. """ - wait = WebDriverWait(self.driver, timeout) + wait = WebDriverWait(self.driver, timeout, poll_frequency=WAIT_POLL_SECONDS) wait.until(lambda driver: driver.execute_script(script)) return self @@ -499,12 +505,43 @@ def _take_prelaunched_chrome() -> webdriver.Chrome | None: def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: # A worker whose tests never use the browser still has to close the # prelaunched Chrome. - driver = _take_prelaunched_chrome() - if driver is not None: + for driver in (_take_prelaunched_chrome(), _reusable_chrome.pop("driver", None)): + if driver is not None: + try: + driver.quit() + except Exception: + pass + + +# Starting Chrome for every test costs the launch, and each test then loads the +# app bundle into a cold cache. One Chrome per worker, reset between tests, +# keeps the cache warm. +_reusable_chrome: dict[str, webdriver.Chrome] = {} + + +def _reset_chrome(driver: webdriver.Chrome, origin: str) -> bool: + """Clears what a test can leave behind. Returns False if Chrome is unusable.""" + try: try: - driver.quit() - except Exception: + driver.switch_to.alert.dismiss() + except NoAlertPresentException: pass + handles = driver.window_handles + for handle in handles[1:]: + driver.switch_to.window(handle) + driver.close() + driver.switch_to.window(handles[0]) + driver.get("about:blank") + driver.execute_cdp_cmd("Network.clearBrowserCookies", {}) + driver.execute_cdp_cmd( + "Storage.clearDataForOrigin", {"origin": origin, "storageTypes": "all"} + ) + driver.execute_cdp_cmd("Emulation.setTouchEmulationEnabled", {"enabled": False}) + driver.execute_cdp_cmd("Emulation.clearDeviceMetricsOverride", {}) + except Exception: + logger.warning("selenium.reset-chrome.failed", exc_info=True) + return False + return True @pytest.fixture(scope="function") @@ -515,7 +552,8 @@ def browser(request, live_server): driver_type = request.config.getoption("selenium_driver") driver: WebDriver if driver_type == "chrome": - driver = _take_prelaunched_chrome() or _launch_chrome(request.config) + reused = _reusable_chrome.pop("driver", None) + driver = reused or _take_prelaunched_chrome() or _launch_chrome(request.config) elif driver_type == "firefox": driver = webdriver.Firefox() else: @@ -550,6 +588,9 @@ def browser(request, live_server): sys.stderr.write("[browser console] ") sys.stderr.write(repr(entry)) sys.stderr.write("\n") + if isinstance(driver, webdriver.Chrome) and _reset_chrome(driver, live_server.url): + _reusable_chrome["driver"] = driver + return # Teardown Selenium. try: driver.quit() @@ -564,7 +605,9 @@ def pytest_runtest_makereport(item, call): summary: MutableSequence[str] = [] extra = getattr(report, "extra", []) driver = getattr(item, "_driver", None) - if driver is not None: + # Each phase of every test used to take a screenshot and dump the page + # source. Only a failure needs them. + if driver is not None and report.failed: _gather_url(item, report, driver, summary, extra) _gather_screenshot(item, report, driver, summary, extra) _gather_html(item, report, driver, summary, extra)