blob: 9d07c839130b295937e0d0f984ea20d5641263a6 [file] [log] [blame]
# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from __future__ import annotations
import abc
import enum
from typing import TYPE_CHECKING, Optional, Self, cast
from selenium.webdriver.safari.options import Options as SafariOptions
from typing_extensions import override
import crossbench.probes.perfetto.traceconv as cb_traceconv
from crossbench.browsers.chromium.webdriver import ChromiumBasedWebDriver
from crossbench.helper.path_finder import TraceconvFinder
from crossbench.probes.probe import Probe, ProbeConfigParser, ProbeContext, \
ProbeKeyT
from crossbench.probes.probe_error import ProbeIncompatibleBrowser, \
ProbeValidationError
from crossbench.probes.result_location import ResultLocation
from crossbench.str_enum_with_help import StrEnumWithHelp
if TYPE_CHECKING:
from selenium.webdriver.common.options import BaseOptions
import crossbench.path as pth
from crossbench.browsers.browser import Browser
from crossbench.env.runner_env import RunnerEnv
from crossbench.probes.results import ProbeResult
from crossbench.runner.run import Run
@enum.unique
class MozProfilerStartupFeatures(StrEnumWithHelp):
"""Options for MOZ_PROFILER_STARTUP_FEATURES env var.
Extracted via MOZ_PROFILER_HELP=1 ./firefox-nightly-en/firefox
"""
JAVA = ("java", "Profile Java code, Android only")
JS = ("js", "Get the JS engine to expose the JS stack to the profiler")
LEAF = ("leaf", "Include the C++ leaf node if not stackwalking")
MAINTHREADIO = ("mainthreadio", "Add main thread file I/O")
FILEIO = ("fileio",
"Add file I/O from all profiled threads, implies mainthreadio")
FILEIOALL = ("fileioall", "Add file I/O from all threads, implies fileio")
NOIOSTACKS = ("noiostacks",
"File I/O markers do not capture stacks, to reduce overhead")
SCREENSHOTS = ("screenshots",
"Take a snapshot of the window on every composition")
SEQSTYLE = ("seqstyle", "Disable parallel traversal in styling")
STACKWALK = ("stackwalk",
"Walk the C++ stack, not available on all platforms")
TASKTRACER = ("tasktracer", "Start profiling with feature TaskTracer")
THREADS = ("threads", "Profile the registered secondary threads")
JSTRACER = ("jstracer", "Enable tracing of the JavaScript engine")
JSALLOCATIONS = ("jsallocations",
"Have the JavaScript engine track allocations")
NOSTACKSAMPLING = (
"nostacksampling",
"Disable all stack sampling: Cancels 'js', 'leaf', 'stackwalk' and labels"
)
PREFERENCEREADS = ("preferencereads", "Track when preferences are read")
NATIVEALLOCATIONS = (
"nativeallocations",
"Collect the stacks from a smaller subset of all native allocations, "
"biasing towards collecting larger allocations")
IPCMESSAGES = ("ipcmessages",
"Have the IPC layer track cross-process messages")
AUDIOCALLBACKTRACING = ("audiocallbacktracing", "Audio callback tracing")
CPU = ("cpu", "CPU utilization")
@enum.unique
class FirefoxProfilerEnvVars(enum.StrEnum):
# If set to any value other than '' or '0'/'N'/'n', starts the
# profiler immediately on start-up.
STARTUP = "MOZ_PROFILER_STARTUP"
# Contains a comma-separated list of MozProfilerStartupFeatures.
STARTUP_FEATURES = "MOZ_PROFILER_STARTUP_FEATURES"
# If set, the profiler saves a profile to the named file on shutdown.
SHUTDOWN = "MOZ_PROFILER_SHUTDOWN"
class BrowserProfilingProbe(Probe):
"""
Browser profiling for generating in-browser performance profiles:
- Firefox https://profiler.firefox.com/
- Chrome: https://developer.chrome.com/docs/devtools/
- Safari: Timelines https://developer.apple.com/safari/tools
"""
NAME = "browser-profiling"
RESULT_LOCATION = ResultLocation.BROWSER
IS_GENERAL_PURPOSE = True
@classmethod
@override
def config_parser(cls) -> ProbeConfigParser[Self]:
parser = super().config_parser()
parser.add_argument(
"moz_profiler_startup_features",
type=MozProfilerStartupFeatures,
is_list=True,
default=[])
cb_traceconv.add_argument(parser)
return parser
def __init__(self,
moz_profiler_startup_features: Optional[
list[MozProfilerStartupFeatures]] = None,
traceconv: Optional[pth.LocalPath] = None) -> None:
super().__init__()
self._moz_profiler_startup_features: list[
MozProfilerStartupFeatures] = moz_profiler_startup_features or []
self._traceconv: pth.LocalPath | None = traceconv
if not traceconv:
self._traceconv = TraceconvFinder(self.host_platform).local_path
@property
@override
def key(self) -> ProbeKeyT:
return super().key + (
("moz_profiler_startup_features",
tuple(map(str, self.moz_profiler_startup_features))),
("traceconv", str(self._traceconv)),
)
@property
def moz_profiler_startup_features(self) -> list[MozProfilerStartupFeatures]:
return self._moz_profiler_startup_features
@property
def traceconv(self) -> pth.LocalPath | None:
return self._traceconv
@override
def validate_browser(self, env: RunnerEnv, browser: Browser) -> None:
super().validate_browser(env, browser)
if browser.platform.is_remote:
raise ProbeValidationError(
self, f"Only works on local browser, but got {browser}.")
attributes = browser.attributes()
if attributes.is_chromium_based or attributes.is_safari:
return
if attributes.is_firefox:
self._validate_firefox(env, browser)
raise ProbeIncompatibleBrowser(self, browser)
def _validate_firefox(self, env: RunnerEnv, browser: Browser) -> None:
browser_env = browser.platform.environ
for env_var in list(FirefoxProfilerEnvVars):
env_var_str = str(env_var)
if env_var_str in browser_env:
env.handle_warning(f"Probe({self}) conflicts with existing "
f"env[{env_var_str}]={browser_env[env_var_str]}")
@override
def create_context(self, run: Run) -> BrowserProfilingProbeContext:
attributes = run.browser.attributes()
if attributes.is_chromium_based:
return ChromiumWebDriverBrowserProfilingProbeContext(self, run)
if attributes.is_firefox:
return FirefoxBrowserProfilingProbeContext(self, run)
if attributes.is_safari:
return SafariWebdriverBrowserProfilingProbeContext(self, run)
raise NotImplementedError(
f"Probe({self}): Unsupported browser: {run.browser}")
class BrowserProfilingProbeContext(
ProbeContext[BrowserProfilingProbe], metaclass=abc.ABCMeta):
@override
def setup(self) -> None:
pass
def start(self) -> None:
pass
def stop(self) -> None:
pass
class ChromiumWebDriverBrowserProfilingProbeContext(BrowserProfilingProbeContext
):
@override
def get_default_result_path(self) -> pth.AnyPath:
return (super().get_default_result_path().parent /
f"{self.browser.type_name()}.profile.pb.gz")
@property
def chromium(self) -> ChromiumBasedWebDriver:
return cast(ChromiumBasedWebDriver, self.browser)
def start(self) -> None:
self.chromium.start_profiling()
def stop(self) -> None:
with self.run.actions(f"Probe({self.probe}): extract DevTools profile."):
profile_bytes = self.chromium.stop_profiling()
self.local_result_path.write_bytes(profile_bytes)
def teardown(self) -> ProbeResult:
trace_file = self.local_result_path
if legacy_json_file := cb_traceconv.convert_to_json(self.host_platform,
self.probe.traceconv,
trace_file):
return self.local_result(perfetto=(trace_file,), json=(legacy_json_file,))
return self.local_result(perfetto=(trace_file,))
class FirefoxBrowserProfilingProbeContext(BrowserProfilingProbeContext):
@override
def get_default_result_path(self) -> pth.AnyPath:
return super().get_default_result_path().parent / "firefox.profile.json"
@override
def setup(self) -> None:
env = self.browser.platform.environ
env[FirefoxProfilerEnvVars.STARTUP] = "y"
if self.probe.moz_profiler_startup_features:
env[FirefoxProfilerEnvVars.STARTUP_FEATURES] = ",".join(
str(feature) for feature in self.probe.moz_profiler_startup_features)
env[FirefoxProfilerEnvVars.SHUTDOWN] = str(self.result_path)
@override
def teardown(self) -> ProbeResult:
env = self.browser.platform.environ
del env[FirefoxProfilerEnvVars.STARTUP]
del env[FirefoxProfilerEnvVars.STARTUP_FEATURES]
del env[FirefoxProfilerEnvVars.SHUTDOWN]
return self.browser_result(json=[self.result_path])
class SafariWebdriverBrowserProfilingProbeContext(BrowserProfilingProbeContext):
@override
def get_default_result_path(self) -> pth.AnyPath:
return super().get_default_result_path().parent / "safari.timeline.json"
@override
def setup_selenium_options(self, options: BaseOptions) -> None:
assert isinstance(options, SafariOptions)
options.automatic_profiling = True
@override
def stop(self) -> None:
# TODO: Update this mess when Safari supports a command-line option
# to download the profile.
# Manually safe the profile using apple script to navigate the safari UI
# Stop profiling.
self.browser_platform.exec_apple_script("""
tell application "System Events"
keystroke "T" using {command down, option down, shift down}
end tell""")
# TODO: explicitly focus the developer pane
# Focus the Developer Tools split pane and use CMD-S to save the profile.
self.browser_platform.exec_apple_script(f"""
tell application "System Events"
keystroke "S" using command down
tell window "Save As"
delay 0.5
keystroke "g" using {{command down, shift down}}
delay 0.5
# Send DELETE key input to clear the current text input.
key code 51
keystroke "{self.result_path}"
delay 0.5
keystroke return
delay 0.5
keystroke return
end tell
end tell""")
@override
def teardown(self) -> ProbeResult:
return self.browser_result(json=[self.result_path])