diff --git a/internal/chrome_desktop.py b/internal/chrome_desktop.py index 5126e3f47..3d7f903fd 100644 --- a/internal/chrome_desktop.py +++ b/internal/chrome_desktop.py @@ -68,6 +68,7 @@ 'MediaRouter', 'OfflinePagesPrefetching', 'OptimizationHints', + 'SidePanelPinning', 'Translate', # Disable noisy Edge features 'msAutofillEdgeCoupons', diff --git a/internal/devtools.py b/internal/devtools.py index 751f91354..3d20de860 100644 --- a/internal/devtools.py +++ b/internal/devtools.py @@ -1362,7 +1362,35 @@ def type_text(self, string): self.send_character(char) except Exception: logging.exception('Error running type command') + def mouse_press(self, command_options): + """Press down the mouse""" + params = { + 'type': 'mousePressed', + 'x': command_options['x'], + 'y': command_options['y'], + 'button': command_options['button'], + 'clickCount': command_options['clickCount'] + } + self.send_command('Input.dispatchMouseEvent', params) + + def mouse_release(self, command_options): + """Let up the mouse""" + self.send_command('Input.dispatchMouseEvent', { + 'type': 'mouseReleased', + 'x': command_options['x'], + 'y': command_options['y'], + 'button': command_options['button'], + 'clickCount': command_options['clickCount'] + }) + def mouse_click(self, params): + """Simulate pressing the mouse""" + try: + self.mouse_press(params) + self.mouse_release(params) + except Exception: + logging.exception('Error running mouse click command') + def enable_target(self, target_id=None): """Hook up the necessary network (or other) events for the given target""" try: diff --git a/internal/devtools_browser.py b/internal/devtools_browser.py index 77b230f64..76aaffc33 100644 --- a/internal/devtools_browser.py +++ b/internal/devtools_browser.py @@ -777,6 +777,43 @@ def process_command(self, command): if keyModifier in KeyModifiers.keys(): modifier = KeyModifiers[keyModifier] self.devtools.keypress(command['target'], modifier) + elif command['command'] == 'mouseClick': + if 'target' in command: + target = command['target'] + separator = target.find('=') + if separator == -1: + separator = target.find("'") + if separator >= 0: + attribute = target[:separator] + attr_value = target[separator + 1:] + try: + query = "JSON.stringify(document.querySelector('[{0}=\"{1}\"]').getBoundingClientRect())".format( + attribute, attr_value) + resp = self.devtools.execute_js(query, use_execution_context = True) + resp_json = json.loads(resp) + + value = command['value'] + button = 'left' + clickCount = 1 + if value in ['left', 'right']: + button = value + elif value == 'double': + clickCount = 2 + elif value is not None: + logging.info("Click type is not defined.") + + if 'x' in resp_json and 'y' in resp_json and 'width' in resp_json and 'height' in resp_json: + x = int(float(resp_json['x'])) + int(float(resp_json['width']))/2 + y = int(float(resp_json['y'])) + int(float(resp_json['height']))/2 + command_options = {} + command_options['x'] = x + command_options['y'] = y + command_options['button'] = button + command_options['clickCount'] = clickCount + self.devtools.mouse_click(command_options) + except: + self.task['error'] = 'Exception parsing mouseClick arguments.' + logging.error(self.task['error']) elif command['command'] == 'waitfor': try: self.devtools.wait_for_script = command['target'] if command['target'] else None @@ -865,7 +902,12 @@ def run_lighthouse_test(self, task): else: cpu_throttle = '{:.3f}'.format(self.job['throttle_cpu']) if 'throttle_cpu' in self.job else '1' if self.job['dtShaper']: - command.extend(['--throttling-method', 'devtools', '--throttling.requestLatencyMs', '150', '--throttling.downloadThroughputKbps', '1600', '--throttling.uploadThroughputKbps', '768', '--throttling.cpuSlowdownMultiplier', cpu_throttle]) + if self.options.android or ('mobile' in self.job and self.job['mobile']): + # 1.6Mbps down, 750Kbps up, 150ms RTT + command.extend(['--throttling-method', 'devtools', '--throttling.requestLatencyMs', '150', '--throttling.downloadThroughputKbps', '1600', '--throttling.uploadThroughputKbps', '750', '--throttling.cpuSlowdownMultiplier', cpu_throttle]) + else: + # 10Mbps, 40ms RTT + command.extend(['--throttling-method', 'devtools', '--throttling.requestLatencyMs', '40', '--throttling.downloadThroughputKbps', '10240', '--throttling.uploadThroughputKbps', '10240', '--throttling.cpuSlowdownMultiplier', cpu_throttle]) elif 'throttle_cpu_requested' in self.job and self.job['throttle_cpu_requested'] > 1: command.extend(['--throttling-method', 'devtools', '--throttling.requestLatencyMs', '0', '--throttling.downloadThroughputKbps', '0', '--throttling.uploadThroughputKbps', '0', '--throttling.cpuSlowdownMultiplier', cpu_throttle]) else: diff --git a/internal/firefox.py b/internal/firefox.py index a533812bd..3d183e46f 100644 --- a/internal/firefox.py +++ b/internal/firefox.py @@ -31,6 +31,8 @@ import json from .desktop_browser import DesktopBrowser +def _get_location_uri(accuracy, lat, lng) -> str: + return f'data:application/json, {{ "status":"OK", "accuracy":{accuracy}, "location":{{ "lat":{lat}, "lng":{lng} }} }}' class Firefox(DesktopBrowser): """Firefox""" @@ -140,26 +142,47 @@ def start_firefox(self, job, task): return from selenium import webdriver # pylint: disable=import-error - capabilities = webdriver.DesiredCapabilities.FIREFOX.copy() - if 'ignoreSSL' in job and job['ignoreSSL']: - capabilities['acceptInsecureCerts'] = True - else: - capabilities['acceptInsecureCerts'] = False + if webdriver.__version__ >= "4.12": + service_args = ["--marionette-port", "2828"] + service = webdriver.FirefoxService(service_args=service_args, log_output=os.environ["MOZ_LOG_FILE"]) - capabilities['moz:firefoxOptions'] = { - 'binary': self.path, - 'args': ['-profile', task['profile']], - 'prefs': self.prepare_prefs(), - "log": {"level": "error"}, - 'env': { - "MOZ_LOG_FILE": os.environ["MOZ_LOG_FILE"], - "MOZ_LOG": os.environ["MOZ_LOG"] - } - } - service_args = ["--marionette-port", "2828"] + options = webdriver.FirefoxOptions() + options.binary_location = self.path + options.add_argument('--profile') + options.add_argument(f'{task["profile"]}') + options.log.level = 'error' + options.prefs = self.prepare_prefs() - self.driver = webdriver.Firefox(desired_capabilities=capabilities, service_args=service_args) - logging.debug(self.driver.capabilities) + capabilities = webdriver.DesiredCapabilities.FIREFOX.copy() + if 'ignoreSSL' in job and job['ignoreSSL']: + capabilities['acceptInsecureCerts'] = True + else: + capabilities['acceptInsecureCerts'] = False + + for key, value in capabilities.items(): + options.set_capability(key, value) + self.driver = webdriver.Firefox(options=options, service=service) + elif webdriver.__version__ <= "4.9": + capabilities = webdriver.DesiredCapabilities.FIREFOX.copy() + if 'ignoreSSL' in job and job['ignoreSSL']: + capabilities['acceptInsecureCerts'] = True + else: + capabilities['acceptInsecureCerts'] = False + + capabilities['moz:firefoxOptions'] = { + 'binary': self.path, + 'args': ['-profile', task['profile']], + 'prefs': self.prepare_prefs(), + "log": {"level": "error"}, + 'env': { + "MOZ_LOG_FILE": os.environ["MOZ_LOG_FILE"], + "MOZ_LOG": os.environ["MOZ_LOG"] + } + } + service_args = ["--marionette-port", "2828"] + self.driver = webdriver.Firefox(desired_capabilities=capabilities, service_args=service_args) + else: + raise Exception("Unsupported selenium version %s", webdriver.__version__) self.driver.set_page_load_timeout(task['time_limit']) if 'browserVersion' in self.driver.capabilities: @@ -208,17 +231,13 @@ def launch(self, job, task): ua_string += ' ' + task['AppendUA'] modified = True if modified: - logging.debug(ua_string) self.driver_set_pref('general.useragent.override', ua_string) # Location if 'lat' in self.job and 'lng' in self.job: try: lat = float(str(self.job['lat'])) lng = float(str(self.job['lng'])) - location_uri = 'data:application/json,{{'\ - '"status":"OK","accuracy":10.0,'\ - '"location":{{"lat":{0:f},"lng":{1:f}}}'\ - '}}'.format(lat, lng) + location_uri = _get_location_uri(10, lat, lng) logging.debug('Setting location: %s', location_uri) self.driver_set_pref('geo.wifi.uri', location_uri) except Exception: @@ -261,20 +280,12 @@ def driver_set_pref(self, key, value): """Set a Firefox pref at runtime""" if self.driver is not None: try: - script = 'const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");' - script += 'Services.prefs.' - if isinstance(value, bool): - script += 'setBoolPref' - elif isinstance(value, (str, unicode)): - script += 'setStringPref' - else: - script += 'setIntPref' - script += '({0}, {1});'.format(json.dumps(key), json.dumps(value)) - logging.debug(script) + script = 'const { Preferences } = ChromeUtils.importESModule("resource://gre/modules/Preferences.sys.mjs");' + script += f'Preferences.set({json.dumps(key)}, {json.dumps(value)});' self.driver.set_context(self.driver.CONTEXT_CHROME) self.driver.execute_script(script) - except Exception: - logging.exception("Error setting pref") + except Exception as err: + logging.exception("Error setting pref %s => %s: %s", key, value, err) finally: self.driver.set_context(self.driver.CONTEXT_CONTENT) @@ -300,6 +311,8 @@ def close_browser(self, job, task): if platform.system() == "Linux": subprocess.call(['killall', '-9', 'firefox']) subprocess.call(['killall', '-9', 'firefox-trunk']) + subprocess.call(['killall', '-9', 'firefox-nightly']) + subprocess.call(['killall', '-9', 'firefox-esr']) os.environ["MOZ_LOG_FILE"] = '' os.environ["MOZ_LOG"] = '' @@ -334,7 +347,7 @@ def run_axe(self, task): script += "'" + "', '".join(axe_cats) + "'" script += ']}).then(results=>{return results;});' except Exception as err: - logging.exception("Exception running Axe: %s", err.__str__()) + logging.exception("Exception running Axe: %s", err) if self.must_exit_now: return completed = False @@ -357,7 +370,7 @@ def run_axe(self, task): axe_info['incomplete'] = axe_results['incomplete'] task['page_data']['axe'] = axe_info except Exception as err: - logging.exception("Exception running Axe: %s", err.__str__()) + logging.exception("Exception running Axe: %s", err) if not completed: task['page_data']['axe_failed'] = 1 self.axe_time = monotonic() - start @@ -384,7 +397,7 @@ def run_task(self, task): logging.exception("Exception running task") if command['record']: self.wait_for_page_load() - if not task['combine_steps'] or not len(task['script']): + if not task['combine_steps'] or not task['script']: self.on_stop_capture(task) self.on_stop_recording(task) recording = False @@ -405,10 +418,9 @@ def run_task(self, task): self.task = None def alert_size(self, _alert_config, _task_dir, _prefix): - '''Checks the agents file size and alert on certain percentage over avg byte size''' + '''Checks the agents file size and alert on certain percentage over avg byte size''' self.alert_desktop_results(_alert_config, 'Firefox', _task_dir, _prefix) - def wait_for_extension(self): """Wait for the extension to send the started message""" if self.job['message_server'] is not None: @@ -514,7 +526,7 @@ def run_js_file(self, file_name): script = None script_file_path = os.path.join(self.script_dir, file_name) if os.path.isfile(script_file_path): - with open(script_file_path, 'r') as script_file: + with open(script_file_path, 'r', encoding='utf-8') as script_file: script = script_file.read() if self.driver is not None and script is not None: try: @@ -526,7 +538,7 @@ def run_js_file(self, file_name): logging.debug(ret) return ret - def get_sorted_requests_json(self, include_bodies): + def get_sorted_requests_json(self, _include_bodies): return 'null' def collect_browser_metrics(self, task): @@ -970,10 +982,7 @@ def process_command(self, command): parts = command['target'].split(',') lat = float(parts[0]) lng = float(parts[1]) - location_uri = 'data:application/json,{{'\ - '"status":"OK","accuracy":{2:d},'\ - '"location":{{"lat":{0:f},"lng":{1:f}}}'\ - '}}'.format(lat, lng, accuracy) + location_uri = _get_location_uri(accuracy, lat, lng) logging.debug('Setting location: %s', location_uri) self.set_pref('geo.wifi.uri', location_uri) except Exception: diff --git a/internal/optimization_checks.py b/internal/optimization_checks.py index a7d041bc0..f193658b9 100644 --- a/internal/optimization_checks.py +++ b/internal/optimization_checks.py @@ -304,6 +304,10 @@ def __init__(self, job, task, requests): '.cdn.dnsv1.com.cn', '.dsa.dnsv1.com', '.dsa.dnsv1.com.cn'], + 'Transparent Edge': ['.edge2befaster.io', + '.edge2befaster.net', + '.edgetcdn.io', + '.edgetcdn.net'], 'TRBCDN': ['.trbcdn.net'], 'Twitter': ['.twimg.com'], 'UnicornCDN': ['.unicorncdn.net'], diff --git a/internal/support/firefox_log_parser.py b/internal/support/firefox_log_parser.py index 31787c6d7..079f0fa0f 100644 --- a/internal/support/firefox_log_parser.py +++ b/internal/support/firefox_log_parser.py @@ -296,6 +296,14 @@ def socket_thread_http_entry(self, msg): socket = self.http['current_socket'] self.http['connections'][connection] = {'socket': socket} del self.http['current_socket'] + elif msg['message'].startswith('TlsHandshaker::SetupSSL '): + match = re.search(r'^TlsHandshaker::SetupSSL (?P[\w\d]+)', + msg['message']) + if match: + connection = match.groupdict().get('connection') + if connection in self.http['connections']: + if 'ssl_start' not in self.http['connections'][connection]: + self.http['connections'][connection]['ssl_start'] = msg['timestamp'] elif msg['message'].startswith('nsHttpConnection::SetupSSL '): match = re.search(r'^nsHttpConnection::SetupSSL (?P[\w\d]+)', msg['message']) @@ -332,6 +340,17 @@ def socket_thread_http_entry(self, msg): if byte_count > 0 and trans_id in self.http['requests'] and \ 'start' not in self.http['requests'][trans_id]: self.http['requests'][trans_id]['start'] = msg['timestamp'] + elif msg['message'].startswith('nsHttpTransaction::OnSocketStatus ') and \ + msg['message'].find(' status=4b0005 progress=') > -1: + match = re.search(r'^nsHttpTransaction::OnSocketStatus ' + r'\[this=(?P[\w\d]+) status=4b0005 progress=(?P[\d+]+)', + msg['message']) + if match: + trans_id = match.groupdict().get('id') + byte_count = int(match.groupdict().get('bytes')) + if byte_count > 0 and trans_id in self.http['requests'] and \ + 'start' not in self.http['requests'][trans_id]: + self.http['requests'][trans_id]['start'] = msg['timestamp'] elif msg['message'].startswith('nsHttpTransaction::ProcessData '): match = re.search(r'^nsHttpTransaction::ProcessData \[this=(?P[\w\d]+)', msg['message']) @@ -446,6 +465,7 @@ def socket_transport_entry(self, msg): port = match.groupdict().get('port') self.http['sockets'][socket] = {'host': host, 'port': port} # nsSocketTransport::SendStatus [this=143f4000 status=804b0007] + # nsSocketTransport::SendStatus [this=7fe074bd2a00 status=4B0007] elif msg['message'].startswith('nsSocketTransport::SendStatus '): match = re.search(r'^nsSocketTransport::SendStatus \[' r'this=(?P[\w\d]+) ' @@ -453,7 +473,7 @@ def socket_transport_entry(self, msg): if match: socket = match.groupdict().get('socket') status = match.groupdict().get('status') - if status == '804b0007': + if status in ['804b0007', '4b0007']: if socket not in self.http['sockets']: self.http['sockets'][socket] = {} if 'start' not in self.http['sockets'][socket]: diff --git a/internal/support/netlog.py b/internal/support/netlog.py index ac172c938..c2fbaf0b8 100644 --- a/internal/support/netlog.py +++ b/internal/support/netlog.py @@ -340,8 +340,12 @@ def post_process_events(self): for dns_id in self.netlog['dns']: dns = self.netlog['dns'][dns_id] if 'host' in dns and 'start' in dns and 'end' in dns \ - and dns['end'] >= dns['start'] and 'address_list' in dns: + and dns['end'] >= dns['start']: hostname = dns['host'] + separator = hostname.find('://') + if separator > 0: + separator += 3 + hostname = hostname[separator:] separator = hostname.find(':') if separator > 0: hostname = hostname[:separator] @@ -731,7 +735,7 @@ def process_dns_event(self, event): parent_id = params['source_dependency']['id'] if 'connect_job' in self.netlog and parent_id in self.netlog['connect_job']: self.netlog['connect_job'][parent_id]['dns'] = request_id - if name == 'HOST_RESOLVER_IMPL_REQUEST' and 'phase' in event: + if (name == 'HOST_RESOLVER_IMPL_REQUEST' or name == 'HOST_RESOLVER_DNS_TASK') and 'phase' in event: if event['phase'] == 'PHASE_BEGIN': if 'start' not in entry or event['time'] < entry['start']: entry['start'] = event['time'] @@ -751,8 +755,6 @@ def process_dns_event(self, event): entry['end'] = event['time'] if 'host' not in entry and 'host' in params: entry['host'] = params['host'] - if 'address_list' in params: - entry['address_list'] = params['address_list'] def process_socket_event(self, event): if 'socket' not in self.netlog: diff --git a/internal/traffic_shaping.py b/internal/traffic_shaping.py index 8c40e76ee..5e085a64e 100644 --- a/internal/traffic_shaping.py +++ b/internal/traffic_shaping.py @@ -18,6 +18,7 @@ def __init__(self, options, root_path): shaper_name = options.shaper self.support_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "support") self.shaper = None + self.options = options plat = platform.system() if shaper_name is None and plat == "Linux": shaper_name = 'netem' @@ -98,13 +99,20 @@ def configure(self, job, task): if 'shaperLimit' in job: shaperLimit = self._to_int(job['shaperLimit']) if self.shaper is not None: - # If a lighthouse test is running, force the Lighthouse 3G profile: + # If a lighthouse test is running, force the Lighthouse 3G profile for mobile + # or 4G for desktop: # https://github.com/GoogleChrome/lighthouse/blob/master/docs/throttling.md - # 1.6Mbps down, 750Kbps up, 150ms RTT if task['running_lighthouse'] and not job['lighthouse_throttle']: - rtt = 150 - in_bps = 1600000 - out_bps = 750000 + if self.options.android or ('mobile' in job and job['mobile']): + # 1.6Mbps down, 750Kbps up, 150ms RTT + rtt = 150 + in_bps = 1600000 + out_bps = 750000 + else: + # 10Mbps, 40ms RTT + rtt = 40 + in_bps = 10240000 + out_bps = 10240000 plr = .0 shaperLimit = 0 logging.debug('Configuring traffic shaping: %d/%d - %d ms, %0.2f%% plr, %d tc-qdisc limit', diff --git a/internal/webpagetest.py b/internal/webpagetest.py index 801c5b972..fdc7342f8 100644 --- a/internal/webpagetest.py +++ b/internal/webpagetest.py @@ -234,10 +234,32 @@ def benchmark_cpu(self): hash_val.update(hash_data) iteration += 1 elapsed = monotonic() - start - self.cpu_scale_multiplier = 1.0 / elapsed + self.cpu_scale_multiplier = min(1.0 / elapsed, float(self.options.maxcpuscale)) logging.debug('CPU Benchmark elapsed time: %0.3f, multiplier: %0.3f', elapsed, self.cpu_scale_multiplier) + # Get the median scale value from the last 9 benchmarks on this machine + try: + cpu_scale = [] + scale_file = os.path.join(self.persistent_dir, 'cpu_scale.json') + if os.path.isfile(scale_file): + with open(scale_file, 'r') as f_in: + cpu_scale = json.load(f_in) + if type(cpu_scale) is list: + if len(cpu_scale) >= 9: + cpu_scale.pop(0) + cpu_scale.append(self.cpu_scale_multiplier) + if not os.path.isdir(self.persistent_dir): + os.makedirs(self.persistent_dir) + with open(scale_file, 'w') as f_out: + json.dump(cpu_scale, f_out) + cpu_scale.sort() + median_index = int((len(cpu_scale) - 1) / 2) + self.cpu_scale_multiplier = cpu_scale[median_index] + logging.debug('CPU Benchmark selected multiplier: %0.3f at index %d of %d values', self.cpu_scale_multiplier, median_index, len(cpu_scale)) + except Exception: + logging.exception('Error processing benchmark history') + def get_persistent_dir(self): """Return the path to the persistent cache directory""" return self.persistent_dir diff --git a/wptagent.py b/wptagent.py index 99886f800..d28bec0b5 100644 --- a/wptagent.py +++ b/wptagent.py @@ -585,6 +585,10 @@ def startup(self, detected_browsers): if self.get_node_version() < 16.0: logging.warning("Node.js 16 or newer is required for Lighthouse testing") + # Force lighthouse 11.4.0 + if self.get_lighthouse_version() != '11.4.0': + subprocess.call(['sudo', 'npm', 'i', '-g', 'lighthouse@11.4.0']) + # Check the iOS install if self.ios is not None: ret = self.requires('usbmuxwrapper') and ret @@ -625,6 +629,20 @@ def get_node_version(self): pass return version + def get_lighthouse_version(self): + """Get the installed version of lighthouse""" + version = None + try: + if sys.version_info >= (3, 0): + stdout = subprocess.check_output(['lighthouse', '--version'], encoding='UTF-8') + else: + stdout = subprocess.check_output(['lighthouse', '--version']) + version = stdout.strip() + except Exception: + pass + + return version + def update_windows_certificates(self): """ Update the root Windows certificates""" try: @@ -1060,6 +1078,19 @@ def get_browser_versions(browsers): browsers[browser]['version'] = get_file_version(exe) +def fix_selenium_version(): + """ + On older python versions we are going to force selenium version 3.141.0, + newer versions are going to use 4.8.3 + """ + from internal.os_util import run_elevated + version = '4.18.1' + if sys.version_info[1] == 6: + version = '3.141.0' + + run_elevated(sys.executable, f'-m pip install selenium=={version}') + + # Constant used for --logformat command line parameter mapping LOG_FORMATS = ["syslog"] @@ -1152,6 +1183,8 @@ def main(): parser.add_argument('--healthcheckport', type=int, default=8889, help='Run a HTTP health check server on the given port.') parser.add_argument('--har', action='store_true', default=False, help="Generate a per-run HAR file as part of the test result (defaults to False).") + parser.add_argument('--maxcpuscale', type=int, default=2, + help='Maximum scaling to apply to CPU throttle based on host benchmark (defaults to 2).') # Video capture/display settings parser.add_argument('--xvfb', action='store_true', default=False, @@ -1267,6 +1300,9 @@ def main(): logging.critical("Requires python 2.7") exit(1) + # Make sure we are using a compatible selenium version + fix_selenium_version() + if options.list: from internal.ios_device import iOSDevice ios = iOSDevice()