blob: 5c3bb94947f0fe3b548c5acffc4475257c292cbc [file] [log] [blame]
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import logging
import os
import shutil
import sys
import tempfile
_SRC_DIR = os.path.abspath(os.path.join(
os.path.dirname(__file__), '..', '..', '..'))
sys.path.append(os.path.join(_SRC_DIR, 'third_party', 'catapult', 'devil'))
from devil.android import device_utils
sys.path.append(os.path.join(_SRC_DIR, 'third_party', 'catapult', 'telemetry',
'third_party', 'websocket-client'))
import websocket
import chrome_cache
import common_util
import controller
import devtools_monitor
import device_setup
import loading_trace
# Standard filenames in the sandwich runner's output directory.
ERROR_FILENAME = 'error'
TRACE_FILENAME = 'trace.json'
VIDEO_FILENAME = 'video.mp4'
WPR_LOG_FILENAME = 'wpr.log'
# Memory dump category used to get memory metrics.
MEMORY_DUMP_CATEGORY = 'disabled-by-default-memory-infra'
# Devtools timeout of 1 minute to avoid websocket timeout on slow
# network condition.
_DEVTOOLS_TIMEOUT = 60
# Categories to enable or disable for all traces collected. Disabled categories
# are prefixed with '-'.
_TRACING_CATEGORIES = [
'blink',
'blink.net',
'blink.user_timing',
'devtools.timeline',
'java',
'navigation',
'toplevel',
'v8',
'-cc', # A lot of unnecessary events are enabled by default in "cc".
]
TTFMP_ADDITIONAL_CATEGORIES = [
'loading',
'disabled-by-default-blink.debug.layout',
]
def _CleanArtefactsFromPastRuns(output_directories_path):
"""Cleans artifacts generated from past run in the output directory.
Args:
output_directories_path: The output directory path where to clean the
previous traces.
"""
for dirname in os.listdir(output_directories_path):
directory_path = os.path.join(output_directories_path, dirname)
if not os.path.isdir(directory_path):
continue
try:
int(dirname)
except ValueError:
continue
shutil.rmtree(directory_path)
class CacheOperation(object):
CLEAR, SAVE, PUSH = range(3)
class SandwichRunnerError(Exception):
pass
class SandwichRunner(object):
"""Sandwich runner.
This object is meant to be configured first and then run using the Run()
method.
"""
_ATTEMPT_COUNT = 3
_STOP_DELAY_MULTIPLIER = 2
_ABORT_RUN_TIMEOUT_SECONDS = 30 * 60
def __init__(self):
"""Configures a sandwich runner out of the box.
Public members are meant to be configured before calling Run().
"""
# Cache operation to do before doing the chrome navigation.
self.cache_operation = CacheOperation.CLEAR
# The cache archive's path to save to or push from. Is str or None.
self.cache_archive_path = None
# List of additional chrome command line flags.
self.chrome_args = []
# Controls whether the WPR server should do script injection.
self.disable_wpr_script_injection = False
# Number of times to repeat the url.
self.repeat = 1
# Network conditions to emulate. None if no emulation.
self.network_condition = None
# Network condition emulator. Can be: browser,wpr
self.network_emulator = 'browser'
# Output directory where to save the traces, videos, etc. Is str or None.
self.output_dir = None
# URL to navigate to.
self.url = None
# Configures whether to record speed-index video.
self.record_video = False
# Configures whether to record memory dumps.
self.record_memory_dumps = False
# Configures whether to record tracing categories needed for TTFMP.
self.record_first_meaningful_paint = False
# Path to the WPR archive to load or save. Is str or None.
self.wpr_archive_path = None
# Configures whether the WPR archive should be read or generated.
self.wpr_record = False
# The android DeviceUtils to run sandwich on or None to run it locally.
self.android_device = None
self._chrome_ctl = None
self._local_cache_directory_path = None
def _CleanTraceOutputDirectory(self):
assert self.output_dir
if not os.path.isdir(self.output_dir):
try:
os.makedirs(self.output_dir)
except OSError:
logging.error('Cannot create directory for results: %s',
self.output_dir)
raise
else:
_CleanArtefactsFromPastRuns(self.output_dir)
def _GetEmulatorNetworkCondition(self, emulator):
if self.network_emulator == emulator:
return self.network_condition
return None
def _RunNavigation(self, clear_cache, repeat_id=None):
"""Run a page navigation to the given URL.
Args:
clear_cache: Whether if the cache should be cleared before navigation.
repeat_id: Id of the run in the output directory. If it is None, then no
trace or video will be saved.
"""
run_path = None
if repeat_id is not None:
run_path = os.path.join(self.output_dir, str(repeat_id))
if not os.path.isdir(run_path):
os.makedirs(run_path)
self._chrome_ctl.SetNetworkEmulation(
self._GetEmulatorNetworkCondition('browser'))
categories = _TRACING_CATEGORIES
if self.record_memory_dumps:
categories += [MEMORY_DUMP_CATEGORY]
if self.record_first_meaningful_paint:
categories += TTFMP_ADDITIONAL_CATEGORIES
stop_delay_multiplier = 0
if self.wpr_record or self.cache_operation == CacheOperation.SAVE:
stop_delay_multiplier = self._STOP_DELAY_MULTIPLIER
# TODO(gabadie): add a way to avoid recording a trace.
with common_util.TimeoutScope(
self._ABORT_RUN_TIMEOUT_SECONDS, 'Sandwich run overdue.'):
with self._chrome_ctl.Open() as connection:
if clear_cache:
connection.ClearCache()
# Binds all parameters of RecordUrlNavigation() to avoid repetition.
def RecordTrace():
return loading_trace.LoadingTrace.RecordUrlNavigation(
url=self.url,
connection=connection,
chrome_metadata=self._chrome_ctl.ChromeMetadata(),
categories=categories,
timeout_seconds=_DEVTOOLS_TIMEOUT,
stop_delay_multiplier=stop_delay_multiplier)
if run_path is not None and self.record_video:
device = self._chrome_ctl.GetDevice()
if device is None:
raise RuntimeError('Can only record video on a remote device.')
video_recording_path = os.path.join(run_path, VIDEO_FILENAME)
with device_setup.RemoteSpeedIndexRecorder(device, connection,
video_recording_path):
trace = RecordTrace()
else:
trace = RecordTrace()
for event in trace.request_track.GetEvents():
if event.failed:
logging.warning(
'request to %s failed: %s', event.url, event.error_text)
if not trace.tracing_track.HasLoadingSucceeded():
raise SandwichRunnerError('Page load has failed.')
if run_path is not None:
trace_path = os.path.join(run_path, TRACE_FILENAME)
trace.ToJsonFile(trace_path)
def _RunInRetryLoop(self, repeat_id, perform_dry_run_before):
"""Attempts to run monitoring navigation.
Args:
repeat_id: Id of the run in the output directory.
perform_dry_run_before: Whether it should do a dry run attempt before the
actual monitoring run.
Returns:
Whether the device should be rebooted to continue attempting for that
given |repeat_id|.
"""
resume_attempt_id = 0
if perform_dry_run_before:
resume_attempt_id = 1
for attempt_id in xrange(resume_attempt_id, self._ATTEMPT_COUNT):
try:
if perform_dry_run_before:
logging.info('Do sandwich dry run attempt %d', attempt_id)
else:
logging.info('Do sandwich run attempt %d', attempt_id)
self._chrome_ctl.ResetBrowserState()
clear_cache = False
if self.cache_operation == CacheOperation.CLEAR:
clear_cache = True
elif self.cache_operation == CacheOperation.PUSH:
self._chrome_ctl.PushBrowserCache(self._local_cache_directory_path)
elif self.cache_operation == CacheOperation.SAVE:
clear_cache = repeat_id == 0
self._RunNavigation(clear_cache=clear_cache, repeat_id=repeat_id)
if not perform_dry_run_before or attempt_id > resume_attempt_id:
break
except controller.ChromeControllerError as error:
request_reboot = False
is_intermittent = error.IsIntermittent()
if (self.android_device and
attempt_id == 0 and
error.error_type is websocket.WebSocketConnectionClosedException):
assert not perform_dry_run_before
# On Android, the first socket connection closure is likely caused by
# memory pressure on the device and therefore considered intermittent,
# and therefore request a reboot of the device to the caller.
request_reboot = True
is_intermittent = True
if is_intermittent and attempt_id + 1 != self._ATTEMPT_COUNT:
dump_filename = '{}_intermittent_failure'.format(attempt_id)
dump_path = os.path.join(
self.output_dir, str(repeat_id), dump_filename)
else:
dump_path = os.path.join(self.output_dir, ERROR_FILENAME)
with open(dump_path, 'w') as dump_output:
error.Dump(dump_output)
if not is_intermittent:
error.RaiseOriginal()
if request_reboot:
assert resume_attempt_id is 0
return True
else:
logging.error('Failed to navigate to %s after %d attemps' % \
(self.url, self._ATTEMPT_COUNT))
error.RaiseOriginal()
return False
def _RunWithWpr(self, resume_repeat_id, perform_dry_run_before):
"""Opens WPR and attempts to run repeated monitoring navigation.
Args:
resume_repeat_id: Id of the run to resume.
perform_dry_run_before: Whether the repeated run to resume should first do
a dry run navigation attempt.
Returns:
Number of repeat performed. If < self.repeat, then it means that the
device should be rebooted.
"""
with self._chrome_ctl.OpenWprHost(self.wpr_archive_path,
record=self.wpr_record,
network_condition_name=self._GetEmulatorNetworkCondition('wpr'),
disable_script_injection=self.disable_wpr_script_injection,
out_log_path=os.path.join(self.output_dir, WPR_LOG_FILENAME)):
for repeat_id in xrange(resume_repeat_id, self.repeat):
reboot_requested = self._RunInRetryLoop(
repeat_id, perform_dry_run_before)
if reboot_requested:
return repeat_id
return self.repeat
def _PullCacheFromDevice(self):
assert self.cache_operation == CacheOperation.SAVE
assert self.cache_archive_path, 'Need to specify where to save the cache'
cache_directory_path = self._chrome_ctl.PullBrowserCache()
chrome_cache.ZipDirectoryContent(
cache_directory_path, self.cache_archive_path)
shutil.rmtree(cache_directory_path)
def Run(self):
"""SandwichRunner main entry point meant to be called once configured."""
assert self.output_dir is not None
assert self._chrome_ctl == None
assert self._local_cache_directory_path == None
self._CleanTraceOutputDirectory()
if self.android_device:
self._chrome_ctl = controller.RemoteChromeController(self.android_device)
else:
self._chrome_ctl = controller.LocalChromeController()
self._chrome_ctl.AddChromeArguments(['--disable-infobars'])
self._chrome_ctl.AddChromeArguments(self.chrome_args)
if self.cache_operation == CacheOperation.SAVE:
self._chrome_ctl.SetSlowDeath()
try:
if self.cache_operation == CacheOperation.PUSH:
assert os.path.isfile(self.cache_archive_path)
self._local_cache_directory_path = tempfile.mkdtemp(suffix='.cache')
chrome_cache.UnzipDirectoryContent(
self.cache_archive_path, self._local_cache_directory_path)
times_repeated = self._RunWithWpr(0, False)
if times_repeated < self.repeat:
self._chrome_ctl.RebootDevice()
self._RunWithWpr(times_repeated, True)
finally:
if self._local_cache_directory_path:
shutil.rmtree(self._local_cache_directory_path)
self._local_cache_directory_path = None
if self.cache_operation == CacheOperation.SAVE:
self._PullCacheFromDevice()
self._chrome_ctl = None
def WalkRepeatedRuns(runner_output_dir):
"""Yields unordered (repeat id, path of the repeat directory).
Args:
runner_output_dir: Same as for SandwichRunner.output_dir.
"""
repeated_run_count = 0
for node_name in os.listdir(runner_output_dir):
repeat_dir = os.path.join(runner_output_dir, node_name)
if not os.path.isdir(repeat_dir):
continue
try:
repeat_id = int(node_name)
except ValueError:
continue
yield repeat_id, repeat_dir
repeated_run_count += 1
assert repeated_run_count > 0, ('Error: not a sandwich runner output '
'directory: {}').format(runner_output_dir)