Skip to content

Commit 471b8a9

Browse files
perf(acceptance): Reuse one Chrome per worker and reset it between tests
Each acceptance test launched and quit its own Chrome. Keep one browser per xdist worker instead, and between tests open a fresh tab, close the old ones, and clear cookies and all origin storage (localStorage, IndexedDB, service workers, Cache Storage) over CDP. The HTTP cache stays warm. If the reset fails or the browser died, it is replaced with a fresh one. Firefox keeps the old launch-per-test behavior. Co-Authored-By: Joshua Li <14209781+joshuarli@users.noreply.github.com>
1 parent 3065324 commit 471b8a9

1 file changed

Lines changed: 85 additions & 21 deletions

File tree

‎src/sentry/testutils/pytest/selenium.py‎

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from selenium.webdriver.common.action_chains import ActionChains
1919
from selenium.webdriver.common.by import By
20+
from selenium.webdriver.remote.webdriver import WebDriver
2021
from selenium.webdriver.support import expected_conditions
2122
from selenium.webdriver.support.wait import WebDriverWait
2223

@@ -397,14 +398,10 @@ def start_chrome(**chrome_args):
397398
)
398399

399400

400-
@pytest.fixture(scope="function")
401-
def browser(request, live_server):
402-
window_size = request.config.getoption("window_size")
403-
window_width, window_height = map(int, window_size.split("x", 1))
404-
405-
driver_type = request.config.getoption("selenium_driver")
406-
headless = not request.config.getoption("no_headless")
407-
slow_network = request.config.getoption("slow_network")
401+
def _start_driver(config):
402+
window_size = config.getoption("window_size")
403+
driver_type = config.getoption("selenium_driver")
404+
headless = not config.getoption("no_headless")
408405
if driver_type == "chrome":
409406
options = webdriver.ChromeOptions()
410407
options.add_argument("no-sandbox")
@@ -413,26 +410,85 @@ def browser(request, live_server):
413410
options.add_argument(f"window-size={window_size}")
414411
if headless:
415412
options.add_argument("headless")
416-
chrome_path = request.config.getoption("chrome_path")
413+
chrome_path = config.getoption("chrome_path")
417414
if chrome_path:
418415
options.binary_location = chrome_path
419-
chromedriver_path = request.config.getoption("chromedriver_path")
416+
chromedriver_path = config.getoption("chromedriver_path")
420417
chrome_args = {"options": options}
421418
if chromedriver_path:
422419
chrome_args["executable_path"] = chromedriver_path
423420

424-
driver = start_chrome(**chrome_args)
425-
if slow_network:
426-
driver.set_network_conditions(
427-
offline=False,
428-
latency=400 * 2, # additional latency (ms)
429-
throughput=500 * 1024, # maximal throughput
430-
)
421+
return start_chrome(**chrome_args)
431422
elif driver_type == "firefox":
432-
driver = webdriver.Firefox()
423+
return webdriver.Firefox()
433424
else:
434425
raise pytest.UsageError("--driver must be specified")
435426

427+
428+
def _quit_driver(driver):
429+
try:
430+
driver.quit()
431+
except Exception:
432+
pass
433+
434+
435+
def _reset_chrome(driver, origin):
436+
# A new tab drops sessionStorage, per-tab CDP emulation overrides, and any
437+
# JS or requests the previous test's page still has in flight, which must
438+
# stop before the database is flushed.
439+
previous_handles = driver.window_handles
440+
driver.switch_to.new_window("tab")
441+
fresh_handle = driver.current_window_handle
442+
for handle in previous_handles:
443+
driver.switch_to.window(handle)
444+
driver.close()
445+
driver.switch_to.window(fresh_handle)
446+
447+
# The HTTP cache stays warm: the app cannot read it, and a cold cache
448+
# would give back part of what skipping the relaunch saves.
449+
driver.execute_cdp_cmd("Network.clearBrowserCookies", {})
450+
driver.execute_cdp_cmd("Storage.clearDataForOrigin", {"origin": origin, "storageTypes": "all"})
451+
452+
453+
class _SessionDriver:
454+
driver: WebDriver | None = None
455+
456+
457+
@pytest.fixture(scope="session")
458+
def _session_driver():
459+
# Session scope is per xdist worker, so each worker owns one Chrome.
460+
holder = _SessionDriver()
461+
yield holder
462+
if holder.driver is not None:
463+
_quit_driver(holder.driver)
464+
465+
466+
@pytest.fixture(scope="function")
467+
def browser(request, live_server, _session_driver):
468+
window_size = request.config.getoption("window_size")
469+
window_width, window_height = map(int, window_size.split("x", 1))
470+
reuse = request.config.getoption("selenium_driver") == "chrome"
471+
472+
driver = _session_driver.driver if reuse else None
473+
if driver is not None:
474+
try:
475+
driver.window_handles
476+
except WebDriverException:
477+
logger.warning("selenium.reused-browser-dead", exc_info=True)
478+
_quit_driver(driver)
479+
driver = None
480+
if driver is None:
481+
driver = _start_driver(request.config)
482+
if reuse:
483+
_session_driver.driver = driver
484+
485+
if request.config.getoption("slow_network"):
486+
driver.set_network_conditions(
487+
offline=False,
488+
latency=400 * 2, # additional latency (ms)
489+
throughput=500 * 1024, # maximal throughput
490+
)
491+
436492
driver.set_window_size(window_width, window_height)
437493

438494
request.node._driver = driver
@@ -462,11 +518,19 @@ def browser(request, live_server):
462518
sys.stderr.write("[browser console] ")
463519
sys.stderr.write(repr(entry))
464520
sys.stderr.write("\n")
465-
# Teardown Selenium.
521+
522+
if not reuse:
523+
_quit_driver(driver)
524+
return
525+
466526
try:
467-
driver.quit()
527+
_reset_chrome(driver, live_server.url)
468528
except Exception:
469-
pass
529+
# A browser that cannot be reset could leak state into the next test,
530+
# so replace it instead.
531+
logger.warning("selenium.reset-failed", exc_info=True)
532+
_session_driver.driver = None
533+
_quit_driver(driver)
470534

471535

472536
@pytest.hookimpl(tryfirst=True, hookwrapper=True)

0 commit comments

Comments
 (0)