Files
sousa-gecko/testing/web-platform/mozilla/tests/webdriver/support/fixtures.py
T
Henrik Skupin f0c596bff6 Bug 2050357 - [wdspec] Make geckodriver fixture module-scoped to avoid unnecessary restarts. r=jdescottes
The geckodriver process is reused across tests within the same module
when command line arguments, environment variables, and profile match.
A custom profile is used to avoid conflicts with the harness Firefox
instance.

Differential Revision: https://phabricator.services.mozilla.com/D308763
2026-07-02 12:31:16 +00:00

433 lines
14 KiB
Python

import os
from copy import deepcopy
import pytest
import pytest_asyncio
import tests.support.fixtures as global_fixtures
from tests.support import defaults
from .chrome_handler import using_chrome_handler
from .context import using_context
from .helpers import (
Browser,
Geckodriver,
create_custom_profile,
get_profile_folder,
read_user_preferences,
)
_geckodriver_state = {"current": None}
def pytest_configure(config):
config.addinivalue_line(
"markers",
"geckodriver(allow_system_access=False, force_new=False): "
"Configure the geckodriver instance for the test",
)
@pytest.fixture(scope="module")
def browser(configuration, firefox_options):
"""Start a Firefox instance without using geckodriver.
geckodriver will automatically use the --remote-allow-hosts and
--remote.allow.origins command line arguments.
Starting Firefox without geckodriver allows to set those command line arguments
as needed. The fixture method returns the browser instance that should be used
to connect to a RemoteAgent supported protocol (WebDriver BiDi).
"""
current_browser = None
def _browser(
clone_profile=True,
extra_args=None,
extra_prefs=None,
use_bidi=False,
use_marionette=False,
):
nonlocal current_browser
webdriver_args = configuration["webdriver"]["args"]
log_level = None
truncate_enabled = True
if "-vvv" in webdriver_args:
log_level = "Trace"
truncate_enabled = False
elif "-vv" in webdriver_args:
log_level = "Trace"
elif "-v" in webdriver_args:
log_level = "Debug"
# If the requested preferences and arguments match the ones for the
# already started firefox, we can reuse the current firefox instance,
# return the instance immediately.
if current_browser:
if (
current_browser.extra_args == extra_args
and current_browser.extra_prefs == extra_prefs
and current_browser.is_running
and current_browser.use_bidi == use_bidi
and current_browser.use_marionette == use_marionette
and current_browser.log_level == log_level
and current_browser.truncate_enabled == truncate_enabled
):
return current_browser
# Otherwise, if firefox is already started, terminate it because we need
# to create a new instance for the provided preferences.
current_browser.quit()
binary = configuration["browser"]["binary"]
env = configuration["browser"]["env"]
profile_path = get_profile_folder(firefox_options)
default_prefs = read_user_preferences(profile_path)
profile = create_custom_profile(
profile_path, default_prefs, clone=clone_profile
)
current_browser = Browser(
binary,
profile,
extra_args=extra_args,
extra_prefs=extra_prefs,
env=env,
log_level=log_level,
truncate_enabled=truncate_enabled,
use_bidi=use_bidi,
use_marionette=use_marionette,
)
current_browser.start()
return current_browser
yield _browser
# Stop firefox at the end of the test module.
if current_browser is not None:
current_browser.quit()
current_browser = None
@pytest.fixture
def default_chrome_handler(current_session):
manifest_path = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "chrome-assets", "chrome.manifest"
)
entries = [["content", "marionette-chrome", "chrome/"]]
with using_chrome_handler(current_session, manifest_path, entries):
yield "chrome://marionette-chrome/content/"
@pytest.fixture
def default_preferences(profile_folder):
return read_user_preferences(profile_folder)
@pytest.fixture
def new_chrome_window(current_session):
opened_chrome_windows = []
def _new_chrome_window(url, focus=True):
# Bug 1944570: Replace with BiDi once scripts can be evaluated
# in the parent process.
with using_context(current_session, "chrome"):
new_window = current_session.execute_async_script(
"""
const { NavigableManager } = ChromeUtils.importESModule(
"chrome://remote/content/shared/NavigableManager.sys.mjs"
);
let [url, focus, resolve] = arguments;
function waitForEvent(target, type, args) {
return new Promise(resolve => {
let params = Object.assign({once: true}, args);
target.addEventListener(type, event => {
dump(`** Received DOM event ${event.type} for ${event.target}\n`);
resolve();
}, params);
});
}
function waitForFocus(win) {
return Promise.all([
waitForEvent(win, "activate"),
waitForEvent(win, "focus", {capture: true}),
]);
}
const isLoaded = window =>
window?.document.readyState === "complete" &&
!window?.document.isUncommittedInitialDocument;
(async function() {
// Open a window, wait for it to receive focus
let newWindow = window.openDialog(url, null, "chrome,centerscreen");
let focused = waitForFocus(newWindow);
newWindow.focus();
await focused;
// The new window shouldn't get focused. As such set the
// focus back to the opening window.
if (!focus && Services.focus.activeWindow != window) {
let focused = waitForFocus(window);
window.focus();
await focused;
}
// Wait for the new window to be finished loading
if (isLoaded(newWindow)) {
resolve(newWindow);
} else {
const onLoad = () => {
if (isLoaded(newWindow)) {
newWindow.removeEventListener("load", onLoad);
resolve(newWindow);
} else {
dump(`** Target window not loaded yet. Waiting for the next "load" event\n`);
}
};
newWindow.addEventListener("load", onLoad);
}
})();
""",
args=[url, focus],
)
# Append opened chrome window to automatic closing on teardown
opened_chrome_windows.append(new_window)
return new_window
yield _new_chrome_window
with using_context(current_session, "chrome"):
for win in opened_chrome_windows:
try:
current_session.window_handle = win.id
current_session.execute_script("arguments[0].close()", args=[win])
except Exception:
pass
current_session.window_handle = current_session.handles[0]
@pytest.fixture(name="create_custom_profile")
def fixture_create_custom_profile(default_preferences, profile_folder):
profile = None
def _create_custom_profile(clone=True):
profile = create_custom_profile(
profile_folder, default_preferences, clone=clone
)
return profile
yield _create_custom_profile
# if profile is not None:
if profile:
profile.cleanup()
@pytest.fixture(scope="session")
def firefox_options(configuration):
return configuration["capabilities"]["moz:firefoxOptions"]
@pytest.fixture(scope="module")
def geckodriver(configuration, firefox_options):
"""Start a geckodriver instance directly."""
custom_profile = None
def _geckodriver(
config=None,
hostname=None,
extra_args=None,
extra_env=None,
popen_kwargs=None,
profile=None,
force_new=False,
):
nonlocal custom_profile
if config is None:
config = deepcopy(configuration)
if extra_args is None:
extra_args = []
if extra_env is None:
extra_env = {}
# When --profile-root is used, geckodriver manages its own profile.
uses_profile_root = "--profile-root" in extra_args
# Use a cloned profile to avoid conflicts with the harness Firefox.
if not uses_profile_root and profile is None:
if custom_profile is None:
profile_path = get_profile_folder(firefox_options)
default_prefs = read_user_preferences(profile_path)
custom_profile = create_custom_profile(
profile_path, default_prefs, clone=True
)
profile = custom_profile
# Reuse the running geckodriver if the configuration matches.
current = _geckodriver_state["current"]
if current is not None:
if (
not force_new
and current.extra_args == extra_args
and current.extra_env == extra_env
and current.hostname == (hostname or config["host"])
and current.proc
and current.proc.poll() is None
and (
uses_profile_root
or (
custom_profile is not None
and profile.profile == custom_profile.profile
)
)
):
return current
current.kill()
if not uses_profile_root:
fx_options = config["capabilities"].setdefault("moz:firefoxOptions", {})
fx_args = fx_options.setdefault("args", [])
if "--profile" in fx_args:
index = fx_args.index("--profile")
fx_args[index + 1] = profile.profile
else:
fx_args.extend(["--profile", profile.profile])
# End any active harness session to free up ports and resources
# before starting the custom geckodriver instance.
harness_session = global_fixtures.get_current_session()
if harness_session is not None:
harness_session.end()
global_fixtures.set_current_session(None)
current = Geckodriver(config, hostname, extra_args, extra_env, popen_kwargs)
current.start()
_geckodriver_state["current"] = current
return current
yield _geckodriver
if _geckodriver_state["current"] is not None:
_geckodriver_state["current"].kill()
_geckodriver_state["current"] = None
if custom_profile is not None:
custom_profile.cleanup()
@pytest.fixture
def profile_folder(firefox_options):
return get_profile_folder(firefox_options)
@pytest_asyncio.fixture
async def bidi_session(request, geckodriver):
"""Override bidi_session to use the geckodriver fixture.
Reads the ``geckodriver`` marker for extra arguments and the
``capabilities`` marker for custom session capabilities.
"""
extra_args, force_new = _get_geckodriver_marker_args(request)
capabilities = _get_capabilities_marker_args(request)
driver = geckodriver(extra_args=extra_args, force_new=force_new)
bidi_capabilities = {"webSocketUrl": True}
if capabilities is not None:
bidi_capabilities.update(capabilities)
driver.new_session(capabilities=bidi_capabilities)
bidi_session = driver.session.bidi_session
# Clear any stale transport from a previous event loop (e.g. from a test
# that used the geckodriver fixture directly to start a bidi session).
bidi_session.transport = None
await bidi_session.start()
if driver.session.capabilities.get("setWindowRect"):
driver.session.window.size = defaults.WINDOW_SIZE
driver.session.window.position = defaults.WINDOW_POSITION
yield bidi_session
if bidi_session.transport is not None:
await bidi_session.transport.end()
bidi_session.transport = None
@pytest.fixture
def session(request, configuration, geckodriver):
"""Override session to use the geckodriver fixture."""
extra_args, force_new = _get_geckodriver_marker_args(request)
capabilities = _get_capabilities_marker_args(request)
driver = geckodriver(extra_args=extra_args, force_new=force_new)
session_capabilities = {"webSocketUrl": False}
if capabilities is not None:
session_capabilities.update(capabilities)
driver.new_session(capabilities=session_capabilities)
session = driver.session
if session.capabilities.get("setWindowRect"):
session.window.size = defaults.WINDOW_SIZE
session.window.position = defaults.WINDOW_POSITION
multiplier = configuration["timeout_multiplier"]
session.timeouts.implicit = defaults.IMPLICIT_WAIT_TIMEOUT * multiplier
session.timeouts.page_load = defaults.PAGE_LOAD_TIMEOUT * multiplier
session.timeouts.script = defaults.SCRIPT_TIMEOUT * multiplier
yield session
@pytest.fixture
def current_session():
"""Override current_session to return the active geckodriver session."""
assert _geckodriver_state["current"] is not None, (
"current_session requires an active geckodriver session. "
"Use the bidi_session or session fixture first."
)
return _geckodriver_state["current"].session
def _get_capabilities_marker_args(request):
"""Read the capabilities marker and return extra capabilities, or None."""
capabilities_marker = request.node.get_closest_marker("capabilities")
if capabilities_marker and capabilities_marker.args:
return capabilities_marker.args[0]
return None
def _get_geckodriver_marker_args(request):
"""Read the geckodriver marker for extra args and force_new."""
extra_args = []
force_new = False
geckodriver_marker = request.node.get_closest_marker("geckodriver")
if geckodriver_marker:
if geckodriver_marker.kwargs.get("allow_system_access"):
extra_args.append("--allow-system-access")
force_new = geckodriver_marker.kwargs.get("force_new", False)
return extra_args, force_new