#!/usr/bin/env python

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

from __future__ import absolute_import, print_function, unicode_literals

import argparse
import copy
import glob
import os
import re
import sys
import subprocess
import tempfile

from shutil import copyfile, rmtree

from six import string_types

import mozharness

from mozharness.base.errors import PythonErrorList
from mozharness.base.log import OutputParser, DEBUG, ERROR, CRITICAL, INFO
from mozharness.mozilla.automation import (
    EXIT_STATUS_DICT, TBPL_SUCCESS, TBPL_RETRY, TBPL_WORST_LEVEL_TUPLE
)
from mozharness.base.python import Python3Virtualenv
from mozharness.mozilla.testing.android import AndroidMixin
from mozharness.mozilla.testing.errors import HarnessErrorList, TinderBoxPrintRe
from mozharness.mozilla.testing.testbase import TestingMixin, testing_config_options
from mozharness.base.vcs.vcsbase import MercurialScript
from mozharness.mozilla.testing.codecoverage import (
    CodeCoverageMixin,
    code_coverage_config_options
)

scripts_path = os.path.abspath(os.path.dirname(os.path.dirname(mozharness.__file__)))
external_tools_path = os.path.join(scripts_path, 'external_tools')
here = os.path.abspath(os.path.dirname(__file__))

RaptorErrorList = PythonErrorList + HarnessErrorList + [
    {'regex': re.compile(r'''run-as: Package '.*' is unknown'''), 'level': DEBUG},
    {'substr': r'''raptorDebug''', 'level': DEBUG},
    {'regex': re.compile(r'''^raptor[a-zA-Z-]*( - )?( )?(?i)error(:)?'''), 'level': ERROR},
    {'regex': re.compile(r'''^raptor[a-zA-Z-]*( - )?( )?(?i)critical(:)?'''), 'level': CRITICAL},
    {'regex': re.compile(r'''No machine_name called '.*' can be found'''), 'level': CRITICAL},
    {'substr': r"""No such file or directory: 'browser_output.txt'""",
     'level': CRITICAL,
     'explanation': "Most likely the browser failed to launch, or the test otherwise "
     "failed to start."},
]


class Raptor(TestingMixin, MercurialScript, CodeCoverageMixin, AndroidMixin, Python3Virtualenv):
    """
    Install and run Raptor tests
    """

    # Options to Browsertime.  Paths are expected to be absolute.
    browsertime_options = [
        [["--browsertime-node"], {
            "dest": "browsertime_node",
            "default": None,
            "help": argparse.SUPPRESS
        }],
        [["--browsertime-browsertimejs"], {
            "dest": "browsertime_browsertimejs",
            "default": None,
            "help": argparse.SUPPRESS
        }],
        [["--browsertime-chromedriver"], {
            "dest": "browsertime_chromedriver",
            "default": None,
            "help": argparse.SUPPRESS
        }],
        [["--browsertime-ffmpeg"], {
            "dest": "browsertime_ffmpeg",
            "default": None,
            "help": argparse.SUPPRESS
        }],
        [["--browsertime-geckodriver"], {
            "dest": "browsertime_geckodriver",
            "default": None,
            "help": argparse.SUPPRESS
        }],
        [["--browsertime-video"], {
            "dest": "browsertime_video",
            "action": "store_true",
            "default": False,
            "help": argparse.SUPPRESS
        }],
        [["--browsertime-no-ffwindowrecorder"], {
            "dest": "browsertime_no_ffwindowrecorder",
            "action": "store_true",
            "default": False,
            "help": argparse.SUPPRESS
        }],
        [["--browsertime"], {
            "dest": "browsertime",
            "action": "store_true",
            "default": False,
            "help": argparse.SUPPRESS
        }],
    ]

    config_options = [
        [["--test"],
         {"action": "store",
          "dest": "test",
          "help": "Raptor test to run"
          }],
        [["--app"],
         {"default": "firefox",
          "choices": [
            "firefox",
            "chrome",
            "chrome-m",
            "chromium",
            "fennec",
            "geckoview",
            "refbrow",
            "fenix"
          ],
          "dest": "app",
          "help": "Name of the application we are testing (default: firefox)."
          }],
        [["--activity"],
         {"dest": "activity",
          "help": "The Android activity used to launch the Android app. "
                  "e.g.: org.mozilla.fenix.browser.BrowserPerformanceTestActivity"
          }],
        [["--intent"],
         {"dest": "intent",
          "help": "Name of the Android intent action used to launch the Android app"
          }],
        [["--is-release-build"],
         {"action": "store_true",
          "dest": "is_release_build",
          "help": "Whether the build is a release build which requires work arounds "
                  "using MOZ_DISABLE_NONLOCAL_CONNECTIONS to support installing unsigned "
                  "webextensions. Defaults to False."
          }],
        [["--add-option"],
         {"action": "extend",
          "dest": "raptor_cmd_line_args",
          "default": None,
          "help": "Extra options to Raptor."
          }],
        [["--enable-webrender"], {
            "action": "store_true",
            "dest": "enable_webrender",
            "default": False,
            "help": "Enable the WebRender compositor in Gecko.",
        }],
        [["--no-conditioned-profile"], {
            "action": "store_true",
            "dest": "no_conditioned_profile",
            "default": False,
            "help": "Run without the conditioned profile.",
        }],
        [["--device-name"], {
            "dest": "device_name",
            "default": None,
            "help": "Device name of mobile device.",
        }],
        [["--geckoProfile"], {
            "dest": "gecko_profile",
            "action": "store_true",
            "default": False,
            "help": argparse.SUPPRESS
        }],
        [["--geckoProfileInterval"], {
            "dest": "gecko_profile_interval",
            "type": "int",
            "help": argparse.SUPPRESS
        }],
        [["--geckoProfileEntries"], {
            "dest": "gecko_profile_entries",
            "type": "int",
            "help": argparse.SUPPRESS
        }],
        [["--gecko-profile"], {
            "dest": "gecko_profile",
            "action": "store_true",
            "default": False,
            "help": "Whether to profile the test run and save the profile results."
        }],
        [["--gecko-profile-interval"], {
            "dest": "gecko_profile_interval",
            "type": "int",
            "help": "The interval between samples taken by the profiler (ms)."
        }],
        [["--gecko-profile-entries"], {
            "dest": "gecko_profile_entries",
            "type": "int",
            "help": "How many samples to take with the profiler."
        }],
        [["--page-cycles"], {
            "dest": "page_cycles",
            "type": "int",
            "help": "How many times to repeat loading the test page (for page load tests); "
                    "for benchmark tests this is how many times the benchmark test will be run."
        }],
        [["--page-timeout"], {
            "dest": "page_timeout",
            "type": "int",
            "help": "How long to wait (ms) for one page_cycle to complete, before timing out."
        }],
        [["--browser-cycles"], {
            "dest": "browser_cycles",
            "type": "int",
            "help": "The number of times a cold load test is repeated (for cold load tests only, "
                    "where the browser is shutdown and restarted between test iterations)."
        }],
        [["--project"], {
            "action": "store",
            "dest": "project",
            "default": "mozilla-central",
            "type": "str",
            "help": "Name of the project (try, mozilla-central, etc.)"
        }],
        [["--test-url-params"], {
            "action": "store",
            "dest": "test_url_params",
            "help": "Parameters to add to the test_url query string."
        }],
        [["--host"], {
            "dest": "host",
            "type": "str",
            "default": "127.0.0.1",
            "help": "Hostname from which to serve urls (default: 127.0.0.1). "
                    "The value HOST_IP will cause the value of host to be "
                    "to be loaded from the environment variable HOST_IP.",
        }],
        [["--power-test"], {
            "dest": "power_test",
            "action": "store_true",
            "default": False,
            "help": "Use Raptor to measure power usage on Android browsers (Geckoview Example, "
                    "Fenix, Refbrow, and Fennec) as well as on Intel-based MacOS machines that "
                    "have Intel Power Gadget installed.",
        }],
        [["--memory-test"], {
            "dest": "memory_test",
            "action": "store_true",
            "default": False,
            "help": "Use Raptor to measure memory usage.",
        }],
        [["--cpu-test"], {
            "dest": "cpu_test",
            "action": "store_true",
            "default": False,
            "help": "Use Raptor to measure CPU usage."
        }],
        [["--disable-perf-tuning"], {
            "action": "store_true",
            "dest": "disable_perf_tuning",
            "default": False,
            "help": "Disable performance tuning on android.",
        }],
        [["--conditioned-profile-scenario"], {
            "dest": "conditioned_profile_scenario",
            "type": "str",
            "default": "settled",
            "help": "Name of profile scenario.",
        }],
        [["--live-sites"], {
            "dest": "live_sites",
            "action": "store_true",
            "default": False,
            "help": "Run tests using live sites instead of recorded sites.",
        }],
        [["--chimera"], {
            "dest": "chimera",
            "action": "store_true",
            "default": False,
            "help": "Run tests in chimera mode. Each browser cycle will run a cold and warm test.",
        }],
        [["--debug-mode"], {
            "dest": "debug_mode",
            "action": "store_true",
            "default": False,
            "help": "Run Raptor in debug mode (open browser console, limited page-cycles, etc.)",
        }],
        [["--noinstall"], {
            "dest": "noinstall",
            "action": "store_true",
            "default": False,
            "help": "Do not offer to install Android APK.",
        }],
        [["--disable-e10s"], {
            "dest": "e10s",
            "action": "store_false",
            "default": True,
            "help": "Run without multiple processes (e10s).",
        }],
        [["--enable-fission"], {
            "action": "store_true",
            "dest": "enable_fission",
            "default": False,
            "help": "Enable Fission (site isolation) in Gecko.",
        }],
        [["--setpref"], {
            "action": "append",
            "dest": "extra_prefs",
            "default": [],
            "help": "A preference to set. Must be a key-value pair separated by a ':'."
        }],
        [["--cold"], {
            "action": "store_true",
            "dest": "cold",
            "default": False,
            "help": "Enable cold page-load for browsertime tp6",
        }],
        [["--verbose"], {
            "action": "store_true",
            "dest": "verbose",
            "default": False,
            "help": "Verbose output",
        }],

    ] + testing_config_options + \
        copy.deepcopy(code_coverage_config_options) + \
        browsertime_options

    def __init__(self, **kwargs):
        kwargs.setdefault('config_options', self.config_options)
        kwargs.setdefault('all_actions', ['clobber',
                                          'download-and-extract',
                                          'populate-webroot',
                                          'install-chrome-android',
                                          'install-chromium-distribution',
                                          'create-virtualenv',
                                          'install',
                                          'run-tests',
                                          ])
        kwargs.setdefault('default_actions', ['clobber',
                                              'download-and-extract',
                                              'populate-webroot',
                                              'install-chromium-distribution',
                                              'create-virtualenv',
                                              'install',
                                              'run-tests',
                                              ])
        kwargs.setdefault('config', {})
        super(Raptor, self).__init__(**kwargs)

        # Convenience
        self.workdir = self.query_abs_dirs()['abs_work_dir']

        self.run_local = self.config.get('run_local')

        # App (browser testing on) defaults to firefox
        self.app = "firefox"

        if self.run_local:
            # Get app from command-line args, passed in from mach, inside 'raptor_cmd_line_args'
            # Command-line args can be in two formats depending on how the user entered them
            # i.e. "--app=geckoview" or separate as "--app", "geckoview" so we have to
            # parse carefully.  It's simplest to use `argparse` to parse partially.
            self.app = "firefox"
            if 'raptor_cmd_line_args' in self.config:
                sub_parser = argparse.ArgumentParser()
                # It's not necessary to limit the allowed values: each value
                # will be parsed and verifed by raptor/raptor.py.
                sub_parser.add_argument('--app', default=None, dest='app')
                sub_parser.add_argument('-i', '--intent', default=None, dest='intent')
                sub_parser.add_argument('-a', '--activity', default=None, dest='activity')

                # We'd prefer to use `parse_known_intermixed_args`, but that's
                # new in Python 3.7.
                known, unknown = sub_parser.parse_known_args(self.config['raptor_cmd_line_args'])

                if known.app:
                    self.app = known.app
                if known.intent:
                    self.intent = known.intent
                if known.activity:
                    self.activity = known.activity
        else:
            # Raptor initiated in production via mozharness
            self.test = self.config['test']
            self.app = self.config.get("app", "firefox")
            self.binary_path = self.config.get("binary_path", None)

            if self.app in ('refbrow', 'fenix'):
                self.app_name = self.binary_path

        self.installer_url = self.config.get("installer_url")
        self.raptor_json_url = self.config.get("raptor_json_url")
        self.raptor_json = self.config.get("raptor_json")
        self.raptor_json_config = self.config.get("raptor_json_config")
        self.repo_path = self.config.get("repo_path")
        self.obj_path = self.config.get("obj_path")
        self.test = None
        self.gecko_profile = self.config.get('gecko_profile') or \
            "--geckoProfile" in self.config.get("raptor_cmd_line_args", [])
        self.gecko_profile_interval = self.config.get('gecko_profile_interval')
        self.gecko_profile_entries = self.config.get('gecko_profile_entries')
        self.test_packages_url = self.config.get('test_packages_url')
        self.test_url_params = self.config.get('test_url_params')
        self.host = self.config.get('host')
        if self.host == 'HOST_IP':
            self.host = os.environ['HOST_IP']
        self.power_test = self.config.get('power_test')
        self.memory_test = self.config.get('memory_test')
        self.cpu_test = self.config.get('cpu_test')
        self.live_sites = self.config.get('live_sites')
        self.chimera = self.config.get('chimera')
        self.disable_perf_tuning = self.config.get('disable_perf_tuning')
        self.conditioned_profile_scenario = self.config.get('conditioned_profile_scenario',
                                                            'settled')
        self.extra_prefs = self.config.get('extra_prefs')
        self.is_release_build = self.config.get('is_release_build')
        self.debug_mode = self.config.get('debug_mode', False)
        self.chromium_dist_path = None
        self.firefox_android_browsers = ["fennec", "geckoview", "refbrow", "fenix"]
        self.android_browsers = self.firefox_android_browsers + ["chrome-m"]

        for (arg,), details in Raptor.browsertime_options:
            # Allow overriding defaults on the `./mach raptor-test ...` command-line.
            value = self.config.get(details['dest'])
            if value and arg not in self.config.get("raptor_cmd_line_args", []):
                setattr(self, details['dest'], value)

    # We accept some configuration options from the try commit message in the
    # format mozharness: <options>. Example try commit message: mozharness:
    # --geckoProfile try: <stuff>
    def query_gecko_profile_options(self):
        gecko_results = []
        # If gecko_profile is set, we add that to Raptor's options
        if self.gecko_profile:
            gecko_results.append('--geckoProfile')
            if self.gecko_profile_interval:
                gecko_results.extend(
                    ['--geckoProfileInterval', str(self.gecko_profile_interval)]
                )
            if self.gecko_profile_entries:
                gecko_results.extend(
                    ['--geckoProfileEntries', str(self.gecko_profile_entries)]
                )
        return gecko_results

    def query_abs_dirs(self):
        if self.abs_dirs:
            return self.abs_dirs
        abs_dirs = super(Raptor, self).query_abs_dirs()
        abs_dirs['abs_blob_upload_dir'] = os.path.join(abs_dirs['abs_work_dir'],
                                                       'blobber_upload_dir')
        abs_dirs['abs_test_install_dir'] = os.path.join(abs_dirs['abs_work_dir'], 'tests')

        self.abs_dirs = abs_dirs
        return self.abs_dirs

    def install_chrome_android(self):
        '''Install Google Chrome for Android in production from tooltool'''
        if self.app != "chrome-m":
            self.info("Google Chrome for Android not required")
            return
        if self.config.get("run_local"):
            self.info(
                "Google Chrome for Android will not be installed "
                "from tooltool when running locally"
            )
            return
        self.info("Fetching and installing Google Chrome for Android")

        # Fetch the APK
        tmpdir = tempfile.mkdtemp()
        self.tooltool_fetch(
            os.path.join(
                self.raptor_path,
                "raptor",
                "tooltool-manifests",
                "chrome-android",
                "chrome80.manifest"
            ),
            output_dir=tmpdir
        )

        # Find the downloaded APK
        files = os.listdir(tmpdir)
        if len(files) > 1:
            raise Exception("Found more than one chrome APK file after tooltool download")
        chromeapk = os.path.join(tmpdir, files[0])

        # Disable verification and install the APK
        self.device.shell_output("settings put global verifier_verify_adb_installs 0")
        self.install_apk(chromeapk, replace=True)

        # Re-enable verification and delete the temporary directory
        self.device.shell_output("settings put global verifier_verify_adb_installs 1")
        rmtree(tmpdir)

        self.info("Google Chrome for Android successfully installed")

    def install_chromium_distribution(self):
        '''Install Google Chromium distribution in production'''
        linux, mac, win = 'linux', 'mac', 'win'
        chrome, chromium = 'chrome', 'chromium'

        available_chromium_dists = [chrome, chromium]
        binary_location = {
            chromium: {
                linux: ['chrome-linux', 'chrome'],
                mac: ['chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'],
                win: ['chrome-win', 'Chrome.exe']
            },
        }

        if self.app not in available_chromium_dists:
            self.info("Google Chrome or Chromium distributions are not required.")
            return

        if self.app == 'chrome':
            self.info("Chrome should be preinstalled.")
            if win in self.platform_name():
                self.chromium_dist_path = "C:\\Progra~2\\Google\\Chrome\\Application\\chrome.exe"
            elif linux in self.platform_name():
                self.chromium_dist_path = "/usr/bin/google-chrome"
            elif mac in self.platform_name():
                self.chromium_dist_path = "/Applications/Google Chrome.app/" \
                    "Contents/MacOS/Google Chrome"
            else:
                self.error(
                    "Chrome is not installed on the platform %s yet." % self.platform_name()
                )

            if os.path.exists(self.chromium_dist_path):
                self.info("Google Chrome found in expected location %s" % self.chromium_dist_path)
            else:
                self.error("Cannot find Google Chrome at %s" % self.chromium_dist_path)

            return

        chromium_dist = self.app

        if self.config.get("run_local"):
            self.info("Expecting %s to be pre-installed locally" % chromium_dist)
            return

        self.info("Getting fetched %s build" % chromium_dist)
        self.chromium_dist_dest = os.path.normpath(os.path.abspath(os.environ['MOZ_FETCHES_DIR']))

        if mac in self.platform_name():
            self.chromium_dist_path = os.path.join(self.chromium_dist_dest,
                                                   *binary_location[chromium_dist][mac])

        elif linux in self.platform_name():
            self.chromium_dist_path = os.path.join(self.chromium_dist_dest,
                                                   *binary_location[chromium_dist][linux])

        else:
            self.chromium_dist_path = os.path.join(self.chromium_dist_dest,
                                                   *binary_location[chromium_dist][win])

        self.info("%s dest is: %s" % (chromium_dist, self.chromium_dist_dest))
        self.info("%s path is: %s" % (chromium_dist, self.chromium_dist_path))

        # Now ensure Chromium binary exists
        if os.path.exists(self.chromium_dist_path):
            self.info("Successfully installed %s to: %s"
                      % (chromium_dist, self.chromium_dist_path))
        else:
            self.info("Abort: failed to install %s" % chromium_dist)

    def raptor_options(self, args=None, **kw):
        """Return options to Raptor"""
        options = []
        kw_options = {}

        # Get the APK location to be able to get the browser version
        # through mozversion
        if self.app in self.firefox_android_browsers and not self.run_local:
            kw_options['installerpath'] = self.installer_path

        # If testing on Firefox, the binary path already came from mozharness/pro;
        # otherwise the binary path is forwarded from command-line arg (raptor_cmd_line_args).
        kw_options['app'] = self.app
        if self.app == "firefox" or (self.app in self.firefox_android_browsers and
                                     not self.run_local):
            binary_path = self.binary_path or self.config.get('binary_path')
            if not binary_path:
                self.fatal("Raptor requires a path to the binary.")
            kw_options['binary'] = binary_path
            if self.app in self.firefox_android_browsers:
                # In production ensure we have correct app name,
                # i.e. fennec_aurora or fennec_release etc.
                kw_options['binary'] = self.query_package_name()
                self.info("Set binary to %s instead of %s" % (kw_options['binary'], binary_path))
        else:  # Running on Chromium
            if not self.run_local:
                # When running locally we already set the Chromium binary above, in init.
                # In production, we already installed Chromium, so set the binary path
                # to our install.
                kw_options['binary'] = self.chromium_dist_path or ""

        # Options overwritten from **kw
        if 'test' in self.config:
            kw_options['test'] = self.config['test']
        if 'binary' in self.config:
            kw_options['binary'] = self.config['binary']
        if self.symbols_path:
            kw_options['symbolsPath'] = self.symbols_path
        if self.config.get('obj_path', None) is not None:
            kw_options['obj-path'] = self.config['obj_path']
        if self.test_url_params:
            kw_options['test-url-params'] = self.test_url_params
        if self.config.get('device_name') is not None:
            kw_options['device-name'] = self.config['device_name']
        if self.config.get('activity') is not None:
            kw_options['activity'] = self.config['activity']
        if self.config.get('conditioned_profile_scenario') is not None:
            kw_options['conditioned-profile-scenario'] = \
                self.config['conditioned_profile_scenario']

        kw_options.update(kw)
        if self.host:
            kw_options['host'] = self.host
        # Configure profiling options
        options.extend(self.query_gecko_profile_options())
        # Extra arguments
        if args is not None:
            options += args
        if self.config.get('run_local', False):
            options.extend(['--run-local'])
        if 'raptor_cmd_line_args' in self.config:
            options += self.config['raptor_cmd_line_args']
        if self.config.get('code_coverage', False):
            options.extend(['--code-coverage'])
        if self.config.get('is_release_build', False):
            options.extend(['--is-release-build'])
        if self.config.get('power_test', False):
            options.extend(['--power-test'])
        if self.config.get('memory_test', False):
            options.extend(['--memory-test'])
        if self.config.get('cpu_test', False):
            options.extend(['--cpu-test'])
        if self.config.get('live_sites', False):
            options.extend(['--live-sites'])
        if self.config.get('chimera', False):
            options.extend(['--chimera'])
        if self.config.get('disable_perf_tuning', False):
            options.extend(['--disable-perf-tuning'])
        if self.config.get('cold', False):
            options.extend(['--cold'])
        if self.config.get('enable_webrender', False):
            options.extend(['--enable-webrender'])
        if self.config.get('no_conditioned_profile', False):
            options.extend(['--no-conditioned-profile'])
        if self.config.get('enable_fission', False):
            options.extend(['--enable-fission'])
        if self.config.get('extra_prefs'):
            options.extend(['--setpref={}'.format(i) for i in self.config.get('extra_prefs')])

        for (arg,), details in Raptor.browsertime_options:
            # Allow overriding defaults on the `./mach raptor-test ...` command-line
            value = self.config.get(details['dest'])
            if value and arg not in self.config.get("raptor_cmd_line_args", []):
                if isinstance(value, string_types):
                    options.extend([arg, os.path.expandvars(value)])
                else:
                    options.extend([arg])

        for key, value in kw_options.items():
            options.extend(['--%s' % key, value])

        return options

    def populate_webroot(self):
        """Populate the production test machines' webroots"""
        self.raptor_path = os.path.join(
            self.query_abs_dirs()['abs_test_install_dir'], 'raptor'
        )
        if self.config.get('run_local'):
            self.raptor_path = os.path.join(self.repo_path, 'testing', 'raptor')

    def clobber(self):
        # Recreate the upload directory for storing the logcat collected
        # during APK installation.
        super(Raptor, self).clobber()
        upload_dir = self.query_abs_dirs()['abs_blob_upload_dir']
        if not os.path.isdir(upload_dir):
            self.mkdir_p(upload_dir)

    def install_apk(self, apk, replace=False):
        # Override AndroidMixin's install_apk in order to capture
        # logcat during the installation. If the installation fails,
        # the logcat file will be left in the upload directory.
        self.logcat_start()
        try:
            super(Raptor, self).install_apk(apk, replace=replace)
        finally:
            self.logcat_stop()

    def download_and_extract(self, extract_dirs=None, suite_categories=None):
        return super(Raptor, self).download_and_extract(
            suite_categories=['common', 'condprof', 'raptor']
        )

    def create_virtualenv(self, **kwargs):
        """VirtualenvMixin.create_virtualenv() assumes we're using
        self.config['virtualenv_modules']. Since we're installing
        raptor from its source, we have to wrap that method here."""
        # If virtualenv already exists, just add to path and don't re-install.
        # We need it in-path to import jsonschema later when validating output for perfherder.
        _virtualenv_path = self.config.get("virtualenv_path")

        if self.run_local and os.path.exists(_virtualenv_path):
            self.info("Virtualenv already exists, skipping creation")
            _python_interp = self.config.get('exes')['python']

            if 'win' in self.platform_name():
                _path = os.path.join(_virtualenv_path,
                                     'Lib',
                                     'site-packages')
            else:
                _path = os.path.join(_virtualenv_path,
                                     'lib',
                                     os.path.basename(_python_interp),
                                     'site-packages')

            sys.path.append(_path)
            return

        # virtualenv doesn't already exist so create it
        # Install mozbase first, so we use in-tree versions
        if not self.run_local:
            mozbase_requirements = os.path.join(
                self.query_abs_dirs()['abs_test_install_dir'],
                'config',
                'mozbase_requirements.txt'
            )
        else:
            mozbase_requirements = os.path.join(
                os.path.dirname(self.raptor_path),
                'config',
                'mozbase_source_requirements.txt'
            )
        self.register_virtualenv_module(
            requirements=[mozbase_requirements],
            two_pass=True,
            editable=True,
        )
        # Require pip >= 1.5 so pip will prefer .whl files to install
        super(Raptor, self).create_virtualenv(
            modules=['pip>=1.5']
        )
        # Install Raptor dependencies
        self.install_module(
            requirements=[os.path.join(self.raptor_path,
                                       'requirements.txt')]
        )

    def install(self):
        if not self.config.get('noinstall', False):
            if self.app in self.firefox_android_browsers:
                self.device.uninstall_app(self.binary_path)
                self.install_apk(self.installer_path)
            else:
                super(Raptor, self).install()

    def _artifact_perf_data(self, src, dest):
        if not os.path.isdir(os.path.dirname(dest)):
            # create upload dir if it doesn't already exist
            self.info("Creating dir: %s" % os.path.dirname(dest))
            os.makedirs(os.path.dirname(dest))
        self.info('Copying raptor results from %s to %s' % (src, dest))
        try:
            copyfile(src, dest)
        except Exception as e:
            self.critical("Error copying results %s to upload dir %s" % (src, dest))
            self.info(str(e))

    def run_tests(self, args=None, **kw):
        """Run raptor tests"""

        # Get Raptor options
        options = self.raptor_options(args=args, **kw)

        # Python version check
        python = self.query_python_path()
        self.run_command([python, "--version"])
        parser = RaptorOutputParser(config=self.config, log_obj=self.log_obj,
                                    error_list=RaptorErrorList)
        env = {}
        env['MOZ_UPLOAD_DIR'] = self.query_abs_dirs()['abs_blob_upload_dir']
        if not self.run_local:
            env['MINIDUMP_STACKWALK'] = self.query_minidump_stackwalk()
        env['MINIDUMP_SAVE_PATH'] = self.query_abs_dirs()['abs_blob_upload_dir']
        env['RUST_BACKTRACE'] = 'full'
        if not os.path.isdir(env['MOZ_UPLOAD_DIR']):
            self.mkdir_p(env['MOZ_UPLOAD_DIR'])
        env = self.query_env(partial_env=env, log_level=INFO)
        # adjust PYTHONPATH to be able to use raptor as a python package
        if 'PYTHONPATH' in env:
            env['PYTHONPATH'] = self.raptor_path + os.pathsep + env['PYTHONPATH']
        else:
            env['PYTHONPATH'] = self.raptor_path

        # mitmproxy needs path to mozharness when installing the cert, and tooltool
        env['SCRIPTSPATH'] = scripts_path
        env['EXTERNALTOOLSPATH'] = external_tools_path

        # disable "GC poisoning" Bug# 1499043
        env['JSGC_DISABLE_POISONING'] = '1'

        # Needed to load unsigned Raptor WebExt on release builds
        if self.is_release_build:
            env['MOZ_DISABLE_NONLOCAL_CONNECTIONS'] = '1'

        if self.repo_path is not None:
            env['MOZ_DEVELOPER_REPO_DIR'] = self.repo_path
        if self.obj_path is not None:
            env['MOZ_DEVELOPER_OBJ_DIR'] = self.obj_path

        # Sets a timeout for how long Raptor should run without output
        output_timeout = self.config.get('raptor_output_timeout', 3600)
        # Run Raptor tests
        run_tests = os.path.join(self.raptor_path, 'raptor', 'raptor.py')

        mozlog_opts = ['--log-tbpl-level=debug']
        if not self.run_local and 'suite' in self.config:
            fname_pattern = '%s_%%s.log' % self.config['test']
            mozlog_opts.append('--log-errorsummary=%s'
                               % os.path.join(env['MOZ_UPLOAD_DIR'],
                                              fname_pattern % 'errorsummary'))
            mozlog_opts.append('--log-raw=%s'
                               % os.path.join(env['MOZ_UPLOAD_DIR'],
                                              fname_pattern % 'raw'))

        def launch_in_debug_mode(cmdline):
            cmdline = set(cmdline)
            debug_opts = {'--debug', '--debugger', '--debugger_args'}

            return bool(debug_opts.intersection(cmdline))

        if self.app in self.android_browsers:
            self.logcat_start()

        command = [python, run_tests] + options + mozlog_opts
        if launch_in_debug_mode(command):
            raptor_process = subprocess.Popen(command, cwd=self.workdir, env=env)
            raptor_process.wait()
        else:
            self.return_code = self.run_command(command, cwd=self.workdir,
                                                output_timeout=output_timeout,
                                                output_parser=parser,
                                                env=env)

        if self.app in self.android_browsers:
            self.logcat_stop()

        if parser.minidump_output:
            self.info("Looking at the minidump files for debugging purposes...")
            for item in parser.minidump_output:
                self.run_command(["ls", "-l", item])

        elif not self.run_local:
            # Copy results to upload dir so they are included as an artifact
            self.info("Copying Raptor results to upload dir:")

            src = os.path.join(self.query_abs_dirs()['abs_work_dir'], 'raptor.json')
            dest = os.path.join(env['MOZ_UPLOAD_DIR'], 'perfherder-data.json')
            self.info(str(dest))
            self._artifact_perf_data(src, dest)

            # Make individual perfherder data JSON's for each supporting data type
            for file in glob.glob(os.path.join(self.query_abs_dirs()['abs_work_dir'], '*')):
                path, filename = os.path.split(file)

                if not filename.startswith('raptor-'):
                    continue

                # filename is expected to contain a unique data name
                # i.e. raptor-os-baseline-power.json would result in
                # the data name os-baseline-power
                data_name = '-'.join(filename.split('-')[1:])
                data_name = '.'.join(data_name.split('.')[:-1])

                src = file
                dest = os.path.join(
                    env['MOZ_UPLOAD_DIR'],
                    'perfherder-data-%s.json' % data_name
                )
                self._artifact_perf_data(src, dest)

            src = os.path.join(self.query_abs_dirs()['abs_work_dir'], 'screenshots.html')
            if os.path.exists(src):
                dest = os.path.join(env['MOZ_UPLOAD_DIR'], 'screenshots.html')
                self.info(str(dest))
                self._artifact_perf_data(src, dest)

        # Allow log failures to over-ride successful runs of the test harness and
        # give log failures priority, so that, for instance, log failures resulting
        # in TBPL_RETRY cause a retry rather than simply reporting an error.
        if parser.tbpl_status != TBPL_SUCCESS:
            parser_status = EXIT_STATUS_DICT[parser.tbpl_status]
            self.info('return code %s changed to %s due to log output' %
                      (str(self.return_code), str(parser_status)))
            self.return_code = parser_status


class RaptorOutputParser(OutputParser):
    minidump_regex = re.compile(r'''raptorError: "error executing: '(\S+) (\S+) (\S+)'"''')
    RE_PERF_DATA = re.compile(r'.*PERFHERDER_DATA:\s+(\{.*\})')

    def __init__(self, **kwargs):
        super(RaptorOutputParser, self).__init__(**kwargs)
        self.minidump_output = None
        self.found_perf_data = []
        self.tbpl_status = TBPL_SUCCESS
        self.worst_log_level = INFO
        self.harness_retry_re = TinderBoxPrintRe['harness_error']['retry_regex']

    def parse_single_line(self, line):
        m = self.minidump_regex.search(line)
        if m:
            self.minidump_output = (m.group(1), m.group(2), m.group(3))

        m = self.RE_PERF_DATA.match(line)
        if m:
            self.found_perf_data.append(m.group(1))

        if self.harness_retry_re.search(line):
            self.critical(' %s' % line)
            self.worst_log_level = self.worst_level(CRITICAL, self.worst_log_level)
            self.tbpl_status = self.worst_level(TBPL_RETRY, self.tbpl_status,
                                                levels=TBPL_WORST_LEVEL_TUPLE)
            return  # skip base parse_single_line
        super(RaptorOutputParser, self).parse_single_line(line)
